From be4d907efb4e2fa658baa7c9b276ed282eb920db Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Tue, 14 Nov 2023 19:07:14 +0100 Subject: 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 --- src/utils/hooks/use-headings-tree/index.ts | 1 + .../use-headings-tree/use-headings-tree.test.ts | 79 +++++++++++ .../hooks/use-headings-tree/use-headings-tree.ts | 148 +++++++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 src/utils/hooks/use-headings-tree/index.ts create mode 100644 src/utils/hooks/use-headings-tree/use-headings-tree.test.ts create mode 100644 src/utils/hooks/use-headings-tree/use-headings-tree.ts (limited to 'src/utils/hooks/use-headings-tree') 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 = ` +

${labels.h1}

+

${labels.firstH2}

+

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.

+

${labels.secondH2}

+

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.

`; + + 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 = ` +

${labels.h1}

+

${labels.firstH2}

+

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.

+

${labels.secondH2}

+

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.

`; + + 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 = ` +

${labels.h1}

+

${labels.firstH2}

+

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.

+

${labels.secondH2}

+

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.

`; + + 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} nodes - The heading elements list. + * @returns {HeadingsTreeNodeWithParentIndex[]} The headings nodes. + */ +const getHeadingNodesFrom = ( + nodes: NodeListOf +): 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} nodes - The heading nodes. + * @returns {HeadingsTreeNode[]} The headings tree. + */ +const buildHeadingsTreeFrom = ( + nodes: NodeListOf +): 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} 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 = ( + ref: RefObject, + 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([]); + const requestedHeadingTags = getHeadingTagsList(options); + const query = requestedHeadingTags.join(', '); + + useEffect(() => { + if (typeof window === 'undefined') return; + + const headingNodes = + ref.current?.querySelectorAll(query); + + if (headingNodes) setTree(buildHeadingsTreeFrom(headingNodes)); + }, [query, ref]); + + return tree; +}; -- cgit v1.2.3