feat(NavigationMenu): handle vertical orientation with Accordion instead of Collapsible

Resolves #4072, resolves #3911
This commit is contained in:
Benjamin Canac
2025-05-20 17:03:39 +02:00
parent 3c78e2fd98
commit 1e2a10b4bd
4 changed files with 522 additions and 277 deletions

View File

@@ -1,6 +1,6 @@
<!-- eslint-disable vue/block-tag-newline -->
<script lang="ts">
import type { NavigationMenuRootProps, NavigationMenuRootEmits, NavigationMenuContentProps, NavigationMenuContentEmits, CollapsibleRootProps } from 'reka-ui'
import type { NavigationMenuRootProps, NavigationMenuRootEmits, NavigationMenuContentProps, NavigationMenuContentEmits, AccordionRootProps } from 'reka-ui'
import type { AppConfig } from '@nuxt/schema'
import theme from '#build/ui/navigation-menu'
import type { AvatarProps, BadgeProps, LinkProps, TooltipProps } from '../types'
@@ -14,7 +14,7 @@ export interface NavigationMenuChildItem extends Omit<NavigationMenuItem, 'type'
[key: string]: any
}
export interface NavigationMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'custom'>, Pick<CollapsibleRootProps, 'defaultOpen' | 'open'> {
export interface NavigationMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'custom'> {
label?: string
/**
* @IconifyIcon
@@ -49,13 +49,15 @@ export interface NavigationMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'cu
*/
value?: string
children?: NavigationMenuChildItem[]
defaultOpen?: boolean
open?: boolean
onSelect?(e: Event): void
class?: any
ui?: Pick<NavigationMenu['slots'], 'item' | 'linkLeadingAvatarSize' | 'linkLeadingAvatar' | 'linkLeadingIcon' | 'linkLabel' | 'linkLabelExternalIcon' | 'linkTrailing' | 'linkTrailingBadgeSize' | 'linkTrailingBadge' | 'linkTrailingIcon' | 'label' | 'link' | 'content' | 'childList' | 'childItem' | 'childLink' | 'childLinkIcon' | 'childLinkWrapper' | 'childLinkLabel' | 'childLinkLabelExternalIcon' | 'childLinkDescription'>
[key: string]: any
}
export interface NavigationMenuProps<T extends ArrayOrNested<NavigationMenuItem> = ArrayOrNested<NavigationMenuItem>> extends Pick<NavigationMenuRootProps, 'modelValue' | 'defaultValue' | 'delayDuration' | 'disableClickTrigger' | 'disableHoverTrigger' | 'skipDelayDuration' | 'disablePointerLeaveClose' | 'unmountOnHide'> {
export interface NavigationMenuProps<T extends ArrayOrNested<NavigationMenuItem> = ArrayOrNested<NavigationMenuItem>> extends Pick<NavigationMenuRootProps, 'modelValue' | 'defaultValue' | 'delayDuration' | 'disableClickTrigger' | 'disableHoverTrigger' | 'skipDelayDuration' | 'disablePointerLeaveClose' | 'unmountOnHide'>, Pick<AccordionRootProps, 'disabled' | 'type' | 'collapsible'> {
/**
* The element or component this component should render as.
* @defaultValue 'div'
@@ -143,8 +145,8 @@ export type NavigationMenuSlots<
<script setup lang="ts" generic="T extends ArrayOrNested<NavigationMenuItem>">
import { computed, toRef } from 'vue'
import { NavigationMenuRoot, NavigationMenuList, NavigationMenuItem, NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink, NavigationMenuIndicator, NavigationMenuViewport, useForwardPropsEmits } from 'reka-ui'
import { createReusableTemplate } from '@vueuse/core'
import { NavigationMenuRoot, NavigationMenuList, NavigationMenuItem, NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink, NavigationMenuIndicator, NavigationMenuViewport, AccordionRoot, AccordionItem, AccordionTrigger, AccordionContent, useForwardPropsEmits } from 'reka-ui'
import { reactivePick, createReusableTemplate } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { get, isArrayOfArray } from '../utils'
import { tv } from '../utils/tv'
@@ -154,7 +156,6 @@ import ULink from './Link.vue'
import UAvatar from './Avatar.vue'
import UIcon from './Icon.vue'
import UBadge from './Badge.vue'
import UCollapsible from './Collapsible.vue'
import UTooltip from './Tooltip.vue'
const props = withDefaults(defineProps<NavigationMenuProps<T>>(), {
@@ -162,6 +163,8 @@ const props = withDefaults(defineProps<NavigationMenuProps<T>>(), {
contentOrientation: 'horizontal',
externalIcon: true,
delayDuration: 0,
type: 'multiple',
collapsible: true,
unmountOnHide: true,
labelKey: 'label'
})
@@ -182,6 +185,7 @@ const rootProps = useForwardPropsEmits(computed(() => ({
disablePointerLeaveClose: props.disablePointerLeaveClose,
unmountOnHide: props.unmountOnHide
})), emits)
const accordionProps = useForwardPropsEmits(reactivePick(props, 'collapsible', 'disabled', 'type', 'unmountOnHide'), emits)
const contentProps = toRef(() => props.content)
const [DefineLinkTemplate, ReuseLinkTemplate] = createReusableTemplate<{ item: NavigationMenuItem, index: number, active?: boolean }>()
@@ -195,7 +199,7 @@ const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: N
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.navigationMenu || {}) })({
orientation: props.orientation,
contentOrientation: props.contentOrientation,
contentOrientation: props.orientation === 'vertical' ? undefined : props.contentOrientation,
collapsed: props.collapsed,
color: props.color,
variant: props.variant,
@@ -210,6 +214,24 @@ const lists = computed<NavigationMenuItem[][]>(() =>
: [props.items]
: []
)
function getAccordionDefaultValue(list: NavigationMenuItem[]) {
function findItemsWithDefaultOpen(items: NavigationMenuItem[], level = 0): string[] {
return items.reduce((acc: string[], item, index) => {
if (item.defaultOpen || item.open) {
acc.push(item.value || (level > 0 ? `item-${level}-${index}` : `item-${index}`))
}
if (item.children?.length) {
acc.push(...findItemsWithDefaultOpen(item.children, level + 1))
}
return acc
}, [])
}
const indexes = findItemsWithDefaultOpen(list)
return props.type === 'single' ? indexes[0] : indexes
}
</script>
<template>
@@ -231,7 +253,7 @@ const lists = computed<NavigationMenuItem[][]>(() =>
<UIcon v-if="item.target === '_blank' && externalIcon !== false" :name="typeof externalIcon === 'string' ? externalIcon : appConfig.ui.icons.external" :class="ui.linkLabelExternalIcon({ class: [props.ui?.linkLabelExternalIcon, item.ui?.linkLabelExternalIcon], active })" />
</span>
<span v-if="(!collapsed || orientation !== 'vertical') && (item.badge || (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>])" :class="ui.linkTrailing({ class: [props.ui?.linkTrailing, item.ui?.linkTrailing] })">
<component :is="orientation === 'vertical' && item.children?.length ? AccordionTrigger : 'span'" v-if="(!collapsed || orientation !== 'vertical') && (item.badge || (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>])" as="span" :class="ui.linkTrailing({ class: [props.ui?.linkTrailing, item.ui?.linkTrailing] })" @click.stop.prevent>
<slot :name="((item.slot ? `${item.slot}-trailing` : 'item-trailing') as keyof NavigationMenuSlots<T>)" :item="item" :active="active" :index="index">
<UBadge
v-if="item.badge"
@@ -245,37 +267,34 @@ const lists = computed<NavigationMenuItem[][]>(() =>
<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" :class="ui.linkTrailingIcon({ class: [props.ui?.linkTrailingIcon, item.ui?.linkTrailingIcon], active })" />
<UIcon v-else-if="item.trailingIcon" :name="item.trailingIcon" :class="ui.linkTrailingIcon({ class: [props.ui?.linkTrailingIcon, item.ui?.linkTrailingIcon], active })" />
</slot>
</span>
</component>
</slot>
</DefineLinkTemplate>
<DefineItemTemplate v-slot="{ item, index, level = 0 }">
<component
:is="(orientation === 'vertical' && item.children?.length && !collapsed) ? UCollapsible : NavigationMenuItem"
:is="(orientation === 'vertical' && item.children?.length) ? AccordionItem : NavigationMenuItem"
as="li"
:value="item.value || `item-${index}`"
:default-open="item.defaultOpen"
:unmount-on-hide="(orientation === 'vertical' && item.children?.length && !collapsed) ? unmountOnHide : undefined"
:open="item.open"
:value="item.value || (level > 0 ? `item-${level}-${index}` : `item-${index}`)"
>
<div v-if="orientation === 'vertical' && item.type === '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="(orientation === 'vertical' && item.children?.length && !collapsed) ? {} : pickLinkProps(item as Omit<NavigationMenuItem, 'type'>)" custom>
<ULink v-else-if="item.type !== 'label'" v-slot="{ active, ...slotProps }" v-bind="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 : NavigationMenuLink"
:is="(orientation === 'horizontal' && (item.children?.length || !!slots[(item.slot ? `${item.slot}-content` : 'item-content') as keyof NavigationMenuSlots<T>])) ? NavigationMenuTrigger : ((orientation === 'vertical' && item.children?.length && !(slotProps as any).href) ? AccordionTrigger : NavigationMenuLink)"
as-child
:active="active || item.active"
:active="active"
:disabled="item.disabled"
@select="item.onSelect"
>
<UTooltip v-if="!!item.tooltip" :content="{ side: 'right' }" v-bind="item.tooltip">
<ULinkBase v-bind="slotProps" :class="ui.link({ class: [props.ui?.link, item.ui?.link, item.class], active: active || item.active, disabled: !!item.disabled, level: orientation === 'horizontal' || level > 0 })">
<ReuseLinkTemplate :item="item" :active="active || item.active" :index="index" />
<ULinkBase v-bind="slotProps" :class="ui.link({ class: [props.ui?.link, item.ui?.link, item.class], active, disabled: !!item.disabled, level: orientation === 'horizontal' || level > 0 })">
<ReuseLinkTemplate :item="item" :active="active" :index="index" />
</ULinkBase>
</UTooltip>
<ULinkBase v-else v-bind="slotProps" :class="ui.link({ class: [props.ui?.link, item.ui?.link, item.class], active: active || item.active, disabled: !!item.disabled, level: orientation === 'horizontal' || level > 0 })">
<ReuseLinkTemplate :item="item" :active="active || item.active" :index="index" />
<ULinkBase v-else v-bind="slotProps" :class="ui.link({ class: [props.ui?.link, item.ui?.link, item.class], active, disabled: !!item.disabled, level: orientation === 'horizontal' || level > 0 })">
<ReuseLinkTemplate :item="item" :active="active" :index="index" />
</ULinkBase>
</component>
@@ -307,7 +326,7 @@ const lists = computed<NavigationMenuItem[][]>(() =>
</NavigationMenuContent>
</ULink>
<template v-if="orientation === 'vertical' && item.children?.length && !collapsed" #content>
<AccordionContent v-if="orientation === 'vertical' && item.children?.length && !collapsed" :class="ui.content({ class: [props.ui?.content, item.ui?.content] })">
<ul :class="ui.childList({ class: props.ui?.childList })">
<ReuseItemTemplate
v-for="(childItem, childIndex) in item.children"
@@ -318,7 +337,7 @@ const lists = computed<NavigationMenuItem[][]>(() =>
:class="ui.childItem({ class: [props.ui?.childItem, childItem.ui?.childItem] })"
/>
</ul>
</template>
</AccordionContent>
</component>
</DefineItemTemplate>
@@ -326,9 +345,17 @@ const lists = computed<NavigationMenuItem[][]>(() =>
<slot name="list-leading" />
<template v-for="(list, listIndex) in lists" :key="`list-${listIndex}`">
<NavigationMenuList :class="ui.list({ class: props.ui?.list })">
<component
v-bind="orientation === 'vertical' ? {
...accordionProps,
defaultValue: getAccordionDefaultValue(list)
} : {}"
:is="orientation === 'vertical' ? AccordionRoot : NavigationMenuList"
as="ul"
:class="ui.list({ class: props.ui?.list })"
>
<ReuseItemTemplate v-for="(item, index) in list" :key="`list-${listIndex}-${index}`" :item="item" :index="index" :class="ui.item({ class: [props.ui?.item, item.ui?.item] })" />
</NavigationMenuList>
</component>
<div v-if="orientation === 'vertical' && listIndex < lists.length - 1" :class="ui.separator({ class: props.ui?.separator })" />
</template>