aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2022-04-22 18:33:04 +0200
committerArmand Philippot <git@armandphilippot.com>2022-04-22 18:33:04 +0200
commit947a06bfdfdc5bca62c27fa2ee27f0ab9fefa0ea (patch)
tree3207696494c9564f7a3d9092ce83471717da7dac /src
parent52c185d0f23504fc6410cf36285968eff9e7b21f (diff)
chore: add a TableOfContents component
Diffstat (limited to 'src')
-rw-r--r--src/components/organisms/widgets/table-of-contents.stories.tsx41
-rw-r--r--src/components/organisms/widgets/table-of-contents.test.tsx12
-rw-r--r--src/components/organisms/widgets/table-of-contents.tsx53
-rw-r--r--src/utils/hooks/use-headings-tree.tsx153
4 files changed, 259 insertions, 0 deletions
diff --git a/src/components/organisms/widgets/table-of-contents.stories.tsx b/src/components/organisms/widgets/table-of-contents.stories.tsx
new file mode 100644
index 0000000..fba7c54
--- /dev/null
+++ b/src/components/organisms/widgets/table-of-contents.stories.tsx
@@ -0,0 +1,41 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import 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,
+ },
+ },
+ },
+ decorators: [
+ (Story) => (
+ <IntlProvider locale="en">
+ <Story />
+ </IntlProvider>
+ ),
+ ],
+} as ComponentMeta<typeof ToCWidget>;
+
+const Template: ComponentStory<typeof ToCWidget> = (args) => (
+ <ToCWidget {...args} />
+);
+
+/**
+ * Widgets Stories - Table of Contents
+ */
+export const TableOfContents = Template.bind({});
+TableOfContents.args = {};
diff --git a/src/components/organisms/widgets/table-of-contents.test.tsx b/src/components/organisms/widgets/table-of-contents.test.tsx
new file mode 100644
index 0000000..2064f39
--- /dev/null
+++ b/src/components/organisms/widgets/table-of-contents.test.tsx
@@ -0,0 +1,12 @@
+import { render, screen } from '@test-utils';
+import TableOfContents from './table-of-contents';
+
+describe('TableOfContents', () => {
+ it('renders the ToC title', () => {
+ const divEl = document.createElement('div');
+ render(<TableOfContents wrapper={divEl} />);
+ expect(
+ screen.getByRole('heading', { level: 2, name: /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
new file mode 100644
index 0000000..3778e02
--- /dev/null
+++ b/src/components/organisms/widgets/table-of-contents.tsx
@@ -0,0 +1,53 @@
+import useHeadingsTree, { type Heading } from '@utils/hooks/use-headings-tree';
+import { FC } from 'react';
+import { useIntl } from 'react-intl';
+import LinksListWidget, { type LinksListItems } from './links-list-widget';
+
+type TableOfContentsProps = {
+ /**
+ * A reference to the HTML element that contains the headings.
+ */
+ wrapper: HTMLElement;
+};
+
+/**
+ * Table of Contents widget component
+ *
+ * Render a table of contents.
+ */
+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[]): LinksListItems[] => {
+ return tree.map((heading) => {
+ return {
+ name: heading.title,
+ url: `#${heading.id}`,
+ child: getItems(heading.children),
+ };
+ });
+ };
+
+ return (
+ <LinksListWidget
+ kind="ordered"
+ title={title}
+ level={2}
+ items={getItems(headingsTree)}
+ />
+ );
+};
+
+export default TableOfContents;
diff --git a/src/utils/hooks/use-headings-tree.tsx b/src/utils/hooks/use-headings-tree.tsx
new file mode 100644
index 0000000..5506e8b
--- /dev/null
+++ b/src/utils/hooks/use-headings-tree.tsx
@@ -0,0 +1,153 @@
+import { slugify } from '@utils/helpers/slugify';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+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.
+ */
+const useHeadingsTree = (wrapper: HTMLElement): Heading[] => {
+ const depths = useMemo(() => ['h2', 'h3', 'h4', 'h5', 'h6'], []);
+ const [allHeadings, setAllHeadings] =
+ useState<NodeListOf<HTMLHeadingElement>>();
+ const [headingsTree, setHeadingsTree] = useState<Heading[]>([]);
+
+ useEffect(() => {
+ const query = depths.join(', ');
+ const result: NodeListOf<HTMLHeadingElement> =
+ wrapper.querySelectorAll(query);
+ setAllHeadings(result);
+ }, [depths, 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;
+};
+
+export default useHeadingsTree;