Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 8 additions & 0 deletions docs/content/docs/2.components/navigation-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -1340,6 +1340,10 @@ You can also use the `#item`, `#item-leading`, `#item-label`, `#item-trailing` a

Use the `#item-trailing` slot or the `slot` property (`#{{ item.slot }}-trailing`) to add a [DropdownMenu](/docs/components/dropdown-menu) that appears on hover, similar to Notion or Linear.

::note
When you pass `#item-trailing` (or `#{{ item.slot }}-trailing`), it replaces the default trailing UI for every item (badge and chevron). Use the slot props (`item`, `active`, etc.) to render different content per row.
::

::component-example
---
collapse: true
Expand All @@ -1351,6 +1355,10 @@ name: 'navigation-menu-trailing-slot-example'

Use the `#item-content` slot or the `slot` property (`#{{ item.slot }}-content`) to customize the content of a specific item.

::note
In **horizontal** orientation, the menu trigger and panel are only rendered for items that define a `children` array. The `#item-content` slot customizes what appears inside that panel, it does not create a dropdown for items without `children`.
::

::component-example
---
collapse: true
Expand Down
15 changes: 9 additions & 6 deletions src/runtime/components/NavigationMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -366,13 +366,16 @@ function onLinkTrailingClick(e: Event, item: NavigationMenuItem) {

<component
:is="orientation === 'vertical' && item.children?.length && !collapsed ? AccordionTrigger : 'span'"
v-if="(item.badge || item.badge === 0) || (orientation === 'horizontal' && (item.children?.length || !!slots[(item.slot ? `${item.slot}-content` : 'item-content') as keyof NavigationMenuSlots<T>])) || (orientation === 'vertical' && item.children?.length) || item.trailingIcon || !!slots[(item.slot ? `${item.slot}-trailing` : 'item-trailing') as keyof NavigationMenuSlots<T>]"
v-if="(item.badge || item.badge === 0) || (orientation === 'horizontal' && item.children?.length) || (orientation === 'vertical' && item.children?.length) || item.trailingIcon || !!slots[(item.slot ? `${item.slot}-trailing` : 'item-trailing') as keyof NavigationMenuSlots<T>]"
:as="orientation === 'vertical' && item.children?.length && !collapsed ? 'span' : undefined"
data-slot="linkTrailing"
:class="ui.linkTrailing({ class: [uiProp?.linkTrailing, item.ui?.linkTrailing] })"
@click="(e: Event) => onLinkTrailingClick(e, item)"
>
<slot :name="((item.slot ? `${item.slot}-trailing` : 'item-trailing') as keyof NavigationMenuSlots<T>)" :item="item" :active="active" :index="index" :ui="ui">
<template v-if="!!slots[(item.slot ? `${item.slot}-trailing` : 'item-trailing') as keyof NavigationMenuSlots<T>]">
<slot :name="((item.slot ? `${item.slot}-trailing` : 'item-trailing') as keyof NavigationMenuSlots<T>)" :item="item" :active="active" :index="index" :ui="ui" />
</template>
<template v-else>
<UBadge
v-if="item.badge || item.badge === 0"
color="neutral"
Expand All @@ -383,9 +386,9 @@ function onLinkTrailingClick(e: Event, item: NavigationMenuItem) {
:class="ui.linkTrailingBadge({ class: [uiProp?.linkTrailingBadge, item.ui?.linkTrailingBadge] })"
/>

<UIcon v-if="(orientation === 'horizontal' && (item.children?.length || !!slots[(item.slot ? `${item.slot}-content` : 'item-content') as keyof NavigationMenuSlots<T>])) || (orientation === 'vertical' && item.children?.length)" :name="item.trailingIcon || trailingIcon || appConfig.ui.icons.chevronDown" data-slot="linkTrailingIcon" :class="ui.linkTrailingIcon({ class: [uiProp?.linkTrailingIcon, item.ui?.linkTrailingIcon], active })" />
<UIcon v-if="(orientation === 'horizontal' && item.children?.length) || (orientation === 'vertical' && item.children?.length)" :name="item.trailingIcon || trailingIcon || appConfig.ui.icons.chevronDown" data-slot="linkTrailingIcon" :class="ui.linkTrailingIcon({ class: [uiProp?.linkTrailingIcon, item.ui?.linkTrailingIcon], active })" />
<UIcon v-else-if="item.trailingIcon" :name="item.trailingIcon" data-slot="linkTrailingIcon" :class="ui.linkTrailingIcon({ class: [uiProp?.linkTrailingIcon, item.ui?.linkTrailingIcon], active })" />
</slot>
</template>
</component>
</slot>
</DefineLinkTemplate>
Expand All @@ -402,7 +405,7 @@ function onLinkTrailingClick(e: Event, item: NavigationMenuItem) {
</div>
<ULink v-else-if="item.type !== 'label'" v-slot="{ active, ...slotProps }" v-bind="(orientation === 'vertical' && item.children?.length && !collapsed && item.type === 'trigger') ? {} : pickLinkProps(item as Omit<NavigationMenuItem, 'type'>)" custom>
<component
:is="(orientation === 'horizontal' && (item.children?.length || !!slots[(item.slot ? `${item.slot}-content` : 'item-content') as keyof NavigationMenuSlots<T>])) ? NavigationMenuTrigger : ((orientation === 'vertical' && item.children?.length && !collapsed && !(slotProps as any).href) ? AccordionTrigger : NavigationMenuLink)"
:is="(orientation === 'horizontal' && item.children?.length) ? NavigationMenuTrigger : ((orientation === 'vertical' && item.children?.length && !collapsed && !(slotProps as any).href) ? AccordionTrigger : NavigationMenuLink)"
as-child
:active="active || item.active"
:disabled="item.disabled"
Expand Down Expand Up @@ -455,7 +458,7 @@ function onLinkTrailingClick(e: Event, item: NavigationMenuItem) {
</ULinkBase>
</component>

<NavigationMenuContent v-if="orientation === 'horizontal' && (item.children?.length || !!slots[(item.slot ? `${item.slot}-content` : 'item-content') as keyof NavigationMenuSlots<T>])" v-bind="contentProps" data-slot="content" :class="ui.content({ class: [uiProp?.content, item.ui?.content] })">
<NavigationMenuContent v-if="orientation === 'horizontal' && item.children?.length" v-bind="contentProps" data-slot="content" :class="ui.content({ class: [uiProp?.content, item.ui?.content] })">
<slot :name="((item.slot ? `${item.slot}-content` : 'item-content') as keyof NavigationMenuSlots<T>)" :item="item" :active="active || item.active" :index="index" :ui="ui">
<ul data-slot="childList" :class="ui.childList({ class: [uiProp?.childList, item.ui?.childList] })">
<li v-for="(childItem, childIndex) in item.children" :key="childIndex" data-slot="childItem" :class="ui.childItem({ class: [uiProp?.childItem, item.ui?.childItem] })">
Expand Down
41 changes: 41 additions & 0 deletions test/components/NavigationMenu.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect, test } from 'vitest'
import { h } from 'vue'
import { axe } from 'vitest-axe'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import { renderEach } from '../component-render'
Expand Down Expand Up @@ -130,6 +131,46 @@ describe('NavigationMenu', () => {
expect(await axe(wrapper.element)).toHaveNoViolations()
})

it('does not treat global item-content slot as a trigger for items without children', async () => {
const wrapper = await mountSuspended(NavigationMenu, {
props: {
orientation: 'horizontal',
variant: 'link',
items: [[
{ label: 'NoChildren', to: '/' },
{ label: 'WithChildren', to: '/', children: [{ label: 'Child', to: '/child' }] }
]]
},
slots: {
'item-content': ({ item }: { item: { children?: unknown[] } }) => h('div', item.children?.length ? 'custom' : '')
}
})

// Only the item with `children` uses NavigationMenuTrigger; the slot must not force a trigger on plain links.
expect(wrapper.findAll('[data-navigation-menu-trigger]')).toHaveLength(1)
})

it('item-trailing slot fully overrides default trailing icons', async () => {
const wrapper = await mountSuspended(NavigationMenu, {
props: {
orientation: 'horizontal',
variant: 'link',
items: [[
{ label: 'NoChildren', to: '/' },
{ label: 'WithChildren', to: '/', children: [{ label: 'Child', to: '/child' }] }
]]
},
slots: {
'item-trailing': ({ item }: { item: { children?: unknown[] } }) => item.children?.length
? h('span', { 'data-testid': 'custom-trailing' }, 'V')
: undefined
}
})

expect(wrapper.findAll('[data-slot="linkTrailingIcon"]')).toHaveLength(0)
expect(wrapper.findAll('[data-testid="custom-trailing"]')).toHaveLength(1)
})

test('should have the correct types', () => {
// normal
expectSlotProps('item', () => NavigationMenu({
Expand Down
Loading