diff --git a/CHANGELOG.md b/CHANGELOG.md index b07d11b..04190a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,16 @@ 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 - **Modal**, **Slideover**, **Drawer**, **Popover** — with `portal={false}`, a Select, DatePicker, DropdownMenu, Tooltip or other floating layer opened inside the container no longer renders behind it when an ancestor sets a `z-index` above 50. Nested layers now render in the container's stacking context, above its panel. Portalled containers are unchanged. ([#217](https://github.com/ndlabdev/sv5ui/issues/217)) - **DatePicker**, **DateRangePicker** — the calendar is now anchored to the whole field instead of the calendar icon at its end, so with the default `align="start"` it opens flush under the field's left edge rather than floating to the right over neighbouring content. `align="center"` now centres on the field; `align="end"` is unchanged. ([#218](https://github.com/ndlabdev/sv5ui/issues/218)) +- **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 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