mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
UI & Core: Support multiple toc active items
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'fumadocs-core': minor
|
||||
'fumadocs-ui': minor
|
||||
---
|
||||
|
||||
Support multiple toc active items
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>,
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user