Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
94 changes: 90 additions & 4 deletions src/lib/components/NavigationMenu/NavigationMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -50,6 +51,7 @@
tooltip = false,
popover = false,
contentOrientation = 'horizontal',
align = 'center',
labelKey = 'label',
exact = true,
delayDuration = 0,
Expand Down Expand Up @@ -220,6 +222,7 @@
collapsed: effectiveCollapsed,
stacked,
contentOrientation,
align,
disabled
})
)
Expand Down Expand Up @@ -329,7 +332,20 @@
rafId = requestAnimationFrame(updateIndicator)
}

useResizeObserver(() => listEl, scheduleIndicator)
let listOverflow = $state(false)

function updateListOverflow() {
const viewport = listEl?.closest<HTMLElement>('[data-scroll-area-viewport]')
listOverflow = !!viewport && viewport.scrollWidth > viewport.clientWidth
}

useResizeObserver(
() => listEl,
() => {
scheduleIndicator()
updateListOverflow()
}
)

$effect(() => {
void value
Expand All @@ -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<typeof setTimeout> | undefined
let wasOpen = false

function updateViewportAnchor() {
const trigger = listEl?.querySelector<HTMLElement>(
'[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<HTMLElement>('[data-scroll-area-viewport]'),
'scroll',
scheduleViewportAnchor,
{ passive: true }
)

$effect(() => () => {
cancelAnimationFrame(rafId)
cancelAnimationFrame(anchorRafId)
clearTimeout(instantTimer)
})
$effect(() => () => clearTimeout(flyoutTimer))
</script>

Expand Down Expand Up @@ -482,7 +558,12 @@
class={classes.root}
{...restProps as Record<string, unknown>}
>
<ScrollArea orientation="horizontal" class={classes.scroll} {...scrollArea}>
<ScrollArea
orientation="horizontal"
class={classes.scroll}
data-overflow={listOverflow || undefined}
{...scrollArea}
>
<Bits.List bind:ref={listEl} class={classes.list}>
{#if showHighlight}
<div
Expand Down Expand Up @@ -567,7 +648,12 @@
</Bits.List>
</ScrollArea>
<div class={classes.viewportWrapper}>
<Bits.Viewport {...contentProps} class={classes.viewport} />
<Bits.Viewport
{...contentProps}
class={classes.viewport}
style={viewportAnchor}
data-instant={viewportInstant || undefined}
/>
</div>
</Bits.Root>
{/snippet}
Expand Down
96 changes: 96 additions & 0 deletions src/lib/components/NavigationMenu/NavigationMenu.svelte.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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')
})
})
})
})
7 changes: 7 additions & 0 deletions src/lib/components/NavigationMenu/navigation-menu.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,13 @@ export interface NavigationMenuProps extends Omit<
/** Layout of the mega-menu content (horizontal only). @default 'horizontal' */
contentOrientation?: NonNullable<NavigationMenuVariantProps['contentOrientation']>

/**
* 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<NavigationMenuVariantProps['align']>

/** Item field used as the label. @default 'label' */
labelKey?: string

Expand Down
11 changes: 9 additions & 2 deletions src/lib/components/NavigationMenu/navigation-menu.variants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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]'
],
Expand Down Expand Up @@ -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: ''
Expand Down Expand Up @@ -283,6 +289,7 @@ export const navigationMenuVariants = tv({
collapsed: false,
stacked: false,
contentOrientation: 'horizontal',
align: 'center',
disabled: false
}
})
Expand Down
53 changes: 53 additions & 0 deletions src/routes/navigation-menu/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down Expand Up @@ -598,6 +618,39 @@
<NavigationMenu items={groupedMega} />
</section>

<!-- Dropdown alignment -->
<section class="space-y-4">
<h2 class="text-lg font-semibold text-on-surface">Dropdown Alignment</h2>
<p class="text-sm text-on-surface-variant">
The dropdown panel opens under the trigger that opened it. Use <code
class="rounded bg-surface-container-highest px-1.5 py-0.5 text-xs">align</code
>
to place it at the trigger's
<code class="rounded bg-surface-container-highest px-1.5 py-0.5 text-xs">start</code>,
<code class="rounded bg-surface-container-highest px-1.5 py-0.5 text-xs">center</code>
(default) or
<code class="rounded bg-surface-container-highest px-1.5 py-0.5 text-xs">end</code>.
Near the edges of the menu the panel shifts back so it never overflows, and it slides
from one trigger to the next.
</p>
<div class="flex flex-wrap items-center gap-2">
{#each alignOptions as option (option)}
<Button
size="sm"
variant={alignValue === option ? 'solid' : 'outline'}
label={option}
onclick={() => (alignValue = option)}
/>
{/each}
<code class="ms-2 rounded bg-surface-container-highest px-1.5 py-0.5 text-xs"
>align="{alignValue}"</code
>
</div>
<div class="rounded-lg border border-outline-variant p-2">
<NavigationMenu items={alignItems} align={alignValue} contentOrientation="vertical" />
</div>
</section>

<!-- Variants -->
<section class="space-y-4">
<h2 class="text-lg font-semibold text-on-surface">Variants</h2>
Expand Down
Loading