diff --git a/lib/ui/src/components/sidebar/Search.stories.tsx b/lib/ui/src/components/sidebar/Search.stories.tsx index 871fc0f4f89..2a5f19bb8e5 100644 --- a/lib/ui/src/components/sidebar/Search.stories.tsx +++ b/lib/ui/src/components/sidebar/Search.stories.tsx @@ -4,6 +4,7 @@ import { action } from '@storybook/addon-actions'; import { stories } from './mockdata.large'; import { Search } from './Search'; import { SearchResults } from './SearchResults'; +import { noResults } from './SearchResults.stories'; import { DEFAULT_REF_ID } from './data'; import { Selection } from './types'; @@ -31,16 +32,17 @@ export const Simple = () => {() => null}; export const FilledIn = () => ( - {() => null} + {() => } ); export const LastViewed = () => ( - {({ query, results, getMenuProps, getItemProps, highlightedIndex }) => ( + {({ query, results, closeMenu, getMenuProps, getItemProps, highlightedIndex }) => ( 0) { results.push({ clearLastViewed }); } @@ -354,6 +362,7 @@ export const Search: FunctionComponent<{ query: input, results, isBrowsing: !isOpen && document.activeElement !== inputRef.current, + closeMenu, getMenuProps, getItemProps, highlightedIndex, diff --git a/lib/ui/src/components/sidebar/SearchResults.stories.tsx b/lib/ui/src/components/sidebar/SearchResults.stories.tsx index d3d29310eba..fc7b1b471d0 100644 --- a/lib/ui/src/components/sidebar/SearchResults.stories.tsx +++ b/lib/ui/src/components/sidebar/SearchResults.stories.tsx @@ -58,16 +58,22 @@ const recents = stories // We need this to prevent react key warnings const passKey = (props: any = {}) => ({ key: props.key }); -const searching = { +export const searching = { query: 'query', results, + closeMenu: () => {}, getMenuProps: passKey, getItemProps: passKey, highlightedIndex: 0, }; -const lastViewed = { +export const noResults = { + ...searching, + results: [] as any, +}; +export const lastViewed = { query: '', results: recents, + closeMenu: () => {}, getMenuProps: passKey, getItemProps: passKey, highlightedIndex: 0, @@ -75,4 +81,6 @@ const lastViewed = { export const Searching = () => ; +export const NoResults = () => ; + export const LastViewed = () => ; diff --git a/lib/ui/src/components/sidebar/SearchResults.tsx b/lib/ui/src/components/sidebar/SearchResults.tsx index 5b666e26ce5..c9dbed3f6ab 100644 --- a/lib/ui/src/components/sidebar/SearchResults.tsx +++ b/lib/ui/src/components/sidebar/SearchResults.tsx @@ -1,11 +1,24 @@ import { styled } from '@storybook/theming'; import { Icons } from '@storybook/components'; -import { DOCS_MODE } from 'global'; -import React, { FunctionComponent, MouseEventHandler, ReactNode, useCallback } from 'react'; +import { document, DOCS_MODE } 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, isClearType, isExpandType, SearchResult } from './types'; +import { + Match, + DownshiftItem, + isCloseType, + isClearType, + isExpandType, + SearchResult, +} from './types'; import { getLink } from './utils'; const ResultsList = styled.ol({ @@ -24,6 +37,18 @@ const ResultRow = styled.li<{ isHighlighted: boolean }>(({ theme, isHighlighted cursor: 'pointer', })); +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, @@ -31,11 +56,12 @@ const Mark = styled.mark(({ theme }) => ({ const ActionRow = styled(ResultRow)({ display: 'flex', - padding: '5px 19px', + padding: '6px 19px', alignItems: 'center', }); const ActionLabel = styled.span(({ theme }) => ({ + flexGrow: 1, color: theme.color.mediumdark, fontSize: `${theme.typography.size.s1}px`, })); @@ -48,6 +74,19 @@ const ActionIcon = styled(Icons)(({ theme }) => ({ 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; @@ -122,10 +161,22 @@ const Result: FunctionComponent< export const SearchResults: FunctionComponent<{ query: string; results: DownshiftItem[]; + closeMenu: (cb?: () => void) => void; getMenuProps: ControllerStateAndHelpers['getMenuProps']; getItemProps: ControllerStateAndHelpers['getItemProps']; highlightedIndex: number | null; -}> = React.memo(({ query, results, getMenuProps, getItemProps, highlightedIndex }) => { +}> = React.memo(({ query, results, closeMenu, getMenuProps, getItemProps, highlightedIndex }) => { + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + const target = event.target as Element; + if (target?.id === 'storybook-explorer-searchfield') return; // handled by downshift + closeMenu(); + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, []); + return ( {results.length > 0 && !query && ( @@ -133,7 +184,29 @@ export const SearchResults: FunctionComponent<{ Recently opened )} + {results.length === 0 && query && ( +
  • + + No components found +
    + Find components by name or path. +
    +
  • + )} {results.map((result: DownshiftItem, index) => { + if (isCloseType(result)) { + return ( + + + Back + Esc + + ); + } if (isClearType(result)) { return ( = React.memo( enableShortcuts={enableShortcuts} {...lastViewed} > - {({ query, results, isBrowsing, getMenuProps, getItemProps, highlightedIndex }) => ( + {({ + query, + results, + isBrowsing, + closeMenu, + getMenuProps, + getItemProps, + highlightedIndex, + }) => ( = React.memo( void; +} export interface ClearType { clearLastViewed: () => void; } @@ -55,12 +61,13 @@ export type SearchItem = Item & { refId: string; path: string[] }; export type SearchResult = Fuse.FuseResultWithMatches & Fuse.FuseResultWithScore; -export type DownshiftItem = SearchResult | ExpandType | ClearType; +export type DownshiftItem = SearchResult | ExpandType | ClearType | CloseType; export type SearchChildrenFn = (args: { query: string; results: DownshiftItem[]; isBrowsing: boolean; + closeMenu: (cb?: () => void) => void; getMenuProps: ControllerStateAndHelpers['getMenuProps']; getItemProps: ControllerStateAndHelpers['getItemProps']; highlightedIndex: number | null; diff --git a/lib/ui/src/components/sidebar/useExpanded.ts b/lib/ui/src/components/sidebar/useExpanded.ts index b4f97805e6d..cd1ddb2d56e 100644 --- a/lib/ui/src/components/sidebar/useExpanded.ts +++ b/lib/ui/src/components/sidebar/useExpanded.ts @@ -94,8 +94,11 @@ export const useExpanded = ({ if (!highlightedElement || highlightedElement.getAttribute('data-ref-id') !== refId) return; const target = event.target as Element; - if (target.hasAttribute('data-action')) return; if (!isAncestor(menuElement, target) && !isAncestor(target, menuElement)) return; + if (target.hasAttribute('data-action')) { + if (['Enter', ' '].includes(event.key)) return; + (target as HTMLButtonElement).blur(); + } event.preventDefault(); @@ -108,21 +111,23 @@ export const useExpanded = ({ if (event.key === 'ArrowLeft') { if (isExpanded === 'true') { + // The highlighted node is expanded, so we collapse it. setExpanded({ ids: [highlightedItemId], value: false }); - } else { - const parentId = highlightedElement.getAttribute('data-parent-id'); - if (!parentId) return; - const parentElement = getElementByDataItemId(parentId); - if (parentElement && parentElement.getAttribute('data-highlightable') === 'true') { - setExpanded({ ids: [parentId], value: false }); - highlightElement(parentElement); - } else { - setExpanded({ - ids: getDescendantIds(data, parentId, true), - value: false, - }); - } + return; } + + const parentId = highlightedElement.getAttribute('data-parent-id'); + const parentElement = parentId && getElementByDataItemId(parentId); + if (parentElement && parentElement.getAttribute('data-highlightable') === 'true') { + // The highlighted node isn't expanded, so we move the highlight to its parent instead. + highlightElement(parentElement); + return; + } + + // The parent can't be highlighted, which means it must be a root. + // The highlighted node is already collapsed, so we collapse its descendants. + setExpanded({ ids: getDescendantIds(data, highlightedItemId, true), value: false }); + return; } if (event.key === 'ArrowRight') { @@ -146,5 +151,13 @@ export const useExpanded = ({ onSelectStoryId, ]); - return [expanded, setExpanded]; + const updateExpanded = useCallback( + ({ ids, value }) => { + setExpanded({ ids, value }); + if (ids.length === 1) setHighlightedItemId(ids[0]); + }, + [setHighlightedItemId] + ); + + return [expanded, updateExpanded]; }; diff --git a/lib/ui/src/components/sidebar/useHighlighted.ts b/lib/ui/src/components/sidebar/useHighlighted.ts index ae79b35b0d4..c668dbc2795 100644 --- a/lib/ui/src/components/sidebar/useHighlighted.ts +++ b/lib/ui/src/components/sidebar/useHighlighted.ts @@ -35,24 +35,30 @@ export const useHighlighted = ({ const highlightedRef = useRef(initialHighlight); const [highlighted, setHighlighted] = useState(initialHighlight); + const updateHighlighted = useCallback( + (highlight) => { + highlightedRef.current = highlight; + setHighlighted(highlight); + }, + [highlightedRef] + ); + // Sets the highlighted node and scrolls it into view, using DOM elements as reference const highlightElement = useCallback( (element: Element, center = false) => { const itemId = element.getAttribute('data-item-id'); const refId = element.getAttribute('data-ref-id'); if (!itemId || !refId) return; - highlightedRef.current = { itemId, refId }; - setHighlighted(highlightedRef.current); + updateHighlighted({ itemId, refId }); scrollIntoView(element, center); }, - [highlightedRef, setHighlighted] + [updateHighlighted] ); // Highlight and scroll to the selected story whenever the selection or dataset changes useEffect(() => { const highlight = fromSelection(selected); - setHighlighted(highlight); - highlightedRef.current = highlight; + updateHighlighted(highlight); if (highlight) { const { itemId, refId } = highlight; setTimeout(() => { @@ -70,31 +76,31 @@ export const useHighlighted = ({ const navigateTree = throttle((event) => { if (isLoading || !isBrowsing || !event.key || !containerRef || !containerRef.current) return; if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return; + if (!['ArrowUp', 'ArrowDown'].includes(event.key)) return; const target = event.target as Element; if (!isAncestor(menuElement, target) && !isAncestor(target, menuElement)) return; + if (target.hasAttribute('data-action')) (target as HTMLButtonElement).blur(); - if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { - event.preventDefault(); - const highlightable = Array.from( - containerRef.current.querySelectorAll('[data-highlightable=true]') - ); - const currentIndex = highlightable.findIndex( - (el) => - el.getAttribute('data-item-id') === highlightedRef.current?.itemId && - el.getAttribute('data-ref-id') === highlightedRef.current?.refId - ); - const nextIndex = cycle(highlightable, currentIndex, event.key === 'ArrowUp' ? -1 : 1); - const didRunAround = - (event.key === 'ArrowDown' && nextIndex === 0) || - (event.key === 'ArrowUp' && nextIndex === highlightable.length - 1); - highlightElement(highlightable[nextIndex], didRunAround); - } + event.preventDefault(); + const highlightable = Array.from( + containerRef.current.querySelectorAll('[data-highlightable=true]') + ); + const currentIndex = highlightable.findIndex( + (el) => + el.getAttribute('data-item-id') === highlightedRef.current?.itemId && + el.getAttribute('data-ref-id') === highlightedRef.current?.refId + ); + const nextIndex = cycle(highlightable, currentIndex, event.key === 'ArrowUp' ? -1 : 1); + const didRunAround = + (event.key === 'ArrowDown' && nextIndex === 0) || + (event.key === 'ArrowUp' && nextIndex === highlightable.length - 1); + highlightElement(highlightable[nextIndex], didRunAround); }, 30); document.addEventListener('keydown', navigateTree); return () => document.removeEventListener('keydown', navigateTree); }, [isLoading, isBrowsing, highlightedRef, highlightElement]); - return [highlighted, setHighlighted]; + return [highlighted, updateHighlighted]; }; diff --git a/lib/ui/src/components/sidebar/useLastViewed.ts b/lib/ui/src/components/sidebar/useLastViewed.ts index 2cd30bf4540..6e1fa480833 100644 --- a/lib/ui/src/components/sidebar/useLastViewed.ts +++ b/lib/ui/src/components/sidebar/useLastViewed.ts @@ -31,9 +31,10 @@ export const useLastViewed = (selection: Selection) => { ); const clearLastViewed = useCallback(() => { - setLastViewed([]); - store.set('lastViewedStoryIds', []); - }, []); + const update = selection ? [selection] : []; + setLastViewed(update); + store.set('lastViewedStoryIds', update); + }, [selection]); useEffect(() => { if (selection) updateLastViewed(selection);