@nuxt/ui 3.0.0-alpha.6 → 3.0.0-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/module.d.mts +6 -6
  2. package/dist/module.d.ts +6 -6
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +391 -257
  5. package/dist/runtime/components/Accordion.vue +13 -5
  6. package/dist/runtime/components/Breadcrumb.vue +14 -6
  7. package/dist/runtime/components/Button.vue +4 -2
  8. package/dist/runtime/components/Carousel.vue +6 -6
  9. package/dist/runtime/components/Checkbox.vue +3 -6
  10. package/dist/runtime/components/CommandPalette.vue +25 -16
  11. package/dist/runtime/components/ContextMenu.vue +31 -7
  12. package/dist/runtime/components/ContextMenuContent.vue +36 -8
  13. package/dist/runtime/components/DropdownMenu.vue +31 -7
  14. package/dist/runtime/components/DropdownMenuContent.vue +38 -8
  15. package/dist/runtime/components/Form.vue +3 -1
  16. package/dist/runtime/components/Input.vue +6 -4
  17. package/dist/runtime/components/InputMenu.vue +40 -24
  18. package/dist/runtime/components/LinkBase.vue +4 -2
  19. package/dist/runtime/components/NavigationMenu.vue +78 -41
  20. package/dist/runtime/components/RadioGroup.vue +22 -3
  21. package/dist/runtime/components/Select.vue +22 -10
  22. package/dist/runtime/components/SelectMenu.vue +43 -31
  23. package/dist/runtime/components/Table.vue +232 -0
  24. package/dist/runtime/components/Tabs.vue +15 -7
  25. package/dist/runtime/components/Toaster.vue +1 -1
  26. package/dist/runtime/composables/defineShortcuts.js +1 -1
  27. package/dist/runtime/composables/useComponentIcons.d.ts +3 -0
  28. package/dist/runtime/composables/useModal.d.ts +1 -1
  29. package/dist/runtime/composables/useSlideover.d.ts +1 -1
  30. package/dist/runtime/index.css +1 -1
  31. package/dist/runtime/keyframes.css +1 -0
  32. package/dist/runtime/types/form.d.ts +2 -1
  33. package/dist/runtime/types/index.d.ts +1 -0
  34. package/dist/runtime/types/index.js +1 -0
  35. package/dist/runtime/utils/form.d.ts +3 -0
  36. package/dist/runtime/utils/form.js +14 -0
  37. package/dist/runtime/utils/index.d.ts +1 -0
  38. package/dist/runtime/utils/index.js +3 -0
  39. package/package.json +15 -12
  40. package/dist/runtime/types/component.d.ts +0 -17
  41. package/dist/runtime/types/component.js +0 -0
@@ -1,3 +1,4 @@
1
+ <!-- eslint-disable vue/block-tag-newline -->
1
2
  <script lang="ts">
2
3
  import { tv } from 'tailwind-variants'
3
4
  import type { DropdownMenuContentProps as RadixDropdownMenuContentProps, DropdownMenuContentEmits as RadixDropdownMenuContentEmits } from 'radix-vue'
@@ -10,6 +11,9 @@ interface DropdownMenuContentProps<T> extends Omit<RadixDropdownMenuContentProps
10
11
  items?: T[] | T[][]
11
12
  portal?: boolean
12
13
  sub?: boolean
14
+ labelKey: string
15
+ checkedIcon?: string
16
+ loadingIcon?: string
13
17
  class?: any
14
18
  ui: typeof _dropdownMenu
15
19
  uiOverride?: any
@@ -20,6 +24,7 @@ interface DropdownMenuContentEmits extends RadixDropdownMenuContentEmits {}
20
24
  type DropdownMenuContentSlots<T extends { slot?: string }> = Omit<DropdownMenuSlots<T>, 'default'> & {
21
25
  default(props?: {}): any
22
26
  }
27
+
23
28
  </script>
24
29
 
25
30
  <script setup lang="ts" generic="T extends DropdownMenuItem">
@@ -28,13 +33,13 @@ import { DropdownMenu } from 'radix-vue/namespaced'
28
33
  import { useForwardPropsEmits } from 'radix-vue'
29
34
  import { reactiveOmit, createReusableTemplate } from '@vueuse/core'
30
35
  import { useAppConfig } from '#imports'
36
+ import { omit, get } from '../utils'
37
+ import { pickLinkProps } from '../utils/link'
31
38
  import ULinkBase from './LinkBase.vue'
32
39
  import ULink from './Link.vue'
33
40
  import UAvatar from './Avatar.vue'
34
41
  import UIcon from './Icon.vue'
35
42
  import UKbd from './Kbd.vue'
36
- import { omit } from '../utils'
37
- import { pickLinkProps } from '../utils/link'
38
43
 
39
44
  const props = defineProps<DropdownMenuContentProps<T>>()
40
45
  const emits = defineEmits<DropdownMenuContentEmits>()
@@ -53,25 +58,30 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
53
58
  <DefineItemTemplate v-slot="{ item, active, index }">
54
59
  <slot :name="item.slot || 'item'" :item="(item as T)" :index="index">
