mirror of
https://github.com/storybookjs/storybook.git
synced 2025-03-17 05:02:23 +08:00
Merge pull request #13107 from storybookjs/tree-selection-performance
UI: Reduce rerenders when changing the selected story
This commit is contained in:
commit
6775282f32
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -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 };
|
||||
|
||||
|
@ -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[],
|
||||
|
@ -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>
|
||||
),
|
||||
</>
|
||||
)),
|
||||
};
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -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,
|
||||
|
@ -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]),
|
||||
};
|
||||
};
|
||||
|
@ -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)(
|
||||
{
|
||||
|
Loading…
x
Reference in New Issue
Block a user