Skip to content

Commit 9aae448

Browse files
committed
UI & Core: Support multiple toc active items
1 parent 36b771b commit 9aae448

File tree

10 files changed

+136
-121
lines changed

10 files changed

+136
-121
lines changed

.changeset/tame-masks-jump.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'fumadocs-core': minor
3+
'fumadocs-ui': minor
4+
---
5+
6+
Support multiple toc active items

packages/core/src/toc.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ import { mergeRefs } from '@/utils/merge-refs';
66
import { useOnChange } from '@/utils/use-on-change';
77
import { useAnchorObserver } from './utils/use-anchor-observer';
88

9-
const ActiveAnchorContext = createContext<string | undefined>(undefined);
9+
const ActiveAnchorContext = createContext<string[]>([]);
1010

1111
const ScrollContext = createContext<RefObject<HTMLElement>>({ current: null });
1212

1313
/**
14-
* The id of active anchor (doesn't include hash)
14+
* The id of active anchors (doesn't include hash)
1515
*/
16-
export function useActiveAnchor(): string | undefined {
16+
export function useActiveAnchor(): string[] {
1717
return useContext(ActiveAnchorContext);
1818
}
1919

@@ -50,10 +50,8 @@ export function AnchorProvider({
5050
return toc.map((item) => item.url.split('#')[1]);
5151
}, [toc]);
5252

53-
const activeAnchor = useAnchorObserver(headings);
54-
5553
return (
56-
<ActiveAnchorContext.Provider value={activeAnchor}>
54+
<ActiveAnchorContext.Provider value={useAnchorObserver(headings)}>
5755
{children}
5856
</ActiveAnchorContext.Provider>
5957
);
@@ -73,7 +71,7 @@ export const TOCItem = forwardRef<HTMLAnchorElement, TOCItemProps>(
7371
const anchorRef = useRef<HTMLAnchorElement>(null);
7472
const mergedRef = mergeRefs(anchorRef, ref);
7573

76-
const isActive = activeAnchor === props.href.split('#')[1];
74+
const isActive = activeAnchor.includes(props.href.split('#')[1]);
7775

7876
useOnChange(isActive, (v) => {
7977
const element = anchorRef.current;

packages/core/src/utils/use-anchor-observer.ts

Lines changed: 14 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,44 +8,29 @@ import { useEffect, useState } from 'react';
88
* @param watch - An array of element ids to watch
99
* @returns Active anchor
1010
*/
11-
export function useAnchorObserver(watch: string[]): string | undefined {
12-
const [activeAnchor, setActiveAnchor] = useState<string>();
11+
export function useAnchorObserver(watch: string[]): string[] {
12+
const [activeAnchor, setActiveAnchor] = useState<string[]>([]);
1313

1414
useEffect(() => {
15+
let visible: string[] = [];
1516
const observer = new IntersectionObserver(
1617
(entries) => {
17-
setActiveAnchor((f) => {
18-
for (const entry of entries) {
19-
if (entry.isIntersecting) {
20-
return entry.target.id;
21-
}
18+
for (const entry of entries) {
19+
if (entry.isIntersecting && !visible.includes(entry.target.id)) {
20+
visible = [...visible, entry.target.id];
21+
} else if (
22+
!entry.isIntersecting &&
23+
visible.includes(entry.target.id)
24+
) {
25+
visible = visible.filter((v) => v !== entry.target.id);
2226
}
27+
}
2328

24-
// use the first item if not found
25-
return f ?? watch[0];
26-
});
29+
if (visible.length > 0) setActiveAnchor(visible);
2730
},
28-
{ rootMargin: `-80px 0% -78% 0%`, threshold: 1 },
31+
{ rootMargin: `-20px 0% -40% 0%`, threshold: 1 },
2932
);
3033

31-
const scroll = (): void => {
32-
const element = document.scrollingElement;
33-
if (!element) return;
34-
35-
if (element.scrollTop === 0) {
36-
setActiveAnchor(watch.at(0));
37-
} else if (
38-
element.scrollTop >=
39-
// assume you have a 10px margin
40-
element.scrollHeight - element.clientHeight - 10
41-
) {
42-
// select the last item when reached the bottom
43-
setActiveAnchor(watch.at(-1));
44-
}
45-
};
46-
47-
window.addEventListener('scroll', scroll);
48-
4934
for (const heading of watch) {
5035
const element = document.getElementById(heading);
5136

@@ -55,7 +40,6 @@ export function useAnchorObserver(watch: string[]): string | undefined {
5540
}
5641

5742
return () => {
58-
window.removeEventListener('scroll', scroll);
5943
observer.disconnect();
6044
};
6145
}, [watch]);

packages/ui/src/components/layout/dynamic-sidebar.tsx

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22
import { type PointerEventHandler, useCallback, useRef, useState } from 'react';
33
import { SidebarIcon } from 'lucide-react';
4+
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
45
import { Sidebar, type SidebarProps } from '@/components/layout/sidebar';
56
import { cn } from '@/utils/cn';
67
import { buttonVariants } from '@/theme/variants';
@@ -14,9 +15,12 @@ export function DynamicSidebar(props: SidebarProps): React.ReactElement {
1415

1516
const onCollapse = useCallback(() => {
1617
setCollapsed((v) => !v);
18+
}, [setCollapsed]);
19+
20+
useOnChange(collapsed, () => {
1721
setHover(false);
1822
closeTimeRef.current = Date.now() + 150;
19-
}, [setCollapsed]);
23+
});
2024

2125
const onEnter: PointerEventHandler = useCallback((e) => {
2226
if (e.pointerType === 'touch' || closeTimeRef.current > Date.now()) return;
@@ -82,25 +86,6 @@ export function DynamicSidebar(props: SidebarProps): React.ReactElement {
8286
],
8387
),
8488
}}
85-
footer={
86-
<>
87-
{props.footer}
88-
<button
89-
type="button"
90-
aria-label="Collapse Sidebar"
91-
className={cn(
92-
buttonVariants({
93-
color: 'ghost',
94-
size: 'icon',
95-
className: 'max-md:hidden',
96-
}),
97-
)}
98-
onClick={onCollapse}
99-
>
100-
<SidebarIcon />
101-
</button>
102-
</>
103-
}
10489
/>
10590
</>
10691
);

packages/ui/src/components/layout/root-toggle.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export function RootToggle({
3737

3838
return (
3939
<Popover open={open} onOpenChange={setOpen}>
40-
<PopoverTrigger className="-mx-2 flex flex-row items-center gap-2.5 rounded-lg p-2 hover:bg-fd-muted">
40+
<PopoverTrigger className="-mx-2 flex flex-row items-center gap-2.5 rounded-lg p-2 hover:bg-fd-accent/50 hover:text-fd-accent-foreground">
4141
<Item {...selected} />
4242
<ChevronDown className="size-4 text-fd-muted-foreground md:me-1.5" />
4343
</PopoverTrigger>

packages/ui/src/components/layout/sidebar.tsx

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ export function Sidebar({
122122
<div
123123
{...props.bannerProps}
124124
className={cn(
125-
'flex flex-col gap-2 px-4 pt-2 md:px-3 md:pt-4',
125+
'flex flex-col gap-1 px-4 pt-2 md:px-3 md:pt-4',
126126
props.bannerProps?.className,
127127
)}
128128
>
@@ -132,48 +132,45 @@ export function Sidebar({
132132
) : null}
133133
</div>
134134
) : null}
135-
<ViewportContent>
136-
{items.length > 0 && (
137-
<div className="flex flex-col md:hidden">
138-
{items.map((item, i) => (
139-
<LinkItem key={i} item={item} on="menu" />
140-
))}
141-
</div>
142-
)}
143-
</ViewportContent>
144-
<div
145-
{...props.footerProps}
146-
className={cn(
147-
'flex flex-row items-center border-t pb-2 pt-1 max-md:px-4 md:mx-3',
148-
props.footerProps?.className,
149-
)}
150-
>
151-
{props.footer}
152-
</div>
135+
<ViewportContent items={items} />
136+
{props.footer ? (
137+
<div
138+
{...props.footerProps}
139+
className={cn(
140+
'flex flex-row items-center border-t py-1 max-md:px-4 md:mx-3',
141+
props.footerProps?.className,
142+
)}
143+
>
144+
{props.footer}
145+
</div>
146+
) : null}
153147
</Base.SidebarList>
154148
</Context.Provider>
155149
);
156150
}
157151

158152
function ViewportContent({
159-
children,
153+
items,
160154
}: {
161-
children: React.ReactNode;
155+
items: LinkItemType[];
162156
}): React.ReactElement {
163157
const { root } = useTreeContext();
164158

165159
return (
166160
<ScrollArea className="flex-1">
167161
<ScrollViewport
168162
style={{
169-
maskImage:
170-
'linear-gradient(to bottom, transparent 2px, white 24px, white calc(100% - 24px), transparent calc(100% - 2px))',
163+
maskImage: 'linear-gradient(to bottom, transparent 2px, white 24px)',
171164
}}
172165
>
173-
<div className="flex flex-col gap-8 px-4 py-6 md:px-3">
174-
{children}
175-
<NodeList items={root.children} />
176-
</div>
166+
{items.length > 0 ? (
167+
<div className="flex flex-col px-4 pt-6 md:hidden">
168+
{items.map((item, i) => (
169+
<LinkItem key={i} item={item} on="menu" />
170+
))}
171+
</div>
172+
) : null}
173+
<NodeList items={root.children} className="px-4 py-6 md:px-3" />
177174
</ScrollViewport>
178175
</ScrollArea>
179176
);

packages/ui/src/components/layout/theme-toggle.tsx

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,15 @@ import { useTheme } from 'next-themes';
44
import { useCallback, type ButtonHTMLAttributes } from 'react';
55
import { cn } from '@/utils/cn';
66

7-
const buttonVariants = cva(
8-
'size-7 rounded-full p-1.5 text-fd-muted-foreground',
9-
{
10-
variants: {
11-
dark: {
12-
true: 'dark:bg-fd-accent dark:text-fd-accent-foreground',
13-
false:
14-
'bg-fd-accent text-fd-accent-foreground dark:bg-transparent dark:text-fd-muted-foreground',
15-
},
7+
const buttonVariants = cva('size-6 rounded-full p-1 text-fd-muted-foreground', {
8+
variants: {
9+
dark: {
10+
true: 'dark:bg-fd-accent dark:text-fd-accent-foreground',
11+
false:
12+
'bg-fd-accent text-fd-accent-foreground dark:bg-transparent dark:text-fd-muted-foreground',
1613
},
1714
},
18-
);
15+
});
1916

2017
export function ThemeToggle({
2118
className,

packages/ui/src/components/layout/toc.tsx

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,7 @@ export function TocPopover({ items, header, footer }: TOCProps): ReactElement {
5858
const { text } = useI18n();
5959
const active = Primitive.useActiveAnchor();
6060
const current = useMemo(() => {
61-
if (!active) return;
62-
return items.find((item) => item.url === `#${active}`)?.title;
61+
return items.find((item) => active.includes(item.url.slice(1)))?.title;
6362
}, [items, active]);
6463

6564
return (
@@ -99,18 +98,30 @@ function TOCItems({
9998
}): React.ReactElement {
10099
const { text } = useI18n();
101100
const containerRef = useRef<HTMLDivElement>(null);
102-
const [pos, setPos] = useState<PosType>();
101+
const [pos, setPos] = useState<PosType>([0, 0]);
103102
const active = Primitive.useActiveAnchor();
104103

105104
useLayoutEffect(() => {
106105
const container = containerRef.current;
107-
if (!active || !container) return;
106+
if (active.length === 0 || !container || container.clientHeight === 0) {
107+
setPos([0, 0]);
108+
return;
109+
}
108110

109-
const element: HTMLElement | null = container.querySelector(
110-
`a[href="#${active}"]`,
111-
);
112-
if (!element) return;
113-
setPos([element.offsetTop, element.clientHeight]);
111+
let upper = Number.MAX_VALUE,
112+
lower = 0;
113+
114+
for (const item of active) {
115+
const element: HTMLElement | null = container.querySelector(
116+
`a[href="#${item}"]`,
117+
);
118+
if (!element) continue;
119+
120+
upper = Math.min(upper, element.offsetTop);
121+
lower = Math.max(lower, element.offsetTop + element.clientHeight);
122+
}
123+
124+
setPos([upper, lower - upper]);
114125
}, [active]);
115126

116127
if (items.length === 0)
@@ -125,18 +136,17 @@ function TOCItems({
125136
<ScrollViewport className="relative min-h-0 text-sm" ref={containerRef}>
126137
<div
127138
role="none"
128-
className="absolute start-0 w-0.5 bg-fd-primary transition-all"
139+
className="absolute start-0 w-px bg-fd-primary transition-all"
129140
style={{
130-
top: pos ? `${pos[0].toString()}px` : undefined,
131-
height: pos ? `${pos[1].toString()}px` : undefined,
132-
display: pos ? 'block' : 'hidden',
141+
top: `${pos[0].toString()}px`,
142+
height: `${pos[1].toString()}px`,
133143
}}
134144
/>
135145
<Primitive.ScrollProvider containerRef={containerRef}>
136146
<div
137147
className={cn(
138148
'flex flex-col gap-1 text-fd-muted-foreground',
139-
!isMenu && 'border-s-2 border-fd-foreground/10',
149+
!isMenu && 'border-s border-fd-foreground/10',
140150
)}
141151
>
142152
{items.map((item) => (
@@ -154,10 +164,10 @@ function TOCItem({ item }: { item: TOCItemType }): React.ReactElement {
154164
<Primitive.TOCItem
155165
href={item.url}
156166
className={cn(
157-
'py-1 transition-colors [overflow-wrap:anywhere] data-[active=true]:font-medium data-[active=true]:text-fd-primary',
158-
item.depth <= 2 && 'ps-4',
159-
item.depth === 3 && 'ps-7',
160-
item.depth >= 4 && 'ps-10',
167+
'py-0.5 transition-colors [overflow-wrap:anywhere] data-[active=true]:text-fd-primary',
168+
item.depth <= 2 && 'ps-3.5',
169+
item.depth === 3 && 'ps-6',
170+
item.depth >= 4 && 'ps-8',
161171
)}
162172
>
163173
{item.title}

0 commit comments

Comments
 (0)