In this article, we will build a project that mimics the lesser known but awesome file search functionality provided by GitHub.

To see how it works, go to any GitHub repository and press the letter t which will land you in search view. Then you can simultaneously search and scroll through the list as shown in the below gif:

Github file search functionality

By building this app, you will learn the following:

  • How to create a UI similar to a GitHub repository
  • How to work with keyboard events in React
  • How to work with navigation using the arrow keys on your keyboard
  • How to highlight matching text while searching
  • How to add icons in React
  • How to render HTML content in a JSX expression

And much more.

You can see the live demo of the application here.

Let’s get started

Create a new project using create-react-app:

create-react-app github-file-search-react

Once the project is created, delete all files from the src folder and create index.js, App.js and styles.scss files inside the src folder. Also create components and utils folders inside the src folder.

Install the necessary dependencies:

yarn add moment@2.27.0 node-sass@4.14.1 prop-types@15.7.2 react-icons@3.10.0

Open styles.scss and add the contents from here inside it.

Create a new file Header.js inside the components folder with the following content:

import React from 'react';

const Header = () => <h1 className="header">GitHub File Search</h1>;

export default Header;

Create a new file api.js inside the utils folder and add the content from here inside it.

In this file, we have created static data to be displayed on the UI to keep the application simple and easy to understand.

Create a new file ListItem.js inside the components folder with the following content:

import React from 'react';
import moment from 'moment';
import { AiFillFolder, AiOutlineFile } from 'react-icons/ai';

const ListItem = ({ type, name, comment, modified_time }) => {
  return (
    <React.Fragment>
      <div className="list-item">
        <div className="file">
          <span className="file-icon">
            {type === 'folder' ? (
              <AiFillFolder color="#79b8ff" size="20" />
            ) : (
              <AiOutlineFile size="18" />
            )}
          </span>
          <span className="label">{name}</span>
        </div>
        <div className="comment">{comment}</div>
        <div className="time" title={modified_time}>
          {moment(modified_time).fromNow()}
        </div>
      </div>
    </React.Fragment>
  );
};

export default ListItem;

In this file, we are taking the data of each file we want to display and we're displaying the folder/file icon, the name of the file, the comment(s), and the last time the file was modified.

For displaying the icons, we'll use the react-icons npm library. It has a really nice website that lets you easily search and use the icons you need. Check it out here.

The icons component accepts the color and size props to customize the icon which we have used in the above code.

Create a new file called FilesList.js inside the components folder with the following content:

import React from 'react';
import ListItem from './ListItem';

const FilesList = ({ files }) => {
  return (
    <div className="list">
      {files.length > 0 ? (
        files.map((file, index) => {
          return <ListItem key={file.id} {...file} />;
        })
      ) : (
        <div>
          <h3 className="no-result">No matching files found</h3>
        </div>
      )}
    </div>
  );
};

export default FilesList;

In this file, we read the static data from the api.js file and then display each element of the files array by using the array map method.

Now open the src/App.js file and add the following code inside it:

import React from 'react';
import Header from './components/Header';
import FilesList from './components/FilesList';
import files from './utils/api';

export default class App extends React.Component {
  state = {
    filesList: files
  };

  render() {
    const { counter, filesList } = this.state;

    return (
      <div className="container">
        <Header />
        <FilesList files={filesList} />
      </div>
    );
  }
}

In this file, we have added a state to store the static files data which we can modify whenever we need to do so. Then we have passed it to the FilesList component to display on the UI.

Now, open the index.js file and add the following code inside it:

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import './styles.scss';

ReactDOM.render(<App />, document.getElementById('root'));

Now, start your application by running the yarn start command from the terminal or command prompt and you will see the following initial screen:

Initial screen

You can find the code up to this point in this branch.

Add basic search functionality

Now, let's add the functionality which changes the UI and allows us to search through files when we press the letter t on our keyboard.

Inside the utils folder create a new file called keyCodes.js with the following content:

