fbredius/storybook

View on GitHub
lib/ui/src/components/sidebar/SearchResults.tsx

Summary

Maintainability
B
5 hrs
Test Coverage
import { styled } from '@storybook/theming';
import { Icons } from '@storybook/components';
import global from 'global';
import React, {
  FunctionComponent,
  MouseEventHandler,
  ReactNode,
  useCallback,
  useEffect,
} from 'react';
import { ControllerStateAndHelpers } from 'downshift';

import { ComponentNode, DocumentNode, Path, RootNode, StoryNode } from './TreeNode';
import {
  Match,
  DownshiftItem,
  isCloseType,
  isClearType,
  isExpandType,
  SearchResult,
} from './types';
import { getLink } from './utils';
import { matchesKeyCode, matchesModifiers } from '../../keybinding';

const { document, DOCS_MODE } = global;

const ResultsList = styled.ol({
  listStyle: 'none',
  margin: 0,
  marginLeft: -20,
  marginRight: -20,
  padding: 0,
});

const ResultRow = styled.li<{ isHighlighted: boolean }>(({ theme, isHighlighted }) => ({
  display: 'block',
  margin: 0,
  padding: 0,
  background: isHighlighted ? theme.background.hoverable : 'transparent',
  cursor: 'pointer',
  'a:hover, button:hover': {
    background: 'transparent',
  },
}));

const NoResults = styled.div(({ theme }) => ({
  marginTop: 20,
  textAlign: 'center',
  fontSize: `${theme.typography.size.s2 - 1}px`,
  lineHeight: `18px`,
  color: theme.color.defaultText,
  small: {
    color: theme.barTextColor,
    fontSize: `${theme.typography.size.s1}px`,
  },
}));

const Mark = styled.mark(({ theme }) => ({
  background: 'transparent',
  color: theme.color.secondary,
}));

const ActionRow = styled(ResultRow)({
  display: 'flex',
  padding: '6px 19px',
  alignItems: 'center',
});

const BackActionRow = styled(ActionRow)({
  marginTop: 8,
});

const ActionLabel = styled.span(({ theme }) => ({
  flexGrow: 1,
  color: theme.color.mediumdark,
  fontSize: `${theme.typography.size.s1}px`,
}));

const ActionIcon = styled(Icons)(({ theme }) => ({
  display: 'inline-block',
  width: 10,
  height: 10,
  marginRight: 6,
  color: theme.color.mediumdark,
}));

const ActionKey = styled.code(({ theme }) => ({
  minWidth: 16,
  height: 16,
  lineHeight: '17px',
  textAlign: 'center',
  fontSize: '11px',
  background: 'rgba(0,0,0,0.1)',
  color: theme.textMutedColor,
  borderRadius: 2,
  userSelect: 'none',
  pointerEvents: 'none',
}));

const Highlight: FunctionComponent<{ match?: Match }> = React.memo(({ children, match }) => {
  if (!match) return <>{children}</>;
  const { value, indices } = match;
  const { nodes: result } = indices.reduce<{ cursor: number; nodes: ReactNode[] }>(
    ({ cursor, nodes }, [start, end], index, { length }) => {
      /* eslint-disable react/no-array-index-key */
      nodes.push(<span key={`${index}-0`}>{value.slice(cursor, start)}</span>);
      nodes.push(<Mark key={`${index}-1`}>{value.slice(start, end + 1)}</Mark>);
      if (index === length - 1) {
        nodes.push(<span key={`${index}-2`}>{value.slice(end + 1)}</span>);
      }
      /* eslint-enable react/no-array-index-key */
      return { cursor: end + 1, nodes };
    },
    { cursor: 0, nodes: [] }
  );
  return <>{result}</>;
});

const Result: FunctionComponent<
  SearchResult & { icon: string; isHighlighted: boolean; onClick: MouseEventHandler }
