Skip to content

Commit

Permalink
Replace Search and Tree in sidebar and implement proper keyboard navi…
Browse files Browse the repository at this point in the history
…gation.
  • Loading branch information
ghengeveld committed Sep 29, 2020
1 parent aa29c91 commit b109fef
Show file tree
Hide file tree
Showing 34 changed files with 29,197 additions and 1,011 deletions.
99 changes: 57 additions & 42 deletions lib/components/src/icon/icons.tsx

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions lib/ui/package.json
Expand Up @@ -42,6 +42,7 @@
"copy-to-clipboard": "^3.0.8",
"core-js": "^3.0.1",
"core-js-pure": "^3.0.1",
"downshift": "^6.0.6",
"emotion-theming": "^10.0.19",
"fuse.js": "^3.6.1",
"global": "^4.3.2",
Expand Down
15 changes: 11 additions & 4 deletions lib/ui/src/components/sidebar/Brand.tsx
Expand Up @@ -16,13 +16,20 @@ export const Img = styled.img({
maxWidth: '100%',
});

export const LogoLink = styled.a({
display: 'block',
width: '100%',
export const LogoLink = styled.a(({ theme }) => ({
display: 'inline-block',
height: '100%',
margin: '-3px -4px',
padding: '2px 3px',
border: '1px solid transparent',
borderRadius: 3,
color: 'inherit',
textDecoration: 'none',
});
'&:focus': {
outline: 0,
borderColor: theme.color.secondary,
},
}));

export const Brand = withTheme(
({
Expand Down
70 changes: 70 additions & 0 deletions lib/ui/src/components/sidebar/Explorer.stories.tsx
@@ -0,0 +1,70 @@
import React from 'react';

import Explorer from './Explorer';
import { mockDataset } from './mockdata';
import { RefType } from './RefHelpers';

export default {
component: Explorer,
title: 'UI/Sidebar/Explorer',
excludeStories: /.*Data$/,
};

const selected = {
refId: 'storybook_internal',
storyId: '1-12-121',
};

const simple: Record<string, RefType> = {
storybook_internal: {
title: null,
id: 'storybook_internal',
url: 'iframe.html',
ready: true,
stories: mockDataset.withRoot,
},
};

const withRefs: Record<string, RefType> = {
...simple,
basic: {
id: 'basic',
title: 'Basic ref',
url: 'https://example.com',
ready: true,
type: 'auto-inject',
stories: mockDataset.noRoot,
},
injected: {
id: 'injected',
title: 'Not ready',
url: 'https://example.com',
ready: false,
type: 'auto-inject',
stories: mockDataset.noRoot,
},
unknown: {
id: 'unknown',
title: 'Unknown ref',
url: 'https://example.com',
ready: true,
type: 'unknown',
stories: mockDataset.noRoot,
},
lazy: {
id: 'lazy',
title: 'Lazy loaded ref',
url: 'https://example.com',
ready: false,
type: 'lazy',
stories: mockDataset.withRoot,
},
};

export const Simple = () => (
<Explorer dataset={{ hash: simple, entries: Object.entries(simple) }} selected={selected} />
);

export const WithRefs = () => (
<Explorer dataset={{ hash: withRefs, entries: Object.entries(withRefs) }} selected={selected} />
);
96 changes: 96 additions & 0 deletions lib/ui/src/components/sidebar/Explorer.tsx
@@ -0,0 +1,96 @@
/* eslint-env browser */

import React, { FunctionComponent, useEffect, useRef, useState, useCallback } from 'react';

import throttle from 'lodash/throttle';

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);
};

interface ExplorerProps {
dataset: CombinedDataset;
selected: Selection;
}

const Explorer: FunctionComponent<ExplorerProps> = React.memo(({ dataset, selected }) => {
const rootRef = useRef<HTMLDivElement>(null);
const highlightedRef = useRef<Selection>(selected);

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 (!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);
}, [highlightedRef, highlightElement]);

return (
<div ref={rootRef}>
{dataset.entries.map(([refId, ref]) => (
<Ref
{...ref}
key={refId}
selectedId={selected.refId === ref.id ? selected.storyId : null}
highlightedId={highlighted.refId === ref.id ? highlighted.storyId : null}
setHighlighted={setHighlighted}
/>
))}
</div>
);
});

