Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 25 additions & 13 deletions src/components/core/TableOfContent/Anchor.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useTableOfContent } from 'components/core/TableOfContent/TableOfContent';
import React, { useEffect, useId } from 'react';
import React, { useEffect, useId, useMemo } from 'react';

export type AnchorProps = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
anchor?: string;
Expand All @@ -9,21 +9,33 @@ export type AnchorProps = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivEl
};

export const Anchor: React.FC<AnchorProps> = React.memo(
({ anchor = null, label = '', subheader = false, children = null, disabled = false, ...props }: AnchorProps) => {
const id = useId();
({ anchor = null, label = '', subheader = false, disabled = false, children = null, ...props }: AnchorProps) => {
const autoID = useId();
const actualID = useMemo(() => anchor || autoID, [anchor, autoID]);

const loadAnchors = useTableOfContent()?.loadAnchors;
const toc = useTableOfContent();
const loadAnchors = toc?.loadAnchors;

useEffect(() => {
if (disabled) return;
loadAnchors({ id: anchor || id, label: label.toString(), subheader });
return () => loadAnchors({});
}, [anchor, disabled, id, label, loadAnchors, subheader]);

return disabled ? (
children
) : (
<div data-anchor={anchor || id} {...props}>
if (!loadAnchors || disabled) return;

const labelText = typeof label === 'string' || typeof label === 'number' ? String(label) : '';

loadAnchors({
id: actualID,
label: labelText,
subheader
});

return () => {
loadAnchors({});
};
}, [actualID, disabled, label, subheader, loadAnchors]);

if (disabled) return <>{children}</>;

return (
<div data-anchor={actualID} {...props}>
{children}
</div>
);
Expand Down
248 changes: 145 additions & 103 deletions src/components/core/TableOfContent/TableOfContent.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import { createFormContext } from 'components/core/form/createFormContext';
import React, { useCallback, useContext, useEffect, useMemo, useRef } from 'react';

export type TableOfContentAnchor = {
id: string;
label: string;
subheader: boolean;
};

export type TableOfContentStore = {
activeID: string;
anchors: { id: string; label: string; subheader: boolean }[];
activeID: string | null;
anchors: TableOfContentAnchor[];
};

export type TableOfContentContextProps = {
rootRef: React.MutableRefObject<HTMLDivElement | null>;
headerRef: React.MutableRefObject<HTMLDivElement | null>;
loadAnchors: (props?: Partial<TableOfContentAnchor>) => void;
scrollTo: (event: React.SyntheticEvent, id: string) => void;
Anchors: React.FC<{ children: (anchors: TableOfContentAnchor[]) => React.ReactNode }>;
ActiveAnchor: React.FC<{ activeID: string | null; children: (active: boolean) => React.ReactNode }>;
};

const TABLE_OF_CONTENT_STORE: TableOfContentStore = Object.freeze({
Expand All @@ -15,115 +30,142 @@ const { FormProvider, useForm } = createFormContext<TableOfContentStore>({
defaultValues: structuredClone(TABLE_OF_CONTENT_STORE)
});

export type TableOfContentContextProps = {
rootRef: React.MutableRefObject<HTMLDivElement>;
headerRef: React.MutableRefObject<HTMLDivElement>;
loadAnchors: (props?: { id?: string; label?: string; subheader?: boolean }) => void;
scrollTo: (event: React.SyntheticEvent, activeAnchor: string) => void;
Anchors: React.FC<{ children: (anchors: TableOfContentStore['anchors']) => React.ReactNode }>;
ActiveAnchor: React.FC<{
activeID: TableOfContentStore['activeID'];
children: (active: boolean) => React.ReactNode;
}>;
};

export const TableOfContentContext = React.createContext<TableOfContentContextProps>(null);
export const TableOfContentContext = React.createContext<TableOfContentContextProps | null>(null);

export function useTableOfContent(): TableOfContentContextProps {
return useContext(TableOfContentContext);
}
export const useTableOfContent = (): TableOfContentContextProps => {
const ctx = useContext(TableOfContentContext);
if (!ctx) {
throw new Error('useTableOfContent must be used inside <TableOfContentProvider>');
}
return ctx;
};

export type TableOfContentProps = {
behavior?: ScrollOptions['behavior'];
children?: React.ReactNode;
};

export const TableOfContent: React.FC<TableOfContentProps> = React.memo(
({ behavior = 'smooth', children = null }: TableOfContentProps) => {
const form = useForm();

const rootRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);

const Anchors = useMemo<TableOfContentContextProps['Anchors']>(
() =>
({ children: render }) => (
<form.Subscribe selector={state => state.values.anchors} children={anchors => render(anchors)} />
),
[form]
);

const ActiveAnchor = useMemo<TableOfContentContextProps['ActiveAnchor']>(
() =>
({ activeID, children: render }) => (
<form.Subscribe selector={state => activeID === state.values.activeID} children={active => render(active)} />
),
[form]
);

const findActive = useCallback(() => {
const elements = rootRef.current?.querySelectorAll('[data-anchor]');
for (let i = elements.length - 1; i >= 0; i--) {
if (
elements.item(i).getBoundingClientRect().top - 2 <=
rootRef.current.getBoundingClientRect().top + headerRef.current.getBoundingClientRect().height
) {
form.setFieldValue('activeID', elements.item(i).getAttribute('data-anchor'));
break;
}
export const TableOfContent: React.FC<TableOfContentProps> = React.memo(({ behavior = 'smooth', children }) => {
const form = useForm();

const rootRef = useRef<HTMLDivElement | null>(null);
const headerRef = useRef<HTMLDivElement | null>(null);

const Anchors = useMemo<TableOfContentContextProps['Anchors']>(
() =>
({ children: render }) => (
<form.Subscribe selector={s => s.values.anchors} children={anchors => render(anchors)} />
),
[form]
);

const ActiveAnchor = useMemo<TableOfContentContextProps['ActiveAnchor']>(
() =>
({ activeID, children: render }) => (
<form.Subscribe selector={s => s.values.activeID === activeID} children={isActive => render(isActive)} />
),
[form]
);

const findActive = useCallback(() => {
const root = rootRef.current;
const header = headerRef.current;
if (!root || !header) return;

const headerOffset = header.getBoundingClientRect().height;
const rootTop = root.getBoundingClientRect().top;

const elements = root.querySelectorAll('[data-anchor]');
for (let i = elements.length - 1; i >= 0; i--) {
const el = elements.item(i);
if (!el) continue;

if (el.getBoundingClientRect().top - 2 <= rootTop + headerOffset) {
const id = el.getAttribute('data-anchor');
if (id) form.setFieldValue('activeID', id);
break;
}
}, [form]);

const loadAnchors = useCallback<TableOfContentContextProps['loadAnchors']>(
({ id = null, label = null, subheader = false }) => {
const elements = rootRef.current?.querySelectorAll('[data-anchor]');
const prevAnchors = form.getFieldValue('anchors');
const nextAnchors: TableOfContentStore['anchors'] = [];

(elements || []).forEach((element: Element) => {
const anchorID = element.getAttribute('data-anchor');
const index = prevAnchors.findIndex(a => a.id === anchorID);
if (id === anchorID) nextAnchors.push({ id, label, subheader });
else if (index >= 0) nextAnchors.push(prevAnchors[index]);
});

form.setFieldValue('activeID', null);
form.setFieldValue('anchors', nextAnchors);
},
[form]
);

const scrollTo = useCallback<TableOfContentContextProps['scrollTo']>(
(event, activeAnchor) => {
event.preventDefault();
event.stopPropagation();

const element: HTMLDivElement = rootRef.current.querySelector("[data-anchor='" + activeAnchor + "']");
rootRef.current.scrollTo({
top: element.offsetTop - rootRef.current.offsetTop - headerRef.current.getBoundingClientRect().height,
behavior: behavior
});
},
[behavior]
);

useEffect(() => {
const rootElement = rootRef.current;
if (!rootElement) return;

rootElement.addEventListener('scroll', findActive, false);
return () => {
rootElement.removeEventListener('scroll', findActive, false);
};
}, [findActive]);

return (
<TableOfContentContext.Provider value={{ rootRef, headerRef, loadAnchors, scrollTo, Anchors, ActiveAnchor }}>
{children}
</TableOfContentContext.Provider>
);
}
);
}
}, [form]);

const loadAnchors = useCallback<TableOfContentContextProps['loadAnchors']>(
({ id, label, subheader = false } = {}) => {
const root = rootRef.current;
if (!root) return;

const elements = root.querySelectorAll('[data-anchor]');
const previous = form.getFieldValue('anchors');
const next: TableOfContentAnchor[] = [];

elements.forEach(el => {
const anchorID = el.getAttribute('data-anchor');
if (!anchorID) return;

if (id === anchorID) {
// Updated anchor
next.push({
id,
label: label ?? '',
subheader
});
} else {
// Existing anchor
const previousIndex = previous.findIndex(a => a.id === anchorID);
if (previousIndex >= 0) next.push(previous[previousIndex]);
}
});

form.setFieldValue('activeID', null);
form.setFieldValue('anchors', next);
},
[form]
);

const scrollTo = useCallback<TableOfContentContextProps['scrollTo']>(
(event, id) => {
event.preventDefault();
event.stopPropagation();

const root = rootRef.current;
const header = headerRef.current;
if (!root || !header) return;

const el = root.querySelector<HTMLDivElement>(`[data-anchor='${id}']`);
if (!el) return;

const headerOffset = header.getBoundingClientRect().height;

root.scrollTo({
top: el.offsetTop - root.offsetTop - headerOffset,
behavior
});
},
[behavior]
);

useEffect(() => {
const root = rootRef.current;
if (!root) return;

root.addEventListener('scroll', findActive, { passive: true });
return () => root.removeEventListener('scroll', findActive);
}, [findActive]);

return (
<TableOfContentContext.Provider
value={{
rootRef,
headerRef,
loadAnchors,
scrollTo,
Anchors,
ActiveAnchor
}}
>
{children}
</TableOfContentContext.Provider>
);
});

export const TableOfContentProvider = React.memo((props: TableOfContentProps) => (
<FormProvider>
Expand Down
Loading