Skip to content

Commit

Permalink
Merge pull request #180 from storybookjs/toc
Browse files Browse the repository at this point in the history
Table of Contents component
  • Loading branch information
kylesuss authored Jul 31, 2020
2 parents 7086f0f + 401233c commit 18640ba
Show file tree
Hide file tree
Showing 11 changed files with 1,914 additions and 0 deletions.
1,305 changes: 1,305 additions & 0 deletions build-storybook.log

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,5 @@ export { default as WithTooltip } from './tooltip/WithTooltip';

export * from './modal/Modal';
export { default as WithModal } from './modal/WithModal';

export * from './table-of-contents/TableOfContents';
94 changes: 94 additions & 0 deletions src/components/table-of-contents/BulletLink.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import React, { useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { color, typography } from '../shared/styles';
import { Link } from '../Link';

const StyledBulletLink = styled(({ isActive, ...rest }) => <Link {...rest} />)`
outline: none;
display: inline-block;
padding: 6px 0;
line-height: 1.5;
position: relative;
z-index: 1;
${(props) => props.isActive && `font-weight: ${typography.weight.bold};`}
&::after {
position: absolute;
top: 0px;
right: auto;
bottom: 0px;
left: 3px;
width: auto;
height: auto;
border-left: 1px solid ${color.mediumlight};
content: '';
z-index: -1;
}
`;

const BulletLinkWrapper = styled.li`
&& {
padding-top: 0;
list-style-type: none;
}
&:first-of-type ${StyledBulletLink} {
margin-top: -6px;
&::after {
height: 50%;
top: 50%;
}
}
&:last-of-type ${StyledBulletLink} {
margin-bottom: -6px;
&::after {
height: 50%;
bottom: 50%;
}
}
`;

const Bullet = styled.span`
display: inline-block;
margin-bottom: 1px;
margin-right: 16px;
background: ${color.medium};
box-shadow: white 0 0 0 4px;
height: 8px;
width: 8px;
border-radius: 1em;
text-decoration: none !important;
content: '';
${(props) => props.isActive && `background: ${color.secondary};`}
`;

export function BulletLink({ currentPath, item, ...rest }) {
const isActive = currentPath === item.path;

return (
<BulletLinkWrapper>
<StyledBulletLink
isActive={isActive}
href={item.path}
LinkWrapper={item.LinkWrapper}
tertiary={!isActive}
>
<Bullet isActive={isActive} />
{item.title}
</StyledBulletLink>
</BulletLinkWrapper>
);
}

BulletLink.propTypes = {
currentPath: PropTypes.string.isRequired,
item: PropTypes.shape({
LinkWrapper: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
path: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
}).isRequired,
};
18 changes: 18 additions & 0 deletions src/components/table-of-contents/BulletLink.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React, { useState } from 'react';
import styled from 'styled-components';
import { BulletLink } from './BulletLink';

export default {
title: 'Design System/TableOfContents/BulletLink',
component: BulletLink,
parameters: { chromatic: { disable: true } },
};

const currentPath = '/path-1';
// Bullet links should always be used in a series
export const Series = (args) => (
<ul>
<BulletLink currentPath={currentPath} item={{ path: '/path-1', title: 'Link 1' }} />
<BulletLink currentPath={currentPath} item={{ path: '/path-2', title: 'Link 2' }} />
</ul>
);
38 changes: 38 additions & 0 deletions src/components/table-of-contents/ItemLink.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React, { useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { color, typography } from '../shared/styles';
import { Link } from '../Link';
import { MenuLink } from './MenuLink';

const ItemLinkWrapper = styled.li`
list-style-type: none;
&:last-of-type ${MenuLink} {
margin-bottom: 0;
}
`;

export function ItemLink({ currentPath, item }) {
return (
<ItemLinkWrapper>
<MenuLink
isActive={currentPath === item.path}
href={item.path}
LinkWrapper={item.LinkWrapper}
tertiary
>
{item.title}
</MenuLink>
</ItemLinkWrapper>
);
}

ItemLink.propTypes = {
currentPath: PropTypes.string.isRequired,
item: PropTypes.shape({
path: PropTypes.string.isRequired,
LinkWrapper: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
title: PropTypes.string.isRequired,
}).isRequired,
};
17 changes: 17 additions & 0 deletions src/components/table-of-contents/ItemLink.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React, { useState } from 'react';
import styled from 'styled-components';
import { ItemLink } from './ItemLink';

export default {
title: 'Design System/TableOfContents/ItemLink',
component: ItemLink,
parameters: { chromatic: { disable: true } },
};

export const Base = () => (
<ItemLink currentPath="/path-2" item={{ path: '/path-1', title: 'Link 1' }} />
);

export const Active = () => (
<ItemLink currentPath="/path-1" item={{ path: '/path-1', title: 'Link 1' }} />
);
11 changes: 11 additions & 0 deletions src/components/table-of-contents/MenuLink.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from 'react';
import styled from 'styled-components';
import { color, typography } from '../shared/styles';
import { Link } from '../Link';

export const MenuLink = styled(({ isActive, ...rest }) => <Link {...rest} />)`
outline: none;
color: ${(props) => (props.isActive ? color.secondary : color.darkest)};
font-weight: ${(props) => (props.isActive ? typography.weight.bold : typography.weight.regular)};
line-height: 24px;
`;
11 changes: 11 additions & 0 deletions src/components/table-of-contents/MenuLink.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React, { useState } from 'react';
import styled from 'styled-components';
import { MenuLink } from './MenuLink';

export default {
title: 'Design System/TableOfContents/MenuLink',
component: MenuLink,
parameters: { chromatic: { disable: true } },
};

export const Base = () => <MenuLink>Menu link</MenuLink>;
147 changes: 147 additions & 0 deletions src/components/table-of-contents/TableOfContents.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import React, { useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { TableOfContentsItems, ITEM_TYPES } from './TableOfContentsItems';
import { breakpoint, color, typography } from '../shared/styles';
import { Icon } from '../Icon';
import { Link } from '../Link';

const toKebabcase = (string) => string.toLowerCase().split(' ').join('-');

const hasActiveChildren = (args) => {
const { children, currentPath } = args;

return !!children.find(
(child) =>
child.path === currentPath ||
(child.children && hasActiveChildren({ ...args, children: child.children }))
);
};

const getOpenState = ({
item,
globalItemUpdate = {},
singleItemUpdate = {},
lastFocusedId,
currentPath,
didChangeCurrentPath,
}) => {
const withActiveChildren = hasActiveChildren({
children: item.children,
currentPath,
lastFocusedId,
});

// If there is no 'isOpen' field yet, set a default based on whether or not
// any of the children are active.
if (typeof item.isOpen !== 'boolean') return withActiveChildren;
// Path changes should open up a tree for all parents of an active item.
if (didChangeCurrentPath && withActiveChildren) return true;

if (typeof globalItemUpdate.isOpen === 'boolean') return globalItemUpdate.isOpen;

if (typeof singleItemUpdate.isOpen === 'boolean' && singleItemUpdate.id === item.id)
return singleItemUpdate.isOpen;

return item.isOpen;
};

const mapItemIds = (items, depth = 0) =>
items.map((item) => ({
...item,
id: `${toKebabcase(item.title)}-${depth}`,
...(item.children && {
children: mapItemIds(item.children, depth + 1),
}),
}));

// Add UI state to the 'items' that are passed in as props
const mapItemUIState = (args) => {
const {
items,
currentPath,
didChangeCurrentPath,
depth = 0,
globalItemUpdate,
singleItemUpdate,
lastFocusedId,
} = args;

return items.map((item) => {
const isMenuWithChildren = item.type === ITEM_TYPES.MENU && !!item.children;

return {
...item,
// The concept of 'isOpen' only applies to menus that have children
...(isMenuWithChildren && {
isOpen: getOpenState({
item,
globalItemUpdate,
singleItemUpdate,
lastFocusedId,
currentPath,
didChangeCurrentPath,
}),
}),
// Recursively set the state of children to an infinite depth.
// getOpenState needs the children to have an id already to determine
// if there is a focused child, hence the placement of the recursive
// mapItemUIState call here before getOpenState is called.
...(item.children && {
children: mapItemUIState({ ...args, items: item.children, depth: depth + 1 }),
}),
};
});
};

// State management and event handlers for the TableOfContentsItems
export function TableOfContents({ children, currentPath, items, ...rest }) {
const [itemsWithIds] = useState(mapItemIds(items));
const [itemsWithUIState, setItemsWithUIState] = useState(
mapItemUIState({ currentPath, items: itemsWithIds })
);
const uiStateCommonArgs = { currentPath, items: itemsWithUIState };
const toggleAllOpenStates = (isOpen) =>
setItemsWithUIState(mapItemUIState({ ...uiStateCommonArgs, globalItemUpdate: { isOpen } }));
const toggleAllOpen = () => toggleAllOpenStates(true);
const toggleAllClosed = () => toggleAllOpenStates(false);
const setMenuOpenStateById = (args) => {
setItemsWithUIState(mapItemUIState({ ...uiStateCommonArgs, singleItemUpdate: args }));
};

const didRunCurrentPathEffectOnMount = useRef(false);
useEffect(() => {
if (didRunCurrentPathEffectOnMount.current) {
setItemsWithUIState(mapItemUIState({ ...uiStateCommonArgs, didChangeCurrentPath: true }));
} else {
didRunCurrentPathEffectOnMount.current = true;
}
}, [currentPath]);

const tableOfContentsMenu = (
<TableOfContentsItems
currentPath={currentPath}
isTopLevel
items={itemsWithUIState}
setMenuOpenStateById={setMenuOpenStateById}
{...rest}
/>
);

return typeof children === 'function'
? children({ menu: tableOfContentsMenu, toggleAllOpen, toggleAllClosed })
: tableOfContentsMenu;
}

TableOfContents.propTypes = {
children: PropTypes.func,
currentPath: PropTypes.string.isRequired,
items: PropTypes.arrayOf(
PropTypes.shape({
type: PropTypes.oneOf(Object.values(ITEM_TYPES)).isRequired,
}).isRequired
).isRequired,
};

TableOfContents.defaultProps = {
children: undefined,
};
Loading

0 comments on commit 18640ba

Please sign in to comment.