Skip to content
Open
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
19 changes: 9 additions & 10 deletions src/runtime/components/NavigationMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export interface NavigationMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'cu
/**
* The type of the item.
* The `label` type is only displayed in `vertical` orientation.
* The `trigger` type is used to force the item to be collapsible when its a link in `vertical` orientation.
* In `vertical` orientation, items with `children` are always rendered as collapsible accordion triggers; the `trigger` type is kept for backward compatibility.
* @defaultValue 'link'
*/
type?: 'label' | 'trigger' | 'link'
Expand Down Expand Up @@ -329,11 +329,12 @@ function onLinkTrailingClick(e: Event, item: NavigationMenuItem) {
return
}

// In `horizontal` orientation the trailing area lives inside the link/trigger,
// so prevent navigation to let the trigger toggle the menu instead.
// In `vertical` orientation the whole row is an `AccordionTrigger`, so the
// click must bubble up to toggle the accordion (no `stopPropagation`).
if (props.orientation === 'horizontal') {
e.preventDefault()
} else if (props.orientation === 'vertical' && !props.collapsed) {
e.preventDefault()
e.stopPropagation()
}
}
</script>
Expand Down Expand Up @@ -367,10 +368,8 @@ function onLinkTrailingClick(e: Event, item: NavigationMenuItem) {
<UIcon v-if="item.target === '_blank' && props.externalIcon !== false" :name="typeof props.externalIcon === 'string' ? props.externalIcon : appConfig.ui.icons.external" data-slot="linkLabelExternalIcon" :class="ui.linkLabelExternalIcon({ class: [props.ui?.linkLabelExternalIcon, item.ui?.linkLabelExternalIcon], active })" />
</span>

<component
:is="props.orientation === 'vertical' && item.children?.length && !props.collapsed ? AccordionTrigger : 'span'"
<span
v-if="(item.badge || item.badge === 0) || (props.orientation === 'horizontal' && (item.children?.length || !!slots[(item.slot ? `${item.slot}-content` : 'item-content') as keyof NavigationMenuSlots<T>])) || (props.orientation === 'vertical' && item.children?.length) || item.trailingIcon || !!slots[(item.slot ? `${item.slot}-trailing` : 'item-trailing') as keyof NavigationMenuSlots<T>]"
:as="props.orientation === 'vertical' && item.children?.length && !props.collapsed ? 'span' : undefined"
data-slot="linkTrailing"
:class="ui.linkTrailing({ class: [props.ui?.linkTrailing, item.ui?.linkTrailing] })"
@click="(e: Event) => onLinkTrailingClick(e, item)"
Expand All @@ -389,7 +388,7 @@ function onLinkTrailingClick(e: Event, item: NavigationMenuItem) {
<UIcon v-if="(props.orientation === 'horizontal' && (item.children?.length || !!slots[(item.slot ? `${item.slot}-content` : 'item-content') as keyof NavigationMenuSlots<T>])) || (props.orientation === 'vertical' && item.children?.length)" :name="item.trailingIcon || props.trailingIcon || appConfig.ui.icons.chevronDown" data-slot="linkTrailingIcon" :class="ui.linkTrailingIcon({ class: [props.ui?.linkTrailingIcon, item.ui?.linkTrailingIcon], active })" />
<UIcon v-else-if="item.trailingIcon" :name="item.trailingIcon" data-slot="linkTrailingIcon" :class="ui.linkTrailingIcon({ class: [props.ui?.linkTrailingIcon, item.ui?.linkTrailingIcon], active })" />
</slot>
</component>
</span>
</slot>
</DefineLinkTemplate>

Expand All @@ -403,9 +402,9 @@ function onLinkTrailingClick(e: Event, item: NavigationMenuItem) {
<div v-if="props.orientation === 'vertical' && item.type === 'label' && !props.collapsed" data-slot="label" :class="ui.label({ class: [props.ui?.label, item.ui?.label, item.class] })">
<ReuseLinkTemplate :item="item" :index="index" />
</div>
<ULink v-else-if="item.type !== 'label'" v-slot="{ active, ...slotProps }" v-bind="(props.orientation === 'vertical' && item.children?.length && !props.collapsed && item.type === 'trigger') ? {} : pickLinkProps(item as Omit<NavigationMenuItem, 'type'>)" custom>
<ULink v-else-if="item.type !== 'label'" v-slot="{ active, ...slotProps }" v-bind="(props.orientation === 'vertical' && item.children?.length && !props.collapsed) ? {} : pickLinkProps(item as Omit<NavigationMenuItem, 'type'>)" custom>
<component
:is="(props.orientation === 'horizontal' && (item.children?.length || !!slots[(item.slot ? `${item.slot}-content` : 'item-content') as keyof NavigationMenuSlots<T>])) ? NavigationMenuTrigger : ((props.orientation === 'vertical' && item.children?.length && !props.collapsed && !(slotProps as any).href) ? AccordionTrigger : NavigationMenuLink)"
:is="(props.orientation === 'horizontal' && (item.children?.length || !!slots[(item.slot ? `${item.slot}-content` : 'item-content') as keyof NavigationMenuSlots<T>])) ? NavigationMenuTrigger : ((props.orientation === 'vertical' && item.children?.length && !props.collapsed) ? AccordionTrigger : NavigationMenuLink)"
as-child
:active="active || item.active"
:disabled="item.disabled"
Expand Down
31 changes: 31 additions & 0 deletions test/components/NavigationMenu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,37 @@ describe('NavigationMenu', () => {
expect(await axe(wrapper.element)).toHaveNoViolations()
})

// Regression: in `vertical` orientation the Accordion drives roving arrow-key
// navigation over every `[data-reka-collection-item]`. Each candidate must be a
// natively focusable element (`a`/`button`) β€” a non-focusable `<span>` trigger
// (the previous trailing chevron) traps focus and breaks keyboard navigation.
it('keeps every keyboard navigation target focusable in vertical orientation', async () => {
const wrapper = await mountSuspended(NavigationMenu, {
props: {
orientation: 'vertical',
// open every section so nested children are rendered too
type: 'multiple',
defaultValue: ['item-0', 'item-1'],
items: [[
{ label: 'Trigger', icon: 'i-lucide-box', children: [{ label: 'Child', to: '/child' }] },
// link + children (no `type: 'trigger'`) must also be a focusable trigger
{ label: 'Link with children', to: '/parent', children: [{ label: 'Nested', to: '/nested' }] },
{ label: 'Plain link', to: '/plain' }
]]
}
})

const candidates = [...wrapper.element.querySelectorAll('[data-reka-collection-item]')]
expect(candidates.length).toBeGreaterThan(0)

for (const el of candidates) {
// only natively focusable elements may be roving-focus targets
expect(['A', 'BUTTON']).toContain(el.tagName)
// and they must not nest another interactive control (invalid + double stop)
expect(el.querySelectorAll('a, button, [tabindex]')).toHaveLength(0)
}
})

test('should have the correct types', () => {
// normal
expectSlotProps('item', () => NavigationMenu({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1029,15 +1029,15 @@ exports[`NavigationMenu > renders with orientation vertical correctly 1`] = `
</div>
<!--v-if-->
</li>
<li data-state="closed" data-orientation="vertical" data-slot="item" class="min-w-0"><button type="button" aria-controls="" aria-expanded="false" data-state="closed" id="reka-accordion-trigger-v-1" data-reka-collection-item="" data-orientation="vertical" data-slot="link" class="group relative w-full flex items-center gap-1.5 font-medium text-sm before:absolute before:z-[-1] before:rounded-md focus:outline-none focus-visible:outline-none focus-visible:before:outline-3 before:outline-primary/25 flex-row px-2.5 py-1.5 before:inset-y-px before:inset-x-0 text-muted hover:text-highlighted hover:before:bg-elevated/50 transition-colors before:transition-colors"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" data-slot="linkLeadingIcon" class="shrink-0 size-5 text-dimmed group-hover:text-default transition-colors"></svg><span data-slot="linkLabel" class="truncate">Documentation<!--v-if--></span><span aria-controls="" aria-expanded="false" data-state="closed" disabled="false" id="reka-accordion-trigger-v-1" data-reka-collection-item="" data-orientation="vertical" data-slot="linkTrailing" class="group ms-auto inline-flex gap-1.5 items-center"><span data-slot="linkTrailingBadge" class="font-medium inline-flex items-center text-[10px]/3 px-1.5 py-1 gap-1 rounded-sm ring ring-inset ring-accented text-default bg-default shrink-0"><!--v-if--><span data-slot="label" class="truncate">10</span>
<li data-state="closed" data-orientation="vertical" data-slot="item" class="min-w-0"><button type="button" aria-controls="" aria-expanded="false" data-state="closed" id="reka-accordion-trigger-v-1" data-reka-collection-item="" data-orientation="vertical" data-slot="link" class="group relative w-full flex items-center gap-1.5 font-medium text-sm before:absolute before:z-[-1] before:rounded-md focus:outline-none focus-visible:outline-none focus-visible:before:outline-3 before:outline-primary/25 flex-row px-2.5 py-1.5 before:inset-y-px before:inset-x-0 text-muted hover:text-highlighted hover:before:bg-elevated/50 transition-colors before:transition-colors"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" data-slot="linkLeadingIcon" class="shrink-0 size-5 text-dimmed group-hover:text-default transition-colors"></svg><span data-slot="linkLabel" class="truncate">Documentation<!--v-if--></span><span data-slot="linkTrailing" class="group ms-auto inline-flex gap-1.5 items-center"><span data-slot="linkTrailingBadge" class="font-medium inline-flex items-center text-[10px]/3 px-1.5 py-1 gap-1 rounded-sm ring ring-inset ring-accented text-default bg-default shrink-0"><!--v-if--><span data-slot="label" class="truncate">10</span>
<!--v-if--></span><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" data-slot="linkTrailingIcon" class="size-5 transform shrink-0 group-data-[state=open]:rotate-180 transition-transform duration-200"></svg></span>
</button>
<!--v-if-->
<div role="region" aria-labelledby="reka-accordion-trigger-v-1" data-state="closed" data-orientation="vertical" style="--reka-accordion-content-width: var(--reka-collapsible-content-width); --reka-accordion-content-height: var(--reka-collapsible-content-height); --reka-collapsible-content-height: 0px; --reka-collapsible-content-width: 0px;" data-slot="content" class="data-[state=open]:animate-[collapsible-down_200ms_ease-out] data-[state=closed]:animate-[collapsible-up_200ms_ease-out] data-[state=closed]:overflow-hidden" id="reka-collapsible-content-v-2" hidden="">
<!--v-if-->
</div>
</li>
<li data-state="closed" data-orientation="vertical" data-slot="item" class="min-w-0"><button type="button" aria-controls="" aria-expanded="false" data-state="closed" id="reka-accordion-trigger-v-3" data-reka-collection-item="" data-orientation="vertical" data-slot="link" class="group relative w-full flex items-center gap-1.5 font-medium text-sm before:absolute before:z-[-1] before:rounded-md focus:outline-none focus-visible:outline-none focus-visible:before:outline-3 before:outline-primary/25 flex-row px-2.5 py-1.5 before:inset-y-px before:inset-x-0 text-primary before:bg-elevated"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" data-slot="linkLeadingIcon" class="shrink-0 size-5 text-primary group-data-[state=open]:text-primary"></svg><span data-slot="linkLabel" class="truncate">Components<!--v-if--></span><span aria-controls="" aria-expanded="false" data-state="closed" disabled="false" id="reka-accordion-trigger-v-3" data-reka-collection-item="" data-orientation="vertical" data-slot="linkTrailing" class="group ms-auto inline-flex gap-1.5 items-center"><!--v-if--><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" data-slot="linkTrailingIcon" class="size-5 transform shrink-0 group-data-[state=open]:rotate-180 transition-transform duration-200"></svg></span></button>
<li data-state="closed" data-orientation="vertical" data-slot="item" class="min-w-0"><button type="button" aria-controls="" aria-expanded="false" data-state="closed" id="reka-accordion-trigger-v-3" data-reka-collection-item="" data-orientation="vertical" data-slot="link" class="group relative w-full flex items-center gap-1.5 font-medium text-sm before:absolute before:z-[-1] before:rounded-md focus:outline-none focus-visible:outline-none focus-visible:before:outline-3 before:outline-primary/25 flex-row px-2.5 py-1.5 before:inset-y-px before:inset-x-0 text-primary before:bg-elevated"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" data-slot="linkLeadingIcon" class="shrink-0 size-5 text-primary group-data-[state=open]:text-primary"></svg><span data-slot="linkLabel" class="truncate">Components<!--v-if--></span><span data-slot="linkTrailing" class="group ms-auto inline-flex gap-1.5 items-center"><!--v-if--><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" data-slot="linkTrailingIcon" class="size-5 transform shrink-0 group-data-[state=open]:rotate-180 transition-transform duration-200"></svg></span></button>
<!--v-if-->
<div role="region" aria-labelledby="reka-accordion-trigger-v-3" data-state="closed" data-orientation="vertical" style="--reka-accordion-content-width: var(--reka-collapsible-content-width); --reka-accordion-content-height: var(--reka-collapsible-content-height); --reka-collapsible-content-height: 0px; --reka-collapsible-content-width: 0px;" data-slot="content" class="data-[state=open]:animate-[collapsible-down_200ms_ease-out] data-[state=closed]:animate-[collapsible-up_200ms_ease-out] data-[state=closed]:overflow-hidden" id="reka-collapsible-content-v-4" hidden="">
<!--v-if-->
Expand Down
Loading
Loading