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);