Merge pull request #13107 from storybookjs/tree-selection-performance

UI: Reduce rerenders when changing the selected story
This commit is contained in:
Michael Shilman 2020-11-17 00:15:26 +08:00 committed by GitHub
commit 6775282f32
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 389 additions and 358 deletions

View File

@ -25,7 +25,7 @@ export interface SubAPI {
restoreAllDefaultShortcuts(): Promise<Shortcuts>;
restoreDefaultShortcut(action: Action): Promise<KeyCollection>;
handleKeydownEvent(event: Event): void;
handleShortcutFeature(feature: Action, event?: KeyboardEvent): void;
handleShortcutFeature(feature: Action): void;
}
export type KeyCollection = string[];
@ -122,11 +122,14 @@ export const init: ModuleFn = ({ store, fullAPI }) => {
shortcutMatchesShortcut(shortcut, shortcuts[feature])
);
if (matchedFeature) {
api.handleShortcutFeature(matchedFeature, event);
// Event.prototype.preventDefault is missing when received from the MessageChannel.
if (event?.preventDefault) event.preventDefault();
api.handleShortcutFeature(matchedFeature);
}
},
handleShortcutFeature(feature, event) {
// warning: event might not have a full prototype chain because it may originate from the channel
handleShortcutFeature(feature) {
const {
layout: { isFullscreen, showNav, showPanel },
ui: { enableShortcuts },
@ -205,13 +208,11 @@ export const init: ModuleFn = ({ store, fullAPI }) => {
}
case 'nextComponent': {
if (event) event.preventDefault();
fullAPI.jumpToComponent(1);
break;
}
case 'prevComponent': {
if (event) event.preventDefault();
fullAPI.jumpToComponent(-1);
break;
}
@ -268,12 +269,10 @@ export const init: ModuleFn = ({ store, fullAPI }) => {
break;
}
case 'collapseAll': {
if (event) event.preventDefault();
fullAPI.collapseAll();
break;
}
case 'expandAll': {
if (event) event.preventDefault();
fullAPI.expandAll();
break;
}

View File

@ -3,6 +3,7 @@ import { queryFromLocation } from '@storybook/router';
import { toId, sanitize } from '@storybook/csf';
import { NAVIGATE_URL } from '@storybook/core-events';
import deepEqual from 'fast-deep-equal';
import { ModuleArgs, ModuleFn } from '../index';
import { PanelPositions } from './layout';
@ -28,6 +29,7 @@ export interface SubState {
// - nav: 0/1 -- show or hide the story list
//
// We also support legacy URLs from storybook <5
let prevParams: ReturnType<typeof queryFromLocation>;
const initialUrlSupport = ({
state: { location, path, viewMode, storyId: storyIdFromUrl },
}: ModuleArgs) => {
@ -46,7 +48,7 @@ const initialUrlSupport = ({
selectedKind,
selectedStory,
path: queryPath,
...customQueryParams
...otherParams
} = query;
if (full === '1') {
@ -89,6 +91,10 @@ const initialUrlSupport = ({
}
}
// Avoid returning a new object each time if no params actually changed.
const customQueryParams = deepEqual(prevParams, otherParams) ? prevParams : otherParams;
prevParams = customQueryParams;
return { viewMode, layout: addition, selectedPanel, location, path, customQueryParams, storyId };
};
@ -133,17 +139,17 @@ export const init: ModuleFn = ({ store, navigate, state, provider, fullAPI, ...r
setQueryParams(input) {
const { customQueryParams } = store.getState();
const queryParams: QueryParams = {};
store.setState({
customQueryParams: {
...customQueryParams,
...Object.entries(input).reduce((acc, [key, value]) => {
if (value !== null) {
acc[key] = value;
}
return acc;
}, queryParams),
},
});
const update = {
...customQueryParams,
...Object.entries(input).reduce((acc, [key, value]) => {
if (value !== null) {
acc[key] = value;
}
return acc;
}, queryParams),
};
const equal = deepEqual(customQueryParams, update);
if (!equal) store.setState({ customQueryParams: update });
},
navigateUrl(url: string, options: NavigateOptions<{}>) {
navigateRouter(url, options);

View File

@ -80,6 +80,31 @@ const App = React.memo<AppProps>(
{content}
</View>
);
},
// This is the default shallowEqual implementation, but with custom behavior for the `size` prop.
(prevProps: any, nextProps: any) => {
if (Object.is(prevProps, nextProps)) return true;
if (typeof prevProps !== 'object' || prevProps === null) return false;
if (typeof nextProps !== 'object' || nextProps === null) return false;
const keysA = Object.keys(prevProps);
const keysB = Object.keys(nextProps);
if (keysA.length !== keysB.length) return false;
// eslint-disable-next-line no-restricted-syntax
for (const key of keysA) {
if (key === 'size') {
// SizeMe injects a new `size` object every time, even if the width/height doesn't change,
// so we chech that one manually.
if (prevProps[key].width !== nextProps[key].width) return false;
if (prevProps[key].height !== nextProps[key].height) return false;
} else {
if (!Object.prototype.hasOwnProperty.call(nextProps, key)) return false;
if (!Object.is(prevProps[key], nextProps[key])) return false;
}
}
return true;
}
);

View File

@ -1,4 +1,4 @@
import React, { Fragment, FunctionComponent, useMemo, useEffect, useRef } from 'react';
import React, { Fragment, useMemo, useEffect, useRef } from 'react';
import { Helmet } from 'react-helmet-async';
import merge from '@storybook/api/dist/lib/merge';
@ -131,7 +131,7 @@ const useTabs = (
}, [story, canvas, ...tabsFromConfig]);
};
const Preview: FunctionComponent<PreviewProps> = (props) => {
const Preview = React.memo<PreviewProps>((props) => {
const {
api,
id: previewId,
@ -195,7 +195,7 @@ const Preview: FunctionComponent<PreviewProps> = (props) => {
</ZoomProvider>
</Fragment>
);
};
});
export { Preview };

View File

@ -1,4 +1,4 @@
import React, { Fragment, useMemo, FunctionComponent, ReactElement } from 'react';
import React, { Fragment, useMemo, FunctionComponent } from 'react';
import { styled } from '@storybook/theming';
@ -111,32 +111,22 @@ const useTools = (
location: PreviewProps['location'],
path: PreviewProps['path']
) => {
const toolsFromConfig = useMemo(() => {
return getTools(getElements);
}, [getElements]);
const toolsFromConfig = useMemo(() => getTools(getElements), [getElements]);
const toolsExtraFromConfig = useMemo(() => getToolsExtra(getElements), [getElements]);
const toolsExtraFromConfig = useMemo(() => {
return getToolsExtra(getElements);
}, [getElements]);
const tools = useMemo(() => {
return [...defaultTools, ...toolsFromConfig];
}, [defaultTools, toolsFromConfig]);
const toolsExtra = useMemo(() => {
return [...defaultToolsExtra, ...toolsExtraFromConfig];
}, [defaultToolsExtra, toolsExtraFromConfig]);
const tools = useMemo(() => [...defaultTools, ...toolsFromConfig], [
defaultTools,
toolsFromConfig,
]);
const toolsExtra = useMemo(() => [...defaultToolsExtra, ...toolsExtraFromConfig], [
defaultToolsExtra,
toolsExtraFromConfig,
]);
return useMemo(() => {
if (story && story.parameters) {
return filterTools(tools, toolsExtra, tabs, {
viewMode,
story,
location,
path,
});
}
return { left: tools, right: toolsExtra };
return story && story.parameters
? filterTools(tools, toolsExtra, tabs, { viewMode, story, location, path })
: { left: tools, right: toolsExtra };
}, [viewMode, story, location, path, tools, toolsExtra, tabs]);
};
@ -146,42 +136,34 @@ export interface ToolData {
api: API;
story: Story | Group;
}
export const ToolRes: FunctionComponent<ToolData & RenderData> = ({
api,
story,
tabs,
isShown,
location,
path,
viewMode,
}) => {
const { left, right } = useTools(api.getElements, tabs, viewMode, story, location, path);
return left || right ? (
<Toolbar key="toolbar" shown={isShown} border>
<Tools key="left" list={left} />
<Tools key="right" list={right} />
</Toolbar>
) : null;
};
export const ToolRes: FunctionComponent<ToolData & RenderData> = React.memo<ToolData & RenderData>(
({ api, story, tabs, isShown, location, path, viewMode }) => {
const { left, right } = useTools(api.getElements, tabs, viewMode, story, location, path);
export const ToolbarComp: FunctionComponent<ToolData> = (p) => (
<Location>{(l) => <ToolRes {...l} {...p} />}</Location>
return left || right ? (
<Toolbar key="toolbar" shown={isShown} border>
<Tools key="left" list={left} />
<Tools key="right" list={right} />
</Toolbar>
) : null;
}
);
export const Tools: FunctionComponent<{
list: Addon[];
}> = ({ list }) =>
list.filter(Boolean).reduce((acc, { render: Render, id, ...t }, index) => {
// @ts-ignore
const key = id || t.key || `f-${index}`;
return (
<Fragment key={key}>
{acc}
<Render />
</Fragment>
);
}, null as ReactElement);
export const ToolbarComp = React.memo<ToolData>((props) => (
<Location>
{({ location, path, viewMode }) => <ToolRes {...props} {...{ location, path, viewMode }} />}
</Location>
));
export const Tools = React.memo<{ list: Addon[] }>(({ list }) => (
<>
{list.filter(Boolean).map(({ render: Render, id, ...t }, index) => (
// @ts-ignore
<Render key={id || t.key || `f-${index}`} />
))}
</>
));
export function filterTools(
tools: Addon[],

View File

@ -1,4 +1,4 @@
import React, { Fragment, Component, FunctionComponent, SyntheticEvent } from 'react';
import React, { Component, SyntheticEvent, useCallback, MouseEventHandler } from 'react';
import { Icons, IconButton, Separator } from '@storybook/components';
import { Addon } from '@storybook/addons';
@ -28,46 +28,58 @@ class ZoomProvider extends Component<{ shouldScale: boolean }, { value: number }
const { Consumer: ZoomConsumer } = Context;
const cancel = (e: SyntheticEvent) => {
e.preventDefault();
return false;
};
const Zoom: FunctionComponent<{ set: Function; reset: Function }> = ({ set, reset }) => (
<Fragment>
<IconButton key="zoomin" onClick={(e: SyntheticEvent) => cancel(e) || set(0.8)} title="Zoom in">
const Zoom = React.memo<{
zoomIn: MouseEventHandler;
zoomOut: MouseEventHandler;
reset: MouseEventHandler;
}>(({ zoomIn, zoomOut, reset }) => (
<>
<IconButton key="zoomin" onClick={zoomIn} title="Zoom in">
<Icons icon="zoom" />
</IconButton>
<IconButton
key="zoomout"
onClick={(e: SyntheticEvent) => cancel(e) || set(1.25)}
title="Zoom out"
>
<IconButton key="zoomout" onClick={zoomOut} title="Zoom out">
<Icons icon="zoomout" />
</IconButton>
<IconButton
key="zoomreset"
onClick={(e: SyntheticEvent) => cancel(e) || reset()}
title="Reset zoom"
>
<IconButton key="zoomreset" onClick={reset} title="Reset zoom">
<Icons icon="zoomreset" />
</IconButton>
</Fragment>
);
</>
));
export { Zoom, ZoomConsumer, ZoomProvider };
const ZoomWrapper = React.memo<{ set: Function; value: number }>(({ set, value }) => {
const zoomIn = useCallback(
(e: SyntheticEvent) => {
e.preventDefault();
set(0.8 * value);
},
[set, value]
);
const zoomOut = useCallback(
(e: SyntheticEvent) => {
e.preventDefault();
set(1.25 * value);
},
[set, value]
);
const reset = useCallback(
(e) => {
e.preventDefault();
set(initialZoom);
},
[set, initialZoom]
);
return <Zoom key="zoom" {...{ zoomIn, zoomOut, reset }} />;
});
export const zoomTool: Addon = {
title: 'zoom',
match: ({ viewMode }) => viewMode === 'story',
render: () => (
<Fragment>
<ZoomConsumer>
{({ set, value }) => (
<Zoom key="zoom" set={(v: number) => set(value * v)} reset={() => set(initialZoom)} />
)}
</ZoomConsumer>
render: React.memo(() => (
<>
<ZoomConsumer>{({ set, value }) => <ZoomWrapper {...{ set, value }} />}</ZoomConsumer>
<Separator />
</Fragment>
),
</>
)),
};

View File

@ -11,9 +11,10 @@ import { Selection } from './types';
const refId = DEFAULT_REF_ID;
const data = { [refId]: { id: refId, url: '/', stories } };
const dataset = { hash: data, entries: Object.entries(data) };
const lastViewed = Object.values(stories)
.filter((item, index) => item.isComponent && index % 20 === 0)
.map((component) => ({ storyId: component.id, refId }));
const getLastViewed = () =>
Object.values(stories)
.filter((item, index) => item.isComponent && index % 20 === 0)
.map((component) => ({ storyId: component.id, refId }));
export default {
component: Search,
@ -25,7 +26,7 @@ export default {
const baseProps = {
dataset,
clearLastViewed: action('clear'),
lastViewed: [] as Selection[],
getLastViewed: () => [] as Selection[],
};
export const Simple = () => <Search {...baseProps}>{() => null}</Search>;
@ -37,7 +38,7 @@ export const FilledIn = () => (
);
export const LastViewed = () => (
<Search {...baseProps} lastViewed={lastViewed}>
<Search {...baseProps} getLastViewed={getLastViewed}>
{({ query, results, closeMenu, getMenuProps, getItemProps, highlightedIndex }) => (
<SearchResults
query={query}

View File

@ -5,7 +5,7 @@ import { styled } from '@storybook/theming';
import { Icons } from '@storybook/components';
import Downshift, { DownshiftState, StateChangeOptions } from 'downshift';
import Fuse, { FuseOptions } from 'fuse.js';
import React, { useEffect, useMemo, useRef, useState, useCallback, FunctionComponent } from 'react';
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { DEFAULT_REF_ID } from './data';
import {
@ -140,238 +140,241 @@ const ClearIcon = styled(Icons)(({ theme }) => ({
const FocusContainer = styled.div({ outline: 0 });
export const Search: FunctionComponent<{
export const Search = React.memo<{
children: SearchChildrenFn;
dataset: CombinedDataset;
isLoading?: boolean;
enableShortcuts?: boolean;
lastViewed: Selection[];
getLastViewed: () => Selection[];
clearLastViewed: () => void;
initialQuery?: string;
}> = ({
children,
dataset,
isLoading = false,
enableShortcuts = true,
lastViewed,
clearLastViewed,
initialQuery = '',
}) => {
const api = useStorybookApi();
const inputRef = useRef<HTMLInputElement>(null);
const [inputPlaceholder, setPlaceholder] = useState('Find components');
const [allComponents, showAllComponents] = useState(false);
}>(
({
children,
dataset,
isLoading = false,
enableShortcuts = true,
getLastViewed,
clearLastViewed,
initialQuery = '',
}) => {
const api = useStorybookApi();
const inputRef = useRef<HTMLInputElement>(null);
const [inputPlaceholder, setPlaceholder] = useState('Find components');
const [allComponents, showAllComponents] = useState(false);
const selectStory = useCallback(
(id: string, refId: string) => {
if (api) api.selectStory(id, undefined, { ref: refId !== DEFAULT_REF_ID && refId });
inputRef.current.blur();
showAllComponents(false);
},
[api, inputRef, showAllComponents, DEFAULT_REF_ID]
);
const selectStory = useCallback(
(id: string, refId: string) => {
if (api) api.selectStory(id, undefined, { ref: refId !== DEFAULT_REF_ID && refId });
inputRef.current.blur();
showAllComponents(false);
},
[api, inputRef, showAllComponents, DEFAULT_REF_ID]
);
useEffect(() => {
const focusSearch = (event: KeyboardEvent) => {
if (!enableShortcuts || isLoading || !inputRef.current) return;
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return;
if (event.key === '/' && inputRef.current !== document.activeElement) {
inputRef.current.focus();
event.preventDefault();
}
};
// Keyup prevents slashes from ending up in the input field when held down
document.addEventListener('keyup', focusSearch);
return () => document.removeEventListener('keyup', focusSearch);
}, [inputRef, isLoading, enableShortcuts]);
const list: SearchItem[] = useMemo(() => {
return dataset.entries.reduce((acc: SearchItem[], [refId, { stories }]) => {
if (stories) {
acc.push(...Object.values(stories).map((item) => searchItem(item, dataset.hash[refId])));
}
return acc;
}, []);
}, [dataset]);
const fuse = useMemo(() => new Fuse(list, options), [list]);
const getResults = useCallback(
(input: string) => {
if (!input) return [];
let results: DownshiftItem[] = [];
const componentResults = (fuse.search(input) as SearchResult[]).filter(
({ item }) => item.isComponent
);
if (componentResults.length) {
results = componentResults.slice(0, allComponents ? 1000 : DEFAULT_MAX_SEARCH_RESULTS);
if (componentResults.length > DEFAULT_MAX_SEARCH_RESULTS && !allComponents) {
results.push({
showAll: () => showAllComponents(true),
totalCount: componentResults.length,
moreCount: componentResults.length - DEFAULT_MAX_SEARCH_RESULTS,
});
useEffect(() => {
const focusSearch = (event: KeyboardEvent) => {
if (!enableShortcuts || isLoading || !inputRef.current) return;
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return;
if (event.key === '/' && inputRef.current !== document.activeElement) {
inputRef.current.focus();
event.preventDefault();
}
}
};
return results;
},
[allComponents, fuse]
);
// Keyup prevents slashes from ending up in the input field when held down
document.addEventListener('keyup', focusSearch);
return () => document.removeEventListener('keyup', focusSearch);
}, [inputRef, isLoading, enableShortcuts]);
const stateReducer = useCallback(
(state: DownshiftState<DownshiftItem>, changes: StateChangeOptions<DownshiftItem>) => {
switch (changes.type) {
case Downshift.stateChangeTypes.blurInput: {
return {
...changes,
// Prevent clearing the input on blur
inputValue: state.inputValue,
// Return to the tree view after selecting an item
isOpen: state.inputValue && !state.selectedItem,
selectedItem: null,
};
const list: SearchItem[] = useMemo(() => {
return dataset.entries.reduce((acc: SearchItem[], [refId, { stories }]) => {
if (stories) {
acc.push(...Object.values(stories).map((item) => searchItem(item, dataset.hash[refId])));
}
return acc;
}, []);
}, [dataset]);
case Downshift.stateChangeTypes.mouseUp: {
// Prevent clearing the input on refocus
return {};
}
const fuse = useMemo(() => new Fuse(list, options), [list]);
case Downshift.stateChangeTypes.keyDownEscape: {
if (state.inputValue) {
// Clear the inputValue, but don't return to the tree view
return { ...changes, inputValue: '', isOpen: true, selectedItem: null };
const getResults = useCallback(
(input: string) => {
if (!input) return [];
let results: DownshiftItem[] = [];
const componentResults = (fuse.search(input) as SearchResult[]).filter(
({ item }) => item.isComponent
);
if (componentResults.length) {
results = componentResults.slice(0, allComponents ? 1000 : DEFAULT_MAX_SEARCH_RESULTS);
if (componentResults.length > DEFAULT_MAX_SEARCH_RESULTS && !allComponents) {
results.push({
showAll: () => showAllComponents(true),
totalCount: componentResults.length,
moreCount: componentResults.length - DEFAULT_MAX_SEARCH_RESULTS,
});
}
// When pressing escape a second time, blur the input and return to the tree view
inputRef.current.blur();
return { ...changes, isOpen: false, selectedItem: null };
}
case Downshift.stateChangeTypes.clickItem:
case Downshift.stateChangeTypes.keyDownEnter: {
if (isSearchResult(changes.selectedItem)) {
const { id, refId } = changes.selectedItem.item;
selectStory(id, refId);
// Return to the tree view, but keep the input value
return { ...changes, inputValue: state.inputValue, isOpen: false };
return results;
},
[allComponents, fuse]
);
const stateReducer = useCallback(
(state: DownshiftState<DownshiftItem>, changes: StateChangeOptions<DownshiftItem>) => {
switch (changes.type) {
case Downshift.stateChangeTypes.blurInput: {
return {
...changes,
// Prevent clearing the input on blur
inputValue: state.inputValue,
// Return to the tree view after selecting an item
isOpen: state.inputValue && !state.selectedItem,
selectedItem: null,
};
}
if (isExpandType(changes.selectedItem)) {
changes.selectedItem.showAll();
// Downshift should completely ignore this
case Downshift.stateChangeTypes.mouseUp: {
// Prevent clearing the input on refocus
return {};
}
if (isClearType(changes.selectedItem)) {
changes.selectedItem.clearLastViewed();
inputRef.current.blur();
// 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;
}
case Downshift.stateChangeTypes.changeInput: {
// Reset the "show more" state whenever the input changes
showAllComponents(false);
return changes;
}
default:
return changes;
}
},
[inputRef, selectStory, showAllComponents]
);
return (
<Downshift<DownshiftItem>
initialInputValue={initialQuery}
stateReducer={stateReducer}
// @ts-ignore
itemToString={(result) => result?.item?.name || ''}
>
{({
isOpen,
openMenu,
closeMenu,
inputValue,
clearSelection,
getInputProps,
getItemProps,
getLabelProps,
getMenuProps,
getRootProps,
highlightedIndex,
}) => {
const input = inputValue ? inputValue.trim() : '';
let results: DownshiftItem[] = input ? getResults(input) : [];
if (!input && lastViewed && lastViewed.length) {
results = lastViewed.reduce((acc, { storyId, refId }) => {
const data = dataset.hash[refId];
if (data && data.stories && data.stories[storyId]) {
const story = data.stories[storyId];
const item =
story.isLeaf && !story.isComponent && !story.isRoot
? data.stories[story.parent]
: story;
// prevent duplicates
if (!acc.some((res) => res.item.refId === refId && res.item.id === item.id)) {
acc.push({ item: searchItem(item, dataset.hash[refId]), matches: [], score: 0 });
}
case Downshift.stateChangeTypes.keyDownEscape: {
if (state.inputValue) {
// Clear the inputValue, but don't return to the tree view
return { ...changes, inputValue: '', isOpen: true, selectedItem: null };
}
return acc;
}, []);
results.push({ closeMenu });
if (results.length > 0) {
results.push({ clearLastViewed });
// When pressing escape a second time, blur the input and return to the tree view
inputRef.current.blur();
return { ...changes, isOpen: false, selectedItem: null };
}
case Downshift.stateChangeTypes.clickItem:
case Downshift.stateChangeTypes.keyDownEnter: {
if (isSearchResult(changes.selectedItem)) {
const { id, refId } = changes.selectedItem.item;
selectStory(id, refId);
// Return to the tree view, but keep the input value
return { ...changes, inputValue: state.inputValue, isOpen: false };
}
if (isExpandType(changes.selectedItem)) {
changes.selectedItem.showAll();
// Downshift should completely ignore this
return {};
}
if (isClearType(changes.selectedItem)) {
changes.selectedItem.clearLastViewed();
inputRef.current.blur();
// 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;
}
case Downshift.stateChangeTypes.changeInput: {
// Reset the "show more" state whenever the input changes
showAllComponents(false);
return changes;
}
default:
return changes;
}
},
[inputRef, selectStory, showAllComponents]
);
const inputProps = getInputProps({
id: 'storybook-explorer-searchfield',
ref: inputRef,
required: true,
type: 'search',
placeholder: inputPlaceholder,
onFocus: () => {
openMenu();
setPlaceholder('Type to find...');
},
onBlur: () => setPlaceholder('Find components'),
});
return (
<Downshift<DownshiftItem>
initialInputValue={initialQuery}
stateReducer={stateReducer}
// @ts-ignore
itemToString={(result) => result?.item?.name || ''}
>
{({
isOpen,
openMenu,
closeMenu,
inputValue,
clearSelection,
getInputProps,
getItemProps,
getLabelProps,
getMenuProps,
getRootProps,
highlightedIndex,
}) => {
const input = inputValue ? inputValue.trim() : '';
let results: DownshiftItem[] = input ? getResults(input) : [];
return (
<>
<ScreenReaderLabel {...getLabelProps()}>Search for components</ScreenReaderLabel>
<SearchField {...getRootProps({ refKey: '' }, { suppressRefError: true })}>
<SearchIcon icon="search" />
<Input {...inputProps} />
{enableShortcuts && <FocusKey>/</FocusKey>}
<ClearIcon icon="cross" onClick={() => clearSelection()} />
</SearchField>
<FocusContainer tabIndex={0} id="storybook-explorer-menu">
{children({
query: input,
results,
isBrowsing: !isOpen && document.activeElement !== inputRef.current,
closeMenu,
getMenuProps,
getItemProps,
highlightedIndex,
})}
</FocusContainer>
</>
);
}}
</Downshift>
);
};
const lastViewed = !input && getLastViewed();
if (lastViewed && lastViewed.length) {
results = lastViewed.reduce((acc, { storyId, refId }) => {
const data = dataset.hash[refId];
if (data && data.stories && data.stories[storyId]) {
const story = data.stories[storyId];
const item =
story.isLeaf && !story.isComponent && !story.isRoot
? data.stories[story.parent]
: story;
// prevent duplicates
if (!acc.some((res) => res.item.refId === refId && res.item.id === item.id)) {
acc.push({ item: searchItem(item, dataset.hash[refId]), matches: [], score: 0 });
}
}
return acc;
}, []);
results.push({ closeMenu });
if (results.length > 0) {
results.push({ clearLastViewed });
}
}
const inputProps = getInputProps({
id: 'storybook-explorer-searchfield',
ref: inputRef,
required: true,
type: 'search',
placeholder: inputPlaceholder,
onFocus: () => {
openMenu();
setPlaceholder('Type to find...');
},
onBlur: () => setPlaceholder('Find components'),
});
return (
<>
<ScreenReaderLabel {...getLabelProps()}>Search for components</ScreenReaderLabel>
<SearchField {...getRootProps({ refKey: '' }, { suppressRefError: true })}>
<SearchIcon icon="search" />
<Input {...inputProps} />
{enableShortcuts && <FocusKey>/</FocusKey>}
<ClearIcon icon="cross" onClick={() => clearSelection()} />
</SearchField>
<FocusContainer tabIndex={0} id="storybook-explorer-menu">
{children({
query: input,
results,
isBrowsing: !isOpen && document.activeElement !== inputRef.current,
closeMenu,
getMenuProps,
getItemProps,
highlightedIndex,
})}
</FocusContainer>
</>
);
}}
</Downshift>
);
}
);

View File

@ -105,7 +105,7 @@ export const Sidebar: FunctionComponent<SidebarProps> = React.memo(
);
const dataset = useCombination(stories, storiesConfigured, storiesFailed, refs);
const isLoading = !dataset.hash[DEFAULT_REF_ID].ready;
const lastViewed = useLastViewed(selected);
const lastViewedProps = useLastViewed(selected);
return (
<Container className="container sidebar-container">
@ -117,7 +117,7 @@ export const Sidebar: FunctionComponent<SidebarProps> = React.memo(
dataset={dataset}
isLoading={isLoading}
enableShortcuts={enableShortcuts}
{...lastViewed}
{...lastViewedProps}
>
{({
query,

View File

@ -1,44 +1,47 @@
import { useCallback, useEffect, useState } from 'react';
import debounce from 'lodash/debounce';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import store from 'store2';
import { Selection, StoryRef } from './types';
const retrieveLastViewedStoryIds = (): StoryRef[] => {
const items = store.get('lastViewedStoryIds');
if (!items || !Array.isArray(items)) return [];
if (!items.some((item) => typeof item === 'object' && item.storyId && item.refId)) return [];
return items;
};
const save = debounce((value) => store.set('lastViewedStoryIds', value), 1000);
export const useLastViewed = (selection: Selection) => {
const [lastViewed, setLastViewed] = useState(retrieveLastViewedStoryIds);
const initialLastViewedStoryIds = useMemo((): StoryRef[] => {
const items = store.get('lastViewedStoryIds');
if (!items || !Array.isArray(items)) return [];
if (!items.some((item) => typeof item === 'object' && item.storyId && item.refId)) return [];
return items;
}, [store]);
const lastViewedRef = useRef(initialLastViewedStoryIds);
const updateLastViewed = useCallback(
(story: StoryRef) =>
setLastViewed((state: StoryRef[]) => {
const index = state.findIndex(
({ storyId, refId }) => storyId === story.storyId && refId === story.refId
);
if (index === 0) return state;
const update =
index === -1
? [story, ...state]
: [story, ...state.slice(0, index), ...state.slice(index + 1)];
store.set('lastViewedStoryIds', update);
return update;
}),
[]
(story: StoryRef) => {
const items = lastViewedRef.current;
const index = items.findIndex(
({ storyId, refId }) => storyId === story.storyId && refId === story.refId
);
if (index === 0) return;
if (index === -1) {
lastViewedRef.current = [story, ...items];
} else {
lastViewedRef.current = [story, ...items.slice(0, index), ...items.slice(index + 1)];
}
save(lastViewedRef.current);
},
[lastViewedRef]
);
const clearLastViewed = useCallback(() => {
const update = selection ? [selection] : [];
setLastViewed(update);
store.set('lastViewedStoryIds', update);
}, [selection]);
useEffect(() => {
if (selection) updateLastViewed(selection);
}, [selection]);
return { lastViewed, clearLastViewed };
return {
getLastViewed: useCallback(() => lastViewedRef.current, [lastViewedRef]),
clearLastViewed: useCallback(() => {
lastViewedRef.current = lastViewedRef.current.slice(0, 1);
save(lastViewedRef.current);
}, [lastViewedRef]),
};
};

View File

@ -8,13 +8,13 @@ import { AboutPage } from './about_page';
import { ReleaseNotesPage } from './release_notes_page';
import { ShortcutsPage } from './shortcuts_page';
const TabBarButton: FunctionComponent<{
const TabBarButton = React.memo<{
changeTab: (tab: string) => void;
id: string;
title: string;
}> = ({ changeTab, id, title }) => (
}>(({ changeTab, id, title }) => (
<Location>
{({ navigate, path }) => {
{({ path }) => {
const active = path.includes(`settings/${id}`);
return (
<TabButton
@ -31,7 +31,7 @@ const TabBarButton: FunctionComponent<{
);
}}
</Location>
);
));
const Content = styled(ScrollArea)(
{