From 01037f5bd37df43257d1f67a4b7aaf6bfdcda715 Mon Sep 17 00:00:00 2001 From: Ravi <103035973+raviships@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:49:05 +0900 Subject: [PATCH] fix: open hidden docs sections for anchor links Co-Authored-By: Claude Opus 5.5 --- docs/src/components/setup-guide.tsx | 63 ++----------------------- docs/src/components/ui/open-tabs.tsx | 57 ++++++++++++++++------ docs/src/hooks/use-reveal-anchor.ts | 70 ++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 73 deletions(-) create mode 100644 docs/src/hooks/use-reveal-anchor.ts diff --git a/docs/src/components/setup-guide.tsx b/docs/src/components/setup-guide.tsx index 65c66efe..c210a344 100644 --- a/docs/src/components/setup-guide.tsx +++ b/docs/src/components/setup-guide.tsx @@ -1,67 +1,14 @@ 'use client'; +import { useRevealAnchor } from '@/hooks/use-reveal-anchor'; import { ChevronRight } from 'lucide-react'; -import { useEffect, useRef, type ReactNode } from 'react'; +import { useRef, type ReactNode } from 'react'; export function SetupGuide({ children }: { children: ReactNode }) { const details = useRef(null); - - useEffect(() => { - let frame: number | undefined; - - function reveal(hash: string) { - let id: string; - try { - id = decodeURIComponent(hash.slice(1)); - } catch { - return; - } - const target = document.getElementById(id); - if (!target || !details.current?.contains(target)) return; - details.current.open = true; - if (frame !== undefined) cancelAnimationFrame(frame); - frame = requestAnimationFrame(() => - target.scrollIntoView({ block: 'start' }), - ); - } - - function onHashChange() { - reveal(window.location.hash); - } - - // Also handle clicks on the current hash after the reader closes the guide. - function onClick(event: MouseEvent) { - if ( - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey - ) - return; - const link = - event.target instanceof Element ? event.target.closest('a') : null; - if (!link || link.target === '_blank' || link.hasAttribute('download')) - return; - const url = new URL(link.href, window.location.href); - if ( - url.origin === window.location.origin && - url.pathname === window.location.pathname && - url.search === window.location.search - ) { - reveal(url.hash); - } - } - - onHashChange(); - window.addEventListener('hashchange', onHashChange); - document.addEventListener('click', onClick, true); - return () => { - window.removeEventListener('hashchange', onHashChange); - document.removeEventListener('click', onClick, true); - if (frame !== undefined) cancelAnimationFrame(frame); - }; - }, []); + useRevealAnchor(details, () => { + if (details.current) details.current.open = true; + }); return (
diff --git a/docs/src/components/ui/open-tabs.tsx b/docs/src/components/ui/open-tabs.tsx index 9cafdea3..4a7b660f 100644 --- a/docs/src/components/ui/open-tabs.tsx +++ b/docs/src/components/ui/open-tabs.tsx @@ -1,10 +1,28 @@ 'use client'; +import { useRevealAnchor } from '@/hooks/use-reveal-anchor'; import { cn } from '@/lib/utils'; import * as TabsPrimitive from '@radix-ui/react-tabs'; import * as React from 'react'; -const OpenTabs = TabsPrimitive.Root; +const SelectTabContext = React.createContext<((value: string) => void) | null>( + null, +); + +function OpenTabs({ + defaultValue, + ...props +}: Omit< + React.ComponentProps, + 'value' | 'onValueChange' +>) { + const [value, setValue] = React.useState(defaultValue); + return ( + + + + ); +} const OpenTabsList = React.forwardRef< React.ComponentRef, @@ -36,19 +54,28 @@ const OpenTabsTrigger = React.forwardRef< )); OpenTabsTrigger.displayName = TabsPrimitive.Trigger.displayName; -const OpenTabsContent = React.forwardRef< - React.ComponentRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -OpenTabsContent.displayName = TabsPrimitive.Content.displayName; +// Inactive panels stay mounted so links to headings inside them can open them. +function OpenTabsContent({ + className, + value, + ...props +}: Omit, 'forceMount'>) { + const selectTab = React.use(SelectTabContext); + const content = React.useRef(null); + useRevealAnchor(content, () => selectTab?.(value)); + + return ( + + ); +} export { OpenTabs, OpenTabsList, OpenTabsTrigger, OpenTabsContent }; diff --git a/docs/src/hooks/use-reveal-anchor.ts b/docs/src/hooks/use-reveal-anchor.ts new file mode 100644 index 00000000..44068cc2 --- /dev/null +++ b/docs/src/hooks/use-reveal-anchor.ts @@ -0,0 +1,70 @@ +'use client'; + +import { useEffect, useEffectEvent, type RefObject } from 'react'; +import { flushSync } from 'react-dom'; + +/** + * Calls `reveal` when the URL hash targets an element inside `container`, then + * scrolls to the target. Use it for content hidden behind collapsed sections or + * inactive tabs, so direct links and table of contents links still work. + */ +export function useRevealAnchor( + container: RefObject, + reveal: () => void, +) { + const onReveal = useEffectEvent(reveal); + + useEffect(() => { + function revealHash(hash: string) { + let id: string; + try { + id = decodeURIComponent(hash.slice(1)); + } catch { + return; + } + const target = id ? document.getElementById(id) : null; + if (!target || !container.current?.contains(target)) return; + if (target.checkVisibility()) return; + flushSync(onReveal); + target.scrollIntoView({ block: 'start' }); + } + + function onHashChange() { + revealHash(window.location.hash); + } + + // Clicking a link to the current hash does not fire `hashchange`. + function onClick(event: MouseEvent) { + if ( + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) + return; + const link = + event.target instanceof Element ? event.target.closest('a') : null; + if (!link || link.target === '_blank' || link.hasAttribute('download')) + return; + const url = new URL(link.href, window.location.href); + if ( + url.origin === window.location.origin && + url.pathname === window.location.pathname && + url.search === window.location.search + ) { + revealHash(url.hash); + } + } + + // Defer the initial check so `flushSync` runs outside React's commit. + const timeout = setTimeout(onHashChange); + window.addEventListener('hashchange', onHashChange); + document.addEventListener('click', onClick, true); + return () => { + clearTimeout(timeout); + window.removeEventListener('hashchange', onHashChange); + document.removeEventListener('click', onClick, true); + }; + }, [container]); +}