From 0473ff85ea27f35e058c9ff90d15a6b90deb8393 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Tue, 15 Sep 2026 11:47:23 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(header):=20=E2=9C=A8=20add=20navigatio?= =?UTF-8?q?n=20menu=20with=20dropdown=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/ui/navigation-menu.tsx | 142 +++++++++++++++++- .../vitnode/src/tanstack/layout/header.tsx | 53 ++++++- .../layouts/theme/header/header-content.tsx | 15 +- .../layouts/theme/header/header-nav-menu.tsx | 80 ++++++++++ .../views/layouts/theme/header/header-nav.ts | 10 +- 5 files changed, 274 insertions(+), 26 deletions(-) create mode 100644 packages/vitnode/src/views/layouts/theme/header/header-nav-menu.tsx diff --git a/packages/vitnode/src/components/ui/navigation-menu.tsx b/packages/vitnode/src/components/ui/navigation-menu.tsx index e11382a5f..6d6ee2b45 100644 --- a/packages/vitnode/src/components/ui/navigation-menu.tsx +++ b/packages/vitnode/src/components/ui/navigation-menu.tsx @@ -26,19 +26,141 @@ function NavigationMenu({ ); } +const HIGHLIGHT_SELECTOR = + '[data-slot="navigation-menu-trigger"], [data-slot="navigation-menu-link"]'; +const OPEN_TRIGGER_SELECTOR = + '[data-slot="navigation-menu-trigger"][data-popup-open]'; +const ACTIVE_LINK_SELECTOR = + '[data-slot="navigation-menu-link"][data-active]:not([data-slot="navigation-menu-content"] *)'; + function NavigationMenuList({ className, + children, + onFocus, + onPointerLeave, + onPointerOver, + ref, ...props }: React.ComponentProps) { + const listRef = React.useRef(null); + const highlightRef = React.useRef(null); + const pointerInsideRef = React.useRef(false); + + const moveHighlightTo = React.useCallback((item: HTMLElement) => { + const list = listRef.current; + const highlight = highlightRef.current; + if (!list || !highlight) return; + + const listBox = list.getBoundingClientRect(); + const itemBox = item.getBoundingClientRect(); + const wasVisible = highlight.dataset.visible !== undefined; + + if (!wasVisible) highlight.dataset.instant = ""; + + highlight.style.height = `${itemBox.height.toString()}px`; + highlight.style.top = `${(itemBox.top - listBox.top).toString()}px`; + highlight.style.translate = `${(itemBox.left - listBox.left).toString()}px`; + highlight.style.width = `${itemBox.width.toString()}px`; + + if (!wasVisible) { + highlight.getBoundingClientRect(); + delete highlight.dataset.instant; + } + + highlight.dataset.visible = ""; + }, []); + + const hideHighlight = React.useCallback(() => { + const highlight = highlightRef.current; + if (highlight) delete highlight.dataset.visible; + }, []); + + const itemUnder = (target: EventTarget | null) => { + const list = listRef.current; + if (!list || !(target instanceof Element)) return null; + + const item = target.closest(HIGHLIGHT_SELECTOR); + + return item && list.contains(item) ? item : null; + }; + + const settleHighlight = React.useCallback(() => { + const list = listRef.current; + const resting = + list?.querySelector(OPEN_TRIGGER_SELECTOR) ?? + list?.querySelector(ACTIVE_LINK_SELECTOR); + + if (resting) { + moveHighlightTo(resting); + + return; + } + + hideHighlight(); + }, [hideHighlight, moveHighlightTo]); + + React.useEffect(() => { + const list = listRef.current; + if (!list) return; + + const settleWhenIdle = () => { + if (!pointerInsideRef.current) settleHighlight(); + }; + + const attributes = new MutationObserver(settleWhenIdle); + attributes.observe(list, { + attributeFilter: ["data-active", "data-popup-open"], + attributes: true, + subtree: true, + }); + + const resize = new ResizeObserver(settleWhenIdle); + resize.observe(list); + + return () => { + attributes.disconnect(); + resize.disconnect(); + }; + }, [settleHighlight]); + return ( { + const item = itemUnder(event.target); + if (item) moveHighlightTo(item); + onFocus?.(event); + }} + onPointerLeave={event => { + pointerInsideRef.current = false; + settleHighlight(); + onPointerLeave?.(event); + }} + onPointerOver={event => { + pointerInsideRef.current = true; + const item = itemUnder(event.target); + if (item) moveHighlightTo(item); + onPointerOver?.(event); + }} + ref={node => { + listRef.current = node; + if (typeof ref === "function") return ref(node); + if (ref) ref.current = node; + }} {...props} - /> + > + + {children} + ); } @@ -56,7 +178,7 @@ function NavigationMenuItem({ } const navigationMenuTriggerStyle = cva( - "group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted", + "group/navigation-menu-trigger relative inline-flex h-9 w-max items-center justify-center rounded-md px-3 py-2 text-sm font-medium transition-all outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:after:absolute data-popup-open:after:inset-y-0 data-popup-open:after:-inset-x-(--navigation-menu-gap) data-popup-open:after:content-['']", ); function NavigationMenuTrigger({ @@ -73,7 +195,7 @@ function NavigationMenuTrigger({ {children}{" "}