export default Explorer;
12 changes: 1 addition & 11 deletions lib/ui/src/components/sidebar/Heading.stories.tsx
Expand Up @@ -7,17 +7,7 @@ import { Heading } from './Heading';
export default {
component: Heading,
title: 'UI/Sidebar/Heading',
decorators: [
(storyFn: any) => (
<div
style={{
maxWidth: '240px',
}}
>
{storyFn()}
</div>
),
],
decorators: [(storyFn: any) => <div style={{ maxWidth: '240px' }}>{storyFn()}</div>],
excludeStories: /.*Data$/,
parameters: {
layout: 'fullscreen',
Expand Down
4 changes: 4 additions & 0 deletions lib/ui/src/components/sidebar/Menu.tsx
Expand Up @@ -45,6 +45,10 @@ export const MenuButton = styled(Button)<MenuButtonProps>(({ highlighted, theme
position: 'relative',
overflow: 'visible',
padding: 7,
'&:focus': {
background: theme.barBg,
boxShadow: `${theme.color.secondary} 0 0 0 1px inset`,
},

...(highlighted && {
'&:after': {
Expand Down
122 changes: 1 addition & 121 deletions lib/ui/src/components/sidebar/RefBlocks.tsx
@@ -1,40 +1,11 @@
import { window, document } from 'global';
import React, {
FunctionComponent,
useState,
useCallback,
Fragment,
useContext,
ComponentProps,
} from 'react';
import React, { FunctionComponent, useState, useCallback, Fragment } from 'react';

import { Icons, WithTooltip, Spaced, Button, Link } from '@storybook/components';
import { logger } from '@storybook/client-logger';
import { useStorybookApi } from '@storybook/api';
import { styled } from '@storybook/theming';
import { transparentize } from 'polished';
import { Location } from '@storybook/router';

import { Tree } from './Tree/Tree';
import { Loader, Contained } from './Loader';
import { ListItem } from './Tree/ListItem';
import { ExpanderContext } from './Tree/State';

import { Item, DataSet, BooleanSet } from './RefHelpers';

export type ListitemProps = ComponentProps<typeof ListItem>;

const Section = styled.section();

const RootHeading = styled.div(({ theme }) => ({
letterSpacing: '0.35em',
textTransform: 'uppercase',
fontWeight: theme.typography.weight.black,
fontSize: theme.typography.size.s1 - 1,
lineHeight: '24px',
color: transparentize(0.5, theme.color.defaultText),
margin: '0 20px',
}));

const TextStyle = styled.div(({ theme }) => ({
fontSize: theme.typography.size.s2 - 1,
Expand All @@ -57,46 +28,6 @@ const Text = styled.p(({ theme }) => ({
},
}));

const Head: FunctionComponent<ListitemProps> = (props) => {
const api = useStorybookApi();
const { setExpanded, expandedSet } = useContext(ExpanderContext);
const { id, isComponent, childIds, refId } = props;

const onClick = useCallback(
(e) => {
e.preventDefault();
if (!expandedSet[id] && isComponent && childIds && childIds.length) {
api.selectStory(childIds[0], undefined, { ref: refId });
}
setExpanded((s) => ({ ...s, [id]: !s[id] }));
},
[id, expandedSet[id]]
);
return <ListItem onClick={onClick} {...props} href={`#${id}`} />;
};

const Leaf: FunctionComponent<ListitemProps> = (props) => {
const api = useStorybookApi();
const { setExpanded } = useContext(ExpanderContext);
const { id, refId } = props;
const onClick = useCallback(
(e) => {
e.preventDefault();
api.selectStory(id, undefined, { ref: refId });
setExpanded((s) => ({ ...s, [id]: !s[id] }));
},
[id]
);

return (
<Location>
{({ viewMode }) => (
<ListItem onClick={onClick} {...props} href={`?path=/${viewMode}/${id}`} />
)}
</Location>
);
};

const ErrorDisplay = styled.pre(
{
width: 420,
Expand Down Expand Up @@ -295,54 +226,3 @@ export const LoaderBlock: FunctionComponent<{ isMain: boolean }> = ({ isMain })
<Loader size={isMain ? 17 : 5} />
</Contained>
);

const TreeComponents = {
Head,
Leaf,
Branch: Tree,
List: styled.div({}),
};
export const ContentBlock: FunctionComponent<{
others: Item[];
dataSet: DataSet;
selectedSet: BooleanSet;
expandedSet: BooleanSet;
roots: Item[];
}> = ({ others, dataSet, selectedSet, expandedSet, roots }) => (
<Fragment>
<Spaced row={1.5}>
{others.length ? (
<Section data-title="categorized" key="categorized">
{others.map(({ id }) => (
<Tree
key={id}
depth={0}
dataset={dataSet}
selected={selectedSet}
expanded={expandedSet}
root={id}
{...TreeComponents}
/>
))}
</Section>
) : null}

{roots.map(({ id, name, children }) => (
<Section data-title={name} key={id}>
<RootHeading className="sidebar-subheading">{name}</RootHeading>
{children.map((child) => (
<Tree
key={child}
depth={0}
dataset={dataSet}
selected={selectedSet}
expanded={expandedSet}
root={child}
{...TreeComponents}
/>
))}
</Section>
))}
</Spaced>
</Fragment>
);

0 comments on commit b109fef

Please sign in to comment.