Skip to content

Commit 2284f31

Browse files
committed
feat: toc as dsfr side menu
1 parent 994e57d commit 2284f31

5 files changed

Lines changed: 183 additions & 1 deletion

File tree

.storybook/DocsContainer.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useIsDark } from "../dist/useIsDark";
1212
import { startReactDsfr } from "../dist/spa";
1313
import { fr } from "../dist/fr";
1414
import { MuiDsfrThemeProvider } from "../dist/mui";
15+
import { TableOfContentsCustom, TocType } from "./TableOfContents";
1516

1617
startReactDsfr({
1718
"defaultColorScheme": "system",
@@ -28,6 +29,16 @@ export const DocsContainer = ({ children, context }: PropsWithChildren<DocsConta
2829

2930
const backgroundColor = fr.colors.decisions.background.default.grey.default;
3031

32+
// took from addon-docs/src/blocks/DocsContainer.tsx
33+
let toc: TocType | undefined;
34+
try {
35+
const meta = context.resolveOf("meta", ["meta"]);
36+
toc = meta.preparedMeta.parameters?.docs?.toc;
37+
} catch (err) {
38+
// No meta, falling back to project annotations
39+
toc = context?.projectAnnotations?.parameters?.docs?.toc;
40+
}
41+
3142
return (
3243
<>
3344
<style>{`
@@ -54,7 +65,10 @@ export const DocsContainer = ({ children, context }: PropsWithChildren<DocsConta
5465
`}</style>
5566
<BaseContainer context={context} theme={isStorybookUiDark ? darkTheme : lightTheme}>
5667
<MuiDsfrThemeProvider>
57-
<Unstyled>{children}</Unstyled>
68+
<Unstyled>
69+
{toc && <TableOfContentsCustom channel={context.channel} />}
70+
{children}
71+
</Unstyled>
5872
</MuiDsfrThemeProvider>
5973
</BaseContainer>
6074
</>

.storybook/Stories.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import React from "react";
2+
import { Stories as BaseStories } from "@storybook/addon-docs/blocks";
3+
import { type PropsOf } from "@emotion/react";
4+
5+
export const Stories = (props: PropsOf<typeof BaseStories>) => {
6+
return <BaseStories {...props} />;
7+
};

.storybook/TableOfContents.tsx

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { styled } from "storybook/theming";
2+
import SideMenu, { SideMenuProps } from "../dist/SideMenu";
3+
import Channel from "storybook/internal/channels";
4+
import { NAVIGATE_URL } from "storybook/internal/core-events";
5+
import React, { useEffect, useState } from "react";
6+
import { DocsTypes } from "@storybook/addon-docs";
7+
8+
export type TocType = Exclude<Required<DocsTypes["parameters"]>["docs"]["toc"], undefined>;
9+
10+
const Aside = styled.div`
11+
position: fixed;
12+
right: 4rem;
13+
top: 0;
14+
bottom: 0;
15+
width: 16rem;
16+
z-index: 1;
17+
overflow: auto;
18+
padding-top: 4rem;
19+
padding-bottom: 2rem;
20+
padding-left: 1rem;
21+
padding-right: 1rem;
22+
23+
-webkit-font-smoothing: antialiased;
24+
-moz-osx-font-smoothing: grayscale;
25+
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
26+
-webkit-overflow-scrolling: touch;
27+
28+
@media (max-width: 768px) {
29+
display: none;
30+
}
31+
32+
& .fr-sidemenu__inner {
33+
padding: 0;
34+
}
35+
36+
& .fr-sidemenu__link {
37+
padding: 0.5rem 0.75rem;
38+
}
39+
`;
40+
41+
/**
42+
* Hook pour détecter le heading actuellement visible avec IntersectionObserver
43+
*/
44+
function useActiveHeading(headings: HTMLHeadingElement[]) {
45+
const [activeId, setActiveId] = useState<string>("");
46+
47+
useEffect(() => {
48+
if (headings.length === 0) return;
49+
50+
// Map pour stocker les ratios d'intersection de chaque heading
51+
const headingObservers = new Map<string, number>();
52+
53+
const observer = new IntersectionObserver(
54+
entries => {
55+
// Mettre à jour le ratio d'intersection pour chaque heading observé
56+
entries.forEach(entry => {
57+
const id = entry.target.id;
58+
if (entry.isIntersecting) {
59+
headingObservers.set(id, entry.intersectionRatio);
60+
} else {
61+
headingObservers.set(id, 0);
62+
}
63+
});
64+
65+
// Trouver le heading avec le plus grand ratio d'intersection
66+
let maxRatio = 0;
67+
let activeHeadingId = "";
68+
69+
headingObservers.forEach((ratio, id) => {
70+
if (ratio > maxRatio) {
71+
maxRatio = ratio;
72+
activeHeadingId = id;
73+
}
74+
});
75+
76+
// Ne mettre à jour que si on a trouvé un heading visible
77+
// Sinon on garde l'état précédent
78+
if (activeHeadingId && activeHeadingId !== activeId) {
79+
setActiveId(activeHeadingId);
80+
} else if (!activeId && headings.length > 0) {
81+
// Cas initial : si aucun heading n'est actif encore, prendre le premier
82+
setActiveId(headings[0].id);
83+
}
84+
},
85+
{
86+
// rootMargin négatif = créer une zone "active" au centre du viewport
87+
// "-20% 0px -35% 0px" = zone active entre 20% du haut et 65% du bas
88+
rootMargin: "-20% 0px -35% 0px",
89+
threshold: [0, 0.25, 0.5, 0.75, 1] // Observer à différents niveaux de visibilité
90+
}
91+
);
92+
93+
// Observer tous les headings
94+
headings.forEach(heading => {
95+
if (heading.id) {
96+
observer.observe(heading);
97+
headingObservers.set(heading.id, 0);
98+
}
99+
});
100+
101+
return () => {
102+
observer.disconnect();
103+
};
104+
}, [headings, activeId]);
105+
106+
return activeId;
107+
}
108+
109+
interface TableOfContentsCustomProps {
110+
channel: Channel;
111+
}
112+
113+
export const TableOfContentsCustom = ({ channel }: TableOfContentsCustomProps) => {
114+
const [headingElements, setHeadingElements] = useState<HTMLHeadingElement[]>([]);
115+
116+
// Initialiser les headings une seule fois
117+
useEffect(() => {
118+
const contentElement = document.querySelector(".sbdocs-content");
119+
const elements = Array.from(
120+
contentElement?.querySelectorAll<HTMLHeadingElement>(
121+
"h3:not(.docs-story *, .skip-toc)"
122+
) ?? []
123+
);
124+
setHeadingElements(elements);
125+
}, []);
126+
127+
// Utiliser le hook pour tracker l'ID actif
128+
const activeId = useActiveHeading(headingElements);
129+
130+
// Créer les items avec isActive
131+
const headings = headingElements.map<SideMenuProps.Item>(heading => ({
132+
text: (heading.innerText || heading.textContent).trim(),
133+
isActive: heading.id === activeId,
134+
linkProps: {
135+
href: `#${heading.id}`,
136+
onClick(e) {
137+
e.preventDefault();
138+
if (e.currentTarget instanceof HTMLAnchorElement) {
139+
const [, headerId] = e.currentTarget.href.split("#");
140+
if (headerId) {
141+
channel.emit(NAVIGATE_URL, { url: `#${headerId}` });
142+
document.querySelector(`#${heading.id}`)?.scrollIntoView({
143+
behavior: "smooth"
144+
});
145+
}
146+
}
147+
}
148+
}
149+
}));
150+
151+
return (
152+
<Aside>
153+
<SideMenu align="right" burgerMenuButtonText="Table des matières" items={headings} />
154+
</Aside>
155+
);
156+
};

.storybook/preview-head.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,8 @@
4646
.sb-argstableBlock-body button {
4747
background-color: var(--background-alt-grey);
4848
}
49+
50+
.sbdocs-toc--custom {
51+
display: none;
52+
}
4953
</style>

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"format": "yarn _format --write",
2424
"format:check": "yarn _format --list-different",
2525
"storybook": "storybook dev -p 6006",
26+
"storybook:dev": "storybook dev -p 6006",
2627
"build-storybook": "storybook build",
2728
"prestorybook": "yarn build && node dist/bin/react-dsfr update-icons",
2829
"prebuild-storybook": "yarn prestorybook"

0 commit comments

Comments
 (0)