-
Notifications
You must be signed in to change notification settings - Fork 585
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #180 from storybookjs/toc
Table of Contents component
- Loading branch information
Showing
11 changed files
with
1,914 additions
and
0 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' }} /> | ||
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
`; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; |
Oops, something went wrong.