"use client"; import * as React from "react"; import { PanelSurface, ScrollFade } from "@repo/ui"; import { controlsPanelLeftPaddingPx, getNavigationLayout, getSectionElementById, hiddenNavigationLayout, navigationLayoutsEqual, pointerIsInsideNavigationRegion, type ControlsPanelSectionNavigationItem, type NavigationLayout, } from "./controls-panel-section-navigation-layout"; export type { ControlsPanelSectionNavigationItem } from "./controls-panel-section-navigation-layout"; type ControlsPanelSectionNavigationProps = { items: readonly ControlsPanelSectionNavigationItem[]; rootElement: HTMLDivElement | null; }; const controlsPanelContentSelector = '[data-slot="toolcraft-panel-content"]'; const controlsPanelSelector = '[data-panel-id="properties"]'; const controlsPanelSectionAnchorSelector = "[data-toolcraft-controls-section-anchor]"; const controlsPanelHoverIntentDelayMs = 300; const controlsPanelLeaveGraceMs = 50; const navigationSurfacePaddingPx = 16; const navigationSurfaceTransitionDurationMs = 120; export function ControlsPanelSectionNavigation({ items, rootElement, }: ControlsPanelSectionNavigationProps): React.JSX.Element | null { const [layout, setLayout] = React.useState( hiddenNavigationLayout, ); const [navigationMounted, setNavigationMounted] = React.useState(false); const [navigationOpen, setNavigationOpen] = React.useState(false); const exitTimerRef = React.useRef(null); const hideTimerRef = React.useRef(null); const pointerInHotZoneRef = React.useRef(false); const revealTimerRef = React.useRef(null); const navigationButtonRefs = React.useRef( new Map(), ); const navigationSurfaceRef = React.useRef(null); const viewportRef = React.useRef(null); const sectionElementsRef = React.useRef([]); const clearHideTimer = React.useCallback((): void => { if (hideTimerRef.current !== null) { window.clearTimeout(hideTimerRef.current); hideTimerRef.current = null; } }, []); const clearExitTimer = React.useCallback((): void => { if (exitTimerRef.current !== null) { window.clearTimeout(exitTimerRef.current); exitTimerRef.current = null; } }, []); const clearRevealTimer = React.useCallback((): void => { if (revealTimerRef.current !== null) { window.clearTimeout(revealTimerRef.current); revealTimerRef.current = null; } }, []); const showNavigation = React.useCallback((): void => { clearExitTimer(); setNavigationMounted(true); setNavigationOpen(true); }, [clearExitTimer]); const startNavigationExit = React.useCallback((): void => { clearExitTimer(); setNavigationOpen(false); exitTimerRef.current = window.setTimeout(() => { exitTimerRef.current = null; setNavigationMounted(false); }, navigationSurfaceTransitionDurationMs); }, [clearExitTimer]); const scheduleHide = React.useCallback((): void => { clearHideTimer(); hideTimerRef.current = window.setTimeout(() => { hideTimerRef.current = null; startNavigationExit(); }, controlsPanelLeaveGraceMs); }, [clearHideTimer, startNavigationExit]); React.useLayoutEffect(() => { if (!rootElement || items.length === 0) { viewportRef.current = null; sectionElementsRef.current = []; setLayout(hiddenNavigationLayout); return undefined; } let animationFrame = 0; let viewport: HTMLElement | null = null; let sectionElements: readonly HTMLElement[] = []; const updateLayout = (): void => { animationFrame = 0; const nextLayout = getNavigationLayout({ items, rootElement, sectionElements, viewport, }); if (!nextLayout.available) { clearHideTimer(); clearExitTimer(); clearRevealTimer(); pointerInHotZoneRef.current = false; setNavigationMounted(false); setNavigationOpen(false); } setLayout((current) => navigationLayoutsEqual(current, nextLayout) ? current : nextLayout, ); }; const scheduleLayoutUpdate = (): void => { if (animationFrame !== 0) { return; } animationFrame = window.requestAnimationFrame(updateLayout); }; const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleLayoutUpdate); const refreshObservedElements = (): void => { const nextViewport = rootElement.querySelector(controlsPanelContentSelector); if (viewport !== nextViewport) { viewport?.removeEventListener("scroll", scheduleLayoutUpdate); nextViewport?.addEventListener("scroll", scheduleLayoutUpdate, { passive: true, }); viewport = nextViewport; viewportRef.current = nextViewport; } sectionElements = Array.from( rootElement.querySelectorAll( controlsPanelSectionAnchorSelector, ), ); sectionElementsRef.current = sectionElements; resizeObserver?.disconnect(); resizeObserver?.observe(rootElement); if (viewport) { resizeObserver?.observe(viewport); } sectionElements.forEach((sectionElement) => { resizeObserver?.observe(sectionElement); }); scheduleLayoutUpdate(); }; const mutationObserver = typeof MutationObserver === "undefined" ? null : new MutationObserver(refreshObservedElements); refreshObservedElements(); mutationObserver?.observe(rootElement, { childList: true, subtree: true }); window.addEventListener("resize", scheduleLayoutUpdate); return () => { if (animationFrame !== 0) { window.cancelAnimationFrame(animationFrame); } viewport?.removeEventListener("scroll", scheduleLayoutUpdate); window.removeEventListener("resize", scheduleLayoutUpdate); mutationObserver?.disconnect(); resizeObserver?.disconnect(); viewportRef.current = null; sectionElementsRef.current = []; }; }, [clearExitTimer, clearHideTimer, clearRevealTimer, items, rootElement]); React.useEffect(() => { if (!rootElement || items.length === 0) { clearHideTimer(); clearExitTimer(); clearRevealTimer(); pointerInHotZoneRef.current = false; setNavigationMounted(false); setNavigationOpen(false); return undefined; } const panelElement = rootElement.querySelector(controlsPanelSelector); if (!panelElement) { clearExitTimer(); setNavigationMounted(false); setNavigationOpen(false); return undefined; } const startRevealIntent = (): void => { clearRevealTimer(); revealTimerRef.current = window.setTimeout(() => { revealTimerRef.current = null; if (pointerInHotZoneRef.current) { showNavigation(); } }, controlsPanelHoverIntentDelayMs); }; const handlePanelPointerPosition = (event: PointerEvent): void => { const panelRect = panelElement.getBoundingClientRect(); const withinPanelHeight = event.clientY >= panelRect.top && event.clientY <= panelRect.bottom; const withinHotZone = withinPanelHeight && event.clientX >= panelRect.left && event.clientX <= Math.min(panelRect.right, panelRect.left + controlsPanelLeftPaddingPx); if (withinHotZone === pointerInHotZoneRef.current) { return; } pointerInHotZoneRef.current = withinHotZone; if (withinHotZone) { clearHideTimer(); startRevealIntent(); return; } clearRevealTimer(); scheduleHide(); }; const handlePanelPointerLeave = (): void => { pointerInHotZoneRef.current = false; clearRevealTimer(); scheduleHide(); }; panelElement.addEventListener("pointerenter", handlePanelPointerPosition); panelElement.addEventListener("pointermove", handlePanelPointerPosition); panelElement.addEventListener("pointerleave", handlePanelPointerLeave); return () => { panelElement.removeEventListener( "pointerenter", handlePanelPointerPosition, ); panelElement.removeEventListener("pointermove", handlePanelPointerPosition); panelElement.removeEventListener("pointerleave", handlePanelPointerLeave); clearHideTimer(); clearExitTimer(); clearRevealTimer(); pointerInHotZoneRef.current = false; }; }, [ clearHideTimer, clearExitTimer, clearRevealTimer, items.length, rootElement, scheduleHide, showNavigation, ]); React.useEffect(() => { if (!navigationMounted || !navigationOpen || !rootElement) { return undefined; } const panelElement = rootElement.querySelector(controlsPanelSelector); if (!panelElement) { return undefined; } const handleDocumentPointerMove = (event: PointerEvent): void => { const surfaceElement = navigationSurfaceRef.current; if ( surfaceElement && pointerIsInsideNavigationRegion({ clientX: event.clientX, clientY: event.clientY, panelRect: panelElement.getBoundingClientRect(), surfaceRect: surfaceElement.getBoundingClientRect(), }) ) { clearHideTimer(); return; } scheduleHide(); }; document.addEventListener("pointermove", handleDocumentPointerMove, { passive: true, }); return () => { document.removeEventListener("pointermove", handleDocumentPointerMove); }; }, [ clearHideTimer, navigationMounted, navigationOpen, rootElement, scheduleHide, ]); const handleNavigationPointerEnter = React.useCallback((): void => { clearHideTimer(); }, [clearHideTimer]); const handleNavigationPointerLeave = React.useCallback((): void => { clearRevealTimer(); pointerInHotZoneRef.current = false; scheduleHide(); }, [clearRevealTimer, scheduleHide]); const navigateToSection = React.useCallback((id: string): void => { const viewport = viewportRef.current; const sectionElement = getSectionElementById( sectionElementsRef.current, id, ); if (!viewport || !sectionElement) { return; } const top = viewport.scrollTop + sectionElement.getBoundingClientRect().top - viewport.getBoundingClientRect().top; setLayout((current) => ({ ...current, activeId: id })); viewport.scrollTo({ behavior: "auto", top, }); }, []); const handleNavigationKeyDown = React.useCallback( (event: React.KeyboardEvent, itemIndex: number): void => { if ( !navigationOpen || (event.key !== "ArrowDown" && event.key !== "ArrowUp") ) { return; } event.preventDefault(); event.stopPropagation(); const direction = event.key === "ArrowDown" ? 1 : -1; const nextIndex = (itemIndex + direction + items.length) % items.length; const nextItem = items[nextIndex]; if (!nextItem) { return; } navigateToSection(nextItem.id); navigationButtonRefs.current.get(nextItem.id)?.focus(); }, [items, navigateToSection, navigationOpen], ); if (!layout.available || !navigationMounted) { return null; } return (
); }