aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-11-14 19:07:14 +0100
committerArmand Philippot <git@armandphilippot.com>2023-11-14 19:07:14 +0100
commitbe4d907efb4e2fa658baa7c9b276ed282eb920db (patch)
tree0a7bd2d955ce9f9d5e252684ae6735bff7e9bd77 /src
parenta3a4c50f26b8750ae1c87f1f1103b84b7d2e6315 (diff)
refactor(components, hooks): rewrite ToC and useHeadingsTree
* replace TableOfContents component with TocWidget to keep the name of widget components coherent * replace `wrapper` prop with `tree` prop (the component no longer uses the hook, it is up to the consumer to provide the headings tree) * let consumer handle the widget title * add options to useHeadingsTree hook to retrieve only the wanted headings (and do not assume that h1 is unwanted) * expect an ref object instead of an element in useHeadingsTree hook * rename most of the types involved
Diffstat (limited to 'src')
-rw-r--r--src/components/organisms/widgets/index.ts2
-rw-r--r--src/components/organisms/widgets/table-of-contents.stories.tsx55
-rw-r--r--src/components/organisms/widgets/table-of-contents.test.tsx11
-rw-r--r--src/components/organisms/widgets/table-of-contents.tsx57
-rw-r--r--src/components/organisms/widgets/toc-widget/index.ts1
-rw-r--r--src/components/organisms/widgets/toc-widget/toc-widget.module.scss (renamed from src/components/organisms/widgets/table-of-contents.module.scss)2
-rw-r--r--src/components/organisms/widgets/toc-widget/toc-widget.stories.tsx46
-rw-r--r--src/components/organisms/widgets/toc-widget/toc-widget.test.tsx44
-rw-r--r--src/components/organisms/widgets/toc-widget/toc-widget.tsx44
-rw-r--r--src/components/templates/page/page-layout.tsx16
-rw-r--r--src/i18n/en.json8
-rw-r--r--src/i18n/fr.json8
-rw-r--r--src/utils/hooks/use-headings-tree.tsx162
-rw-r--r--src/utils/hooks/use-headings-tree/index.ts1
-rw-r--r--src/utils/hooks/use-headings-tree/use-headings-tree.test.ts79
-rw-r--r--src/utils/hooks/use-headings-tree/use-headings-tree.ts148
16 files changed, 386 insertions, 298 deletions
diff --git a/src/components/organisms/widgets/index.ts b/src/components/organisms/widgets/index.ts
index 972561e..aaaefb3 100644
--- a/src/components/organisms/widgets/index.ts
+++ b/src/components/organisms/widgets/index.ts
@@ -2,4 +2,4 @@ export * from './image-widget';
export * from './links-widget';
export * from './sharing-widget';
export * from './social-media-widget';
-export * from './table-of-contents';
+export * from './toc-widget';
diff --git a/src/components/organisms/widgets/table-of-contents.stories.tsx b/src/components/organisms/widgets/table-of-contents.stories.tsx
deleted file mode 100644
index d464715..0000000
--- a/src/components/organisms/widgets/table-of-contents.stories.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import type { ComponentMeta, ComponentStory } from '@storybook/react';
-import { TableOfContents as ToCWidget } from './table-of-contents';
-
-/**
- * TableOfContents - Storybook Meta
- */
-export default {
- title: 'Organisms/Widgets',
- component: ToCWidget,
- argTypes: {
- wrapper: {
- control: {
- type: null,
- },
- description:
- 'A reference to the HTML element that contains the headings.',
- type: {
- name: 'string',
- required: true,
- },
- },
- },
-} as ComponentMeta<typeof ToCWidget>;
-
-const Template: ComponentStory<typeof ToCWidget> = (args) => (
- <ToCWidget {...args} />
-);
-
-/* eslint-disable max-statements */
-const getWrapper = () => {
- const wrapper = document.createElement('div');
- const firstTitle = document.createElement('h2');
- const firstParagraph = document.createElement('p');
- const secondTitle = document.createElement('h2');
- const secondParagraph = document.createElement('p');
-
- firstTitle.textContent = 'dignissimos odit odit';
- firstParagraph.textContent =
- 'Sint error saepe in. Vel doloribus facere deleniti minima magni. Consequatur veniam quia rerum praesentium eaque culpa culpa quas optio.';
- secondTitle.textContent = 'aliquam exercitationem ut';
- secondParagraph.textContent =
- 'Doloribus sunt ut pariatur et praesentium rerum quam deserunt. Quod omnis quia qui quis debitis recusandae. Voluptate et impedit quam quidem quis id explicabo similique enim. Velit illum amet quos veniam consequatur amet nam sunt et. Et odit atque totam culpa officia saepe sed eaque consequatur.';
-
- wrapper.append(...[firstTitle, firstParagraph, secondTitle, secondParagraph]);
-
- return wrapper;
-};
-
-/**
- * Widgets Stories - Table of Contents
- */
-export const TableOfContents = Template.bind({});
-TableOfContents.args = {
- wrapper: getWrapper(),
-};
diff --git a/src/components/organisms/widgets/table-of-contents.test.tsx b/src/components/organisms/widgets/table-of-contents.test.tsx
deleted file mode 100644
index f5b2a87..0000000
--- a/src/components/organisms/widgets/table-of-contents.test.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import { describe, expect, it } from '@jest/globals';
-import { render, screen as rtlScreen } from '../../../../tests/utils';
-import { TableOfContents } from './table-of-contents';
-
-describe('TableOfContents', () => {
- it('renders a title', () => {
- const divEl = document.createElement('div');
- render(<TableOfContents wrapper={divEl} />);
- expect(rtlScreen.getByText(/Table of Contents/i)).toBeInTheDocument();
- });
-});
diff --git a/src/components/organisms/widgets/table-of-contents.tsx b/src/components/organisms/widgets/table-of-contents.tsx
deleted file mode 100644
index 5f14415..0000000
--- a/src/components/organisms/widgets/table-of-contents.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import type { FC } from 'react';
-import { useIntl } from 'react-intl';
-import { useHeadingsTree, type Heading } from '../../../utils/hooks';
-import { Heading as HeadingComponent } from '../../atoms';
-import { LinksWidget, type LinksWidgetItemData } from './links-widget';
-import styles from './table-of-contents.module.scss';
-
-type TableOfContentsProps = {
- /**
- * A reference to the HTML element that contains the headings.
- */
- wrapper: HTMLElement;
-};
-
-/**
- * Table of Contents widget component
- *
- * Render a table of contents.
- */
-export const TableOfContents: FC<TableOfContentsProps> = ({ wrapper }) => {
- const intl = useIntl();
- const headingsTree = useHeadingsTree(wrapper);
- const title = intl.formatMessage({
- defaultMessage: 'Table of Contents',
- description: 'TableOfContents: the widget title',
- id: 'WKG9wj',
- });
-
- /**
- * Convert an headings tree to list items.
- *
- * @param {Heading[]} tree - The headings tree.
- * @returns {LinksListItems[]} The list items.
- */
- const getItems = (tree: Heading[]): LinksWidgetItemData[] =>
- tree.map((heading) => {
- return {
- id: heading.id,
- label: heading.title,
- url: `#${heading.id}`,
- child: getItems(heading.children),
- };
- });
-
- return (
- <LinksWidget
- className={styles.list}
- heading={
- <HeadingComponent isFake level={3}>
- {title}
- </HeadingComponent>
- }
- isOrdered
- items={getItems(headingsTree)}
- />
- );
-};
diff --git a/src/components/organisms/widgets/toc-widget/index.ts b/src/components/organisms/widgets/toc-widget/index.ts
new file mode 100644
index 0000000..611b3df
--- /dev/null
+++ b/src/components/organisms/widgets/toc-widget/index.ts
@@ -0,0 +1 @@
+export * from './toc-widget';
diff --git a/src/components/organisms/widgets/table-of-contents.module.scss b/src/components/organisms/widgets/toc-widget/toc-widget.module.scss
index 36217ed..e754507 100644
--- a/src/components/organisms/widgets/table-of-contents.module.scss
+++ b/src/components/organisms/widgets/toc-widget/toc-widget.module.scss
@@ -1,4 +1,4 @@
-.list {
+.toc {
font-size: var(--font-size-sm);
font-weight: 500;
}
diff --git a/src/components/organisms/widgets/toc-widget/toc-widget.stories.tsx b/src/components/organisms/widgets/toc-widget/toc-widget.stories.tsx
new file mode 100644
index 0000000..3563a94
--- /dev/null
+++ b/src/components/organisms/widgets/toc-widget/toc-widget.stories.tsx
@@ -0,0 +1,46 @@
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+import { Heading } from '../../../atoms';
+import { TocWidget } from './toc-widget';
+
+/**
+ * TocWidget - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Widgets/Table of Contents',
+ component: TocWidget,
+ argTypes: {
+ tree: {
+ description: 'The headings tree.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ },
+} as ComponentMeta<typeof TocWidget>;
+
+const Template: ComponentStory<typeof TocWidget> = (args) => (
+ <TocWidget {...args} />
+);
+
+/**
+ * Widgets Stories - Table of Contents
+ */
+export const TableOfContents = Template.bind({});
+TableOfContents.args = {
+ heading: <Heading level={3}>Table of contents</Heading>,
+ tree: [
+ { children: [], depth: 2, id: 'title1', label: 'Title 1' },
+ {
+ children: [
+ { children: [], depth: 3, id: 'subtitle1', label: 'Subtitle 1' },
+ { children: [], depth: 3, id: 'subtitle2', label: 'Subtitle 2' },
+ ],
+ depth: 2,
+ id: 'title2',
+ label: 'Title 2',
+ },
+ { children: [], depth: 2, id: 'title3', label: 'Title 3' },
+ ],
+};
diff --git a/src/components/organisms/widgets/toc-widget/toc-widget.test.tsx b/src/components/organisms/widgets/toc-widget/toc-widget.test.tsx
new file mode 100644
index 0000000..e4e63ac
--- /dev/null
+++ b/src/components/organisms/widgets/toc-widget/toc-widget.test.tsx
@@ -0,0 +1,44 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '@testing-library/react';
+import { Heading } from '../../../atoms';
+import { TocWidget } from './toc-widget';
+
+describe('TocWidget', () => {
+ it('renders the widget heading and a list of links', () => {
+ const heading = 'fugit iusto qui';
+ const headingLvl = 3;
+ const tree = [
+ { children: [], depth: 2, id: 'title1', label: 'Title 1' },
+ {
+ children: [
+ { children: [], depth: 3, id: 'subtitle1', label: 'Subtitle 1' },
+ { children: [], depth: 3, id: 'subtitle2', label: 'Subtitle 2' },
+ ],
+ depth: 2,
+ id: 'title2',
+ label: 'Title 2',
+ },
+ { children: [], depth: 2, id: 'title3', label: 'Title 3' },
+ ];
+
+ render(
+ <TocWidget
+ heading={<Heading level={headingLvl}>{heading}</Heading>}
+ tree={tree}
+ />
+ );
+
+ const totalLinks =
+ tree.length +
+ tree.reduce(
+ (accumulator, currentValue) =>
+ accumulator + currentValue.children.length,
+ 0
+ );
+
+ expect(
+ rtlScreen.getByRole('heading', { level: headingLvl })
+ ).toHaveTextContent(heading);
+ expect(rtlScreen.getAllByRole('link')).toHaveLength(totalLinks);
+ });
+});
diff --git a/src/components/organisms/widgets/toc-widget/toc-widget.tsx b/src/components/organisms/widgets/toc-widget/toc-widget.tsx
new file mode 100644
index 0000000..c2d015a
--- /dev/null
+++ b/src/components/organisms/widgets/toc-widget/toc-widget.tsx
@@ -0,0 +1,44 @@
+import { type ForwardRefRenderFunction, forwardRef } from 'react';
+import type { HeadingsTreeNode } from '../../../../utils/hooks';
+import {
+ LinksWidget,
+ type LinksWidgetItemData,
+ type LinksWidgetProps,
+} from '../links-widget';
+import styles from './toc-widget.module.scss';
+
+const getLinksItemFrom = (tree: HeadingsTreeNode[]): LinksWidgetItemData[] =>
+ tree.map((node) => {
+ return {
+ child: node.children.length ? getLinksItemFrom(node.children) : undefined,
+ id: node.id,
+ label: node.label,
+ url: `#${node.id}`,
+ };
+ });
+
+export type TocWidgetProps = Omit<LinksWidgetProps, 'isOrdered' | 'items'> & {
+ /**
+ * The headings tree.
+ */
+ tree: HeadingsTreeNode[];
+};
+
+const TocWidgetWithRef: ForwardRefRenderFunction<
+ HTMLDivElement,
+ TocWidgetProps
+> = ({ className = '', tree, ...props }, ref) => {
+ const wrapperClass = `${styles.toc} ${className}`;
+
+ return (
+ <LinksWidget
+ {...props}
+ className={wrapperClass}
+ isOrdered
+ items={getLinksItemFrom(tree)}
+ ref={ref}
+ />
+ );
+};
+
+export const TocWidget = forwardRef(TocWidgetWithRef);
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
index 28c850b..8ea0087 100644
--- a/src/components/templates/page/page-layout.tsx
+++ b/src/components/templates/page/page-layout.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable max-statements */
import Script from 'next/script';
import {
type FC,
@@ -10,7 +11,7 @@ import { useIntl } from 'react-intl';
import type { BreadcrumbList } from 'schema-dts';
import { sendComment } from '../../../services/graphql';
import type { SendCommentInput } from '../../../types';
-import { useIsMounted } from '../../../utils/hooks';
+import { useHeadingsTree, useIsMounted } from '../../../utils/hooks';
import { Heading, Sidebar } from '../../atoms';
import {
PageFooter,
@@ -22,7 +23,7 @@ import {
CommentForm,
CommentsList,
type CommentsListProps,
- TableOfContents,
+ TocWidget,
Breadcrumbs,
type BreadcrumbsItem,
type CommentFormSubmit,
@@ -130,9 +131,15 @@ export const PageLayout: FC<PageLayoutProps> = ({
description: 'PageLayout: comment form accessible name',
id: 'l+Jcf6',
});
+ const tocTitle = intl.formatMessage({
+ defaultMessage: 'Table of Contents',
+ description: 'PageLayout: table of contents title',
+ id: 'eys2uX',
+ });
const bodyRef = useRef<HTMLDivElement>(null);
const isMounted = useIsMounted(bodyRef);
+ const headingsTree = useHeadingsTree(bodyRef, { fromLevel: 2 });
const saveComment: CommentFormSubmit = useCallback(
async (data) => {
@@ -217,7 +224,10 @@ export const PageLayout: FC<PageLayoutProps> = ({
className={`${styles.sidebar} ${styles['sidebar--first']}`}
>
{isMounted && bodyRef.current ? (
- <TableOfContents wrapper={bodyRef.current} />
+ <TocWidget
+ heading={<Heading level={3}>{tocTitle}</Heading>}
+ tree={headingsTree}
+ />
) : null}
</Sidebar>
) : null}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 02fe61b..008f476 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -471,10 +471,6 @@
"defaultMessage": "Search",
"description": "SearchPage: SEO - Page title"
},
- "WKG9wj": {
- "defaultMessage": "Table of Contents",
- "description": "TableOfContents: the widget title"
- },
"WMqQrv": {
"defaultMessage": "Search",
"description": "SearchForm: button accessible name"
@@ -555,6 +551,10 @@
"defaultMessage": "Main navigation",
"description": "Layout: main nav accessible name"
},
+ "eys2uX": {
+ "defaultMessage": "Table of Contents",
+ "description": "PageLayout: table of contents title"
+ },
"fN04AJ": {
"defaultMessage": "<link>Download the CV in PDF</link>",
"description": "CVPage: download CV in PDF text"
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index 27eb582..c83d146 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -471,10 +471,6 @@
"defaultMessage": "Recherche",
"description": "SearchPage: SEO - Page title"
},
- "WKG9wj": {
- "defaultMessage": "Table des matières",
- "description": "TableOfContents: the widget title"
- },
"WMqQrv": {
"defaultMessage": "Rechercher",
"description": "SearchForm: button accessible name"
@@ -555,6 +551,10 @@
"defaultMessage": "Navigation principale",
"description": "Layout: main nav accessible name"
},
+ "eys2uX": {
+ "defaultMessage": "Table des matières",
+ "description": "PageLayout: table of contents title"
+ },
"fN04AJ": {
"defaultMessage": "<link>Télécharger le CV au format PDF</link>",
"description": "CVPage: download CV in PDF text"
diff --git a/src/utils/hooks/use-headings-tree.tsx b/src/utils/hooks/use-headings-tree.tsx
deleted file mode 100644
index 049ab29..0000000
--- a/src/utils/hooks/use-headings-tree.tsx
+++ /dev/null
@@ -1,162 +0,0 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
-import { slugify } from '../helpers';
-import { useMutationObserver } from './use-mutation-observer';
-
-export type Heading = {
- /**
- * The heading depth.
- */
- depth: number;
- /**
- * The heading id.
- */
- id: string;
- /**
- * The heading children.
- */
- children: Heading[];
- /**
- * The heading title.
- */
- title: string;
-};
-
-/**
- * Get the headings tree of the given HTML element.
- *
- * @param {HTMLElement} wrapper - An HTML element that contains the headings.
- * @returns {Heading[]} The headings tree.
- */
-export const useHeadingsTree = (wrapper: HTMLElement): Heading[] => {
- const depths = useMemo(() => ['h2', 'h3', 'h4', 'h5', 'h6'], []);
- const [allHeadings, setAllHeadings] =
- useState<NodeListOf<HTMLHeadingElement>>();
- const [headingsTree, setHeadingsTree] = useState<Heading[]>([]);
-
- const getHeadingsInWrapper = useCallback(() => {
- const query = depths.join(', ');
- const result: NodeListOf<HTMLHeadingElement> =
- wrapper.querySelectorAll(query);
- setAllHeadings(result);
- }, [depths, wrapper]);
-
- useEffect(() => {
- getHeadingsInWrapper();
- }, [getHeadingsInWrapper]);
-
- useMutationObserver({
- callback: getHeadingsInWrapper,
- options: { childList: true },
- nodeOrSelector: wrapper,
- });
-
- const getDepth = useCallback(
- /**
- * Retrieve the heading element depth.
- *
- * @param {HTMLHeadingElement} el - An heading element.
- * @returns {number} The heading depth.
- */
- (el: HTMLHeadingElement): number => {
- return depths.findIndex((depth) => depth === el.localName);
- },
- [depths]
- );
-
- const formatHeadings = useCallback(
- /**
- * Convert a list of headings into an array of Heading objects.
- *
- * @param {NodeListOf<HTMLHeadingElement>} headings - A list of headings.
- * @returns {Heading[]} An array of Heading objects.
- */
- (headings: NodeListOf<HTMLHeadingElement>): Heading[] => {
- const formattedHeadings: Heading[] = [];
-
- Array.from(headings).forEach((heading) => {
- const title: string = heading.textContent!;
- const id = slugify(title);
- const depth = getDepth(heading);
- const children: Heading[] = [];
-
- heading.id = id;
-
- formattedHeadings.push({
- depth,
- id,
- children,
- title,
- });
- });
-
- return formattedHeadings;
- },
- [getDepth]
- );
-
- const buildSubTree = useCallback(
- /**
- * Build the heading subtree.
- *
- * @param {Heading} parent - The heading parent.
- * @param {Heading} currentHeading - The current heading element.
- */
- (parent: Heading, currentHeading: Heading): void => {
- if (parent.depth === currentHeading.depth - 1) {
- parent.children.push(currentHeading);
- } else {
- const lastItem = parent.children[parent.children.length - 1];
- buildSubTree(lastItem, currentHeading);
- }
- },
- []
- );
-
- const buildTree = useCallback(
- /**
- * Build a heading tree.
- *
- * @param {Heading[]} headings - An array of Heading objects.
- * @returns {Heading[]} The headings tree.
- */
- (headings: Heading[]): Heading[] => {
- const tree: Heading[] = [];
-
- headings.forEach((heading) => {
- if (heading.depth === 0) {
- tree.push(heading);
- } else {
- const lastItem = tree[tree.length - 1];
- buildSubTree(lastItem, heading);
- }
- });
-
- return tree;
- },
- [buildSubTree]
- );
-
- const getHeadingsTree = useCallback(
- /**
- * Retrieve a headings tree from a list of headings element.
- *
- * @param {NodeListOf<HTMLHeadingElement>} headings - A headings list.
- * @returns {Heading[]} The headings tree.
- */
- (headings: NodeListOf<HTMLHeadingElement>): Heading[] => {
- const formattedHeadings = formatHeadings(headings);
-
- return buildTree(formattedHeadings);
- },
- [formatHeadings, buildTree]
- );
-
- useEffect(() => {
- if (allHeadings) {
- const headingsList = getHeadingsTree(allHeadings);
- setHeadingsTree(headingsList);
- }
- }, [allHeadings, getHeadingsTree]);
-
- return headingsTree;
-};
diff --git a/src/utils/hooks/use-headings-tree/index.ts b/src/utils/hooks/use-headings-tree/index.ts
new file mode 100644
index 0000000..8f4c115
--- /dev/null
+++ b/src/utils/hooks/use-headings-tree/index.ts
@@ -0,0 +1 @@
+export * from './use-headings-tree';
diff --git a/src/utils/hooks/use-headings-tree/use-headings-tree.test.ts b/src/utils/hooks/use-headings-tree/use-headings-tree.test.ts
new file mode 100644
index 0000000..ad30a4f
--- /dev/null
+++ b/src/utils/hooks/use-headings-tree/use-headings-tree.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from '@jest/globals';
+import { renderHook } from '@testing-library/react';
+import { useHeadingsTree } from './use-headings-tree';
+
+const labels = {
+ h1: 'Title 1',
+ firstH2: 'First subtitle',
+ secondH2: 'Second subtitle',
+};
+
+describe('useHeadingsTree', () => {
+ it('returns a ref object and the headings tree', () => {
+ const wrapper = document.createElement('div');
+
+ wrapper.innerHTML = `
+<h1>${labels.h1}</h1>
+<h2>${labels.firstH2}</h2>
+<p>Expedita et necessitatibus qui numquam sunt et ut et. Earum nostrum esse nemo nisi qui. Ab in iure qui repellat voluptatibus nostrum odit aut qui. Architecto eum fugit quod excepturi numquam qui maxime accusantium. Fugit ipsam harum tempora.</p>
+<h2>${labels.secondH2}</h2>
+<p>Totam cumque aut ipsum. Necessitatibus magnam necessitatibus. Qui illo nulla non ab. Accusamus voluptatem ab fugiat voluptas aspernatur velit dolore reprehenderit. Voluptatem quod minima asperiores voluptatum distinctio cumque quo.</p>`;
+
+ const wrapperRef = { current: wrapper };
+ const { result } = renderHook(() => useHeadingsTree(wrapperRef));
+
+ expect(result.current.length).toBe(1);
+ expect(result.current[0].label).toBe(labels.h1);
+ expect(result.current[0].children.length).toBe(2);
+ });
+
+ it('can return a headings tree starting at the specified level', () => {
+ const wrapper = document.createElement('div');
+
+ wrapper.innerHTML = `
+<h1>${labels.h1}</h1>
+<h2>${labels.firstH2}</h2>
+<p>Expedita et necessitatibus qui numquam sunt et ut et. Earum nostrum esse nemo nisi qui. Ab in iure qui repellat voluptatibus nostrum odit aut qui. Architecto eum fugit quod excepturi numquam qui maxime accusantium. Fugit ipsam harum tempora.</p>
+<h2>${labels.secondH2}</h2>
+<p>Totam cumque aut ipsum. Necessitatibus magnam necessitatibus. Qui illo nulla non ab. Accusamus voluptatem ab fugiat voluptas aspernatur velit dolore reprehenderit. Voluptatem quod minima asperiores voluptatum distinctio cumque quo.</p>`;
+
+ const wrapperRef = { current: wrapper };
+ const { result } = renderHook(() =>
+ useHeadingsTree(wrapperRef, { fromLevel: 2 })
+ );
+
+ expect(result.current.length).toBe(2);
+ expect(result.current[0].label).toBe(labels.firstH2);
+ expect(result.current[1].label).toBe(labels.secondH2);
+ });
+
+ it('can return a headings tree stopping at the specified level', () => {
+ const wrapper = document.createElement('div');
+
+ wrapper.innerHTML = `
+<h1>${labels.h1}</h1>
+<h2>${labels.firstH2}</h2>
+<p>Expedita et necessitatibus qui numquam sunt et ut et. Earum nostrum esse nemo nisi qui. Ab in iure qui repellat voluptatibus nostrum odit aut qui. Architecto eum fugit quod excepturi numquam qui maxime accusantium. Fugit ipsam harum tempora.</p>
+<h2>${labels.secondH2}</h2>
+<p>Totam cumque aut ipsum. Necessitatibus magnam necessitatibus. Qui illo nulla non ab. Accusamus voluptatem ab fugiat voluptas aspernatur velit dolore reprehenderit. Voluptatem quod minima asperiores voluptatum distinctio cumque quo.</p>`;
+
+ const wrapperRef = { current: wrapper };
+ const { result } = renderHook(() =>
+ useHeadingsTree(wrapperRef, { toLevel: 1 })
+ );
+
+ expect(result.current.length).toBe(1);
+ expect(result.current[0].label).toBe(labels.h1);
+ expect(result.current[0].children).toStrictEqual([]);
+ });
+
+ it('throws an error if the options are invalid', () => {
+ const wrapperRef = { current: null };
+
+ expect(() =>
+ useHeadingsTree(wrapperRef, { fromLevel: 2, toLevel: 1 })
+ ).toThrowError(
+ 'Invalid options: `fromLevel` must be lower or equal to `toLevel`.'
+ );
+ });
+});
diff --git a/src/utils/hooks/use-headings-tree/use-headings-tree.ts b/src/utils/hooks/use-headings-tree/use-headings-tree.ts
new file mode 100644
index 0000000..6a081e7
--- /dev/null
+++ b/src/utils/hooks/use-headings-tree/use-headings-tree.ts
@@ -0,0 +1,148 @@
+import { useEffect, useState, type RefObject } from 'react';
+import type { HeadingLevel } from '../../../components';
+
+export type HeadingsTreeNode = {
+ /**
+ * The heading children.
+ */
+ children: HeadingsTreeNode[];
+ /**
+ * The heading depth.
+ */
+ depth: number;
+ /**
+ * The heading id.
+ */
+ id: string;
+ /**
+ * The heading label.
+ */
+ label: string;
+};
+
+const headingTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const;
+
+type HeadingTagNames = (typeof headingTags)[number];
+
+export type UseHeadingsTreeOptions = {
+ /**
+ * Look for headings starting from this level (1 = `h1`, ...).
+ *
+ * @default undefined
+ */
+ fromLevel?: HeadingLevel;
+ /**
+ * Look for headings ending with this level (1 = `h1`, ...).
+ *
+ * @default undefined
+ */
+ toLevel?: HeadingLevel;
+};
+
+/**
+ * Retrieve a list of heading tags.
+ *
+ * @param {UseHeadingsTreeOptions} options - An options object.
+ * @returns {HeadingTagNames[]} The heading tags list.
+ */
+const getHeadingTagsList = (
+ options?: UseHeadingsTreeOptions
+): HeadingTagNames[] => {
+ const tagsList = headingTags.slice(0);
+
+ if (options?.toLevel) tagsList.length = options.toLevel;
+ if (options?.fromLevel) tagsList.splice(0, options.fromLevel - 1);
+
+ return tagsList;
+};
+
+type HeadingsTreeNodeWithParentIndex = HeadingsTreeNode & {
+ parentIndex: number;
+};
+
+/**
+ * Convert a node list of heading elements to an array of indexed nodes.
+ *
+ * @param {NodeListOf<HTMLHeadingElement>} nodes - The heading elements list.
+ * @returns {HeadingsTreeNodeWithParentIndex[]} The headings nodes.
+ */
+const getHeadingNodesFrom = (
+ nodes: NodeListOf<HTMLHeadingElement>
+): HeadingsTreeNodeWithParentIndex[] => {
+ const depthLastIndexes = Array.from({ length: headingTags.length }, () => -1);
+
+ return Array.from(nodes).map(
+ (node, index): HeadingsTreeNodeWithParentIndex => {
+ const depth = headingTags.findIndex((tag) => tag === node.localName);
+ const parentDepthIndexes = depthLastIndexes.slice(0, depth);
+
+ depthLastIndexes[depth] = index;
+
+ return {
+ children: [],
+ depth,
+ id: node.id,
+ label: node.textContent ?? '',
+ parentIndex: Math.max(...parentDepthIndexes),
+ };
+ }
+ );
+};
+
+/**
+ * Build an headings tree from a list of heading elements.
+ *
+ * @param {NodeListOf<HTMLHeadingElement>} nodes - The heading nodes.
+ * @returns {HeadingsTreeNode[]} The headings tree.
+ */
+const buildHeadingsTreeFrom = (
+ nodes: NodeListOf<HTMLHeadingElement>
+): HeadingsTreeNode[] => {
+ const headings = getHeadingNodesFrom(nodes);
+ const treeNodes: HeadingsTreeNode[] = [];
+
+ for (const heading of headings) {
+ const { parentIndex, ...node } = heading;
+
+ if (parentIndex >= 0) headings[parentIndex].children.push(node);
+ else treeNodes.push(node);
+ }
+
+ return treeNodes;
+};
+
+/**
+ * React hook to retrieve the headings tree in a document or in a given wrapper.
+ *
+ * @param {RefObject<T>} ref - A ref to the element where to look for headings.
+ * @param {UseHeadingsTreeOptions} options - The headings tree config.
+ * @returns {HeadingsTreeNode[]} The headings tree.
+ */
+export const useHeadingsTree = <T extends HTMLElement = HTMLElement>(
+ ref: RefObject<T>,
+ options?: UseHeadingsTreeOptions
+): HeadingsTreeNode[] => {
+ if (
+ options?.fromLevel &&
+ options.toLevel &&
+ options.fromLevel > options.toLevel
+ )
+ throw new Error(
+ 'Invalid options: `fromLevel` must be lower or equal to `toLevel`.'
+ );
+
+ const [tree, setTree] = useState<HeadingsTreeNode[]>([]);
+ const requestedHeadingTags = getHeadingTagsList(options);
+ const query = requestedHeadingTags.join(', ');
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+
+ const headingNodes =
+ ref.current?.querySelectorAll<HTMLHeadingElement>(query);
+
+ if (headingNodes) setTree(buildHeadingsTreeFrom(headingNodes));
+ }, [query, ref]);
+
+ return tree;
+};