export const ESCAPE_CODE = 27;
export const HOTKEY_CODE = 84; // key code of letter t
export const UP_ARROW_CODE = 38;
export const DOWN_ARROW_CODE = 40;

Create a new file called SearchView.js inside the components folder with the following content:

import React, { useState, useEffect, useRef } from 'react';

const SearchView = ({ onSearch }) => {
  const [input, setInput] = useState('');
  const inputRef = useRef();

  useEffect(() => {
    inputRef.current.focus();
  }, []);

  const onInputChange = (event) => {
    const input = event.target.value;
    setInput(input);
    onSearch(input);
  };

  return (
    <div className="search-box">
      My Repository <span className="slash">/</span>
      <input
        type="text"
        name="input"
        value={input}
        ref={inputRef}
        autoComplete="off"
        onChange={onInputChange}
      />
    </div>
  );
};

export default SearchView;

We are using React Hooks here for our state and lifecycle methods. If you are new to React Hooks check out this article for an introduction.

In this file, we have first declared a state to store the user typed input. Then we added a ref using the useRef Hook so we can focus on the input field when the component is mounted.

const inputRef = useRef();

useEffect(() => {
  inputRef.current.focus();
}, []);

...

<input
    type="text"
    name="input"
    value={input}
    ref={inputRef}
    autoComplete="off"
    onChange={onInputChange}
  />

In this code, by passing the Empty array [] as the second argument to the useEffect hook, the code inside the useEffect hook will only be executed once when the component is mounted. This acts as the componentDidMount lifecycle method in class components.

Then we assigned the ref to the input field as ref={inputRef}. On change of the input field inside the onInputChange handler, we are calling the onSearch method passed as a prop to the component from the App.js file.

Now, open App.js and replace its contents with following code:

import React from 'react';
import Header from './components/Header';
import FilesList from './components/FilesList';
import SearchView from './components/SearchView';
import { ESCAPE_CODE, HOTKEY_CODE } from './utils/keyCodes';
import files from './utils/api';

export default class App extends React.Component {
  state = {
    isSearchView: false,
    filesList: files
  };

  componentDidMount() {
    window.addEventListener('keydown', this.handleEvent);
  }

  componentWillUnmount() {
    window.removeEventListener('keydown', this.handleEvent);
  }

  handleEvent = (event) => {
    const keyCode = event.keyCode || event.which;

    switch (keyCode) {
      case HOTKEY_CODE:
        this.setState((prevState) => ({
          isSearchView: true,
          filesList: prevState.filesList.filter((file) => file.type === 'file')
        }));
        break;
      case ESCAPE_CODE:
        this.setState({ isSearchView: false, filesList: files });
        break;
      default:
        break;
    }
  };

  handleSearch = (searchTerm) => {
    let list;
    if (searchTerm) {
      list = files.filter(
        (file) =>
          file.name.toLowerCase().indexOf(searchTerm.toLowerCase()) > -1 &&
          file.type === 'file'
      );
    } else {
      list = files.filter((file) => file.type === 'file');
    }

    this.setState({
      filesList: list
    });
  };

  render() {
    const { isSearchView, filesList } = this.state;

    return (
      <div className="container">
        <Header />
        {isSearchView ? (
          <div className="search-view">
            <SearchView onSearch={this.handleSearch} />
            <FilesList files={filesList} isSearchView={isSearchView} />
          </div>
        ) : (
          <FilesList files={filesList} />
        )}
      </div>
    );
  }
}

Now, restart the app by running the yarn start command again and check its functionality.

Initial working search functionality

As you can see, initially all folders and files are displayed. Then when we press the letter t on the keyboard, the view changes to allow us to search through the displayed files.

Now, let's understand the code from the App.js file.

In this file, we have first declared isSearchView as a state variable. Then inside the componentDidMount and componentWillUnmount lifecycle methods we are adding and removing the keydown event handler, respectively.

Then inside the handleEvent function, we are checking which key is pressed by the user.

  • If the user presses the t key, then we set the isSearchView state to true and update the filesList state array to include only files and exclude the folders.
  • If use presses the escape key then we set the isSearchView state to false and update the filesList state array to include all the files and folders.

