From 06dc8af5bbc1d28afe7ef883db32688313ccaeff Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Thu, 17 Sep 2026 23:30:45 +0700 Subject: [PATCH] fix(navigation-menu): open the dropdown panel under its trigger A horizontal NavigationMenu rendered every dropdown in one shared panel pinned to the menu's left edge, so a trigger near the end opened a panel hundreds of pixels away, detached from it when the content was narrow. The panel now follows the open trigger. The component measures the trigger's inline start and width; CSS places the panel with clamp() so it stays inside the menu, using the panel width the viewport already exposes. It slides between triggers, appears in place when the menu first opens, follows the list when it scrolls, and respects dir="rtl". Below the sm breakpoint the panel keeps its full width. A new align prop ('start' | 'center' | 'end', default 'center') chooses where the panel sits relative to the trigger. The docs page gains a Dropdown Alignment section to switch it live. The list also reserved 8px under its items for the overlay scrollbar at all times. That room is now added only while the list overflows, so a menu that fits no longer shows the gap. Closes #220 --- CHANGELOG.md | 9 ++ .../NavigationMenu/NavigationMenu.svelte | 94 +++++++++++++++++- .../NavigationMenu.svelte.spec.ts | 96 +++++++++++++++++++ .../NavigationMenu/navigation-menu.types.ts | 7 ++ .../navigation-menu.variants.ts | 11 ++- src/routes/navigation-menu/+page.svelte | 53 ++++++++++ 6 files changed, 264 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5344d0e..d55c50f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **NavigationMenu** — `align` prop (`'start'`, `'center'`, `'end'`, default `'center'`) places the dropdown panel of a horizontal menu relative to the open trigger. ([#220](https://github.com/ndlabdev/sv5ui/issues/220)) + +### Fixed + +- **NavigationMenu** — the dropdown panel of a horizontal menu now opens under the trigger that opened it instead of always at the menu's left edge. It slides between triggers, stays inside the menu when the trigger sits near an edge, follows the list when it scrolls, and respects `dir="rtl"`. Below the `sm` breakpoint the panel keeps its full width. ([#220](https://github.com/ndlabdev/sv5ui/issues/220)) +- **NavigationMenu** — a horizontal menu no longer leaves an 8px gap under its items. The room for the scrollbar is now reserved only while the list overflows and can scroll, which also brings the dropdown 8px closer to the trigger. ([#220](https://github.com/ndlabdev/sv5ui/issues/220)) + ## [2.7.0] - 2026-09-09 ### Added diff --git a/src/lib/components/NavigationMenu/NavigationMenu.svelte b/src/lib/components/NavigationMenu/NavigationMenu.svelte index 4e6beaf..642e1a1 100644 --- a/src/lib/components/NavigationMenu/NavigationMenu.svelte +++ b/src/lib/components/NavigationMenu/NavigationMenu.svelte @@ -11,6 +11,7 @@ import { slide } from 'svelte/transition' import { page } from '$app/state' import { useResizeObserver } from '../../hooks/useResizeObserver/index.js' + import { useEventListener } from '../../hooks/useEventListener/index.js' import { navigationMenuVariants, navigationMenuDefaults } from './navigation-menu.variants.js' import type { NavigationMenuItem, NavigationMenuChildItem } from './navigation-menu.types.js' import { getComponentConfig, iconsDefaults } from '../../config.js' @@ -50,6 +51,7 @@ tooltip = false, popover = false, contentOrientation = 'horizontal', + align = 'center', labelKey = 'label', exact = true, delayDuration = 0, @@ -220,6 +222,7 @@ collapsed: effectiveCollapsed, stacked, contentOrientation, + align, disabled }) ) @@ -329,7 +332,20 @@ rafId = requestAnimationFrame(updateIndicator) } - useResizeObserver(() => listEl, scheduleIndicator) + let listOverflow = $state(false) + + function updateListOverflow() { + const viewport = listEl?.closest('[data-scroll-area-viewport]') + listOverflow = !!viewport && viewport.scrollWidth > viewport.clientWidth + } + + useResizeObserver( + () => listEl, + () => { + scheduleIndicator() + updateListOverflow() + } + ) $effect(() => { void value @@ -340,7 +356,67 @@ if (showHighlight) tick().then(updateIndicator) }) - $effect(() => () => cancelAnimationFrame(rafId)) + const VIEWPORT_OPEN_MS = 250 + + let viewportAnchor = $state('') + let viewportInstant = $state(false) + let anchorRafId = 0 + let instantTimer: ReturnType | undefined + let wasOpen = false + + function updateViewportAnchor() { + const trigger = listEl?.querySelector( + '[data-navigation-menu-trigger][data-state="open"]' + ) + if (!ref || !trigger) return + const root = ref.getBoundingClientRect() + const rect = trigger.getBoundingClientRect() + const start = + getComputedStyle(ref).direction === 'rtl' + ? root.right - rect.right + : rect.left - root.left + viewportAnchor = `--sv5ui-nav-trigger-start:${start}px;--sv5ui-nav-trigger-width:${rect.width}px` + } + + function scheduleViewportAnchor() { + if (!horizontalValue) return + cancelAnimationFrame(anchorRafId) + anchorRafId = requestAnimationFrame(updateViewportAnchor) + } + + $effect(() => { + if (!horizontalValue) { + wasOpen = false + return + } + if (!wasOpen) { + wasOpen = true + viewportInstant = true + clearTimeout(instantTimer) + instantTimer = setTimeout(() => (viewportInstant = false), VIEWPORT_OPEN_MS) + } + tick().then(updateViewportAnchor) + }) + + useResizeObserver( + () => ref, + () => { + scheduleViewportAnchor() + updateListOverflow() + } + ) + useEventListener( + () => listEl?.closest('[data-scroll-area-viewport]'), + 'scroll', + scheduleViewportAnchor, + { passive: true } + ) + + $effect(() => () => { + cancelAnimationFrame(rafId) + cancelAnimationFrame(anchorRafId) + clearTimeout(instantTimer) + }) $effect(() => () => clearTimeout(flyoutTimer)) @@ -482,7 +558,12 @@ class={classes.root} {...restProps as Record} > - + {#if showHighlight}
- +
{/snippet} diff --git a/src/lib/components/NavigationMenu/NavigationMenu.svelte.spec.ts b/src/lib/components/NavigationMenu/NavigationMenu.svelte.spec.ts index 9aff531..826971f 100644 --- a/src/lib/components/NavigationMenu/NavigationMenu.svelte.spec.ts +++ b/src/lib/components/NavigationMenu/NavigationMenu.svelte.spec.ts @@ -1,3 +1,4 @@ +import '../../../routes/layout.css' import { describe, expect, it, vi } from 'vitest' import { createRawSnippet } from 'svelte' import { render } from 'vitest-browser-svelte' @@ -498,4 +499,99 @@ describe('NavigationMenu', () => { expect(container.querySelectorAll('[data-test-action]').length).toBe(2) }) }) + + describe('dropdown panel position', () => { + const navItems: NavigationMenuItem[] = [ + 'Alpha', + 'Bravo', + 'Charlie', + 'Delta', + 'Echo', + 'Foxtrot' + ].map((label, i) => ({ + label, + children: [ + { label: `${label} one`, href: `#${i}-1` }, + { label: `${label} two`, href: `#${i}-2` } + ] + })) + + function measure(label: string) { + const trigger = [...document.querySelectorAll('[data-navigation-menu-trigger]')] + .find((el) => el.textContent?.trim() === label)! + .getBoundingClientRect() + const panel = document + .querySelector('[data-navigation-menu-viewport]')! + .getBoundingClientRect() + const nav = document.querySelector('nav')!.getBoundingClientRect() + return { trigger, panel, nav } + } + + async function openMenu(label: string, align?: 'start' | 'center' | 'end') { + await page.viewport(1280, 800) + render(NavigationMenu, { items: navItems, contentOrientation: 'vertical', align }) + await page.getByRole('button', { name: label, exact: true }).click() + await vi.waitFor(() => + expect(document.querySelector('[data-navigation-menu-viewport]')).not.toBeNull() + ) + } + + it('centres the panel on the open trigger by default', async () => { + await openMenu('Delta') + await vi.waitFor(() => { + const { trigger, panel } = measure('Delta') + const offset = panel.left + panel.width / 2 - (trigger.left + trigger.width / 2) + expect(Math.abs(offset)).toBeLessThanOrEqual(1) + }) + }) + + it('aligns the panel start with the trigger when align is start', async () => { + await openMenu('Delta', 'start') + await vi.waitFor(() => { + const { trigger, panel } = measure('Delta') + expect(Math.abs(panel.left - trigger.left)).toBeLessThanOrEqual(1) + }) + }) + + it('aligns the panel end with the trigger when align is end', async () => { + await openMenu('Delta', 'end') + await vi.waitFor(() => { + const { trigger, panel } = measure('Delta') + expect(Math.abs(panel.right - trigger.right)).toBeLessThanOrEqual(1) + }) + }) + + it('keeps the panel inside the menu for a trigger at its edge', async () => { + await openMenu('Alpha') + await vi.waitFor(() => { + const { panel, nav } = measure('Alpha') + expect(Math.abs(panel.left - nav.left)).toBeLessThanOrEqual(1) + expect(panel.right).toBeLessThanOrEqual(nav.right + 1) + }) + }) + }) + + describe('scroll spacing', () => { + const getScrollRoot = () => + document.querySelector('[data-scroll-area-viewport]')!.parentElement as HTMLElement + + it('reserves room for the scrollbar only while the list overflows', async () => { + await page.viewport(1280, 800) + const { container } = render(NavigationMenu, { items: linkItems }) + await vi.waitFor(() => { + expect(getScrollRoot().hasAttribute('data-overflow')).toBe(false) + expect(getComputedStyle(getScrollRoot()).paddingBottom).toBe('0px') + }) + container.style.width = '120px' + await vi.waitFor(() => { + expect(getScrollRoot().hasAttribute('data-overflow')).toBe(true) + expect(getComputedStyle(getScrollRoot()).paddingBottom).toBe('8px') + }) + container.style.width = '' + await vi.waitFor(() => { + expect(getScrollRoot().hasAttribute('data-overflow')).toBe(false) + expect(getComputedStyle(getScrollRoot()).paddingBottom).toBe('0px') + }) + }) + }) }) diff --git a/src/lib/components/NavigationMenu/navigation-menu.types.ts b/src/lib/components/NavigationMenu/navigation-menu.types.ts index 9ed2cc8..59fa693 100644 --- a/src/lib/components/NavigationMenu/navigation-menu.types.ts +++ b/src/lib/components/NavigationMenu/navigation-menu.types.ts @@ -227,6 +227,13 @@ export interface NavigationMenuProps extends Omit< /** Layout of the mega-menu content (horizontal only). @default 'horizontal' */ contentOrientation?: NonNullable + /** + * Where the dropdown panel sits relative to the open trigger (horizontal only). + * The panel stays inside the menu, so a wide panel shifts back when it would overflow. + * @default 'center' + */ + align?: NonNullable + /** Item field used as the label. @default 'label' */ labelKey?: string diff --git a/src/lib/components/NavigationMenu/navigation-menu.variants.ts b/src/lib/components/NavigationMenu/navigation-menu.variants.ts index 036992e..aa8a382 100644 --- a/src/lib/components/NavigationMenu/navigation-menu.variants.ts +++ b/src/lib/components/NavigationMenu/navigation-menu.variants.ts @@ -4,7 +4,7 @@ export const navigationMenuVariants = tv({ slots: { root: 'relative', list: 'relative isolate min-w-0', - scroll: 'w-full pb-2', + scroll: 'w-full data-[overflow]:pb-2', highlight: 'absolute z-0 rounded-full transition-all duration-200', item: 'min-w-0', label: 'w-full flex items-center gap-1.5 px-2.5 pt-3 pb-1 font-semibold text-sm text-on-surface-variant', @@ -55,7 +55,8 @@ export const navigationMenuVariants = tv({ viewport: [ 'relative h-(--bits-navigation-menu-viewport-height) w-full origin-[top_center] overflow-hidden sm:w-(--bits-navigation-menu-viewport-width)', 'rounded-xl border border-outline-variant bg-surface-container-low shadow-lg', - 'transition-[width,height] duration-200', + 'sm:[inset-inline-start:clamp(0px,calc(var(--sv5ui-nav-trigger-start)_+_(var(--sv5ui-nav-trigger-width)_-_var(--bits-navigation-menu-viewport-width))_*_var(--sv5ui-nav-align)),calc(100%_-_var(--bits-navigation-menu-viewport-width)))]', + 'transition-[width,height,inset-inline-start] duration-200 data-[instant]:transition-none', 'data-[state=open]:animate-[nav-scale-in_200ms_ease]', 'data-[state=closed]:animate-[nav-scale-out_200ms_ease]' ], @@ -120,6 +121,11 @@ export const navigationMenuVariants = tv({ horizontal: {}, vertical: {} }, + align: { + start: { viewport: '[--sv5ui-nav-align:0]' }, + center: { viewport: '[--sv5ui-nav-align:0.5]' }, + end: { viewport: '[--sv5ui-nav-align:1]' } + }, disabled: { true: '', false: '' @@ -283,6 +289,7 @@ export const navigationMenuVariants = tv({ collapsed: false, stacked: false, contentOrientation: 'horizontal', + align: 'center', disabled: false } }) diff --git a/src/routes/navigation-menu/+page.svelte b/src/routes/navigation-menu/+page.svelte index 87f8cdb..8cca8ae 100644 --- a/src/routes/navigation-menu/+page.svelte +++ b/src/routes/navigation-menu/+page.svelte @@ -295,6 +295,26 @@ } ] + const alignOptions = ['start', 'center', 'end'] as const + let alignValue = $state<(typeof alignOptions)[number]>('center') + + const alignItems: NavigationMenuItem[] = [ + ['Products', 'lucide:package'], + ['Solutions', 'lucide:lightbulb'], + ['Resources', 'lucide:book-open'], + ['Customers', 'lucide:users'], + ['Company', 'lucide:building-2'], + ['Support', 'lucide:life-buoy'] + ].map(([label, icon]) => ({ + label, + icon, + children: [ + { label: `${label} overview`, href: `#${label.toLowerCase()}-overview` }, + { label: `${label} updates`, href: `#${label.toLowerCase()}-updates` }, + { label: `${label} contact`, href: `#${label.toLowerCase()}-contact` } + ] + })) + const featuredItems: NavigationMenuItem[] = [ { label: 'Solutions', value: 'solutions', type: 'trigger' }, { label: 'Enterprise', href: '/enterprise' } @@ -598,6 +618,39 @@ + +
+

Dropdown Alignment

+

+ The dropdown panel opens under the trigger that opened it. Use align + to place it at the trigger's + start, + center + (default) or + end. + Near the edges of the menu the panel shifts back so it never overflows, and it slides + from one trigger to the next. +

+
+ {#each alignOptions as option (option)} +
+
+ +
+
+

Variants