Merge pull request #13027 from storybookjs/13000-sidebar-rfcs

UI: Fixes for Sidebar and Search
This commit is contained in:
Michael Shilman 2020-11-06 21:28:38 +08:00 committed by GitHub
commit 9d5071bb81
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 179 additions and 51 deletions

View File

@ -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 = () => <Search {...baseProps}>{() => null}</Search>;
export const FilledIn = () => (
<Search {...baseProps} initialQuery="Search query">
{() => null}
{() => <SearchResults {...noResults} />}
</Search>
);
export const LastViewed = () => (
<Search {...baseProps} lastViewed={lastViewed}>
{({ query, results, getMenuProps, getItemProps, highlightedIndex }) => (
{({ query, results, closeMenu, getMenuProps, getItemProps, highlightedIndex }) => (
<SearchResults
query={query}
results={results}
closeMenu={closeMenu}
getMenuProps={getMenuProps}
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}

View File

@ -18,6 +18,7 @@ import {
isSearchResult,
isExpandType,
isClearType,
isCloseType,
} from './types';
import { searchItem } from './utils';
@ -268,6 +269,11 @@ export const Search: FunctionComponent<{
// Nothing to see anymore, so return to the tree view
return { isOpen: false };
}
if (isCloseType(changes.selectedItem)) {
inputRef.current.blur();
// Return to the tree view
return { isOpen: false };
}
return changes;
}
@ -294,6 +300,7 @@ export const Search: FunctionComponent<{
{({
isOpen,
openMenu,
closeMenu,
inputValue,
clearSelection,
getInputProps,
@ -322,6 +329,7 @@ export const Search: FunctionComponent<{
}
return acc;
}, []);
results.push({ closeMenu });
if (results.length > 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,

View File

@ -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 = () => <SearchResults {...searching} />;
export const NoResults = () => <SearchResults {...noResults} />;
export const LastViewed = () => <SearchResults {...lastViewed} />;

View File

@ -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<DownshiftItem>['getMenuProps'];
getItemProps: ControllerStateAndHelpers<DownshiftItem>['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 (
<ResultsList {...getMenuProps()}>
{results.length > 0 && !query && (
@ -133,7 +184,29 @@ export const SearchResults: FunctionComponent<{
<RootNode>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 (
<ActionRow
{...result}
{...getItemProps({ key: index, index, item: result })}
isHighlighted={highlightedIndex === index}
>
<ActionIcon icon="arrowleft" />
<ActionLabel>Back</ActionLabel>
<ActionKey>Esc</ActionKey>
</ActionRow>
);
}
if (isClearType(result)) {
return (
<ActionRow

View File

@ -119,7 +119,15 @@ export const Sidebar: FunctionComponent<SidebarProps> = React.memo(
enableShortcuts={enableShortcuts}
{...lastViewed}
>
{({ query, results, isBrowsing, getMenuProps, getItemProps, highlightedIndex }) => (
{({
query,
results,
isBrowsing,
closeMenu,
getMenuProps,
getItemProps,
highlightedIndex,
}) => (
<Swap condition={isBrowsing}>
<Explorer
dataset={dataset}
@ -130,6 +138,7 @@ export const Sidebar: FunctionComponent<SidebarProps> = React.memo(
<SearchResults
query={query}
results={results}
closeMenu={closeMenu}
getMenuProps={getMenuProps}
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}

View File

@ -30,6 +30,9 @@ export interface Match {
arrayIndex: number;
}
export function isCloseType(x: any): x is CloseType {
return !!(x && x.closeMenu);
}
export function isClearType(x: any): x is ClearType {
return !!(x && x.clearLastViewed);
}
@ -40,6 +43,9 @@ export function isSearchResult(x: any): x is SearchResult {
return !!(x && x.item);
}
export interface CloseType {
closeMenu: () => void;
}
export interface ClearType {
clearLastViewed: () => void;
}
@ -55,12 +61,13 @@ export type SearchItem = Item & { refId: string; path: string[] };
export type SearchResult = Fuse.FuseResultWithMatches<SearchItem> &
Fuse.FuseResultWithScore<SearchItem>;
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<DownshiftItem>['getMenuProps'];
getItemProps: ControllerStateAndHelpers<DownshiftItem>['getItemProps'];
highlightedIndex: number | null;

View File

@ -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];
};

View File

@ -35,24 +35,30 @@ export const useHighlighted = ({
const highlightedRef = useRef<Highlight>(initialHighlight);
const [highlighted, setHighlighted] = useState<Highlight>(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];
};

View File

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