The reason we declare HOTKEY_CODE and ESCAPE_CODE in separate files (keyCodes.js instead of directly using the keycode like 84) is that later, if we want to change the hotkey from t to s, then we just need to change the keycode in that file. It will reflect the change in all files where it's used without needing to change it in every file.

Now, let's understand the handleSearch function. In this function, we check if the user has entered something in the input search box and then filter out the matching file name(s) which include that search term. Then we update the state with the filtered result(s).

Then inside the render method, based on the isSearchView value, we display either the file list view or search view to the user.

You can find code up to this point in this branch.

Add functionality to navigate between files

Now, lets add the functionality to display an arrow in front of the currently selected file while navigating the list of files.

Create a new file called InfoMessage.js inside the components folder with the following content:

import React from 'react';

const InfoMessage = () => {
  return (
    <div className="info-message">
      You've activated the <em>file finder</em>. Start typing to filter the file
      list. Use <span className="navigation">↑</span> and{' '}
      <span className="navigation">↓</span> to navigate,{' '}
      <span className="navigation">esc</span> to exit.
    </div>
  );
};

export default InfoMessage;

Now, open the App.js file and import the InfoMessage component to use it:

import InfoMessage from './components/InfoMessage';

Add a new state variable called counter with the initial value of 0. This is to keep track of the index of the arrow.

Inside the handleEvent handler, get the filesList and counter values from state:

const { filesList, counter } = this.state;

Add two new switch cases:

case UP_ARROW_CODE:
  if (counter > 0) {
    this.setState({ counter: counter - 1 });
  }
  break;
case DOWN_ARROW_CODE:
  if (counter < filesList.length - 1) {
    this.setState({ counter: counter + 1 });
  }
  break;

Here, we decrement the counter state value when we press the up arrow on the keyboard and increment when we press the down arrow.

Also import the up and down array constants at the top of the file:

import {
  ESCAPE_CODE,
  HOTKEY_CODE,
  UP_ARROW_CODE,
  DOWN_ARROW_CODE
} from './utils/keyCodes';

Inside the handleSearch function, reset the counter state to 0 at the end of the function so the arrow will always display for the first file from the list while filtering the files list.

this.setState({
  filesList: list,
  counter: 0
});

Change the render method to display the InfoMessage component and pass counter and isSearchView as props to the FilesList component:

render() {
  const { isSearchView, counter, filesList } = this.state;

  return (
    <div className="container">
      <Header />
      {isSearchView ? (
        <div className="search-view">
          <SearchView onSearch={this.handleSearch} />
          <InfoMessage />
          <FilesList
            files={filesList}
            isSearchView={isSearchView}
            counter={counter}
          />
        </div>
      ) : (
        <FilesList files={filesList} />
      )}
    </div>
  );
}

Now, open the FilesList.js file and accept the isSearchView and counter props and pass them to the ListItem component.

Your FilesList.js file will look like this now:

import React from 'react';
import ListItem from './ListItem';

const FilesList = ({ files, isSearchView, counter }) => {
  return (
    <div className="list">
      {files.length > 0 ? (
        files.map((file, index) => {
          return (
            <ListItem
              key={file.id}
              {...file}
              index={index}
              isSearchView={isSearchView}
              counter={counter}
            />
          );
        })
      ) : (
        <div>
          <h3 className="no-result">No matching files found</h3>
        </div>
      )}
    </div>
  );
};

export default FilesList;

Now, open ListItem.js file and replace its contents with the following content:

import React from 'react';
import moment from 'moment';
import { AiFillFolder, AiOutlineFile, AiOutlineRight } from 'react-icons/ai';

