UI & Core: Support multiple toc active items

This commit is contained in:
fuma-nama
2024-08-14 18:17:48 +08:00
parent 36b771be99
commit 9aae448a41
10 changed files with 136 additions and 121 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'fumadocs-core': minor
'fumadocs-ui': minor
---
Support multiple toc active items
+5 -7
View File
@@ -6,14 +6,14 @@ import { mergeRefs } from '@/utils/merge-refs';
import { useOnChange } from '@/utils/use-on-change';
import { useAnchorObserver } from './utils/use-anchor-observer';
const ActiveAnchorContext = createContext<string | undefined>(undefined);
const ActiveAnchorContext = createContext<string[]>([]);
const ScrollContext = createContext<RefObject<HTMLElement>>({ current: null });
/**
* The id of active anchor (doesn't include hash)
* The id of active anchors (doesn't include hash)
*/
export function useActiveAnchor(): string | undefined {
export function useActiveAnchor(): string[] {
return useContext(ActiveAnchorContext);
}
@@ -50,10 +50,8 @@ export function AnchorProvider({
return toc.map((item) => item.url.split('#')[1]);
}, [toc]);
const activeAnchor = useAnchorObserver(headings);
return (
<ActiveAnchorContext.Provider value={activeAnchor}>
<ActiveAnchorContext.Provider value={useAnchorObserver(headings)}>
{children}
</ActiveAnchorContext.Provider>
);
@@ -73,7 +71,7 @@ export const TOCItem = forwardRef<HTMLAnchorElement, TOCItemProps>(
const anchorRef = useRef<HTMLAnchorElement>(null);
const mergedRef = mergeRefs(anchorRef, ref);
const isActive = activeAnchor === props.href.split('#')[1];
const isActive = activeAnchor.includes(props.href.split('#')[1]);
useOnChange(isActive, (v) => {
const element = anchorRef.current;
+14 -30
View File
@@ -8,44 +8,29 @@ import { useEffect, useState } from 'react';
* @param watch - An array of element ids to watch
* @returns Active anchor
*/
export function useAnchorObserver(watch: string[]): string | undefined {
const [activeAnchor, setActiveAnchor] = useState<string>();
export function useAnchorObserver(watch: string[]): string[] {
const [activeAnchor, setActiveAnchor] = useState<string[]>([]);
useEffect(() => {
let visible: string[] = [];
const observer = new IntersectionObserver(
(entries) => {
setActiveAnchor((f) => {
for (const entry of entries) {
if (entry.isIntersecting) {
return entry.target.id;
}
for (const entry of entries) {
if (entry.isIntersecting && !visible.includes(entry.target.id)) {
visible = [...visible, entry.target.id];
} else if (
!entry.isIntersecting &&
visible.includes(entry.target.id)
) {
visible = visible.filter((v) => v !== entry.target.id);
}
}
// use the first item if not found
return f ?? watch[0];
});
if (visible.length > 0) setActiveAnchor(visible);
},
{ rootMargin: `-80px 0% -78% 0%`, threshold: 1 },
{ rootMargin: `-20px 0% -40% 0%`, threshold: 1 },
);
const scroll = (): void => {
const element = document.scrollingElement;
if (!element) return;
if (element.scrollTop === 0) {
setActiveAnchor(watch.at(0));
} else if (
element.scrollTop >=
// assume you have a 10px margin
element.scrollHeight - element.clientHeight - 10
) {
// select the last item when reached the bottom
setActiveAnchor(watch.at(-1));
}
};
window.addEventListener('scroll', scroll);
for (const heading of watch) {
const element = document.getElementById(heading);
@@ -55,7 +40,6 @@ export function useAnchorObserver(watch: string[]): string | undefined {
}
return () => {
window.removeEventListener('scroll', scroll);
observer.disconnect();
};
}, [watch]);
@@ -1,6 +1,7 @@
'use client';
import { type PointerEventHandler, useCallback, useRef, useState } from 'react';
import { SidebarIcon } from 'lucide-react';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import { Sidebar, type SidebarProps } from '@/components/layout/sidebar';
import { cn } from '@/utils/cn';
import { buttonVariants } from '@/theme/variants';
@@ -14,9 +15,12 @@ export function DynamicSidebar(props: SidebarProps): React.ReactElement {
const onCollapse = useCallback(() => {
setCollapsed((v) => !v);
}, [setCollapsed]);
useOnChange(collapsed, () => {
setHover(false);
closeTimeRef.current = Date.now() + 150;
}, [setCollapsed]);
});
const onEnter: PointerEventHandler = useCallback((e) => {
if (e.pointerType === 'touch' || closeTimeRef.current > Date.now()) return;
@@ -82,25 +86,6 @@ export function DynamicSidebar(props: SidebarProps): React.ReactElement {
],
),
}}
footer={
<>
{props.footer}
<button
type="button"
aria-label="Collapse Sidebar"
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon',
className: 'max-md:hidden',
}),
)}
onClick={onCollapse}
>
<SidebarIcon />
</button>
</>
}
/>
</>
);
@@ -37,7 +37,7 @@ export function RootToggle({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger className="-mx-2 flex flex-row items-center gap-2.5 rounded-lg p-2 hover:bg-fd-muted">
<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">
<Item {...selected} />
<ChevronDown className="size-4 text-fd-muted-foreground md:me-1.5" />
</PopoverTrigger>
+24 -27
View File
@@ -122,7 +122,7 @@ export function Sidebar({
<div
{...props.bannerProps}
className={cn(
'flex flex-col gap-2 px-4 pt-2 md:px-3 md:pt-4',
'flex flex-col gap-1 px-4 pt-2 md:px-3 md:pt-4',
props.bannerProps?.className,
)}
>
@@ -132,33 +132,27 @@ export function Sidebar({
) : null}
</div>
) : null}
<ViewportContent>
{items.length > 0 && (
<div className="flex flex-col md:hidden">
{items.map((item, i) => (
<LinkItem key={i} item={item} on="menu" />
))}
</div>
)}
</ViewportContent>
<div
{...props.footerProps}
className={cn(
'flex flex-row items-center border-t pb-2 pt-1 max-md:px-4 md:mx-3',
props.footerProps?.className,
)}
>
{props.footer}
</div>
<ViewportContent items={items} />
{props.footer ? (
<div
{...props.footerProps}
className={cn(
'flex flex-row items-center border-t py-1 max-md:px-4 md:mx-3',
props.footerProps?.className,
)}
>
{props.footer}
</div>
) : null}
</Base.SidebarList>
</Context.Provider>
);
}
function ViewportContent({
children,
items,
}: {
children: React.ReactNode;
items: LinkItemType[];
}): React.ReactElement {
const { root } = useTreeContext();
@@ -166,14 +160,17 @@ function ViewportContent({
<ScrollArea className="flex-1">
<ScrollViewport
style={{
maskImage:
'linear-gradient(to bottom, transparent 2px, white 24px, white calc(100% - 24px), transparent calc(100% - 2px))',
maskImage: 'linear-gradient(to bottom, transparent 2px, white 24px)',
}}
>
<div className="flex flex-col gap-8 px-4 py-6 md:px-3">
{children}
<NodeList items={root.children} />
</div>
{items.length > 0 ? (
<div className="flex flex-col px-4 pt-6 md:hidden">
{items.map((item, i) => (
<LinkItem key={i} item={item} on="menu" />
))}
</div>
) : null}
<NodeList items={root.children} className="px-4 py-6 md:px-3" />
</ScrollViewport>
</ScrollArea>
);
@@ -4,18 +4,15 @@ import { useTheme } from 'next-themes';
import { useCallback, type ButtonHTMLAttributes } from 'react';
import { cn } from '@/utils/cn';
const buttonVariants = cva(
'size-7 rounded-full p-1.5 text-fd-muted-foreground',
{
variants: {
dark: {
true: 'dark:bg-fd-accent dark:text-fd-accent-foreground',
false:
'bg-fd-accent text-fd-accent-foreground dark:bg-transparent dark:text-fd-muted-foreground',
},
const buttonVariants = cva('size-6 rounded-full p-1 text-fd-muted-foreground', {
variants: {
dark: {
true: 'dark:bg-fd-accent dark:text-fd-accent-foreground',
false:
'bg-fd-accent text-fd-accent-foreground dark:bg-transparent dark:text-fd-muted-foreground',
},
},
);
});
export function ThemeToggle({
className,
+28 -18
View File
@@ -58,8 +58,7 @@ export function TocPopover({ items, header, footer }: TOCProps): ReactElement {
const { text } = useI18n();
const active = Primitive.useActiveAnchor();
const current = useMemo(() => {
if (!active) return;
return items.find((item) => item.url === `#${active}`)?.title;
return items.find((item) => active.includes(item.url.slice(1)))?.title;
}, [items, active]);
return (
@@ -99,18 +98,30 @@ function TOCItems({
}): React.ReactElement {
const { text } = useI18n();
const containerRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<PosType>();
const [pos, setPos] = useState<PosType>([0, 0]);
const active = Primitive.useActiveAnchor();
useLayoutEffect(() => {
const container = containerRef.current;
if (!active || !container) return;
if (active.length === 0 || !container || container.clientHeight === 0) {
setPos([0, 0]);
return;
}
const element: HTMLElement | null = container.querySelector(
`a[href="#${active}"]`,
);
if (!element) return;
setPos([element.offsetTop, element.clientHeight]);
let upper = Number.MAX_VALUE,
lower = 0;
for (const item of active) {
const element: HTMLElement | null = container.querySelector(
`a[href="#${item}"]`,
);
if (!element) continue;
upper = Math.min(upper, element.offsetTop);
lower = Math.max(lower, element.offsetTop + element.clientHeight);
}
setPos([upper, lower - upper]);
}, [active]);
if (items.length === 0)
@@ -125,18 +136,17 @@ function TOCItems({
<ScrollViewport className="relative min-h-0 text-sm" ref={containerRef}>
<div
role="none"
className="absolute start-0 w-0.5 bg-fd-primary transition-all"
className="absolute start-0 w-px bg-fd-primary transition-all"
style={{
top: pos ? `${pos[0].toString()}px` : undefined,
height: pos ? `${pos[1].toString()}px` : undefined,
display: pos ? 'block' : 'hidden',
top: `${pos[0].toString()}px`,
height: `${pos[1].toString()}px`,
}}
/>
<Primitive.ScrollProvider containerRef={containerRef}>
<div
className={cn(
'flex flex-col gap-1 text-fd-muted-foreground',
!isMenu && 'border-s-2 border-fd-foreground/10',
!isMenu && 'border-s border-fd-foreground/10',
)}
>
{items.map((item) => (
@@ -154,10 +164,10 @@ function TOCItem({ item }: { item: TOCItemType }): React.ReactElement {
<Primitive.TOCItem
href={item.url}
className={cn(
'py-1 transition-colors [overflow-wrap:anywhere] data-[active=true]:font-medium data-[active=true]:text-fd-primary',
item.depth <= 2 && 'ps-4',
item.depth === 3 && 'ps-7',
item.depth >= 4 && 'ps-10',
'py-0.5 transition-colors [overflow-wrap:anywhere] data-[active=true]:text-fd-primary',
item.depth <= 2 && 'ps-3.5',
item.depth === 3 && 'ps-6',
item.depth >= 4 && 'ps-8',
)}
>
{item.title}
+25 -1
View File
@@ -1,7 +1,8 @@
'use client';
import { SidebarTrigger } from 'fumadocs-core/sidebar';
import { Menu, X } from 'lucide-react';
import { Menu, SidebarIcon, X } from 'lucide-react';
import { useCallback } from 'react';
import { useSidebar } from '@/contexts/sidebar';
import { useSearchContext } from '@/contexts/search';
import { SearchToggle } from '@/components/layout/search-toggle';
@@ -44,6 +45,29 @@ export function SubNav({
);
}
export function SidebarCollapseTrigger(): React.ReactElement {
const { setCollapsed } = useSidebar();
return (
<button
type="button"
aria-label="Collapse Sidebar"
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon',
className: 'ms-auto max-md:hidden',
}),
)}
onClick={useCallback(() => {
setCollapsed((prev) => !prev);
}, [setCollapsed])}
>
<SidebarIcon />
</button>
);
}
export { LinksMenu } from '@/components/layout/link-item';
export { Sidebar } from './components/layout/sidebar';
export { TreeContextProvider } from './contexts/tree';
+21 -7
View File
@@ -12,10 +12,11 @@ import { type BaseLayoutProps, getLinks } from './layout.shared';
declare const {
TreeContextProvider,
SidebarCollapseTrigger,
ThemeToggle,
SubNav,
LinksMenu,
Sidebar,
ThemeToggle,
}: typeof import('./docs-layout.client');
// We can use dynamic imports to avoid loading a client component when they are not used
@@ -71,12 +72,14 @@ export function DocsLayout({
const Aside = collapsible ? DynamicSidebar : Sidebar;
const banner: ReactNode[] = [];
const footer: ReactNode[] = [];
if (nav?.title)
banner.push(
<Link
key="title"
href={nav.url ?? '/'}
className="inline-flex items-center gap-2.5 font-medium"
className="inline-flex items-center gap-2.5 py-1 font-medium"
>
{nav.title}
</Link>,
@@ -99,6 +102,16 @@ export function DocsLayout({
</LinksMenu>,
);
footer.push(<ThemeToggle />);
if (i18n) {
footer.push(<LanguageToggle key="i18n" />);
}
if (collapsible) {
footer.push(<SidebarCollapseTrigger key="sidebar" />);
}
return (
<TreeContextProvider tree={props.tree}>
{replaceOrDefault(nav, <SubNav {...nav} />)}
@@ -132,11 +145,12 @@ export function DocsLayout({
),
}}
footer={
<>
<ThemeToggle className="me-auto" />
{i18n ? <LanguageToggle /> : null}
{sidebar.footer}
</>
footer.length > 0 || sidebar.footer ? (
<>
{sidebar.footer}
{footer}
</>
) : null
}
/>,
)}