theforeman/foreman

View on GitHub
webpack/assets/javascripts/react_app/components/Layout/NavigationSearch.js

Summary

Maintainability
A
2 hrs
Test Coverage
import React, { useState, useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import {
  Menu,
  MenuContent,
  MenuItem,
  MenuList,
  Popper,
  SearchInput,
} from '@patternfly/react-core';
import { translate as __ } from '../../common/I18n';

export const NavigationSearch = ({ items, clickAndNavigate }) => {
  let parent = null;
  const navLinksArray = [];
  items.forEach(item => {
    item.subItems.forEach(group => {
      if (group.isDivider) {
        parent = group.title;
      } else {
        navLinksArray.push({
          ...group,
          parents: [item.title, parent].filter(Boolean),
        });
      }
    });
    parent = null;
  });

  const navItems = navLinksArray.map(item => item.title);
  const menuNav = ({ id, title, href, onClick, parents }, key) => (
    <MenuItem
      to={href}
      onClick={event => clickAndNavigate(onClick, href, event)}
      itemId={`${id}_${key}`}
      key={`${id}_${key}`}
      description={[...parents, title].join(' > ')}
    >
      {title}
    </MenuItem>
  );
  const [autocompleteOptions, setAutocompleteOptions] = useState(
    navLinksArray.slice(0, 10).map(menuNav)
  );
  const [value, setValue] = useState('');

  const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false);

  const searchInputRef = useRef(null);
  const autocompleteRef = useRef(null);

  const onClear = () => {
    setValue('');
    setAutocompleteOptions(navLinksArray.slice(0, 10).map(menuNav));
  };

  const onChange = newValue => {
    // When the value of the search input changes, build a list of no more than 10 autocomplete options.
    let options = navLinksArray
      .filter(({ title }) =>
        title.toLowerCase().includes(newValue.toLowerCase())
      )
      .map(menuNav);
    if (options.length > 10) {
      options = options.slice(0, 10);
    }

    setAutocompleteOptions(options);
    if (
      newValue !== '' &&
      searchInputRef?.current?.contains(document.activeElement)
    ) {
      // The menu is hidden if there are no options
      setIsAutocompleteOpen(options.length > 0);
    } else {
      setIsAutocompleteOpen(false);
    }
    setValue(newValue);
  };
  // Whenever an autocomplete option is selected, set the search input value, close the menu, and put the browser
  // focus back on the search input
  const onSelect = e => {
    e.stopPropagation();
    setIsAutocompleteOpen(false);
  };

  useEffect(() => {
    const handleClick = event => {
      if (
        isAutocompleteOpen &&
        autocompleteRef?.current &&
        !autocompleteRef.current.contains(event.target)
      ) {
        // The autocomplete menu should close if the user clicks outside the menu.
        setIsAutocompleteOpen(false);
      } else if (
        !isAutocompleteOpen &&
        searchInputRef?.current &&
        searchInputRef.current.contains(event.target)
      ) {
        // The autocomplete menu should open if the user clicks on the search input.
        setIsAutocompleteOpen(true);
      }
    };
    const handleMenuKeys = event => {
      if (
        isAutocompleteOpen &&
        searchInputRef.current &&
        searchInputRef.current === event.target
      ) {
        // the escape key closes the autocomplete menu and keeps the focus on the search input.
        if (event.key === 'Escape') {
          setIsAutocompleteOpen(false);
          searchInputRef.current.focus();
          // the up and down arrow keys move browser focus into the autocomplete menu
        } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
          const firstElement = autocompleteRef.current.querySelector(
            'li > a:not(:disabled)'
          );
          firstElement && firstElement.focus();
          event.preventDefault(); // by default, the up and down arrow keys scroll the window
          // the tab, enter, and space keys will close the menu, and the tab key will move browser
          // focus forward one element (by default)
        } else if (
          event.key === 'Tab' ||
          event.key === 'Enter' ||
          event.key === 'Space'
        ) {
          setIsAutocompleteOpen(false);
          if (event.key === 'Enter' || event.key === 'Space') {
            event.preventDefault();
          }
        }
        // If the autocomplete is open and the browser focus is in the autocomplete menu
        // hitting tab will close the autocomplete and but browser focus back on the search input.
      } else if (
        isAutocompleteOpen &&
        autocompleteRef?.current?.contains(event.target) &&
        event.key === 'Tab'
      ) {
        event.preventDefault();
        setIsAutocompleteOpen(false);
        searchInputRef.current.focus();
      }
    };
    window.addEventListener('keydown', handleMenuKeys);
    window.addEventListener('click', handleClick);
    return () => {
      window.removeEventListener('keydown', handleMenuKeys);
      window.removeEventListener('click', handleClick);
    };
  }, [isAutocompleteOpen]);
  useEffect(() => {
    const handleKeyDown = event => {
      if (event.ctrlKey && event.shiftKey && event.key === 'F') {
        // Focus the search input on Ctrl+Shift+F
        if (searchInputRef.current) {
          searchInputRef.current.focus();
        }
      }
    };

    // Add the event listener
    window.addEventListener('keydown', handleKeyDown);

    // Remove the event listener when the component unmounts
    return () => {
      window.removeEventListener('keydown', handleKeyDown);
    };
  }, []);

  const searchInput = (
    <SearchInput
      value={value}
      placeholder={__('Search and go')}
      onChange={onChange}
      onClear={onClear}
      ref={searchInputRef}
      id="navigation-search"
      onSearch={(_, event) => {
        const firstItem = navItems.find(option =>
          option.toLowerCase().includes(value.toLowerCase())
        );
        if (firstItem) {
          const navLink = navLinksArray.find(
            ({ title }) => title === firstItem
          );
          clickAndNavigate(navLink.onClick, navLink.href, event);
        }
      }}
    />
  );

  const autocomplete = (
    <Menu
      ouiaId="navigation-search-menu"
      ref={autocompleteRef}
      onSelect={onSelect}
      className="navigation-search-menu"
    >
      <MenuContent>
        <MenuList>{autocompleteOptions}</MenuList>
      </MenuContent>
    </Menu>
  );

  return (
    <Popper
      trigger={searchInput}
      popper={autocomplete}
      isVisible={isAutocompleteOpen}
      enableFlip={false}
      // append the autocomplete menu to the search input in the DOM for the sake of the keyboard navigation experience
      appendTo={() => document.querySelector('#navigation-search')}
    />
  );
};

NavigationSearch.propTypes = {
  items: PropTypes.array.isRequired,
  clickAndNavigate: PropTypes.func.isRequired,
};