mirror of
https://github.com/storybookjs/storybook.git
synced 2025-03-18 05:02:24 +08:00
Merge pull request #13027 from storybookjs/13000-sidebar-rfcs
UI: Fixes for Sidebar and Search
This commit is contained in:
commit
9d5071bb81
@ -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}
|
||||
|
@ -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,
|
||||
|
@ -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} />;
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
|
@ -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;
|
||||
|
@ -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];
|
||||
};
|
||||
|
@ -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];
|
||||
};
|
||||
|
@ -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);
|
||||
|
Loading…
x
Reference in New Issue
Block a user