mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-14 20:19:34 +01:00
feat(NavigationMenu): handle vertical orientation with Accordion instead of Collapsible
Resolves #4072, resolves #3911
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -10,7 +10,7 @@ export default (options: Required<ModuleOptions>) => ({
|
||||
linkLeadingIcon: 'shrink-0 size-5',
|
||||
linkLeadingAvatar: 'shrink-0',
|
||||
linkLeadingAvatarSize: '2xs',
|
||||
linkTrailing: 'ms-auto inline-flex gap-1.5 items-center',
|
||||
linkTrailing: 'group ms-auto inline-flex gap-1.5 items-center',
|
||||
linkTrailingBadge: 'shrink-0',
|
||||
linkTrailingBadgeSize: 'sm',
|
||||
linkTrailingIcon: 'size-5 transform shrink-0 group-data-[state=open]:rotate-180 transition-transform duration-200',
|
||||
@@ -27,7 +27,7 @@ export default (options: Required<ModuleOptions>) => ({
|
||||
separator: 'px-2 h-px bg-border',
|
||||
viewportWrapper: 'absolute top-full left-0 flex w-full',
|
||||
viewport: 'relative overflow-hidden bg-default shadow-lg rounded-md ring ring-default h-(--reka-navigation-menu-viewport-height) w-full transition-[width,height,left] duration-200 origin-[top_center] data-[state=open]:animate-[scale-in_100ms_ease-out] data-[state=closed]:animate-[scale-out_100ms_ease-in] z-[1]',
|
||||
content: 'absolute top-0 left-0 w-full',
|
||||
content: '',
|
||||
indicator: 'absolute data-[state=visible]:animate-[fade-in_100ms_ease-out] data-[state=hidden]:animate-[fade-out_100ms_ease-in] data-[state=hidden]:opacity-0 bottom-0 z-[2] w-(--reka-navigation-menu-indicator-size) translate-x-(--reka-navigation-menu-indicator-position) flex h-2.5 items-end justify-center overflow-hidden transition-[translate,width] duration-200',
|
||||
arrow: 'relative top-[50%] size-2.5 rotate-45 border border-default bg-default z-[1] rounded-xs'
|
||||
},
|
||||
@@ -56,11 +56,13 @@ export default (options: Required<ModuleOptions>) => ({
|
||||
list: 'flex items-center',
|
||||
item: 'py-2',
|
||||
link: 'px-2.5 py-1.5 before:inset-x-px before:inset-y-0',
|
||||
childList: 'grid p-2'
|
||||
childList: 'grid p-2',
|
||||
content: 'absolute top-0 left-0 w-full'
|
||||
},
|
||||
vertical: {
|
||||
root: 'flex-col',
|
||||
link: 'flex-row px-2.5 py-1.5 before:inset-y-px before:inset-x-0'
|
||||
link: 'flex-row px-2.5 py-1.5 before:inset-y-px before:inset-x-0',
|
||||
content: 'data-[state=open]:animate-[collapsible-down_200ms_ease-out] data-[state=closed]:animate-[collapsible-up_200ms_ease-out] overflow-hidden'
|
||||
}
|
||||
},
|
||||
contentOrientation: {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user