const ListItem = ({
  index,
  type,
  name,
  comment,
  modified_time,
  isSearchView,
  counter
}) => {
  const isSelected = counter === index;

  return (
    <React.Fragment>
      <div className={`list-item ${isSelected ? 'active' : ''}`}>
        <div className="file">
          {isSearchView && (
            <span
              className={`arrow-icon ${isSelected ? 'visible' : 'invisible'}`}
            >
              <AiOutlineRight color="#0366d6" />
            </span>
          )}
          <span className="file-icon">
            {type === 'folder' ? (
              <AiFillFolder color="#79b8ff" size="20" />
            ) : (
              <AiOutlineFile size="18" />
            )}
          </span>
          <span className="label">{name}</span>
        </div>
        {!isSearchView && (
          <React.Fragment>
            <div className="comment">{comment}</div>
            <div className="time" title={modified_time}>
              {moment(modified_time).fromNow()}
            </div>
          </React.Fragment>
        )}
      </div>
    </React.Fragment>
  );
};

export default ListItem;

In this file, we first accept the isSearchView and counter prop. Then we check if the index of the currently displayed file from the list matches with the counter value.

Based on that, we display the arrow in front only for that file. Then when we use the down or up arrow to navigate through the list, we increment or decrement the counter value respectively in the App.js file.

Based on the isSearchView value we display or hide the comment and time column in the search view on the UI.

Now, restart the app by running the yarn start command again and check its functionality:

Search and Navigate

You can find the code up to this point in this branch.

Add functionality to highlight matching text

Now, let's add the functionality to highlight the matching text from the filename when we filter the file.

Open App.js and change the handleSearch function to the following code:

handleSearch = (searchTerm) => {
  let list;
  if (searchTerm) {
    const pattern = new RegExp(searchTerm, 'gi');
    list = files
      .filter(
        (file) =>
          file.name.toLowerCase().indexOf(searchTerm.toLowerCase()) > -1 &&
          file.type === 'file'
      )
      .map((file) => {
        return {
          ...file,
          name: file.name.replace(pattern, (match) => {
            return `<mark>${match}</mark>`;
          })
        };
      });
  } else {
    list = files.filter((file) => file.type === 'file');
  }

  this.setState({
    filesList: list,
    counter: 0
  });
};

In this code, first we use the RegExp constructor to create a dynamic regular expression for global and case insensentive search:

const pattern = new RegExp(searchTerm, 'gi');

Then we filter out the files which match that search criteria:

files.filter(
  (file) =>
    file.name.toLowerCase().indexOf(searchTerm.toLowerCase()) > -1 &&
    file.type === 'file'
);

Then we call the array map method on the result we got from above filter functionality.

In the map method, we use the string replace method.
The replace method accepts two parameters:

  • pattern to search for
  • function to execute for each matched pattern

We use the replace method to find all the matches for the pattern and replace it with the string <mark>${match}</mark>. Here match will contain the matched text from the file name.

If you check the JSON structure from the utils/api.js file, the structure of each file looks like this:

{
  id: 12,
  type: 'file',
  name: 'Search.js',
  comment: 'changes using react context',
  modified_time: '2020-06-30T07:55:33Z'
}

As we want to replace the text from the name field only, we spread out the file object properties and only change the name, keeping other values as they are.

{
  ...file,
  name: file.name.replace(pattern, (match) => {
    return `<mark>${match}</mark>`;
  })
}

Now, restart the app by running the yarn start command again and check its functionality.

You will see that the HTML is displayed as it is on the UI when you search:

HTML not rendered correctly

This is because we are displaying the file name in the ListItem.js file in the following way:

<span className="label">{name}</span>

And to prevent Cross-site scripting (XSS) attacks, React escapes all the content displayed using the JSX Expression (which is in curly brackets).

So if we want to actually display the correct HTML, we need to use a special prop known as dangerouslySetInnerHTML. It passes the __html name with the HTML to display as the value like this:

<span className="label" dangerouslySetInnerHTML={{ __html: name }}></span>

Now, restart the app by running the yarn start command again and check its functionality:

Final working application

As you can see, the search term is correctly getting highlighted in the name of the file.

That's it!

You can find the code up to this point in this branch.

Complete GitHub Source Code: here
Live Demo: here

Check out my other React, Node.js, and Javascript articles at Medium, dev.to and subscribe to get weekly updates directly in your inbox here.