Refactor to custom hooks. Fix docs mode. Cleanup some old stuff.

This commit is contained in:
Gert Hengeveld 2020-09-30 14:39:26 +02:00
parent eaff61c536
commit 3b3f05d25f
15 changed files with 476 additions and 497 deletions

View File

@ -1,31 +1,8 @@
/* eslint-env browser */
import React, { FunctionComponent, useEffect, useRef, useState, useCallback } from 'react';
import throttle from 'lodash/throttle';
import React, { FunctionComponent, useRef } from 'react';
import { Ref } from './Refs';
import { CombinedDataset, Selection } from './types';
function cycleArray<T>(array: T[], index: number, delta: number): T {
let next = index + (delta % array.length);
if (next < 0) next = array.length + next;
if (next >= array.length) next -= array.length;
return array[next];
}
const scrollIntoView = (
element: Element,
options: ScrollIntoViewOptions = { block: 'nearest' }
) => {
if (!element) return;
setTimeout(() => {
const { top, bottom } = element.getBoundingClientRect();
const isInView =
top >= 0 && bottom <= (window.innerHeight || document.documentElement.clientHeight);
if (!isInView) element.scrollIntoView(options);
}, 0);
};
import { useHighlighted } from './useHighlighted';
interface ExplorerProps {
dataset: CombinedDataset;
@ -34,56 +11,19 @@ interface ExplorerProps {
}
const Explorer: FunctionComponent<ExplorerProps> = React.memo(
({ dataset, selected, isBrowsing }) => {
const rootRef = useRef<HTMLDivElement>(null);
const highlightedRef = useRef<Selection>(selected);
({ isBrowsing, dataset, selected }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [highlighted, setHighlighted] = useState<Selection>(selected);
const highlightElement = useCallback(
(element: Element) => {
const selection = {
storyId: element.getAttribute('data-id'),
refId: element.getAttribute('data-ref'),
};
scrollIntoView(element);
setHighlighted(selection);
highlightedRef.current = selection;
},
[setHighlighted]
);
useEffect(() => {
const { storyId, refId } = selected;
const element = rootRef.current.querySelector(`[data-id="${storyId}"][data-ref="${refId}"]`);
scrollIntoView(element, { block: 'center' });
setHighlighted(selected);
highlightedRef.current = selected;
}, [dataset, highlightedRef, selected]); // dataset is needed here
useEffect(() => {
const navigateTree = throttle((event) => {
if (!isBrowsing || !event.key || !rootRef || !rootRef.current) return;
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return;
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault();
const focusable = Array.from(
rootRef.current.querySelectorAll('[data-highlightable=true]')
);
const focusedIndex = focusable.findIndex(
(el) =>
el.getAttribute('data-id') === highlightedRef.current.storyId &&
el.getAttribute('data-ref') === highlightedRef.current.refId
);
highlightElement(cycleArray(focusable, focusedIndex, event.key === 'ArrowUp' ? -1 : 1));
}
}, 16);
document.addEventListener('keydown', navigateTree);
return () => document.removeEventListener('keydown', navigateTree);
}, [isBrowsing, highlightedRef, highlightElement]);
// Track highlighted nodes, keep it in sync with props and enable keyboard navigation
const [highlighted, setHighlighted] = useHighlighted({
containerRef,
isBrowsing, // only enable keyboard navigation when tree is visible
dataset,
selected,
});
return (
<div ref={rootRef}>
<div ref={containerRef}>
{dataset.entries.map(([refId, ref]) => (
<Ref
{...ref}

View File

@ -1,12 +1,13 @@
import React from 'react';
import { isRoot, Provider } from '@storybook/api';
import { isRoot } from '@storybook/api';
import { stories } from './mockdata.large';
import Search from './Search';
import SearchResults from './SearchResults';
import { ItemWithRefId } from './types';
import { DEFAULT_REF_ID } from './utils';
const refId = 'storybook_internal';
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)

View File

@ -7,7 +7,7 @@ import Downshift, { DownshiftState, StateChangeOptions } from 'downshift';
import Fuse, { FuseOptions } from 'fuse.js';
import React, { useEffect, useMemo, useRef, useState, useCallback, FunctionComponent } from 'react';
import { DEFAULT_REF_ID } from './Sidebar';
import { Refs, RefType } from './RefHelpers';
import {
ItemWithRefId,
RawSearchresults,
@ -17,7 +17,7 @@ import {
isSearchResult,
isExpandType,
} from './types';
import { Refs, RefType } from './RefHelpers';
import { DEFAULT_REF_ID } from './utils';
const options = {
shouldSort: true,

View File

@ -1,10 +1,12 @@
import { styled } from '@storybook/theming';
import { Icons } from '@storybook/components';
import React, { FunctionComponent, ReactNode } from 'react';
import { DOCS_MODE } from 'global';
import React, { FunctionComponent, MouseEventHandler, ReactNode } from 'react';
import { ControllerStateAndHelpers } from 'downshift';
import { ComponentNode, RootNode, NodeLabel, StoryNode } from './TreeNode';
import { ComponentNode, DocumentNode, NodeLabel, RootNode, StoryNode } from './TreeNode';
import { Match, DownshiftItem, isExpandType, RawSearchresults, ItemWithRefId } from './types';
import { storyLink } from './utils';
const ResultsList = styled.ol({
listStyle: 'none',
@ -60,8 +62,30 @@ const Highlight: FunctionComponent<{ match?: Match }> = React.memo(({ children,
});
const Result: FunctionComponent<
RawSearchresults[0] & { path: string[]; icon: string; isHighlighted: boolean }
> = React.memo(({ item, matches, path, icon, ...props }) => {
RawSearchresults[0] & {
path: string[];
icon: string;
isHighlighted: boolean;
onClick: MouseEventHandler;
}
> = React.memo(({ item, matches, path, icon, onClick, ...props }) => {
if (DOCS_MODE) {
const click: MouseEventHandler = (event) => {
event.preventDefault();
onClick(event);
};
return (
<ResultRow {...props}>
<DocumentNode depth={0} onClick={click} href={storyLink(item.id, item.refId)}>
<NodeLabel path={path}>
<strong>
<Highlight match={matches[0]}>{item.name}</Highlight>
</strong>
</NodeLabel>
</DocumentNode>
</ResultRow>
);
}
const TreeNode = item.isComponent ? ComponentNode : StoryNode;
return (
<ResultRow {...props}>

View File

@ -4,6 +4,7 @@ import Sidebar from './Sidebar';
import { standardData as standardHeaderData } from './Heading.stories';
import { mockDataset } from './mockdata';
import { RefType } from './RefHelpers';
import { DEFAULT_REF_ID } from './utils';
export default {
component: Sidebar,
@ -13,7 +14,7 @@ export default {
const { menu } = standardHeaderData;
const stories = mockDataset.withRoot;
const refId = 'storybook_internal';
const refId = DEFAULT_REF_ID;
const storyId = '1-12-121';
export const simpleData = { menu, stories, storyId };

View File

@ -1,5 +1,4 @@
/* eslint-env browser */
import { window, DOCS_MODE } from 'global';
import React, { FunctionComponent, useEffect, useMemo, useState, useCallback } from 'react';
import { styled } from '@storybook/theming';
@ -8,15 +7,15 @@ import { StoriesHash, State, isRoot } from '@storybook/api';
import { Heading } from './Heading';
import { collapseAllStories, collapseDocsOnlyStories } from './data';
import Explorer from './Explorer';
import Search from './Search';
import SearchResults from './SearchResults';
import { CombinedDataset, Selection, ItemWithRefId } from './types';
import { DEFAULT_REF_ID } from './utils';
import { Refs } from './RefHelpers';
export const DEFAULT_REF_ID = 'storybook_internal';
const getLastViewedStoryIds = (): Selection[] => {
try {
const raw = window.localStorage.getItem('lastViewedStoryIds');
@ -111,7 +110,7 @@ const Sidebar: FunctionComponent<SidebarProps> = React.memo(
({
storyId,
refId = DEFAULT_REF_ID,
stories,
stories: storiesHash,
storiesConfigured,
storiesFailed,
menu,
@ -119,6 +118,10 @@ const Sidebar: FunctionComponent<SidebarProps> = React.memo(
refs = {},
}) => {
const selected = useMemo(() => ({ storyId, refId }), [storyId, refId]);
const stories = useMemo(
() => (DOCS_MODE ? collapseAllStories : collapseDocsOnlyStories)(storiesHash),
[DOCS_MODE, storiesHash]
);
const dataset = useCombination(stories, storiesConfigured, storiesFailed, refs);
const getPath = useCallback(
function getPath(item: ItemWithRefId): string[] {

View File

@ -1,48 +1,13 @@
/* eslint-env browser */
import { StoriesHash, isRoot, isStory } from '@storybook/api';
import { styled } from '@storybook/theming';
import { Icons } from '@storybook/components';
import { transparentize } from 'polished';
import throttle from 'lodash/throttle';
import React, { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import React, { useCallback, useMemo, useRef } from 'react';
import { getParents } from './old/utils';
import { ComponentNode, DocumentNode, GroupNode, RootNode, StoryNode } from './TreeNode';
import { useExpanded, ExpandAction } from './useExpanded';
import { Item } from './types';
export const getAncestorIds = (data: StoriesHash, id: string): string[] =>
getParents(id, data).map((item) => item.id);
export const getDescendantIds = (data: StoriesHash, id: string, skipLeafs = false): string[] => {
const { children = [] } = data[id] || {};
return children.reduce((acc, childId) => {
if (skipLeafs && data[childId].isLeaf) return acc;
acc.push(childId, ...getDescendantIds(data, childId, skipLeafs));
return acc;
}, []);
};
type ExpandedState = Record<string, boolean>;
interface ExpandAction {
ids: string[];
value: boolean;
}
const initializeExpanded = ({
data,
highlightedId,
roots,
}: {
data: StoriesHash;
highlightedId?: string;
roots: string[];
}) => {
const highlightedAncestors = highlightedId ? getAncestorIds(data, highlightedId) : [];
return [...roots, ...highlightedAncestors].reduce<ExpandedState>(
(acc, id) => Object.assign(acc, { [id]: true }),
{}
);
};
import { getAncestorIds, getDescendantIds, storyLink } from './utils';
export const Action = styled.button(({ theme }) => ({
display: 'inline-flex',
@ -110,7 +75,7 @@ const Node = React.memo<NodeProps>(
const LeafNode = node.isComponent ? DocumentNode : StoryNode;
return (
<LeafNode
href={`?path=/story/${node.id}`}
href={storyLink(node.id, refId)}
key={node.id}
data-id={node.id}
data-ref={refId}
@ -214,10 +179,12 @@ const Tree = React.memo<{
selectedId,
onSelectId,
}) => {
const nodeIds = useMemo(() => Object.keys(data), [data]);
const [roots, orphans] = useMemo(
const containerRef = useRef<HTMLDivElement>(null);
// Find top-level nodes and group them so we can hoist any orphans and expand any roots.
const [rootIds, orphanIds] = useMemo(
() =>
nodeIds.reduce<[string[], string[]]>(
Object.keys(data).reduce<[string[], string[]]>(
(acc, id) => {
const node = data[id];
if (isRoot(node)) acc[0].push(id);
@ -226,102 +193,42 @@ const Tree = React.memo<{
},
[[], []]
),
[data, nodeIds]
[data]
);
// Pull up (hoist) any "orphan" nodes that don't have a root node as ancestor so they get
// displayed at the top of the tree, before any root nodes.
// Also create a map of expandable descendants for each root/orphan node, which is needed later.
// Doing that here is a performance enhancement, as it avoids traversing the tree again later.
const { orphansFirst, expandableDescendants } = useMemo(() => {
return orphans
.concat(roots)
return orphanIds
.concat(rootIds)
.reduce<{ orphansFirst: string[]; expandableDescendants: Record<string, string[]> }>(
(acc, id) => {
const descendantIds = getDescendantIds(data, id);
acc.orphansFirst.push(id, ...descendantIds);
acc.expandableDescendants[id] = descendantIds.filter((d) => !data[d].isLeaf);
(acc, nodeId) => {
const descendantIds = getDescendantIds(data, nodeId, false);
acc.orphansFirst.push(nodeId, ...descendantIds);
acc.expandableDescendants[nodeId] = descendantIds.filter((d) => !data[d].isLeaf);
return acc;
},
{ orphansFirst: [], expandableDescendants: {} }
);
}, [data, roots, orphans]);
}, [data, rootIds, orphanIds]);
const [expanded, setExpanded] = useReducer<
React.Reducer<ExpandedState, ExpandAction>,
{ data: StoriesHash; highlightedId?: string; roots: string[] }
>(
(state, { ids, value }) =>
ids.reduce((acc, id) => Object.assign(acc, { [id]: value }), { ...state }),
{ data, highlightedId, roots },
initializeExpanded
);
useEffect(() => {
setExpanded({ ids: getAncestorIds(data, selectedId), value: true });
}, [data, selectedId]);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const getElementByDataId = (id: string) =>
rootRef.current && rootRef.current.querySelector(`[data-id="${id}"]`);
const highlightElement = (element: Element) => {
setHighlightedId(element.getAttribute('data-id'));
const { top, bottom } = element.getBoundingClientRect();
const inView =
top >= 0 && bottom <= (window.innerHeight || document.documentElement.clientHeight);
if (!inView) element.scrollIntoView({ block: 'nearest' });
};
const navigateTree = throttle((event) => {
if (!isBrowsing || !event.key || !rootRef || !rootRef.current || !highlightedId) return;
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return;
if (!['Enter', ' ', 'ArrowLeft', 'ArrowRight'].includes(event.key)) return;
event.preventDefault();
const highlightedElement = getElementByDataId(highlightedId);
if (!highlightedElement || highlightedElement.getAttribute('data-ref') !== refId) return;
const type = highlightedElement.getAttribute('data-nodetype');
if (
['Enter', ' '].includes(event.key) &&
['component', 'story', 'document'].includes(type)
) {
onSelectId(highlightedId);
}
const isExpanded = highlightedElement.getAttribute('aria-expanded');
if (event.key === 'ArrowLeft') {
if (isExpanded === 'true') {
setExpanded({ ids: [highlightedId], value: false });
} else {
const parentId = highlightedElement.getAttribute('data-parent');
if (!parentId) return;
const parentElement = getElementByDataId(parentId);
if (parentElement && parentElement.getAttribute('data-highlightable') === 'true') {
setExpanded({ ids: [parentId], value: false });
highlightElement(parentElement);
} else {
setExpanded({
ids: getDescendantIds(data, parentId, true),
value: false,
});
}
}
}
if (event.key === 'ArrowRight') {
if (isExpanded === 'false') {
setExpanded({ ids: [highlightedId], value: true });
} else if (isExpanded === 'true') {
setExpanded({ ids: getDescendantIds(data, highlightedId, true), value: true });
}
}
}, 16);
document.addEventListener('keydown', navigateTree);
return () => document.removeEventListener('keydown', navigateTree);
}, [data, isBrowsing, highlightedId, setHighlightedId]);
// Track expanded nodes, keep it in sync with props and enable keyboard shortcuts.
const [expanded, setExpanded] = useExpanded({
containerRef,
isBrowsing, // only enable keyboard shortcuts when tree is visible
refId,
data,
rootIds,
highlightedId,
setHighlightedId,
selectedId,
onSelectId,
});
return (
<Container ref={rootRef} hasOrphans={isMain && orphans.length > 0}>
<Container ref={containerRef} hasOrphans={isMain && orphanIds.length > 0}>
{orphansFirst.map((id) => {
const node = data[id];
@ -353,7 +260,7 @@ const Tree = React.memo<{
key={id}
refId={refId}
node={node}
isOrphan={orphans.some((oid) => id === oid || id.startsWith(`${oid}-`))}
isOrphan={orphanIds.some((oid) => id === oid || id.startsWith(`${oid}-`))}
isDisplayed={isDisplayed}
isSelected={selectedId === id}
isHighlighted={highlightedId === id}

View File

@ -1,5 +1,6 @@
import { styled, Color, Theme } from '@storybook/theming';
import { Icons } from '@storybook/components';
import { DOCS_MODE } from 'global';
import React, { FunctionComponent, ComponentProps } from 'react';
export const CollapseIcon = styled.span<{ isExpanded: boolean }>(({ theme, isExpanded }) => ({
@ -149,8 +150,8 @@ export const ComponentNode: FunctionComponent<ComponentProps<typeof BranchNode>>
export const DocumentNode: FunctionComponent<ComponentProps<typeof LeafNode>> = React.memo(
({ theme, children, ...props }) => (
<LeafNode href="https://github.com/emotion-js/emotion" tabIndex={-1} {...props}>
<TypeIcon icon="document" color="#DC9544" />
<LeafNode tabIndex={-1} {...props}>
<TypeIcon icon="document" color={DOCS_MODE ? 'secondary' : '#DC9544'} />
{children}
</LeafNode>
)

View File

@ -1,5 +1,5 @@
import { StoriesHash } from '@storybook/api';
import { collapseDocsOnlyStories, collapseAllStories } from './State';
import { collapseDocsOnlyStories, collapseAllStories } from './data';
type Item = StoriesHash[keyof StoriesHash];
@ -32,6 +32,7 @@ const a1: Item = {
isComponent: false,
isRoot: false,
parent: 'a',
args: [],
};
const b: Item = {
id: 'b',
@ -52,6 +53,7 @@ const b1: Item = {
isRoot: false,
isComponent: false,
parent: 'b',
args: [],
};
const b2: Item = {
id: 'b2',
@ -62,6 +64,7 @@ const b2: Item = {
isRoot: false,
isComponent: false,
parent: 'b',
args: [],
};
const stories: StoriesHash = { root, a, a1, b, b1, b2 };
@ -103,6 +106,7 @@ describe('collapse all stories', () => {
isRoot: false,
isComponent: true,
isLeaf: true,
args: [],
},
b1: {
id: 'b1',
@ -114,6 +118,7 @@ describe('collapse all stories', () => {
isRoot: false,
isComponent: true,
isLeaf: true,
args: [],
},
root: {
id: 'root',

View File

@ -0,0 +1,115 @@
import { Story, StoriesHash } from '@storybook/api';
import { Item } from './types';
export const collapseAllStories = (stories: StoriesHash) => {
// keep track of component IDs that have been rewritten to the ID of their first leaf child
const componentIdToLeafId: Record<string, string> = {};
// 1) remove all leaves
const leavesRemoved = Object.values(stories).filter(
(item) => !(item.isLeaf && stories[item.parent].isComponent)
);
// 2) make all components leaves and rewrite their ID's to the first leaf child
const componentsFlattened = leavesRemoved.map((item) => {
const { id, isComponent, children, ...rest } = item;
// this is a folder, so just leave it alone
if (!isComponent) {
return item;
}
const nonLeafChildren: string[] = [];
const leafChildren: string[] = [];
children.forEach((child) =>
(stories[child].isLeaf ? leafChildren : nonLeafChildren).push(child)
);
if (leafChildren.length === 0) {
return item; // pass through, we'll handle you later
}
const leafId = leafChildren[0];
const component = {
...rest,
id: leafId,
kind: (stories[leafId] as Story).kind,
isRoot: false,
isLeaf: true,
isComponent: true,
children: [] as string[],
};
componentIdToLeafId[id] = leafId;
// this is a component, so it should not have any non-leaf children
if (nonLeafChildren.length !== 0) {
throw new Error(
`Unexpected '${item.id}': ${JSON.stringify({ isComponent, nonLeafChildren })}`
);
}
return component;
});
// 3) rewrite all the children as needed
const childrenRewritten = componentsFlattened.map((item) => {
if (item.isLeaf) {
return item;
}
const { children, ...rest } = item;
const rewritten = children.map((child) => componentIdToLeafId[child] || child);
return { children: rewritten, ...rest };
});
const result = {} as StoriesHash;
childrenRewritten.forEach((item) => {
result[item.id] = item as Item;
});
return result;
};
export const collapseDocsOnlyStories = (storiesHash: StoriesHash) => {
// keep track of component IDs that have been rewritten to the ID of their first leaf child
const componentIdToLeafId: Record<string, string> = {};
const docsOnlyStoriesRemoved = Object.values(storiesHash).filter((item) => {
if (item.isLeaf && item.parameters && item.parameters.docsOnly) {
componentIdToLeafId[item.parent] = item.id;
return false; // filter it out
}
return true;
});
const docsOnlyComponentsCollapsed = docsOnlyStoriesRemoved.map((item) => {
// collapse docs-only components
const { isComponent, children, id } = item;
if (isComponent && children.length === 1) {
const leafId = componentIdToLeafId[id];
if (leafId) {
const collapsed = {
...item,
id: leafId,
isLeaf: true,
children: [] as string[],
};
return collapsed;
}
}
// update groups
if (children) {
const rewritten = children.map((child) => componentIdToLeafId[child] || child);
return { ...item, children: rewritten };
}
// pass through stories unmodified
return item;
});
const result = {} as StoriesHash;
docsOnlyComponentsCollapsed.forEach((item) => {
result[item.id] = item as Item;
});
return result;
};

View File

@ -1,268 +0,0 @@
import { DOCS_MODE } from 'global';
import React, { useMemo, useState, useEffect } from 'react';
import { isRoot as isRootFn, Story, StoriesHash } from '@storybook/api';
import { toFiltered, getMains, getParents } from './utils';
import { BooleanSet, FilteredType, Item, DataSet } from '../RefHelpers';
export type ItemType = StoriesHash[keyof StoriesHash];
export const collapseAllStories = (stories: StoriesHash) => {
// keep track of component IDs that have been rewritten to the ID of their first leaf child
const componentIdToLeafId: Record<string, string> = {};
// 1) remove all leaves
const leavesRemoved = Object.values(stories).filter(
(item) => !(item.isLeaf && stories[item.parent].isComponent)
);
// 2) make all components leaves and rewrite their ID's to the first leaf child
const componentsFlattened = leavesRemoved.map((item) => {
const { id, isComponent, isRoot, children, ...rest } = item;
// this is a folder, so just leave it alone
if (!isComponent) {
return item;
}
const nonLeafChildren: string[] = [];
const leafChildren: string[] = [];
children.forEach((child) =>
(stories[child].isLeaf ? leafChildren : nonLeafChildren).push(child)
);
if (leafChildren.length === 0) {
return item; // pass through, we'll handle you later
}
const leafId = leafChildren[0];
const component = {
...rest,
id: leafId,
kind: (stories[leafId] as Story).kind,
isRoot: false,
isLeaf: true,
isComponent: true,
children: [] as string[],
};
componentIdToLeafId[id] = leafId;
// this is a component, so it should not have any non-leaf children
if (nonLeafChildren.length !== 0) {
throw new Error(
`Unexpected '${item.id}': ${JSON.stringify({ isComponent, nonLeafChildren })}`
);
}
return component;
});
// 3) rewrite all the children as needed
const childrenRewritten = componentsFlattened.map((item) => {
if (item.isLeaf) {
return item;
}
const { children, ...rest } = item;
const rewritten = children.map((child) => componentIdToLeafId[child] || child);
return { children: rewritten, ...rest };
});
const result = {} as StoriesHash;
childrenRewritten.forEach((item) => {
result[item.id] = item as Item;
});
return result;
};
export const collapseDocsOnlyStories = (storiesHash: StoriesHash) => {
// keep track of component IDs that have been rewritten to the ID of their first leaf child
const componentIdToLeafId: Record<string, string> = {};
const docsOnlyStoriesRemoved = Object.values(storiesHash).filter((item) => {
if (item.isLeaf && item.parameters && item.parameters.docsOnly) {
componentIdToLeafId[item.parent] = item.id;
return false; // filter it out
}
return true;
});
const docsOnlyComponentsCollapsed = docsOnlyStoriesRemoved.map((item) => {
// collapse docs-only components
const { isComponent, children, id } = item;
if (isComponent && children.length === 1) {
const leafId = componentIdToLeafId[id];
if (leafId) {
const collapsed = {
...item,
id: leafId,
isLeaf: true,
children: [] as string[],
};
return collapsed;
}
}
// update groups
if (children) {
const rewritten = children.map((child) => componentIdToLeafId[child] || child);
return { ...item, children: rewritten };
}
// pass through stories unmodified
return item;
});
const result = {} as StoriesHash;
docsOnlyComponentsCollapsed.forEach((item) => {
result[item.id] = item as Item;
});
return result;
};
export const ExpanderContext = React.createContext<{
setExpanded: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
expandedSet: BooleanSet;
}>({
setExpanded: () => {},
expandedSet: {},
});
const useExpanded = (
type: FilteredType,
parents: Item[],
initialFiltered: BooleanSet,
initialUnfiltered: BooleanSet
) => {
const expandedSets = {
filtered: useState(initialFiltered),
unfiltered: useState(initialUnfiltered),
};
const [state, setState] = expandedSets[type];
useEffect(() => {
expandedSets.filtered[1](initialFiltered);
expandedSets.unfiltered[1](initialUnfiltered);
}, [initialFiltered, initialUnfiltered]);
const set = useMemo(
() => ({
...state,
...parents.reduce((acc, item) => ({ ...acc, [item.id]: true }), {} as BooleanSet),
}),
[state, parents]
);
return { expandedSet: set, setExpanded: setState };
};
const useSelected = (dataset: DataSet, storyId: string) => {
return useMemo(() => {
return Object.keys(dataset).reduce(
(acc, k) => Object.assign(acc, { [k]: k === storyId }),
{} as BooleanSet
);
}, [dataset, storyId]);
};
const useFiltered = (dataset: DataSet, filter: string, parents: Item[], storyId: string) => {
const extra = useMemo(() => {
if (dataset[storyId]) {
return parents.reduce(
(acc, item) => ({ ...acc, [item.id]: item }),
dataset[storyId]
? {
[storyId]: dataset[storyId],
}
: {}
);
}
return {};
}, [parents]);
const filteredSet = useMemo(() => (filter ? toFiltered(dataset, filter) : dataset), [
dataset,
filter,
]);
return useMemo(
() =>
filteredSet[storyId]
? filteredSet
: {
...extra,
...filteredSet,
},
[extra, filteredSet]
);
};
export const useDataset = (storiesHash: DataSet = {}, filter: string, storyId: string) => {
const dataset = useMemo(() => {
return DOCS_MODE ? collapseAllStories(storiesHash) : collapseDocsOnlyStories(storiesHash);
}, [DOCS_MODE, storiesHash]);
const emptyInitial = useMemo(
() => ({
filtered: {},
unfiltered: {},
}),
[]
);
const datasetKeys = useMemo(() => Object.keys(dataset), [dataset]);
const initial = useMemo(() => {
if (datasetKeys.length) {
return Object.keys(dataset).reduce(
(acc, k) => {
acc.filtered[k] = true;
acc.unfiltered[k] = false;
return acc;
},
{ filtered: {} as BooleanSet, unfiltered: {} as BooleanSet }
);
}
return emptyInitial;
}, [dataset]);
const type: FilteredType = filter.length >= 2 ? 'filtered' : 'unfiltered';
const parents = useMemo(() => getParents(storyId, dataset), [dataset[storyId]]);
const { expandedSet, setExpanded } = useExpanded(
type,
parents,
initial.filtered,
initial.unfiltered
);
const selectedSet = useSelected(dataset, storyId);
const filteredSet = useFiltered(dataset, filter, parents, storyId);
const length = useMemo(() => Object.keys(filteredSet).length, [filteredSet]);
const topLevel = useMemo(
() =>
Object.values(filteredSet).filter(
(i) => (i.depth === 0 && !isRootFn(i)) || (!isRootFn(i) && isRootFn(filteredSet[i.parent]))
),
[filteredSet]
);
useEffect(() => {
if (type === 'filtered') {
if (topLevel.length < 18) {
setExpanded(initial.filtered);
} else {
setExpanded(initial.unfiltered);
}
}
}, [filter]);
const { roots, others } = useMemo(
() =>
getMains(filteredSet).reduce(
(acc, item) => {
return isRootFn(item)
? Object.assign(acc, { roots: [...acc.roots, item] })
: Object.assign(acc, { others: [...acc.others, item] });
},
{ roots: [] as Item[], others: [] as Item[] }
),
[filteredSet]
);
return {
roots,
others,
length,
dataSet: filteredSet,
selectedSet,
expandedSet,
setExpanded,
};
};

View File

@ -0,0 +1,135 @@
import { StoriesHash } from '@storybook/api';
import { document } from 'global';
import throttle from 'lodash/throttle';
import React, { Dispatch, MutableRefObject, useCallback, useEffect, useReducer } from 'react';
import { getAncestorIds, getDescendantIds, scrollIntoView } from './utils';
export type ExpandedState = Record<string, boolean>;
export interface ExpandAction {
ids: string[];
value: boolean;
}
export interface ExpandedProps {
containerRef: MutableRefObject<HTMLElement>;
isBrowsing: boolean;
refId: string;
data: StoriesHash;
rootIds: string[];
highlightedId: string | null;
setHighlightedId: (id: string) => void;
selectedId: string | null;
onSelectId: (id: string) => void;
}
const initializeExpanded = ({
data,
highlightedId,
rootIds,
}: {
data: StoriesHash;
highlightedId: string | null;
rootIds: string[];
}) => {
const highlightedAncestors = highlightedId ? getAncestorIds(data, highlightedId) : [];
return [...rootIds, ...highlightedAncestors].reduce<ExpandedState>(
(acc, id) => Object.assign(acc, { [id]: true }),
{}
);
};
export const useExpanded = ({
containerRef,
isBrowsing,
refId,
data,
rootIds,
highlightedId,
setHighlightedId,
selectedId,
onSelectId,
}: ExpandedProps): [Record<string, boolean>, Dispatch<ExpandAction>] => {
// Track the set of currently expanded nodes within this tree.
// Root nodes are expanded by default (and cannot be collapsed).
const [expanded, setExpanded] = useReducer<
React.Reducer<ExpandedState, ExpandAction>,
{ data: StoriesHash; highlightedId: string | null; rootIds: string[] }
>(
(state, { ids, value }) =>
ids.reduce((acc, id) => Object.assign(acc, { [id]: value }), { ...state }),
{ data, highlightedId, rootIds },
initializeExpanded
);
const getElementByDataId = useCallback(
(id: string) => containerRef.current && containerRef.current.querySelector(`[data-id="${id}"]`),
[containerRef]
);
const highlightElement = useCallback(
(element: Element) => {
setHighlightedId(element.getAttribute('data-id'));
scrollIntoView(element);
},
[setHighlightedId]
);
// Expand the whole ancestry of the currently selected story whenever it changes.
useEffect(() => {
setExpanded({ ids: getAncestorIds(data, selectedId), value: true });
}, [data, selectedId]);
// Expand, collapse or select nodes in the tree using keyboard shortcuts.
useEffect(() => {
const navigateTree = throttle((event) => {
if (!isBrowsing || !event.key || !containerRef.current || !highlightedId) return;
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return;
if (!['Enter', ' ', 'ArrowLeft', 'ArrowRight'].includes(event.key)) return;
event.preventDefault();
const highlightedElement = getElementByDataId(highlightedId);
if (!highlightedElement || highlightedElement.getAttribute('data-ref') !== refId) return;
const type = highlightedElement.getAttribute('data-nodetype');
if (['Enter', ' '].includes(event.key) && ['component', 'story', 'document'].includes(type)) {
onSelectId(highlightedId);
}
const isExpanded = highlightedElement.getAttribute('aria-expanded');
if (event.key === 'ArrowLeft') {
if (isExpanded === 'true') {
setExpanded({ ids: [highlightedId], value: false });
} else {
const parentId = highlightedElement.getAttribute('data-parent');
if (!parentId) return;
const parentElement = getElementByDataId(parentId);
if (parentElement && parentElement.getAttribute('data-highlightable') === 'true') {
setExpanded({ ids: [parentId], value: false });
highlightElement(parentElement);
} else {
setExpanded({
ids: getDescendantIds(data, parentId, true),
value: false,
});
}
}
}
if (event.key === 'ArrowRight') {
if (isExpanded === 'false') {
setExpanded({ ids: [highlightedId], value: true });
} else if (isExpanded === 'true') {
setExpanded({ ids: getDescendantIds(data, highlightedId, true), value: true });
}
}
}, 16);
document.addEventListener('keydown', navigateTree);
return () => document.removeEventListener('keydown', navigateTree);
}, [containerRef, isBrowsing, refId, data, highlightedId, setHighlightedId, onSelectId]);
return [expanded, setExpanded];
};

View File

@ -0,0 +1,86 @@
import throttle from 'lodash/throttle';
import { document } from 'global';
import {
Dispatch,
MutableRefObject,
SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { CombinedDataset, Selection } from './types';
import { cycle, scrollIntoView } from './utils';
export interface HighlightedProps {
containerRef: MutableRefObject<HTMLElement>;
isBrowsing: boolean;
dataset: CombinedDataset;
selected: Selection;
}
export const useHighlighted = ({
containerRef,
isBrowsing,
dataset,
selected,
}: HighlightedProps): [Selection, Dispatch<SetStateAction<Selection>>] => {
const highlightedRef = useRef<Selection>(selected);
const [highlighted, setHighlighted] = useState<Selection>(selected);
// Sets the highlighted node and scrolls it into view, using DOM elements as reference
const highlightElement = useCallback(
(element: Element, center = false) => {
const storyId = element.getAttribute('data-id');
const refId = element.getAttribute('data-ref');
if (!storyId || !refId) return;
highlightedRef.current = { storyId, refId };
setHighlighted(highlightedRef.current);
scrollIntoView(element, center);
},
[highlightedRef, setHighlighted]
);
// Highlight and scroll to the selected story whenever the selection or dataset changes
useEffect(() => {
setHighlighted(selected);
highlightedRef.current = selected;
const { storyId, refId } = selected;
setTimeout(() => {
scrollIntoView(
containerRef.current.querySelector(`[data-id="${storyId}"][data-ref="${refId}"]`),
true // make sure it's clearly visible by centering it
);
}, 0);
}, [dataset, highlightedRef, selected]);
// Highlight nodes up/down the tree using arrow keys
useEffect(() => {
const navigateTree = throttle((event) => {
if (!isBrowsing || !event.key || !containerRef || !containerRef.current) return;
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return;
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-id') === highlightedRef.current.storyId &&
el.getAttribute('data-ref') === 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);
}, [isBrowsing, highlightedRef, highlightElement]);
return [highlighted, setHighlighted];
};

View File

@ -1,4 +1,4 @@
import { mockDataset, mockExpanded, mockSelected } from '../mockdata';
import { mockDataset, mockExpanded, mockSelected } from './mockdata';
import * as utils from './utils';

View File

@ -1,11 +1,19 @@
import memoize from 'memoizerific';
import Fuse from 'fuse.js';
import { DOCS_MODE } from 'global';
import { document, window, DOCS_MODE } from 'global';
import { SyntheticEvent } from 'react';
import { StoriesHash, isRoot, isStory } from '@storybook/api';
const FUZZY_SEARCH_THRESHOLD = 0.35;
export const DEFAULT_REF_ID = 'storybook_internal';
export const storyLink = (storyId: string, refId: string) => {
const refPrefix = refId === DEFAULT_REF_ID ? '' : `${refId}_`;
const [, type = 'story'] = document.location.search.match(/path=\/([a-z]+)\//) || [];
return `${document.location.pathname}?path=/${type}/${refPrefix}${storyId}`;
};
export const prevent = (e: SyntheticEvent) => {
e.preventDefault();
return false;
@ -58,19 +66,25 @@ export const createId = (id: string, prefix: string) => `${prefix}_${id}`;
export const get = memoize(1000)((id: string, dataset: Dataset) => dataset[id]);
export const getParent = memoize(1000)((id: string, dataset: Dataset) => {
const item = get(id, dataset);
if (item && !isRoot(item)) {
return get(item.parent, dataset);
}
return undefined;
return item && !isRoot(item) ? get(item.parent, dataset) : undefined;
});
export const getParents = memoize(1000)((id: string, dataset: Dataset): Item[] => {
const parent = getParent(id, dataset);
if (!parent) {
return [];
}
return [parent, ...getParents(parent.id, dataset)];
return parent ? [parent, ...getParents(parent.id, dataset)] : [];
});
export const getAncestorIds = memoize(1000)((data: StoriesHash, id: string): string[] =>
getParents(id, data).map((item) => item.id)
);
export const getDescendantIds = memoize(1000)(
(data: StoriesHash, id: string, skipLeafs: boolean): string[] => {
const { children = [] } = data[id] || {};
return children.reduce((acc, childId) => {
if (skipLeafs && data[childId].isLeaf) return acc;
acc.push(childId, ...getDescendantIds(data, childId, skipLeafs));
return acc;
}, []);
}
);
export const getMains = memoize(1)((dataset: Dataset) =>
toList(dataset).filter((m) => m.depth === 0)
@ -245,3 +259,18 @@ export const toFiltered = (dataset: Dataset, filter: string) => {
return acc;
}, {} as Dataset);
};
export function cycle<T>(array: T[], index: number, delta: number): number {
let next = index + (delta % array.length);
if (next < 0) next = array.length + next;
if (next >= array.length) next -= array.length;
return next;
}
export const scrollIntoView = (element: Element, center = false) => {
if (!element) return;
const { top, bottom } = element.getBoundingClientRect();
const isInView =
top >= 0 && bottom <= (window.innerHeight || document.documentElement.clientHeight);
if (!isInView) element.scrollIntoView({ block: center ? 'center' : 'nearest' });
};