Skip to content

Commit 1e2a10b

Browse files
committed
feat(NavigationMenu): handle vertical orientation with Accordion instead of Collapsible
Resolves #4072, resolves #3911
1 parent 3c78e2f commit 1e2a10b

File tree

4 files changed

+522
-277
lines changed

4 files changed

+522
-277
lines changed

src/runtime/components/NavigationMenu.vue

Lines changed: 52 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<!-- eslint-disable vue/block-tag-newline -->
22
<script lang="ts">
3-
import type { NavigationMenuRootProps, NavigationMenuRootEmits, NavigationMenuContentProps, NavigationMenuContentEmits, CollapsibleRootProps } from 'reka-ui'
3+
import type { NavigationMenuRootProps, NavigationMenuRootEmits, NavigationMenuContentProps, NavigationMenuContentEmits, AccordionRootProps } from 'reka-ui'
44
import type { AppConfig } from '@nuxt/schema'
55
import theme from '#build/ui/navigation-menu'
66
import type { AvatarProps, BadgeProps, LinkProps, TooltipProps } from '../types'
@@ -14,7 +14,7 @@ export interface NavigationMenuChildItem extends Omit<NavigationMenuItem, 'type'
1414
[key: string]: any
1515
}
1616
17-
export interface NavigationMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'custom'>, Pick<CollapsibleRootProps, 'defaultOpen' | 'open'> {
17+
export interface NavigationMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'custom'> {
1818
label?: string
1919
/**
2020
* @IconifyIcon
@@ -49,13 +49,15 @@ export interface NavigationMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'cu
4949
*/
5050
value?: string
5151
children?: NavigationMenuChildItem[]
52+
defaultOpen?: boolean
53+
open?: boolean
5254
onSelect?(e: Event): void
5355
class?: any
5456
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'>
5557
[key: string]: any
5658
}
5759
58-
export interface NavigationMenuProps<T extends ArrayOrNested<NavigationMenuItem> = ArrayOrNested<NavigationMenuItem>> extends Pick<NavigationMenuRootProps, 'modelValue' | 'defaultValue' | 'delayDuration' | 'disableClickTrigger' | 'disableHoverTrigger' | 'skipDelayDuration' | 'disablePointerLeaveClose' | 'unmountOnHide'> {
60+
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'> {
5961
/**
6062
* The element or component this component should render as.
6163
* @defaultValue 'div'
@@ -143,8 +145,8 @@ export type NavigationMenuSlots<
143145

144146
<script setup lang="ts" generic="T extends ArrayOrNested<NavigationMenuItem>">
145147
import { computed, toRef } from 'vue'
146-
import { NavigationMenuRoot, NavigationMenuList, NavigationMenuItem, NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink, NavigationMenuIndicator, NavigationMenuViewport, useForwardPropsEmits } from 'reka-ui'
147-
import { createReusableTemplate } from '@vueuse/core'
148+
import { NavigationMenuRoot, NavigationMenuList, NavigationMenuItem, NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink, NavigationMenuIndicator, NavigationMenuViewport, AccordionRoot, AccordionItem, AccordionTrigger, AccordionContent, useForwardPropsEmits } from 'reka-ui'
149+
import { reactivePick, createReusableTemplate } from '@vueuse/core'
148150
import { useAppConfig } from '#imports'
149151
import { get, isArrayOfArray } from '../utils'
150152
import { tv } from '../utils/tv'
@@ -154,14 +156,15 @@ import ULink from './Link.vue'
154156
import UAvatar from './Avatar.vue'
155157
import UIcon from './Icon.vue'
156158
import UBadge from './Badge.vue'
157-
import UCollapsible from './Collapsible.vue'
158159
import UTooltip from './Tooltip.vue'
159160
160161
const props = withDefaults(defineProps<NavigationMenuProps<T>>(), {
161162
orientation: 'horizontal',
162163
contentOrientation: 'horizontal',
163164
externalIcon: true,
164165
delayDuration: 0,
166+
type: 'multiple',
167+
collapsible: true,
165168
unmountOnHide: true,
166169
labelKey: 'label'
167170
})
@@ -182,6 +185,7 @@ const rootProps = useForwardPropsEmits(computed(() => ({
182185
disablePointerLeaveClose: props.disablePointerLeaveClose,
183186
unmountOnHide: props.unmountOnHide
184187
})), emits)
188+
const accordionProps = useForwardPropsEmits(reactivePick(props, 'collapsible', 'disabled', 'type', 'unmountOnHide'), emits)
185189
const contentProps = toRef(() => props.content)
186190
187191
const [DefineLinkTemplate, ReuseLinkTemplate] = createReusableTemplate<{ item: NavigationMenuItem, index: number, active?: boolean }>()
@@ -195,7 +199,7 @@ const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: N
195199
196200
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.navigationMenu || {}) })({
197201
orientation: props.orientation,
198-
contentOrientation: props.contentOrientation,
202+
contentOrientation: props.orientation === 'vertical' ? undefined : props.contentOrientation,
199203
collapsed: props.collapsed,
200204
color: props.color,
201205
variant: props.variant,
@@ -210,6 +214,24 @@ const lists = computed<NavigationMenuItem[][]>(() =>
210214
: [props.items]
211215
: []
212216
)
217+
218+
function getAccordionDefaultValue(list: NavigationMenuItem[]) {
219+
function findItemsWithDefaultOpen(items: NavigationMenuItem[], level = 0): string[] {
220+
return items.reduce((acc: string[], item, index) => {
221+
if (item.defaultOpen || item.open) {
222+
acc.push(item.value || (level > 0 ? `item-${level}-${index}` : `item-${index}`))
223+
}
224+
if (item.children?.length) {
225+
acc.push(...findItemsWithDefaultOpen(item.children, level + 1))
226+
}
227+
return acc
228+
}, [])
229+
}
230+
231+
const indexes = findItemsWithDefaultOpen(list)
232+
233+
return props.type === 'single' ? indexes[0] : indexes
234+
}
213235
</script>
214236

215237
<template>
@@ -231,7 +253,7 @@ const lists = computed<NavigationMenuItem[][]>(() =>
231253
<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 })" />
232254
</span>
233255

234-
<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] })">
256+
<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>
235257
<slot :name="((item.slot ? `${item.slot}-trailing` : 'item-trailing') as keyof NavigationMenuSlots<T>)" :item="item" :active="active" :index="index">
236258
<UBadge
237259
v-if="item.badge"
@@ -245,37 +267,34 @@ const lists = computed<NavigationMenuItem[][]>(() =>
245267
<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 })" />
246268
<UIcon v-else-if="item.trailingIcon" :name="item.trailingIcon" :class="ui.linkTrailingIcon({ class: [props.ui?.linkTrailingIcon, item.ui?.linkTrailingIcon], active })" />
247269
</slot>
248-
</span>
270+
</component>
249271
</slot>
250272
</DefineLinkTemplate>
251273

252274
<DefineItemTemplate v-slot="{ item, index, level = 0 }">
253275
<component
254-
:is="(orientation === 'vertical' && item.children?.length && !collapsed) ? UCollapsible : NavigationMenuItem"
276+
:is="(orientation === 'vertical' && item.children?.length) ? AccordionItem : NavigationMenuItem"
255277
as="li"
256-
:value="item.value || `item-${index}`"
257-
:default-open="item.defaultOpen"
258-
:unmount-on-hide="(orientation === 'vertical' && item.children?.length && !collapsed) ? unmountOnHide : undefined"
259-
:open="item.open"
278+
:value="item.value || (level > 0 ? `item-${level}-${index}` : `item-${index}`)"
260279
>
261280
<div v-if="orientation === 'vertical' && item.type === 'label'" :class="ui.label({ class: [props.ui?.label, item.ui?.label, item.class] })">
262281
<ReuseLinkTemplate :item="item" :index="index" />
263282
</div>
264-
<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>
283+
<ULink v-else-if="item.type !== 'label'" v-slot="{ active, ...slotProps }" v-bind="pickLinkProps(item as Omit<NavigationMenuItem, 'type'>)" custom>
265284
<component
266-
:is="(orientation === 'horizontal' && (item.children?.length || !!slots[(item.slot ? `${item.slot}-content` : 'item-content') as keyof NavigationMenuSlots<T>])) ? NavigationMenuTrigger : NavigationMenuLink"
285+
: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)"
267286
as-child
268-
:active="active || item.active"
287+
:active="active"
269288
:disabled="item.disabled"
270289
@select="item.onSelect"
271290
>
272291
<UTooltip v-if="!!item.tooltip" :content="{ side: 'right' }" v-bind="item.tooltip">
273-
<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 })">
274-
<ReuseLinkTemplate :item="item" :active="active || item.active" :index="index" />
292+
<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 })">
293+
<ReuseLinkTemplate :item="item" :active="active" :index="index" />
275294
</ULinkBase>
276295
</UTooltip>
277-
<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 })">
278-
<ReuseLinkTemplate :item="item" :active="active || item.active" :index="index" />
296+
<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 })">
297+
<ReuseLinkTemplate :item="item" :active="active" :index="index" />
279298
</ULinkBase>
280299
</component>
281300

@@ -307,7 +326,7 @@ const lists = computed<NavigationMenuItem[][]>(() =>
307326
</NavigationMenuContent>
308327
</ULink>
309328

310-
<template v-if="orientation === 'vertical' && item.children?.length && !collapsed" #content>
329+
<AccordionContent v-if="orientation === 'vertical' && item.children?.length && !collapsed" :class="ui.content({ class: [props.ui?.content, item.ui?.content] })">
311330
<ul :class="ui.childList({ class: props.ui?.childList })">
312331
<ReuseItemTemplate
313332
v-for="(childItem, childIndex) in item.children"
@@ -318,17 +337,25 @@ const lists = computed<NavigationMenuItem[][]>(() =>
318337
:class="ui.childItem({ class: [props.ui?.childItem, childItem.ui?.childItem] })"
319338
/>
320339
</ul>
321-
</template>
340+
</AccordionContent>
322341
</component>
323342
</DefineItemTemplate>
324343

325344
<NavigationMenuRoot v-bind="rootProps" :data-collapsed="collapsed" :class="ui.root({ class: [props.ui?.root, props.class] })">
326345
<slot name="list-leading" />
327346

328347
<template v-for="(list, listIndex) in lists" :key="`list-${listIndex}`">
329-
<NavigationMenuList :class="ui.list({ class: props.ui?.list })">
348+
<component
349+
v-bind="orientation === 'vertical' ? {
350+
...accordionProps,
351+
defaultValue: getAccordionDefaultValue(list)
352+
} : {}"
353+
:is="orientation === 'vertical' ? AccordionRoot : NavigationMenuList"
354+
as="ul"
355+
:class="ui.list({ class: props.ui?.list })"
356+
>
330357
<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] })" />
331-
</NavigationMenuList>
358+
</component>
332359

333360
<div v-if="orientation === 'vertical' && listIndex < lists.length - 1" :class="ui.separator({ class: props.ui?.separator })" />
334361
</template>

src/theme/navigation-menu.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export default (options: Required<ModuleOptions>) => ({
1010
linkLeadingIcon: 'shrink-0 size-5',
1111
linkLeadingAvatar: 'shrink-0',
1212
linkLeadingAvatarSize: '2xs',
13-
linkTrailing: 'ms-auto inline-flex gap-1.5 items-center',
13+
linkTrailing: 'group ms-auto inline-flex gap-1.5 items-center',
1414
linkTrailingBadge: 'shrink-0',
1515
linkTrailingBadgeSize: 'sm',
1616
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>) => ({
2727
separator: 'px-2 h-px bg-border',
2828
viewportWrapper: 'absolute top-full left-0 flex w-full',
2929
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]',
30-
content: 'absolute top-0 left-0 w-full',
30+
content: '',
3131
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',
3232
arrow: 'relative top-[50%] size-2.5 rotate-45 border border-default bg-default z-[1] rounded-xs'
3333
},
@@ -56,11 +56,13 @@ export default (options: Required<ModuleOptions>) => ({
5656
list: 'flex items-center',
5757
item: 'py-2',
5858
link: 'px-2.5 py-1.5 before:inset-x-px before:inset-y-0',
59-
childList: 'grid p-2'
59+
childList: 'grid p-2',
60+
content: 'absolute top-0 left-0 w-full'
6061
},
6162
vertical: {
6263
root: 'flex-col',
63-
link: 'flex-row px-2.5 py-1.5 before:inset-y-px before:inset-x-0'
64+
link: 'flex-row px-2.5 py-1.5 before:inset-y-px before:inset-x-0',
65+
content: 'data-[state=open]:animate-[collapsible-down_200ms_ease-out] data-[state=closed]:animate-[collapsible-up_200ms_ease-out] overflow-hidden'
6466
}
6567
},
6668
contentOrientation: {

0 commit comments

Comments
 (0)