55
60
  <slot :name="item.slot ? `${item.slot}-leading`: 'item-leading'" :item="(item as T)" :active="active" :index="index">
56
- <UAvatar v-if="item.avatar" :size="((props.uiOverride?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ class: uiOverride?.itemLeadingAvatar, active })" />
61
+ <UIcon v-if="item.loading" :name="loadingIcon || appConfig.ui.icons.loading" :class="ui.itemLeadingIcon({ class: uiOverride?.itemLeadingIcon, loading: true })" />
57
62
  <UIcon v-else-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: uiOverride?.itemLeadingIcon, active })" />
63
+ <UAvatar v-else-if="item.avatar" :size="((props.uiOverride?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ class: uiOverride?.itemLeadingAvatar, active })" />
58
64
  </slot>
59
65
 
60
- <span v-if="item.label || !!slots[item.slot ? `${item.slot}-label`: 'item-label']" :class="ui.itemLabel({ class: uiOverride?.itemLabel, active })">
66
+ <span v-if="get(item, props.labelKey as string) || !!slots[item.slot ? `${item.slot}-label`: 'item-label']" :class="ui.itemLabel({ class: uiOverride?.itemLabel, active })">
61
67
  <slot :name="item.slot ? `${item.slot}-label`: 'item-label'" :item="(item as T)" :active="active" :index="index">
62
- {{ item.label }}
68
+ {{ get(item, props.labelKey as string) }}
63
69
  </slot>
64
70
 
65
71
  <UIcon v-if="item.target === '_blank'" :name="appConfig.ui.icons.external" :class="ui.itemLabelExternalIcon({ class: uiOverride?.itemLabelExternalIcon, active })" />
66
72
  </span>
67
73
 
68
- <span v-if="item.children?.length || item.kbds?.length || !!slots[item.slot ? `${item.slot}-trailing`: 'item-trailing']" :class="ui.itemTrailing({ class: uiOverride?.itemTrailing })">
74
+ <span :class="ui.itemTrailing({ class: uiOverride?.itemTrailing })">
69
75
  <slot :name="item.slot ? `${item.slot}-trailing`: 'item-trailing'" :item="(item as T)" :active="active" :index="index">
70
76
  <UIcon v-if="item.children?.length" :name="appConfig.ui.icons.chevronRight" :class="ui.itemTrailingIcon({ class: uiOverride?.itemTrailingIcon, active })" />
71
77
  <span v-else-if="item.kbds?.length" :class="ui.itemTrailingKbds({ class: uiOverride?.itemTrailingKbds })">
72
78
  <UKbd v-for="(kbd, kbdIndex) in item.kbds" :key="kbdIndex" :size="((props.uiOverride?.itemTrailingKbdsSize || ui.itemTrailingKbdsSize()) as KbdProps['size'])" v-bind="typeof kbd === 'string' ? { value: kbd } : kbd" />
73
79
  </span>
74
80
  </slot>
81
+
82
+ <DropdownMenu.ItemIndicator as-child>
83
+ <UIcon :name="checkedIcon || appConfig.ui.icons.check" :class="ui.itemTrailingIcon({ class: uiOverride?.itemTrailingIcon })" />
84
+ </DropdownMenu.ItemIndicator>
75
85
  </span>
76
86
  </slot>
77
87
  </DefineItemTemplate>
@@ -89,7 +99,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
89
99
  as="button"
90
100
  type="button"
91
101
  :disabled="item.disabled"
92
- :text-value="item.label"
102
+ :text-value="get(item, props.labelKey as string)"
93
103
  :class="ui.item({ class: uiOverride?.item })"
94
104
  >
95
105
  <ReuseItemTemplate :item="item" :index="index" />
@@ -106,6 +116,9 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
106
116
  align="start"
107
117
  :align-offset="-4"
108
118
  :side-offset="3"
119
+ :label-key="labelKey"
120
+ :checked-icon="checkedIcon"
121
+ :loading-icon="loadingIcon"
109
122
  v-bind="item.content"
110
123
  >
111
124
  <template v-for="(_, name) in proxySlots" #[name]="slotData: any">
@@ -113,7 +126,24 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
113
126
  </template>
114
127
  </UDropdownMenuContent>
115
128
  </DropdownMenu.Sub>
116
- <DropdownMenu.Item v-else as-child :disabled="item.disabled" :text-value="item.label" @select="item.select">
129
+ <DropdownMenu.CheckboxItem
130
+ v-else-if="item.type === 'checkbox'"
131
+ :checked="item.checked"
132
+ :disabled="item.disabled"
133
+ :text-value="get(item, props.labelKey as string)"
134
+ :class="ui.item({ class: [uiOverride?.item, item.class] })"
135
+ @update:checked="item.onUpdateChecked"
136
+ @select="item.onSelect"
137
+ >
138
+ <ReuseItemTemplate :item="item" :index="index" />
139
+ </DropdownMenu.CheckboxItem>
140
+ <DropdownMenu.Item
141
+ v-else
142
+ as-child
143
+ :disabled="item.disabled"
144
+ :text-value="get(item, props.labelKey as string)"
145
+ @select="item.onSelect"
146
+ >
117
147
  <ULink v-slot="{ active, ...slotProps }" v-bind="pickLinkProps(item as Omit<DropdownMenuItem, 'type'>)" custom>
