diff --git a/code/addons/docs/template/stories/toc/basic.stories.ts b/code/addons/docs/template/stories/toc/basic.stories.ts new file mode 100644 index 000000000000..c66fe554aa34 --- /dev/null +++ b/code/addons/docs/template/stories/toc/basic.stories.ts @@ -0,0 +1,22 @@ +import { global as globalThis } from '@storybook/global'; + +export default { + component: globalThis.Components.Button, + tags: ['autodocs'], + parameters: { + chromatic: { disable: true }, + docs: { toc: {} }, + }, +}; + +export const One = { + args: { label: 'One' }, +}; + +export const Two = { + args: { label: 'Two' }, +}; + +export const Three = { + args: { label: 'Two' }, +}; diff --git a/code/addons/docs/template/stories/toc/custom-selector.stories.ts b/code/addons/docs/template/stories/toc/custom-selector.stories.ts new file mode 100644 index 000000000000..48f1ee785156 --- /dev/null +++ b/code/addons/docs/template/stories/toc/custom-selector.stories.ts @@ -0,0 +1,14 @@ +import { global as globalThis } from '@storybook/global'; +import { One, Two, Three } from './basic.stories'; + +export default { + component: globalThis.Components.Button, + tags: ['autodocs'], + parameters: { + chromatic: { disable: true }, + // Select all the headings in the document + docs: { toc: { headingSelector: 'h1, h2, h3' } }, + }, +}; + +export { One, Two, Three }; diff --git a/code/addons/docs/template/stories/toc/custom-title.stories.ts b/code/addons/docs/template/stories/toc/custom-title.stories.ts new file mode 100644 index 000000000000..2fde6ebcabf3 --- /dev/null +++ b/code/addons/docs/template/stories/toc/custom-title.stories.ts @@ -0,0 +1,14 @@ +import { global as globalThis } from '@storybook/global'; +import { One, Two, Three } from './basic.stories'; + +export default { + component: globalThis.Components.Button, + tags: ['autodocs'], + parameters: { + chromatic: { disable: true }, + // Custom title label + docs: { toc: { title: 'Contents' } }, + }, +}; + +export { One, Two, Three }; diff --git a/code/addons/docs/template/stories/toc/ignore-selector.stories.ts b/code/addons/docs/template/stories/toc/ignore-selector.stories.ts new file mode 100644 index 000000000000..d355ac9ae966 --- /dev/null +++ b/code/addons/docs/template/stories/toc/ignore-selector.stories.ts @@ -0,0 +1,14 @@ +import { global as globalThis } from '@storybook/global'; +import { One, Two, Three } from './basic.stories'; + +export default { + component: globalThis.Components.Button, + tags: ['autodocs'], + parameters: { + chromatic: { disable: true }, + // Skip the first story in the TOC + docs: { toc: { ignoreSelector: '#one' } }, + }, +}; + +export { One, Two, Three }; diff --git a/code/builders/builder-manager/src/index.ts b/code/builders/builder-manager/src/index.ts index 3597a206ec46..e2bdf248be82 100644 --- a/code/builders/builder-manager/src/index.ts +++ b/code/builders/builder-manager/src/index.ts @@ -190,7 +190,6 @@ const starter: StarterFunction = async function* starterGeneratorFn({ } }); router.use(`/index.html`, ({ path }, res) => { - console.log({ path }); res.status(200).send(html); }); diff --git a/code/ui/.storybook/preview.tsx b/code/ui/.storybook/preview.tsx index 7c52ad023776..c89c15e85db4 100644 --- a/code/ui/.storybook/preview.tsx +++ b/code/ui/.storybook/preview.tsx @@ -277,6 +277,7 @@ export const parameters = { }, docs: { theme: themes.light, + toc: {}, }, controls: { presetColors: [ diff --git a/code/ui/blocks/package.json b/code/ui/blocks/package.json index b3d11cff17df..564ebb3a4c77 100644 --- a/code/ui/blocks/package.json +++ b/code/ui/blocks/package.json @@ -63,6 +63,7 @@ "polished": "^4.2.2", "react-colorful": "^5.1.2", "telejson": "^7.0.3", + "tocbot": "^4.20.1", "ts-dedent": "^2.0.0", "util-deprecate": "^1.0.2" }, diff --git a/code/ui/blocks/src/blocks/DocsContainer.tsx b/code/ui/blocks/src/blocks/DocsContainer.tsx index 8640f06a68f1..07b855295f10 100644 --- a/code/ui/blocks/src/blocks/DocsContainer.tsx +++ b/code/ui/blocks/src/blocks/DocsContainer.tsx @@ -9,6 +9,7 @@ import type { DocsContextProps } from './DocsContext'; import { DocsContext } from './DocsContext'; import { SourceContainer } from './SourceContainer'; import { scrollToElement } from './utils'; +import { TableOfContents } from '../components/TableOfContents'; const { document, window: globalWindow } = global; @@ -22,6 +23,16 @@ export const DocsContainer: FC> = ({ theme, children, }) => { + let toc; + + try { + const meta = context.resolveOf('meta', ['meta']); + toc = meta.preparedMeta.parameters?.docs?.toc; + } catch (err) { + // No meta, falling back to project annotations + toc = context?.projectAnnotations?.parameters?.docs?.toc; + } + useEffect(() => { let url; try { @@ -44,7 +55,11 @@ export const DocsContainer: FC> = ({ - {children} + : null} + > + {children} + diff --git a/code/ui/blocks/src/components/DocsPage.tsx b/code/ui/blocks/src/components/DocsPage.tsx index 6b097e66063e..112025269863 100644 --- a/code/ui/blocks/src/components/DocsPage.tsx +++ b/code/ui/blocks/src/components/DocsPage.tsx @@ -1,9 +1,9 @@ +import type { FC } from 'react'; +import React from 'react'; +import { transparentize } from 'polished'; import { withReset } from '@storybook/components'; import type { CSSObject } from '@storybook/theming'; import { styled } from '@storybook/theming'; -import { transparentize } from 'polished'; -import type { FC } from 'react'; -import React from 'react'; /** * This selector styles all raw elements inside the DocsPage like this example with a `
`: @@ -429,16 +429,19 @@ export const DocsWrapper = styled.div(({ theme }) => ({ padding: '4rem 20px', minHeight: '100vh', boxSizing: 'border-box', + gap: '3rem', [`@media (min-width: ${breakpoint}px)`]: {}, })); interface DocsPageWrapperProps { children?: React.ReactNode; + toc?: React.ReactNode; } -export const DocsPageWrapper: FC = ({ children }) => ( +export const DocsPageWrapper: FC = ({ children, toc }) => ( {children} + {toc} ); diff --git a/code/ui/blocks/src/components/TableOfContents.tsx b/code/ui/blocks/src/components/TableOfContents.tsx new file mode 100644 index 000000000000..892f1e137f20 --- /dev/null +++ b/code/ui/blocks/src/components/TableOfContents.tsx @@ -0,0 +1,181 @@ +import React, { useEffect } from 'react'; +import type { FC, ReactElement } from 'react'; +import { styled } from '@storybook/theming'; +import tocbot from 'tocbot'; + +export interface TocParameters { + /** CSS selector for the container to search for headings. */ + contentsSelector?: string; + + /** + * When true, hide the TOC. We still show the empty container + * (as opposed to showing nothing at all) because it affects the + * page layout and we want to preserve the layout across pages. + */ + disable?: boolean; + + /** CSS selector to match headings to list in the TOC. */ + headingSelector?: string; + + /** Headings that match the ignoreSelector will be skipped. */ + ignoreSelector?: string; + + /** Custom title ReactElement or string to display above the TOC. */ + title?: ReactElement | string | null; + + /** + * TocBot options, not guaranteed to be available in future versions. + * @see tocbot docs {@link https://tscanlin.github.io/tocbot/#usage} + */ + unsafeTocbotOptions?: tocbot.IStaticOptions; +} + +const Wrapper = styled.div(({ theme }) => ({ + width: '10rem', + + '@media (max-width: 768px)': { + display: 'none', + }, +})); + +const Content = styled.div(({ theme }) => ({ + position: 'fixed', + top: 0, + width: '10rem', + paddingTop: '4rem', + + fontFamily: theme.typography.fonts.base, + fontSize: theme.typography.size.s2, + + WebkitFontSmoothing: 'antialiased', + MozOsxFontSmoothing: 'grayscale', + WebkitTapHighlightColor: 'rgba(0, 0, 0, 0)', + WebkitOverflowScrolling: 'touch', + + '& *': { + boxSizing: 'border-box', + }, + + '& > .toc-wrapper > .toc-list': { + paddingLeft: 0, + borderLeft: `solid 2px ${theme.color.mediumlight}`, + + '.toc-list': { + paddingLeft: 0, + borderLeft: `solid 2px ${theme.color.mediumlight}`, + + '.toc-list': { + paddingLeft: 0, + borderLeft: `solid 2px ${theme.color.mediumlight}`, + }, + }, + }, + '& .toc-list-item': { + position: 'relative', + listStyleType: 'none', + marginLeft: 20, + paddingTop: 3, + paddingBottom: 3, + }, + '& .toc-list-item::before': { + content: '""', + position: 'absolute', + height: '100%', + top: 0, + left: 0, + transform: `translateX(calc(-2px - 20px))`, + borderLeft: `solid 2px ${theme.color.mediumdark}`, + opacity: 0, + transition: 'opacity 0.2s', + }, + '& .toc-list-item.is-active-li::before': { + opacity: 1, + }, + '& .toc-list-item > a': { + color: theme.color.defaultText, + textDecoration: 'none', + }, + '& .toc-list-item.is-active-li > a': { + fontWeight: 600, + color: theme.color.secondary, + textDecoration: 'none', + }, +})); + +const Heading = styled.p(({ theme }) => ({ + fontWeight: 600, + fontSize: '0.875em', + color: theme.textColor, + textTransform: 'uppercase', + marginBottom: 10, +})); + +type TableOfContentsProps = React.PropsWithChildren< + TocParameters & { + className?: string; + } +>; + +const OptionalTitle: FC<{ title: TableOfContentsProps['title'] }> = ({ title }) => { + if (title === null) { + return null; + } + if (typeof title === 'string') { + return {title}; + } + return title; +}; + +export const TableOfContents = ({ + title, + disable, + headingSelector, + contentsSelector, + ignoreSelector, + unsafeTocbotOptions, +}: TableOfContentsProps) => { + useEffect(() => { + const configuration = { + tocSelector: '.toc-wrapper', + contentSelector: contentsSelector ?? '.sbdocs-content', + headingSelector: headingSelector ?? 'h3', + ignoreSelector: ignoreSelector ?? '.skip-toc', + headingsOffset: 40, + scrollSmoothOffset: -40, + /** + * Ignore headings that did not + * come from the main markdown code. + */ + // ignoreSelector: ':not(.sbdocs), .hide-from-toc', + orderedList: false, + /** + * Prevent default linking behavior, + * leaving only the smooth scrolling. + */ + onClick: () => false, + ...unsafeTocbotOptions, + }; + + /** + * Wait for the DOM to be ready. + */ + const timeout = setTimeout(() => tocbot.init(configuration), 100); + return () => { + clearTimeout(timeout); + tocbot.destroy(); + }; + }, [disable]); + + return ( + <> + + {!disable ? ( + + +
+ + ) : null} + + + ); +}; diff --git a/code/yarn.lock b/code/yarn.lock index 4ec2c6d73313..627d6e00186f 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -5802,6 +5802,7 @@ __metadata: polished: ^4.2.2 react-colorful: ^5.1.2 telejson: ^7.0.3 + tocbot: ^4.20.1 ts-dedent: ^2.0.0 util-deprecate: ^1.0.2 peerDependencies: @@ -29218,6 +29219,13 @@ __metadata: languageName: node linkType: hard +"tocbot@npm:^4.20.1": + version: 4.21.0 + resolution: "tocbot@npm:4.21.0" + checksum: 877d99df40c07ec5e5c2259b820be9c8af9a9f52d582a61b7bed3d43daff820f23031bc613a5cc3bb14ecc34b79c1a45349dcbae8f3a79de7ecc127f366ed3c6 + languageName: node + linkType: hard + "toggle-selection@npm:^1.0.6": version: 1.0.6 resolution: "toggle-selection@npm:1.0.6"