mirror of
https://github.com/storybookjs/storybook.git
synced 2025-03-17 05:02:23 +08:00
Refactor to custom hooks. Fix docs mode. Cleanup some old stuff.
This commit is contained in:
parent
eaff61c536
commit
3b3f05d25f
@ -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}
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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}>
|
||||
|
@ -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 };
|
||||
|
@ -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[] {
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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',
|
115
lib/ui/src/components/sidebar/data.ts
Normal file
115
lib/ui/src/components/sidebar/data.ts
Normal 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;
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
135
lib/ui/src/components/sidebar/useExpanded.ts
Normal file
135
lib/ui/src/components/sidebar/useExpanded.ts
Normal 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];
|
||||
};
|
86
lib/ui/src/components/sidebar/useHighlighted.ts
Normal file
86
lib/ui/src/components/sidebar/useHighlighted.ts
Normal 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];
|
||||
};
|
@ -1,4 +1,4 @@
|
||||
import { mockDataset, mockExpanded, mockSelected } from '../mockdata';
|
||||
import { mockDataset, mockExpanded, mockSelected } from './mockdata';
|
||||
|
||||
import * as utils from './utils';
|
||||
|
@ -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' });
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user