Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Addon-docs: Add opt-in table of contents #23142

Merged
merged 14 commits into from
Jul 6, 2023
Merged
1 change: 1 addition & 0 deletions code/ui/blocks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
12 changes: 11 additions & 1 deletion code/ui/blocks/src/blocks/DocsContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { DocsContextProps } from './DocsContext';
import { DocsContext } from './DocsContext';
import { SourceContainer } from './SourceContainer';
import { scrollToElement } from './utils';
import { useOf } from './useOf';

const { document, window: globalWindow } = global;

Expand All @@ -22,6 +23,15 @@ export const DocsContainer: FC<PropsWithChildren<DocsContainerProps>> = ({
theme,
children,
}) => {
let toc;
try {
const meta = useOf('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 {
Expand All @@ -44,7 +54,7 @@ export const DocsContainer: FC<PropsWithChildren<DocsContainerProps>> = ({
<DocsContext.Provider value={context}>
<SourceContainer channel={context.channel}>
<ThemeProvider theme={ensureTheme(theme)}>
<DocsPageWrapper>{children}</DocsPageWrapper>
<DocsPageWrapper toc={toc}>{children}</DocsPageWrapper>
</ThemeProvider>
</SourceContainer>
</DocsContext.Provider>
Expand Down
31 changes: 26 additions & 5 deletions code/ui/blocks/src/components/DocsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
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';
import { TableOfContents } from './TableOfContents';
import type { TocParameters } from './TableOfContents';

/**
* This selector styles all raw elements inside the DocsPage like this example with a `<div/>`:
Expand Down Expand Up @@ -433,12 +435,31 @@ export const DocsWrapper = styled.div(({ theme }) => ({
[`@media (min-width: ${breakpoint}px)`]: {},
}));

const TocWrapper = styled.div`
display: flex;
gap: 0rem;

@media only screen and (min-width: 1200px) {
gap: 3rem;
}
`;

const ContentWrapper = styled.div`
width: 100%;
`;

interface DocsPageWrapperProps {
children?: React.ReactNode;
toc?: TocParameters;
}

export const DocsPageWrapper: FC<DocsPageWrapperProps> = ({ children }) => (
export const DocsPageWrapper: FC<DocsPageWrapperProps> = ({ children, toc }) => (
<DocsWrapper className="sbdocs sbdocs-wrapper">
<DocsContent className="sbdocs sbdocs-content">{children}</DocsContent>
<DocsContent className="sbdocs sbdocs-content">
<TocWrapper>
<ContentWrapper>{children}</ContentWrapper>
JReinhold marked this conversation as resolved.
Show resolved Hide resolved
{toc ? <TableOfContents className="sbdocs sbdocs-toc--custom" {...toc} /> : null}
</TocWrapper>
</DocsContent>
</DocsWrapper>
);
162 changes: 162 additions & 0 deletions code/ui/blocks/src/components/TableOfContents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
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.
Comment on lines +11 to +13
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do I understand this correctly, that if a user has toc in project annotations, and for a single component have disable, we always show the TOC container (but sometimes empty), so that layout won't shift?

But if the user doesn't have toc set global, but just for a single story, then we'll have layout shifts?

I think this is a good solution, I just want to make sure my understanding is correct.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe so. This is @cdedreuille 's comment I believe.

*/
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](https://tscanlin.github.io/tocbot/#usage)
shilman marked this conversation as resolved.
Show resolved Hide resolved
*/
unsafeTocbotOptions?: tocbot.IStaticOptions;
}

const space = (n: number) => `${n * 10}px`;

const Container = styled('div')`
font-family: ${(p) => p.theme.typography.fonts.base};
height: 100%;
display: none;
width: 10rem;

@media only screen and (min-width: 1200px) {
display: block;
}
`;

const Content = styled('div')`
position: fixed;
top: 0;
width: 10rem;
padding-top: 4rem;

& > .toc-wrapper > .toc-list {
padding-left: 0;
border-left: solid 2px ${(p) => p.theme.color.mediumlight};

.toc-list {
padding-left: 0;
border-left: solid 2px ${(p) => p.theme.color.mediumlight};

.toc-list {
padding-left: 0;
border-left: solid 2px ${(p) => p.theme.color.mediumlight};
}
}
}
& .toc-list-item {
position: relative;
list-style-type: none;
margin-left: ${space(2)};
}
& .toc-list-item::before {
content: '';
position: absolute;
height: 100%;
top: 0;
left: 0;
transform: translateX(calc(-2px - ${space(2)}));
border-left: solid 2px ${(p) => p.theme.color.mediumdark};
opacity: 0;
transition: opacity 0.2s;
}
& .toc-list-item.is-active-li::before {
opacity: 1;
}
& .toc-list-item > a {
color: ${(p) => p.theme.color.defaultText};
}
& .toc-list-item.is-active-li > a {
font-weight: 600;
color: ${(p) => p.theme.color.secondary};
}
`;

const Heading = styled('p')`
font-weight: 600;
font-size: 0.875em;
color: ${(p) => p.theme.textColor};
text-transform: uppercase;
margin-bottom: ${space(1)};
`;

type TableOfContentsProps = React.PropsWithChildren<
TocParameters & {
className?: string;
}
>;

const OptionalTitle: FC<{ title: TableOfContentsProps['title'] }> = ({ title }) => {
if (title === null) return null;
if (typeof title === 'string') return <Heading>{title}</Heading>;
return title;
};

export const TableOfContents = ({
title,
disable,
headingSelector,
contentsSelector,
ignoreSelector,
unsafeTocbotOptions,
}: TableOfContentsProps) => {
console.log({ title, disable, headingSelector, ignoreSelector, unsafeTocbotOptions });
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,
};
console.log({ configuration });

/**
* Wait for the DOM to be ready.
*/
setTimeout(() => tocbot.init(configuration), 100);
}, [disable]);

return (
<Container>
{!disable && (
<Content>
<OptionalTitle title={title || null} />
<div className="toc-wrapper" />
</Content>
)}
</Container>
);
};
8 changes: 8 additions & 0 deletions code/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -29196,6 +29197,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"
Expand Down