118
148
  <ULinkBase v-bind="slotProps" :class="ui.item({ class: [uiOverride?.item, item.class], active })">
119
149
  <ReuseItemTemplate :item="item" :active="active" :index="index" />
@@ -35,7 +35,7 @@ export interface FormSlots {
35
35
  import { provide, inject, nextTick, ref, onUnmounted, onMounted, computed, useId, readonly } from 'vue'
36
36
  import { useEventBus } from '@vueuse/core'
37
37
  import { formOptionsInjectionKey, formInputsInjectionKey, formBusInjectionKey, formLoadingInjectionKey } from '../composables/useFormField'
38
- import { getYupErrors, isYupSchema, getValibotErrors, isValibotSchema, getZodErrors, isZodSchema, getJoiErrors, isJoiSchema, getStandardErrors, isStandardSchema } from '../utils/form'
38
+ import { getYupErrors, isYupSchema, getValibotErrors, isValibotSchema, getZodErrors, isZodSchema, getJoiErrors, isJoiSchema, getStandardErrors, isStandardSchema, getSuperStructErrors, isSuperStructSchema } from '../utils/form'
39
39
  import { FormValidationException } from '../types/form'
40
40
 
41
41
  const props = withDefaults(defineProps<FormProps<T>>(), {
@@ -113,6 +113,8 @@ async function getErrors(): Promise<FormErrorWithId[]> {
113
113
  errs = errs.concat(await getJoiErrors(props.state, props.schema))
114
114
  } else if (isValibotSchema(props.schema)) {
115
115
  errs = errs.concat(await getValibotErrors(props.state, props.schema))
116
+ } else if (isSuperStructSchema(props.schema)) {
117
+ errs = errs.concat(await getSuperStructErrors(props.state, props.schema))
116
118
  } else if (isStandardSchema(props.schema)) {
117
119
  errs = errs.concat(await getStandardErrors(props.state, props.schema))
118
120
  } else {
@@ -5,6 +5,7 @@ import type { AppConfig } from '@nuxt/schema'
5
5
  import _appConfig from '#build/app.config'
6
6
  import theme from '#build/ui/input'
7
7
  import type { UseComponentIconsProps } from '../composables/useComponentIcons'
8
+ import type { AvatarProps } from '../types'
8
9
  import type { PartialString } from '../types/utils'
9
10
 
10
11
  const appConfig = _appConfig as AppConfig & { ui: { input: Partial<typeof theme> } }
@@ -51,8 +52,8 @@ import { ref, computed, onMounted } from 'vue'
51
52
  import { useButtonGroup } from '../composables/useButtonGroup'
52
53
  import { useComponentIcons } from '../composables/useComponentIcons'
53
54
  import { useFormField } from '../composables/useFormField'
54
- import UIcon from './Icon.vue'
55
55
  import { looseToNumber } from '../utils'
56
+ import UIcon from './Icon.vue'
56
57
 
57
58
  defineOptions({ inheritAttrs: false })
58
59
 
@@ -79,7 +80,7 @@ const ui = computed(() => input({
79
80
  size: inputSize?.value,
80
81
  loading: props.loading,
81
82
  highlight: highlight.value,
82
- leading: isLeading.value || !!slots.leading,
83
+ leading: isLeading.value || !!props.avatar || !!slots.leading,
83
84
  trailing: isTrailing.value || !!slots.trailing,
84
85
  buttonGroup: orientation.value
85
86
  }))
@@ -165,9 +166,10 @@ onMounted(() => {
165
166
 
166
167
  <slot />
167
168
 
168
- <span v-if="isLeading || !!slots.leading" :class="ui.leading({ class: props.ui?.leading })">
169
+ <span v-if="isLeading || !!avatar || !!slots.leading" :class="ui.leading({ class: props.ui?.leading })">
169
170
  <slot name="leading">
170
- <UIcon v-if="leadingIconName" :name="leadingIconName" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" />
171
+ <UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" />
172
+ <UAvatar v-else-if="!!avatar" :size="((props.ui?.leadingAvatarSize || ui.leadingAvatarSize()) as AvatarProps['size'])" v-bind="avatar" :class="ui.leadingAvatar({ class: props.ui?.leadingAvatar })" />
171
173
  </slot>
172
174
  </span>
173
175
 
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { InputHTMLAttributes } from 'vue'
3
3
  import { tv, type VariantProps } from 'tailwind-variants'
4
- import type { ComboboxRootProps, ComboboxRootEmits, ComboboxContentProps, ComboboxItemProps, ComboboxArrowProps } from 'radix-vue'
4
+ import type { ComboboxRootProps, ComboboxRootEmits, ComboboxContentProps, ComboboxArrowProps } from 'radix-vue'
5
5
  import type { AppConfig } from '@nuxt/schema'
6
6
  import _appConfig from '#build/app.config'
7
7
  import theme from '#build/ui/input-menu'
@@ -13,7 +13,7 @@ const appConfig = _appConfig as AppConfig & { ui: { inputMenu: Partial<typeof th
13
13
 
14
14
  const inputMenu = tv({ extend: tv(theme), ...(appConfig.ui?.inputMenu || {}) })
15
15
 
16
- export interface InputMenuItem extends Pick<ComboboxItemProps, 'disabled'> {
16
+ export interface InputMenuItem {
17
17
  label?: string
18
18
  icon?: string
19
19
  avatar?: AvatarProps
@@ -23,7 +23,8 @@ export interface InputMenuItem extends Pick<ComboboxItemProps, 'disabled'> {
23
23
  * @defaultValue 'item'
24
24
  */
25
25
  type?: 'label' | 'separator' | 'item'
26
- select?(e?: Event): void
26
+ disabled?: boolean
27
+ onSelect?(e?: Event): void
27
28
  }
28
29
 
29
30
  type InputMenuVariants = VariantProps<typeof inputMenu>
@@ -86,6 +87,11 @@ export interface InputMenuProps<T> extends Pick<ComboboxRootProps<T>, 'modelValu
86
87
  * @defaultValue undefined
87
88
  */
88
89
  valueKey?: keyof T
90
+ /**
91
+ * When `items` is an array of objects, select the field to use as the label.
92
+ * @defaultValue 'label'
93
+ */
94
+ labelKey?: keyof T
89
95
  items?: T[] | T[][]
90
96
  /** Highlight the ring color like a focus state. */
91
97
  highlight?: boolean
@@ -118,15 +124,16 @@ export interface InputMenuSlots<T> {
118
124
  import { computed, ref, toRef, onMounted } from 'vue'
119
125
  import { ComboboxRoot, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxPortal, ComboboxContent, ComboboxViewport, ComboboxEmpty, ComboboxGroup, ComboboxLabel, ComboboxSeparator, ComboboxItem, ComboboxItemIndicator, TagsInputRoot, TagsInputItem, TagsInputItemText, TagsInputItemDelete, TagsInputInput, useForwardPropsEmits } from 'radix-vue'
120
126
  import { defu } from 'defu'
127
+ import isEqual from 'fast-deep-equal'
121
128
  import { reactivePick } from '@vueuse/core'
122
129
  import { useAppConfig } from '#imports'
123
130
  import { useButtonGroup } from '../composables/useButtonGroup'
124
131
  import { useComponentIcons } from '../composables/useComponentIcons'
125
132
  import { useFormField } from '../composables/useFormField'
133
+ import { get, escapeRegExp } from '../utils'
126
134
  import UIcon from './Icon.vue'
127
135
  import UAvatar from './Avatar.vue'
128
136
  import UChip from './Chip.vue'
129
- import { get } from '../utils'
130
137
 
131
138
  defineOptions({ inheritAttrs: false })
132
139
 
@@ -134,7 +141,8 @@ const props = withDefaults(defineProps<InputMenuProps<T>>(), {
134
141
  type: 'text',
135
142
  autofocusDelay: 0,
136
143
  portal: true,
137
- filter: () => ['label']
144
+ filter: () => ['label'],
145
+ labelKey: 'label' as keyof T
138
146
  })
139
147
  const emits = defineEmits<InputMenuEmits<T>>()
140
148
  const slots = defineSlots<InputMenuSlots<T>>()
@@ -156,18 +164,16 @@ const ui = computed(() => inputMenu({
156
164
  size: inputSize?.value,
157
165
  loading: props.loading,
158
166
  highlight: highlight.value,
159
- leading: isLeading.value || !!slots.leading,
167
+ leading: isLeading.value || !!props.avatar || !!slots.leading,
160
168
  trailing: isTrailing.value || !!slots.trailing,
161
169
  multiple: props.multiple,
162
170
  buttonGroup: orientation.value
163
171
  }))
164
172
 
165
- function displayValue(val: AcceptableValue) {
166
- if (typeof val === 'object') {
167
- return val.label
168
- }
173
+ function displayValue(value: AcceptableValue): string {
174
+ const item = items.value.find(item => props.valueKey ? isEqual(get(item as Record<string, any>, props.valueKey as string), value) : isEqual(item, value))
169
175
 
170
- return val && String(val)
176
+ return item && (typeof item === 'object' ? get(item, props.labelKey as string) : item)
171
177
  }
172
178
 
173
179
  function filterFunction(items: ArrayOrWrapped<AcceptableValue>, searchTerm: string): ArrayOrWrapped<AcceptableValue> {
@@ -175,22 +181,25 @@ function filterFunction(items: ArrayOrWrapped<AcceptableValue>, searchTerm: stri
175
181
  return items
176
182
  }
177
183
 
178
- const fields = Array.isArray(props.filter) ? props.filter : ['label']
184
+ const fields = Array.isArray(props.filter) ? props.filter : [props.labelKey]
185
+ const escapedSearchTerm = escapeRegExp(searchTerm)
179
186
 
180
187
  return items.filter((item) => {
181
188
  if (typeof item !== 'object') {
182
- return String(item).search(new RegExp(searchTerm, 'i')) !== -1
189
+ return String(item).search(new RegExp(escapedSearchTerm, 'i')) !== -1
183
190
  }
184
191
 
185
192
  return fields.some((field) => {
186
- const child = get(item, field)
193
+ const child = get(item, field as string)
187
194
 
188
- return child !== null && child !== undefined && String(child).search(new RegExp(searchTerm, 'i')) !== -1
195
+ return child !== null && child !== undefined && String(child).search(new RegExp(escapedSearchTerm, 'i')) !== -1
189
196
  })
190
197
  }) as ArrayOrWrapped<T>
191
198
  }
192
199
 
193
200
  const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0]) ? props.items : [props.items]) as InputMenuItem[][] : [])
201
+ // eslint-disable-next-line vue/no-dupe-keys
202
+ const items = computed(() => groups.value.flatMap(group => group) as T[])
194
203
 
195
204
  const inputRef = ref<InstanceType<typeof ComboboxInput> | null>(null)
196
205
 
@@ -219,6 +228,10 @@ function onBlur(event: FocusEvent) {
219
228
  emitFormBlur()
220
229
  }
221
230
 
231
+ function onFocus(event: FocusEvent) {
232
+ emits('focus', event)
233
+ }
234
+
222
235
  function onUpdateOpen(value: boolean) {
223
236
  if (!value) {
224
237
  const event = new FocusEvent('blur')
@@ -260,11 +273,12 @@ defineExpose({
260
273
  delimiter=""
261
274
  as-child
262
275
  @blur="onBlur"
276
+ @focus="onFocus"
263
277
  >
264
278
  <TagsInputItem v-for="(item, index) in tags" :key="index" :value="(item as string)" :class="ui.tagsItem({ class: props.ui?.tagsItem })">
265
279
  <TagsInputItemText :class="ui.tagsItemText({ class: props.ui?.tagsItemText })">
266
280
  <slot name="tags-item-text" :item="(item as T)" :index="index">
267
- {{ typeof item === 'object' ? item.label : item }}
281
+ {{ displayValue(item as T) }}
268
282
  </slot>
269
283
  </TagsInputItemText>
270
284
 
@@ -296,11 +310,13 @@ defineExpose({
296
310
  :required="required"
297
311
  :class="ui.base({ class: props.ui?.base })"
298
312
  @blur="onBlur"
313
+ @focus="onFocus"
299
314
  />
300
315
 
301
- <span v-if="isLeading || !!slots.leading" :class="ui.leading({ class: props.ui?.leading })">
316
+ <span v-if="isLeading || !!avatar || !!slots.leading" :class="ui.leading({ class: props.ui?.leading })">
302
317
  <slot name="leading" :model-value="(modelValue as T)" :open="open" :ui="ui">
303
- <UIcon v-if="leadingIconName" :name="leadingIconName" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" />
318
+ <UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" />
319
+ <UAvatar v-else-if="!!avatar" :size="((props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="avatar" :class="ui.itemLeadingAvatar({ class: props.ui?.itemLeadingAvatar })" />
304
320
  </slot>
305
321
  </span>
306
322
 
@@ -323,7 +339,7 @@ defineExpose({
323
339
  <ComboboxGroup v-for="(group, groupIndex) in groups" :key="`group-${groupIndex}`" :class="ui.group({ class: props.ui?.group })">
324
340
  <template v-for="(item, index) in group" :key="`group-${groupIndex}-${index}`">
325
341
  <ComboboxLabel v-if="item?.type === 'label'" :class="ui.label({ class: props.ui?.label })">
326
- {{ item.label }}
342
+ {{ get(item, props.labelKey as string) }}
327
343
  </ComboboxLabel>
328
344
 
329
345
  <ComboboxSeparator v-else-if="item?.type === 'separator'" :class="ui.separator({ class: props.ui?.separator })" />
@@ -332,13 +348,13 @@ defineExpose({
332
348
  v-else
333
349
  :class="ui.item({ class: props.ui?.item })"
334
350
  :disabled="item.disabled"
335
- :value="valueKey && typeof item === 'object' ? (item[valueKey as keyof InputMenuItem]) as AcceptableValue : item"
336
- @select="item.select"
351
+ :value="valueKey && typeof item === 'object' ? get(item, props.valueKey as string) : item"
352
+ @select="item.onSelect"
337
353
  >
338
354
  <slot name="item" :item="(item as T)" :index="index">
339
355
  <slot name="item-leading" :item="(item as T)" :index="index">
340
- <UAvatar v-if="item.avatar" :size="((props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ class: props.ui?.itemLeadingAvatar })" />
341
- <UIcon v-else-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: props.ui?.itemLeadingIcon })" />
356
+ <UIcon v-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: props.ui?.itemLeadingIcon })" />
357
+ <UAvatar v-else-if="item.avatar" :size="((props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ class: props.ui?.itemLeadingAvatar })" />
342
358
  <UChip
343
359
  v-else-if="item.chip"
344
360
  :size="((props.ui?.itemLeadingChipSize || ui.itemLeadingChipSize()) as ChipProps['size'])"
@@ -351,7 +367,7 @@ defineExpose({
351
367
 
352
368
  <span :class="ui.itemLabel({ class: props.ui?.itemLabel })">
353
369
  <slot name="item-label" :item="(item as T)" :index="index">
354
- {{ displayValue(item as T) }}
370
+ {{ typeof item === 'object' ? get(item, props.labelKey as string) : item }}
355
371
  </slot>
356
372
  </span>
357
373
 
@@ -3,7 +3,7 @@ export interface LinkBaseProps {
3
3
  as?: string
4
4
  type?: string
5
5
  disabled?: boolean
6
- onClick?: (e: MouseEvent) => void | Promise<void>
6
+ onClick?: ((e: MouseEvent) => void | Promise<void>) | Array<((e: MouseEvent) => void | Promise<void>)>
7
7
  href?: string
8
8
  navigate?: (e: MouseEvent) => void
9
9
  rel?: string
@@ -28,7 +28,9 @@ function onClickWrapper(e: MouseEvent) {
28
28
  }
29
29
 
30
30
  if (props.onClick) {
31
- props.onClick(e)
31
+ for (const onClick of Array.isArray(props.onClick) ? props.onClick : [props.onClick]) {
32
+ onClick(e)
33
+ }
32
34
  }
33
35
 
34
36
  if (props.href && props.navigate && !props.isExternal) {
@@ -1,7 +1,7 @@
1
1
  <!-- eslint-disable vue/block-tag-newline -->
2
2
  <script lang="ts">
3
3
  import { tv, type VariantProps } from 'tailwind-variants'
4
- import type { NavigationMenuRootProps, NavigationMenuRootEmits, NavigationMenuItemProps, NavigationMenuContentProps } from 'radix-vue'
4
+ import type { NavigationMenuRootProps, NavigationMenuRootEmits, NavigationMenuContentProps } from 'radix-vue'
5
5
  import type { AppConfig } from '@nuxt/schema'
6
6
  import _appConfig from '#build/app.config'
7
7
  import theme from '#build/ui/navigation-menu'
@@ -16,18 +16,20 @@ export interface NavigationMenuChildItem extends Omit<LinkProps, 'raw' | 'custom
16
16
  label: string
17
17
  description?: string
18
18
  icon?: string
19
- select?(e: Event): void
19
+ badge?: string | number | BadgeProps
20
+ onSelect?(e: Event): void
20
21
  }
21
22
 
22
- export interface NavigationMenuItem extends Omit<LinkProps, 'raw' | 'custom'>, Pick<NavigationMenuItemProps, 'value'> {
23
+ export interface NavigationMenuItem extends Omit<LinkProps, 'raw' | 'custom'> {
23
24
  label?: string
24
25
  icon?: string
25
26
  avatar?: AvatarProps
26
27
  badge?: string | number | BadgeProps
27
28
  trailingIcon?: string
28
29
  slot?: string
30
+ value?: string
29
31
  children?: NavigationMenuChildItem[]
30
- select?(e: Event): void
32
+ onSelect?(e: Event): void
31
33
  }
32
34
 
33
35
  type NavigationMenuVariants = VariantProps<typeof navigationMenu>
@@ -61,6 +63,11 @@ export interface NavigationMenuProps<T> extends Pick<NavigationMenuRootProps, 'd
61
63
  * @defaultValue false
62
64
  */
63
65
  arrow?: boolean
66
+ /**
67
+ * The key used to get the label from the item.
68
+ * @defaultValue 'label'
69
+ */
70
+ labelKey?: string
64
71
  class?: any
65
72
  ui?: PartialString<typeof navigationMenu.slots>
66
73
  }
@@ -81,17 +88,20 @@ export type NavigationMenuSlots<T extends { slot?: string }> = {
81
88
  <script setup lang="ts" generic="T extends NavigationMenuItem">
82
89
  import { computed, toRef } from 'vue'
83
90
  import { NavigationMenuRoot, NavigationMenuList, NavigationMenuItem, NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink, NavigationMenuIndicator, NavigationMenuViewport, useForwardPropsEmits } from 'radix-vue'
84
- import { reactivePick } from '@vueuse/core'
91
+ import { reactivePick, createReusableTemplate } from '@vueuse/core'
92
+ import { get } from '../utils'
93
+ import { pickLinkProps } from '../utils/link'
85
94
  import ULinkBase from './LinkBase.vue'
86
95
  import ULink from './Link.vue'
87
96
  import UAvatar from './Avatar.vue'
88
97
  import UIcon from './Icon.vue'
89
98
  import UBadge from './Badge.vue'
90
- import { pickLinkProps } from '../utils/link'
99
+ import UCollapsible from './Collapsible.vue'
91
100
 
92
101
  const props = withDefaults(defineProps<NavigationMenuProps<T>>(), {
93
102
  orientation: 'horizontal',
94
- delayDuration: 0
103
+ delayDuration: 0,
104
+ labelKey: 'label'
95
105
  })
96
106
  const emits = defineEmits<NavigationMenuEmits>()
97
107
  const slots = defineSlots<NavigationMenuSlots<T>>()
@@ -99,6 +109,8 @@ const slots = defineSlots<NavigationMenuSlots<T>>()
99
109
  const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'delayDuration', 'skipDelayDuration', 'orientation'), emits)
100
110
  const contentProps = toRef(() => props.content)
101
111
 
112
+ const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: NavigationMenuItem, active?: boolean, index: number }>()
113
+
102
114
  const ui = computed(() => navigationMenu({
103
115
  orientation: props.orientation,
104
116
  color: props.color,
@@ -111,47 +123,58 @@ const lists = computed(() => props.items?.length ? (Array.isArray(props.items[0]
111
123
  </script>
112
124
 
113
125
  <template>
126
+ <DefineItemTemplate v-slot="{ item, active, index }">
127
+ <slot :name="item.slot || 'item'" :item="(item as T)" :index="index">
128
+ <slot :name="item.slot ? `${item.slot}-leading` : 'item-leading'" :item="(item as T)" :active="active" :index="index">
129
+ <UAvatar v-if="item.avatar" :size="((props.ui?.linkLeadingAvatarSize || ui.linkLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.linkLeadingAvatar({ class: props.ui?.linkLeadingAvatar, active, disabled: !!item.disabled })" />
130
+ <UIcon v-else-if="item.icon" :name="item.icon" :class="ui.linkLeadingIcon({ class: props.ui?.linkLeadingIcon, active, disabled: !!item.disabled })" />
131
+ </slot>
132
+
133
+ <span v-if="get(item, props.labelKey as string) || !!slots[item.slot ? `${item.slot}-label` : 'item-label']" :class="ui.linkLabel({ class: props.ui?.linkLabel })">
134
+ <slot :name="item.slot ? `${item.slot}-label` : 'item-label'" :item="(item as T)" :active="active" :index="index">
135
+ {{ get(item, props.labelKey as string) }}
136
+ </slot>
137
+
138
+ <UIcon v-if="item.target === '_blank'" :name="appConfig.ui.icons.external" :class="ui.linkLabelExternalIcon({ class: props.ui?.linkLabelExternalIcon, active })" />
139
+ </span>
140
+
141
+ <span v-if="item.badge || item.children?.length || !!slots[item.slot ? `${item.slot}-trailing` : 'item-trailing']" :class="ui.linkTrailing({ class: props.ui?.linkTrailing })">
142
+ <slot :name="item.slot ? `${item.slot}-trailing` : 'item-trailing'" :item="(item as T)" :active="active" :index="index">
143
+ <UBadge
144
+ v-if="item.badge"
145
+ color="neutral"
146
+ variant="outline"
147
+ :size="((props.ui?.linkTrailingBadgeSize || ui.linkTrailingBadgeSize()) as BadgeProps['size'])"
148
+ v-bind="(typeof item.badge === 'string' || typeof item.badge === 'number') ? { label: item.badge } : item.badge"
149
+ :class="ui.linkTrailingBadge({ class: props.ui?.linkTrailingBadge })"
150
+ />
151
+ <UIcon v-if="item.children?.length" :name="item.trailingIcon || trailingIcon || appConfig.ui.icons.chevronDown" :class="ui.linkTrailingIcon({ class: props.ui?.linkTrailingIcon, active })" />
152
+ </slot>
153
+ </span>
154
+ </slot>
155
+ </DefineItemTemplate>
156
+
114
157
  <NavigationMenuRoot v-bind="rootProps" :class="ui.root({ class: [props.class, props.ui?.root] })">
115
158
  <template v-for="(list, listIndex) in lists" :key="`list-${listIndex}`">
116
159
  <NavigationMenuList :class="ui.list({ class: props.ui?.list })">
117
- <NavigationMenuItem v-for="(item, index) in list" :key="`list-${listIndex}-${index}`" :value="item.value || String(index)" :class="ui.item({ class: props.ui?.item })">
160
+ <component
161
+ :is="(item.children?.length && orientation === 'vertical') ? UCollapsible : NavigationMenuItem"
162
+ v-for="(item, index) in list"
163
+ :key="`list-${listIndex}-${index}`"
164
+ as="li"
165
+ :value="item.value || String(index)"
166
+ :class="ui.item({ class: props.ui?.item })"
167
+ >
118
168
  <ULink v-slot="{ active, ...slotProps }" v-bind="pickLinkProps(item)" custom>
119
169
  <component
120
- :is="item.children?.length ? NavigationMenuTrigger : NavigationMenuLink"
170
+ :is="(item.children?.length && orientation === 'horizontal') ? NavigationMenuTrigger : NavigationMenuLink"
121
171
  v-bind="item.children?.length ? { disabled: item.disabled } : { active }"
122
172
  as-child
123
173
  :active="active"
124
- @select="item.select"
174
+ @select="item.onSelect"
125
175
  >
126
176
  <ULinkBase v-bind="slotProps" :class="ui.link({ class: [props.ui?.link, item.class], active, disabled: !!item.disabled })">
127
- <slot :name="item.slot || 'item'" :item="item" :index="index">
128
- <slot :name="item.slot ? `${item.slot}-leading`: 'item-leading'" :item="item" :active="active" :index="index">
129
- <UAvatar v-if="item.avatar" :size="((props.ui?.linkLeadingAvatarSize || ui.linkLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.linkLeadingAvatar({ class: props.ui?.linkLeadingAvatar, active, disabled: !!item.disabled })" />
130
- <UIcon v-else-if="item.icon" :name="item.icon" :class="ui.linkLeadingIcon({ class: props.ui?.linkLeadingIcon, active, disabled: !!item.disabled })" />
131
- </slot>
132
-
133
- <span v-if="item.label || !!slots[item.slot ? `${item.slot}-label`: 'item-label']" :class="ui.linkLabel({ class: props.ui?.linkLabel })">
134
- <slot :name="item.slot ? `${item.slot}-label`: 'item-label'" :item="item" :active="active" :index="index">
135
- {{ item.label }}
136
- </slot>
137
-
138
- <UIcon v-if="item.target === '_blank'" :name="appConfig.ui.icons.external" :class="ui.linkLabelExternalIcon({ class: props.ui?.linkLabelExternalIcon, active })" />
139
- </span>
140
-
141
- <span v-if="item.badge || (item.children?.length && orientation === 'horizontal') || !!slots[item.slot ? `${item.slot}-trailing`: 'item-trailing']" :class="ui.linkTrailing({ class: props.ui?.linkTrailing })">
142
- <slot :name="item.slot ? `${item.slot}-trailing`: 'item-trailing'" :item="item" :active="active" :index="index">
143
- <UBadge
144
- v-if="item.badge"
145
- color="neutral"
146
- variant="outline"
147
- :size="((props.ui?.linkTrailingBadgeSize || ui.linkTrailingBadgeSize()) as BadgeProps['size'])"
148
- v-bind="(typeof item.badge === 'string' || typeof item.badge === 'number') ? { label: item.badge } : item.badge"
149
- :class="ui.linkTrailingBadge({ class: props.ui?.linkTrailingBadge })"
150
- />
151
- <UIcon v-if="item.children?.length && orientation === 'horizontal'" :name="item.trailingIcon || trailingIcon || appConfig.ui.icons.chevronDown" :class="ui.linkTrailingIcon({ class: props.ui?.linkTrailingIcon, active })" />
152
- </slot>
153
- </span>
154
- </slot>
177
+ <ReuseItemTemplate :item="item" :active="active" :index="index" />
155
178
  </ULinkBase>
156
179
  </component>
157
180
 
@@ -159,13 +182,13 @@ const lists = computed(() => props.items?.length ? (Array.isArray(props.items[0]
159
182
  <ul :class="ui.childList({ class: props.ui?.childList })">
160
183
  <li v-for="(childItem, childIndex) in item.children" :key="childIndex" :class="ui.childItem({ class: props.ui?.childItem })">
161
184
  <ULink v-slot="{ active: childActive, ...childSlotProps }" v-bind="pickLinkProps(childItem)" custom>
162
- <NavigationMenuLink as-child :active="childActive" @select="childItem.select">
185
+ <NavigationMenuLink as-child :active="childActive" @select="childItem.onSelect">
163
186
  <ULinkBase v-bind="childSlotProps" :class="ui.childLink({ class: [props.ui?.childLink, childItem.class], active: childActive })">
164
187
  <UIcon v-if="childItem.icon" :name="childItem.icon" :class="ui.childLinkIcon({ class: props.ui?.childLinkIcon, active: childActive })" />
165
188
 
166
189
  <div :class="ui.childLinkWrapper({ class: props.ui?.childLinkWrapper })">
167
190
  <p :class="ui.childLinkLabel({ class: props.ui?.childLinkLabel, active: childActive })">
168
- {{ childItem.label }}
191
+ {{ get(childItem, props.labelKey as string) }}
169
192
 
170
193
  <UIcon v-if="childItem.target === '_blank'" :name="appConfig.ui.icons.external" :class="ui.childLinkLabelExternalIcon({ class: props.ui?.childLinkLabelExternalIcon, active: childActive })" />
171
194
  </p>
@@ -180,7 +203,21 @@ const lists = computed(() => props.items?.length ? (Array.isArray(props.items[0]
180
203
  </ul>
181
204
  </NavigationMenuContent>
182
205
  </ULink>
183
- </NavigationMenuItem>
206
+
207
+ <template v-if="item.children?.length && orientation === 'vertical'" #content>
208
+ <ul :class="ui.childList({ class: props.ui?.childList })">
209
+ <li v-for="(childItem, childIndex) in item.children" :key="childIndex" :class="ui.childItem({ class: props.ui?.childItem })">
210
+ <ULink v-slot="{ active: childActive, ...childSlotProps }" v-bind="pickLinkProps(childItem)" custom>
211
+ <NavigationMenuLink as-child :active="childActive" @select="childItem.onSelect">
212
+ <ULinkBase v-bind="childSlotProps" :class="ui.link({ class: [props.ui?.link, childItem.class], active: childActive, disabled: !!childItem.disabled })">
213
+ <ReuseItemTemplate :item="childItem" :active="childActive" :index="childIndex" />
214
+ </ULinkBase>
215
+ </NavigationMenuLink>
216
+ </ULink>
217
+ </li>
218
+ </ul>
219
+ </template>
220
+ </component>
184
221
  </NavigationMenuList>
185
222
 
186
223
  <div v-if="orientation === 'vertical' && listIndex < lists.length - 1" :class="ui.separator({ class: props.ui?.separator })" />