> = React.memo(({ item, matches, icon, onClick, ...props }) => {
  const click: MouseEventHandler = useCallback(
    (event) => {
      event.preventDefault();
      onClick(event);
    },
    [onClick]
  );

  const nameMatch = matches.find((match: Match) => match.key === 'name');
  const pathMatches = matches.filter((match: Match) => match.key === 'path');
  const label = (
    <div className="search-result-item--label">
      <strong>
        <Highlight match={nameMatch}>{item.name}</Highlight>
      </strong>
      <Path>
        {item.path.map((group, index) => (
          // eslint-disable-next-line react/no-array-index-key
          <span key={index}>
            <Highlight match={pathMatches.find((match: Match) => match.arrayIndex === index)}>
              {group}
            </Highlight>
          </span>
        ))}
      </Path>
    </div>
  );
  const title = `${item.path.join(' / ')} / ${item.name}`;

  if (DOCS_MODE) {
    return (
      <ResultRow {...props}>
        <DocumentNode depth={0} onClick={click} href={getLink(item.id, item.refId)} title={title}>
          {label}
        </DocumentNode>
      </ResultRow>
    );
  }

  const TreeNode = item.isComponent ? ComponentNode : StoryNode;
  return (
    <ResultRow {...props}>
      <TreeNode isExpanded={false} depth={0} onClick={onClick} title={title}>
        {label}
      </TreeNode>
    </ResultRow>
  );
});

export const SearchResults: FunctionComponent<{
  query: string;
  results: DownshiftItem[];
  closeMenu: (cb?: () => void) => void;
  getMenuProps: ControllerStateAndHelpers<DownshiftItem>['getMenuProps'];
  getItemProps: ControllerStateAndHelpers<DownshiftItem>['getItemProps'];
  highlightedIndex: number | null;
  isLoading?: boolean;
  enableShortcuts?: boolean;
}> = React.memo(
  ({
    query,
    results,
    closeMenu,
    getMenuProps,
    getItemProps,
    highlightedIndex,
    isLoading = false,
    enableShortcuts = true,
  }) => {
    useEffect(() => {
      const handleEscape = (event: KeyboardEvent) => {
        if (!enableShortcuts || isLoading || event.repeat) return;
        if (matchesModifiers(false, event) && matchesKeyCode('Escape', event)) {
          const target = event.target as Element;
          if (target?.id === 'storybook-explorer-searchfield') return; // handled by downshift
          event.preventDefault();
          closeMenu();
        }
      };

      document.addEventListener('keydown', handleEscape);
      return () => document.removeEventListener('keydown', handleEscape);
    }, [enableShortcuts, isLoading]);

    return (
      <ResultsList {...getMenuProps()}>
        {results.length > 0 && !query && (
          <li>
            <RootNode className="search-result-recentlyOpened">Recently opened</RootNode>
          </li>
        )}
        {results.length === 0 && query && (
          <li>
            <NoResults>
              <strong>No components found</strong>
              <br />
              <small>Find components by name or path.</small>
            </NoResults>
          </li>
        )}
        {results.map((result: DownshiftItem, index) => {
          if (isCloseType(result)) {
            return (
              <BackActionRow
                {...result}
                {...getItemProps({ key: index, index, item: result })}
                isHighlighted={highlightedIndex === index}
                className="search-result-back"
              >
                <ActionIcon icon="arrowleft" />
                <ActionLabel>Back to components</ActionLabel>
                <ActionKey>ESC</ActionKey>
              </BackActionRow>
            );
          }
          if (isClearType(result)) {
            return (
              <ActionRow
                {...result}
                {...getItemProps({ key: index, index, item: result })}
                isHighlighted={highlightedIndex === index}
                className="search-result-clearHistory"
              >
                <ActionIcon icon="trash" />
                <ActionLabel>Clear history</ActionLabel>
              </ActionRow>
            );
          }
          if (isExpandType(result)) {
            return (
              <ActionRow
                {...result}
                {...getItemProps({ key: index, index, item: result })}
                isHighlighted={highlightedIndex === index}
                className="search-result-more"
              >
                <ActionIcon icon="plus" />
                <ActionLabel>Show {result.moreCount} more results</ActionLabel>
              </ActionRow>
            );
          }

          const { item } = result;
          const key = `${item.refId}::${item.id}`;
          return (
            <Result
              {...result}
              {...getItemProps({ key, index, item: result })}
              isHighlighted={highlightedIndex === index}
              className="search-result-item"
            />
          );
        })}
      </ResultsList>
    );
  }
);