@nuxt/ui 3.0.0-alpha.8 → 3.0.0-alpha.9

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 (121) hide show
  1. package/.nuxt/ui/button.ts +1 -1
  2. package/.nuxt/ui/card.ts +1 -1
  3. package/.nuxt/ui/carousel.ts +2 -2
  4. package/.nuxt/ui/container.ts +1 -1
  5. package/.nuxt/ui/index.ts +2 -0
  6. package/.nuxt/ui/input-menu.ts +3 -3
  7. package/.nuxt/ui/input-number.ts +204 -0
  8. package/.nuxt/ui/input.ts +2 -2
  9. package/.nuxt/ui/navigation-menu.ts +1 -1
  10. package/.nuxt/ui/pin-input.ts +145 -0
  11. package/.nuxt/ui/select-menu.ts +8 -13
  12. package/.nuxt/ui/select.ts +2 -2
  13. package/.nuxt/ui/table.ts +1 -0
  14. package/.nuxt/ui/tabs.ts +1 -1
  15. package/.nuxt/ui/textarea.ts +2 -2
  16. package/.nuxt/ui/toaster.ts +1 -1
  17. package/.nuxt/ui/tooltip.ts +1 -1
  18. package/README.md +52 -12
  19. package/cli/commands/{init.mjs → make/component.mjs} +4 -4
  20. package/cli/commands/make/index.mjs +14 -0
  21. package/cli/commands/make/locale.mjs +64 -0
  22. package/cli/index.mjs +2 -2
  23. package/cli/templates.mjs +46 -1
  24. package/cli/utils.mjs +14 -0
  25. package/dist/chunks/component-meta.cjs +7320 -3335
  26. package/dist/chunks/component-meta.mjs +7320 -3335
  27. package/dist/client/devtools/200.html +1 -1
  28. package/dist/client/devtools/404.html +1 -1
  29. package/dist/client/devtools/_nuxt/{hKiujHsR.js → BDC8ywlR.js} +1 -1
  30. package/dist/client/devtools/_nuxt/{DM3_wAgz.js → BT004Uxp.js} +22 -31
  31. package/dist/client/devtools/_nuxt/{Bj9hdafK.js → C1H2Soz3.js} +1 -1
  32. package/dist/client/devtools/_nuxt/builds/latest.json +1 -1
  33. package/dist/client/devtools/_nuxt/builds/meta/f98c13ea-d751-4b37-8ce3-94e99708745f.json +1 -0
  34. package/dist/client/devtools/_nuxt/entry.Bx6WJjrx.css +1 -0
  35. package/dist/client/devtools/_nuxt/error-404.Cq4qshcf.css +1 -0
  36. package/dist/client/devtools/_nuxt/error-500.ax1aM34m.css +1 -0
  37. package/dist/client/devtools/index.html +1 -1
  38. package/dist/module.cjs +13 -12
  39. package/dist/module.json +1 -1
  40. package/dist/module.mjs +13 -12
  41. package/dist/runtime/components/Alert.vue +4 -1
  42. package/dist/runtime/components/App.vue +11 -5
  43. package/dist/runtime/components/Breadcrumb.vue +8 -3
  44. package/dist/runtime/components/Carousel.vue +11 -7
  45. package/dist/runtime/components/CommandPalette.vue +5 -3
  46. package/dist/runtime/components/ContextMenuContent.vue +2 -0
  47. package/dist/runtime/components/DropdownMenuContent.vue +2 -0
  48. package/dist/runtime/components/Form.vue +18 -20
  49. package/dist/runtime/components/FormField.vue +7 -3
  50. package/dist/runtime/components/InputMenu.vue +97 -21
  51. package/dist/runtime/components/InputNumber.vue +192 -0
  52. package/dist/runtime/components/Link.vue +19 -5
  53. package/dist/runtime/components/Modal.vue +12 -3
  54. package/dist/runtime/components/NavigationMenu.vue +4 -2
  55. package/dist/runtime/components/Pagination.vue +12 -5
  56. package/dist/runtime/components/PinInput.vue +96 -0
  57. package/dist/runtime/components/Popover.vue +17 -1
  58. package/dist/runtime/components/Select.vue +1 -1
  59. package/dist/runtime/components/SelectMenu.vue +106 -30
  60. package/dist/runtime/components/Slideover.vue +12 -3
  61. package/dist/runtime/components/Table.vue +11 -1
  62. package/dist/runtime/components/Textarea.vue +1 -1
  63. package/dist/runtime/components/Toast.vue +4 -1
  64. package/dist/runtime/components/Toaster.vue +22 -14
  65. package/dist/runtime/composables/defineLocale.d.ts +9 -0
  66. package/dist/runtime/composables/defineLocale.js +4 -0
  67. package/dist/runtime/composables/useFormField.d.ts +4 -1
  68. package/dist/runtime/composables/useFormField.js +1 -1
  69. package/dist/runtime/composables/useLocale.d.ts +4 -0
  70. package/dist/runtime/composables/useLocale.js +10 -0
  71. package/dist/runtime/index.css +1 -1
  72. package/dist/runtime/locale/ar.d.ts +2 -0
  73. package/dist/runtime/locale/ar.js +47 -0
  74. package/dist/runtime/locale/cs.d.ts +2 -0
  75. package/dist/runtime/locale/cs.js +46 -0
  76. package/dist/runtime/locale/de.d.ts +2 -0
  77. package/dist/runtime/locale/de.js +46 -0
  78. package/dist/runtime/locale/en.d.ts +2 -0
  79. package/dist/runtime/locale/en.js +46 -0
  80. package/dist/runtime/locale/es.d.ts +2 -0
  81. package/dist/runtime/locale/es.js +46 -0
  82. package/dist/runtime/locale/fa_ir.d.ts +2 -0
  83. package/dist/runtime/locale/fa_ir.js +47 -0
  84. package/dist/runtime/locale/fr.d.ts +2 -0
  85. package/dist/runtime/locale/fr.js +46 -0
  86. package/dist/runtime/locale/index.d.ts +12 -0
  87. package/dist/runtime/locale/index.js +12 -0
  88. package/dist/runtime/locale/it.d.ts +2 -0
  89. package/dist/runtime/locale/it.js +46 -0
  90. package/dist/runtime/locale/pl.d.ts +2 -0
  91. package/dist/runtime/locale/pl.js +46 -0
  92. package/dist/runtime/locale/ru.d.ts +2 -0
  93. package/dist/runtime/locale/ru.js +46 -0
  94. package/dist/runtime/locale/zh_hans.d.ts +2 -0
  95. package/dist/runtime/locale/zh_hans.js +46 -0
  96. package/dist/runtime/locale/zh_hant.d.ts +2 -0
  97. package/dist/runtime/locale/zh_hant.js +46 -0
  98. package/dist/runtime/types/form.d.ts +7 -2
  99. package/dist/runtime/types/index.d.ts +3 -0
  100. package/dist/runtime/types/index.js +3 -0
  101. package/dist/runtime/types/locale.d.ts +48 -0
  102. package/dist/runtime/types/locale.js +0 -0
  103. package/dist/runtime/utils/form.d.ts +5 -9
  104. package/dist/runtime/utils/form.js +115 -50
  105. package/dist/runtime/utils/link.d.ts +1 -1
  106. package/dist/runtime/utils/locale.d.ts +15 -0
  107. package/dist/runtime/utils/locale.js +25 -0
  108. package/dist/shared/{ui.BU7wzH0D.cjs → ui.BPnAe4QS.cjs} +194 -36
  109. package/dist/shared/{ui.BufKhDiD.mjs → ui.d52SSCwA.mjs} +194 -36
  110. package/dist/unplugin.cjs +1 -1
  111. package/dist/unplugin.d.cts +5 -3
  112. package/dist/unplugin.d.mts +5 -3
  113. package/dist/unplugin.d.ts +5 -3
  114. package/dist/unplugin.mjs +1 -1
  115. package/dist/vite.cjs +1 -1
  116. package/dist/vite.mjs +1 -1
  117. package/package.json +33 -26
  118. package/dist/client/devtools/_nuxt/builds/meta/e6b425f7-b919-4014-915b-0ea07c316790.json +0 -1
  119. package/dist/client/devtools/_nuxt/entry.DaJP-ZMB.css +0 -24
  120. package/dist/client/devtools/_nuxt/error-404.B0Nmv3fV.css +0 -1
  121. package/dist/client/devtools/_nuxt/error-500.C8fgTxVz.css +0 -1
@@ -73,8 +73,8 @@ export interface LinkProps extends NuxtLinkProps {
73
73
  active?: boolean
74
74
  /** Will only be active if the current route is an exact match. */
75
75
  exact?: boolean
76
- /** Will only be active if the current route query is an exact match. */
77
- exactQuery?: boolean
76
+ /** Allows controlling how the current route query sets the link as active. */
77
+ exactQuery?: boolean | 'partial'
78
78
  /** Will only be active if the current route hash is an exact match. */
79
79
  exactHash?: boolean
80
80
  /** The class to apply when the link is inactive. */
@@ -94,10 +94,11 @@ extendDevtoolsMeta({ example: 'LinkExample' })
94
94
 
95
95
  <script setup lang="ts">
96
96
  import { computed } from 'vue'
97
- import { isEqual } from 'ohash'
97
+ import { isEqual, diff } from 'ohash'
98
98
  import { useForwardProps } from 'radix-vue'
99
99
  import { reactiveOmit } from '@vueuse/core'
100
100
  import { useRoute } from '#imports'
101
+ import ULinkBase from './LinkBase.vue'
101
102
 
102
103
  defineOptions({ inheritAttrs: false })
103
104
 
@@ -123,14 +124,27 @@ const ui = computed(() => tv({
123
124
  }
124
125
  }))
125
126
 
127
+ function isPartiallyEqual(item1: any, item2: any) {
128
+ const diffedKeys = diff(item1, item2).reduce((filtered, q) => {
129
+ if (q.type === 'added') {
130
+ filtered.push(q.key)
131
+ }
132
+ return filtered
133
+ }, [] as string[])
134
+ return isEqual(item1, item2, { excludeKeys: key => diffedKeys.includes(key) })
135
+ }
136
+
126
137
  function isLinkActive({ route: linkRoute, isActive, isExactActive }: any) {
127
138
  if (props.active !== undefined) {
128
139
  return props.active
129
140
  }
130
141
 
131
- if (props.exactQuery && !isEqual(linkRoute.query, route.query)) {
132
- return false
142
+ if (props.exactQuery === 'partial') {
143
+ if (!isPartiallyEqual(linkRoute.query, route.query)) return false
144
+ } else if (props.exactQuery === true) {
145
+ if (!isEqual(linkRoute.query, route.query)) return false
133
146
  }
147
+
134
148
  if (props.exactHash && linkRoute.hash !== route.hash) {
135
149
  return false
136
150
  }
@@ -77,6 +77,7 @@ import { computed, toRef } from 'vue'
77
77
  import { DialogRoot, DialogTrigger, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose, useForwardPropsEmits } from 'radix-vue'
78
78
  import { reactivePick } from '@vueuse/core'
79
79
  import { useAppConfig } from '#imports'
80
+ import { useLocale } from '../composables/useLocale'
80
81
  import UButton from './Button.vue'
81
82
 
82
83
  const props = withDefaults(defineProps<ModalProps>(), {
@@ -95,14 +96,22 @@ const contentEvents = computed(() => {
95
96
  if (props.preventClose) {
96
97
  return {
97
98
  pointerDownOutside: (e: Event) => e.preventDefault(),
98
- interactOutside: (e: Event) => e.preventDefault()
99
+ interactOutside: (e: Event) => e.preventDefault(),
100
+ escapeKeyDown: (e: Event) => e.preventDefault()
99
101
  }
100
102
  }
101
103
 
102
- return {}
104
+ return {
105
+ interactOutside: (e: Event) => {
106
+ if (e.target instanceof Element && e.target.closest('[data-sonner-toaster]')) {
107
+ return e.preventDefault()
108
+ }
109
+ }
110
+ }
103
111
  })
104
112
 
105
113
  const appConfig = useAppConfig()
114
+ const { t } = useLocale()
106
115
 
107
116
  const ui = computed(() => modal({
108
117
  transition: props.transition,
@@ -143,7 +152,7 @@ const ui = computed(() => modal({
143
152
  size="md"
144
153
  color="neutral"
145
154
  variant="ghost"
146
- aria-label="Close"
155
+ :aria-label="t('modal.close')"
147
156
  v-bind="typeof close === 'object' ? close : undefined"
148
157
  :class="ui.close({ class: props.ui?.close })"
149
158
  />
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { tv, type VariantProps } from 'tailwind-variants'
3
- import type { NavigationMenuRootProps, NavigationMenuRootEmits, NavigationMenuContentProps } from 'radix-vue'
3
+ import type { NavigationMenuRootProps, NavigationMenuRootEmits, NavigationMenuContentProps, CollapsibleRootProps } from 'radix-vue'
4
4
  import type { AppConfig } from '@nuxt/schema'
5
5
  import _appConfig from '#build/app.config'
6
6
  import theme from '#build/ui/navigation-menu'
@@ -17,7 +17,7 @@ export interface NavigationMenuChildItem extends Omit<NavigationMenuItem, 'child
17
17
  description?: string
18
18
  }
19
19
 
20
- export interface NavigationMenuItem extends Omit<LinkProps, 'raw' | 'custom'> {
20
+ export interface NavigationMenuItem extends Omit<LinkProps, 'raw' | 'custom'>, Pick<CollapsibleRootProps, 'defaultOpen' | 'open'> {
21
21
  label?: string
22
22
  icon?: string
23
23
  avatar?: AvatarProps
@@ -208,6 +208,8 @@ const lists = computed(() => props.items?.length ? (Array.isArray(props.items[0]
208
208
  :key="`list-${listIndex}-${index}`"
209
209
  as="li"
210
210
  :value="item.value || String(index)"
211
+ :default-open="item.defaultOpen"
212
+ :open="item.open"
211
213
  :class="ui.item({ class: props.ui?.item })"
212
214
  >
213
215
  <ULink v-slot="{ active, ...slotProps }" v-bind="pickLinkProps(item)" custom>
@@ -103,9 +103,11 @@ extendDevtoolsMeta({ defaultProps: { total: 50 } })
103
103
  </script>
104
104
 
105
105
  <script setup lang="ts">
106
+ import { computed } from 'vue'
106
107
  import { PaginationRoot, PaginationList, PaginationListItem, PaginationFirst, PaginationPrev, PaginationEllipsis, PaginationNext, PaginationLast, useForwardPropsEmits } from 'radix-vue'
107
108
  import { reactivePick } from '@vueuse/core'
108
109
  import { useAppConfig } from '#imports'
110
+ import { useLocale } from '../composables/useLocale'
109
111
  import UButton from './Button.vue'
110
112
 
111
113
  const props = withDefaults(defineProps<PaginationProps>(), {
@@ -124,9 +126,14 @@ const emits = defineEmits<PaginationEmits>()
124
126
  const slots = defineSlots<PaginationSlots>()
125
127
 
126
128
  const appConfig = useAppConfig()
127
-
129
+ const { dir } = useLocale()
128
130
  const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'defaultPage', 'disabled', 'itemsPerPage', 'page', 'showEdges', 'siblingCount', 'total'), emits)
129
131
 
132
+ const firstIcon = computed(() => props.firstIcon || (dir.value === 'rtl' ? appConfig.ui.icons.chevronDoubleRight : appConfig.ui.icons.chevronDoubleLeft))
133
+ const prevIcon = computed(() => props.prevIcon || (dir.value === 'rtl' ? appConfig.ui.icons.chevronRight : appConfig.ui.icons.chevronLeft))
134
+ const nextIcon = computed(() => props.nextIcon || (dir.value === 'rtl' ? appConfig.ui.icons.chevronLeft : appConfig.ui.icons.chevronRight))
135
+ const lastIcon = computed(() => props.lastIcon || (dir.value === 'rtl' ? appConfig.ui.icons.chevronDoubleLeft : appConfig.ui.icons.chevronDoubleRight))
136
+
130
137
  // eslint-disable-next-line vue/no-dupe-keys
131
138
  const ui = pagination()
132
139
  </script>
@@ -136,12 +143,12 @@ const ui = pagination()
136
143
  <PaginationList v-slot="{ items }" :class="ui.list({ class: props.ui?.list })">
137
144
  <PaginationFirst v-if="showControls || !!slots.first" as-child>
138
145
  <slot name="first">
139
- <UButton :color="color" :variant="variant" :size="size" :icon="firstIcon || appConfig.ui.icons.chevronDoubleLeft" :to="to?.(1)" />
146
+ <UButton :color="color" :variant="variant" :size="size" :icon="firstIcon" :to="to?.(1)" />
140
147
  </slot>
141
148
  </PaginationFirst>
142
149
  <PaginationPrev v-if="showControls || !!slots.prev" as-child>
143
150
  <slot name="prev">
144
- <UButton :color="color" :variant="variant" :size="size" :icon="prevIcon || appConfig.ui.icons.chevronLeft" :to="page > 1 ? to?.(page - 1) : undefined" />
151
+ <UButton :color="color" :variant="variant" :size="size" :icon="prevIcon" :to="page > 1 ? to?.(page - 1) : undefined" />
145
152
  </slot>
146
153
  </PaginationPrev>
147
154
 
@@ -169,12 +176,12 @@ const ui = pagination()
169
176
 
170
177
  <PaginationNext v-if="showControls || !!slots.next" as-child>
171
178
  <slot name="next">
172
- <UButton :color="color" :variant="variant" :size="size" :icon="nextIcon || appConfig.ui.icons.chevronRight" :to="page < pageCount ? to?.(pageCount) : undefined" />
179
+ <UButton :color="color" :variant="variant" :size="size" :icon="nextIcon" :to="page < pageCount ? to?.(pageCount) : undefined" />
173
180
  </slot>
174
181
  </PaginationNext>
175
182
  <PaginationLast v-if="showControls || !!slots.last" as-child>
176
183
  <slot name="last">
177
- <UButton :color="color" :variant="variant" :size="size" :icon="lastIcon || appConfig.ui.icons.chevronDoubleRight" :to=" to?.(pageCount)" />
184
+ <UButton :color="color" :variant="variant" :size="size" :icon="lastIcon" :to=" to?.(pageCount)" />
178
185
  </slot>
179
186
  </PaginationLast>
180
187
  </PaginationList>
@@ -0,0 +1,96 @@
1
+ <script lang="ts">
2
+ import _appConfig from '#build/app.config'
3
+ import theme from '#build/ui/pin-input'
4
+ import type { AppConfig } from '@nuxt/schema'
5
+ import type { PinInputRootEmits, PinInputRootProps } from 'radix-vue'
6
+ import { tv, type VariantProps } from 'tailwind-variants'
7
+ import type { PartialString } from '../types/utils'
8
+
9
+ const appConfig = _appConfig as AppConfig & { ui: { pinInput: Partial<typeof theme> } }
10
+
11
+ const pinInput = tv({ extend: tv(theme), ...(appConfig.ui?.pinInput || {}) })
12
+
13
+ type PinInputVariants = VariantProps<typeof pinInput>
14
+
15
+ export interface PinInputProps extends Pick<PinInputRootProps, 'defaultValue' | 'disabled' | 'id' | 'mask' | 'modelValue' | 'name' | 'otp' | 'placeholder' | 'required' | 'type'> {
16
+ /**
17
+ * The element or component this component should render as.
18
+ * @defaultValue 'div'
19
+ */
20
+ as?: any
21
+ color?: PinInputVariants['color']
22
+ variant?: PinInputVariants['variant']
23
+ size?: PinInputVariants['size']
24
+ length?: number | string
25
+ highlight?: boolean
26
+ class?: any
27
+ ui?: PartialString<typeof pinInput.slots>
28
+ }
29
+
30
+ export type PinInputEmits = PinInputRootEmits & {
31
+ change: [payload: Event]
32
+ blur: [payload: Event]
33
+ }
34
+ </script>
35
+
36
+ <script setup lang="ts">
37
+ import { ref, computed } from 'vue'
38
+ import { PinInputInput, PinInputRoot, useForwardPropsEmits } from 'radix-vue'
39
+ import { reactivePick } from '@vueuse/core'
40
+ import { useFormField } from '../composables/useFormField'
41
+ import { looseToNumber } from '../utils'
42
+
43
+ defineOptions({ inheritAttrs: false })
44
+
45
+ const props = withDefaults(defineProps<PinInputProps>(), {
46
+ type: 'text',
47
+ length: 5
48
+ })
49
+ const emits = defineEmits<PinInputEmits>()
50
+
51
+ const rootProps = useForwardPropsEmits(reactivePick(props, 'defaultValue', 'disabled', 'id', 'mask', 'modelValue', 'name', 'otp', 'placeholder', 'required', 'type'), emits)
52
+ const { emitFormInput, emitFormChange, emitFormBlur, size, color, id, name, highlight, disabled } = useFormField<PinInputProps>(props)
53
+
54
+ const ui = computed(() => pinInput({
55
+ color: color.value,
56
+ variant: props.variant,
57
+ size: size.value,
58
+ highlight: highlight.value
59
+ }))
60
+
61
+ const completed = ref(false)
62
+ function onComplete(value: string[]) {
63
+ // @ts-expect-error - 'target' does not exist in type 'EventInit'
64
+ const event = new Event('change', { target: { value } })
65
+ emits('change', event)
66
+ emitFormChange()
67
+ }
68
+
69
+ function onBlur(event: FocusEvent) {
70
+ if (!event.relatedTarget || completed.value) {
71
+ emits('blur', event)
72
+ emitFormBlur()
73
+ }
74
+ }
75
+ </script>
76
+
77
+ <template>
78
+ <PinInputRoot
79
+ v-bind="rootProps"
80
+ :id="id"
81
+ :name="name"
82
+ :class="ui.root({ class: [props.class, props.ui?.root] })"
83
+ @update:model-value="emitFormInput()"
84
+ @complete="onComplete"
85
+ >
86
+ <PinInputInput
87
+ v-for="(ids, index) in looseToNumber(props.length)"
88
+ :key="ids"
89
+ :index="index"
90
+ :class="ui.base({ class: props.ui?.base })"
91
+ v-bind="$attrs"
92
+ :disabled="disabled"
93
+ @blur="onBlur"
94
+ />
95
+ </PinInputRoot>
96
+ </template>
@@ -31,6 +31,11 @@ export interface PopoverProps extends PopoverRootProps, Pick<HoverCardRootProps,
31
31
  * @defaultValue true
32
32
  */
33
33
  portal?: boolean
34
+ /**
35
+ * When `true`, the popover will not close when clicking outside.
36
+ * @defaultValue false
37
+ */
38
+ preventClose?: boolean
34
39
  class?: any
35
40
  ui?: Partial<typeof popover.slots>
36
41
  }
@@ -64,6 +69,17 @@ const slots = defineSlots<PopoverSlots>()
64
69
  const pick = props.mode === 'hover' ? reactivePick(props, 'defaultOpen', 'open', 'openDelay', 'closeDelay') : reactivePick(props, 'defaultOpen', 'open', 'modal')
65
70
  const rootProps = useForwardPropsEmits(pick, emits)
66
71
  const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8 }) as PopoverContentProps)
72
+ const contentEvents = computed(() => {
73
+ if (props.preventClose) {
74
+ return {
75
+ pointerDownOutside: (e: Event) => e.preventDefault(),
76
+ interactOutside: (e: Event) => e.preventDefault(),
77
+ escapeKeyDown: (e: Event) => e.preventDefault()
78
+ }
79
+ }
80
+
81
+ return {}
82
+ })
67
83
  const arrowProps = toRef(() => props.arrow as PopoverArrowProps)
68
84
 
69
85
  // eslint-disable-next-line vue/no-dupe-keys
@@ -81,7 +97,7 @@ const Component = computed(() => props.mode === 'hover' ? HoverCard : Popover)
81
97
  </Component.Trigger>
82
98
 
83
99
  <Component.Portal :disabled="!portal">
84
- <Component.Content v-bind="contentProps" :class="ui.content({ class: [props.class, props.ui?.content] })">
100
+ <Component.Content v-bind="contentProps" :class="ui.content({ class: [props.class, props.ui?.content] })" v-on="contentEvents">
85
101
  <slot name="content" />
86
102
 
87
103
  <Component.Arrow v-if="!!arrow" v-bind="arrowProps" :class="ui.arrow({ class: props.ui?.arrow })" />
@@ -70,7 +70,7 @@ export interface SelectProps<T extends MaybeArrayOfArrayItem<I>, I extends Maybe
70
70
  * When `items` is an array of objects, select the field to use as the label.
71
71
  * @defaultValue 'label'
72
72
  */
73
- labelKey?: SelectItemKey<T>
73
+ labelKey?: V
74
74
  items?: I
75
75
  /** Highlight the ring color like a focus state. */
76
76
  highlight?: boolean
@@ -34,11 +34,12 @@ export interface SelectMenuProps<T extends MaybeArrayOfArrayItem<I>, I extends M
34
34
  /** The placeholder text when the select is empty. */
35
35
  placeholder?: string
36
36
  /**
37
- * Wether to display the search input or not.
37
+ * Whether to display the search input or not.
38
38
  * Can be an object to pass additional props to the input.
39
- * @defaultValue { placeholder: 'Search...' }
39
+ * `{ placeholder: 'Search...', variant: 'none' }`{lang="ts-type"}
40
+ * @defaultValue true
40
41
  */
41
- searchInput?: boolean | { placeholder?: string }
42
+ searchInput?: boolean | InputProps
42
43
  color?: SelectMenuVariants['color']
43
44
  variant?: SelectMenuVariants['variant']
44
45
  size?: SelectMenuVariants['size']
@@ -69,9 +70,10 @@ export interface SelectMenuProps<T extends MaybeArrayOfArrayItem<I>, I extends M
69
70
  */
70
71
  portal?: boolean
71
72
  /**
72
- * Whether to filter items or not, can be an array of fields to filter.
73
+ * Whether to filter items or not, can be an array of fields to filter. Defaults to `[labelKey]`.
73
74
  * When `false`, items will not be filtered which is useful for custom filtering (useAsyncData, useFetch, etc.).
74
- * @defaultValue ['label']
75
+ * `['label']`{lang="ts-type"}
76
+ * @defaultValue true
75
77
  */
76
78
  filter?: boolean | string[]
77
79
  /**
@@ -83,10 +85,15 @@ export interface SelectMenuProps<T extends MaybeArrayOfArrayItem<I>, I extends M
83
85
  * When `items` is an array of objects, select the field to use as the label.
84
86
  * @defaultValue 'label'
85
87
  */
86
- labelKey?: SelectItemKey<T>
88
+ labelKey?: V
87
89
  items?: I
88
90
  /** Highlight the ring color like a focus state. */
89
91
  highlight?: boolean
92
+ /**
93
+ * Determines if custom user input that does not exist in options can be added.
94
+ * @defaultValue false
95
+ */
96
+ createItem?: boolean | 'always' | { placement?: 'top' | 'bottom', when?: 'empty' | 'always' }
90
97
  class?: any
91
98
  ui?: PartialString<typeof selectMenu.slots>
92
99
  /** The controlled value of the Combobox. Can be binded-with with `v-model`. */
@@ -99,6 +106,7 @@ export type SelectMenuEmits<T, V, M extends boolean> = Omit<ComboboxRootEmits<T>
99
106
  change: [payload: Event]
100
107
  blur: [payload: FocusEvent]
101
108
  focus: [payload: FocusEvent]
109
+ create: [payload: Event, item: T]
102
110
  } & SelectModelValueEmits<T, V, M>
103
111
 
104
112
  type SlotProps<T> = (props: { item: T, index: number }) => any
@@ -112,32 +120,35 @@ export interface SelectMenuSlots<T> {
112
120
  'item-leading': SlotProps<T>
113
121
  'item-label': SlotProps<T>
114
122
  'item-trailing': SlotProps<T>
123
+ 'create-item-label'(props: { item: T }): any
115
124
  }
116
125
 
117
126
  extendDevtoolsMeta({ defaultProps: { items: ['Option 1', 'Option 2', 'Option 3'] } })
118
127
  </script>
119
128
 
120
129
  <script setup lang="ts" generic="T extends MaybeArrayOfArrayItem<I>, I extends MaybeArrayOfArray<SelectMenuItem | AcceptableValue> = MaybeArrayOfArray<SelectMenuItem | AcceptableValue>, V extends SelectItemKey<T> | undefined = undefined, M extends boolean = false">
121
- import { computed, toRef } from 'vue'
130
+ import { computed, toRef, toRaw } from 'vue'
122
131
  import { ComboboxRoot, ComboboxArrow, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxPortal, ComboboxContent, ComboboxViewport, ComboboxEmpty, ComboboxGroup, ComboboxLabel, ComboboxSeparator, ComboboxItem, ComboboxItemIndicator, useForwardPropsEmits } from 'radix-vue'
123
132
  import { defu } from 'defu'
124
- import * as isEqual from 'fast-deep-equal'
125
- import { reactivePick } from '@vueuse/core'
133
+ import { isEqual } from 'ohash'
134
+ import { reactivePick, createReusableTemplate } from '@vueuse/core'
126
135
  import { useAppConfig } from '#imports'
127
136
  import { useButtonGroup } from '../composables/useButtonGroup'
128
137
  import { useComponentIcons } from '../composables/useComponentIcons'
129
138
  import { useFormField } from '../composables/useFormField'
139
+ import { useLocale } from '../composables/useLocale'
130
140
  import { get, escapeRegExp } from '../utils'
131
141
  import UIcon from './Icon.vue'
132
142
  import UAvatar from './Avatar.vue'
133
143
  import UChip from './Chip.vue'
144
+ import UInput from './Input.vue'
134
145
 
135
146
  const props = withDefaults(defineProps<SelectMenuProps<T, I, V, M>>(), {
136
147
  search: true,
137
148
  portal: true,
138
149
  autofocusDelay: 0,
139
- searchInput: () => ({ placeholder: 'Search...' }),
140
- filter: () => ['label'],
150
+ searchInput: true,
151
+ filter: true,
141
152
  labelKey: 'label' as never
142
153
  })
143
154
 
@@ -147,9 +158,16 @@ const slots = defineSlots<SelectMenuSlots<T>>()
147
158
  const searchTerm = defineModel<string>('searchTerm', { default: '' })
148
159
 
149
160
  const appConfig = useAppConfig()
150
- const rootProps = useForwardPropsEmits(reactivePick(props, 'modelValue', 'defaultValue', 'selectedValue', 'open', 'defaultOpen', 'multiple', 'resetSearchTermOnBlur'), emits)
161
+
162
+ const { t } = useLocale()
163
+ const rootProps = useForwardPropsEmits(reactivePick(props, 'modelValue', 'defaultValue', 'selectedValue', 'open', 'defaultOpen', 'resetSearchTermOnBlur'), emits)
151
164
  const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, position: 'popper' }) as ComboboxContentProps)
152
165
  const arrowProps = toRef(() => props.arrow as ComboboxArrowProps)
166
+ const searchInputProps = toRef(() => defu(props.searchInput, { placeholder: 'Search...', variant: 'none' }) as InputProps)
167
+ // This is a hack due to generic boolean casting (see https://github.com/nuxt/ui/issues/2541)
168
+ const multiple = toRef(() => typeof props.multiple === 'string' ? true : props.multiple)
169
+
170
+ const [DefineCreateItemTemplate, ReuseCreateItemTemplate] = createReusableTemplate()
153
171
 
154
172
  const { emitFormBlur, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled } = useFormField<InputProps>(props)
155
173
  const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
@@ -169,32 +187,40 @@ const ui = computed(() => selectMenu({
169
187
  }))
170
188
 
171
189
  function displayValue(value: T | T[]): string {
172
- if (props.multiple && Array.isArray(value)) {
173
- return value.map(v => displayValue(v)).join(', ')
190
+ if (multiple.value && Array.isArray(value)) {
191
+ return value.map(v => displayValue(v)).filter(Boolean).join(', ')
174
192
  }
175
193
 
176
- const item = items.value.find(item => props.valueKey ? isEqual.default(get(item as Record<string, any>, props.valueKey as string), value) : isEqual.default(item, value))
194
+ if (!props.valueKey) {
195
+ return value && (typeof value === 'object' ? get(value, props.labelKey as string) : value)
196
+ }
197
+
198
+ const item = items.value.find(item => isEqual(get(item as Record<string, any>, props.valueKey as string), value)) ?? (props.createItem && value)
177
199
 
178
200
  return item && (typeof item === 'object' ? get(item, props.labelKey as string) : item)
179
201
  }
180
202
 
181
- function filterFunction(items: ArrayOrWrapped<T>, searchTerm: string): ArrayOrWrapped<T> {
203
+ function filterFunction(
204
+ inputItems: ArrayOrWrapped<T> = items.value as ArrayOrWrapped<T>,
205
+ filterSearchTerm: string = searchTerm.value,
206
+ comparator = (item: any, term: string) => String(item).search(new RegExp(term, 'i')) !== -1
207
+ ): ArrayOrWrapped<T> {
182
208
  if (props.filter === false) {
183
- return items
209
+ return inputItems
184
210
  }
185
211
 
186
212
  const fields = Array.isArray(props.filter) ? props.filter : [props.labelKey]
187
- const escapedSearchTerm = escapeRegExp(searchTerm)
213
+ const escapedSearchTerm = escapeRegExp(filterSearchTerm)
188
214
 
189
- return items.filter((item: T) => {
215
+ return inputItems.filter((item: T) => {
190
216
  if (typeof item !== 'object') {
191
- return String(item).search(new RegExp(escapedSearchTerm, 'i')) !== -1
217
+ return comparator(item, escapedSearchTerm)
192
218
  }
193
219
 
194
220
  return fields.some((field) => {
195
221
  const child = get(item, field as string)
196
222
 
197
- return child !== null && child !== undefined && String(child).search(new RegExp(escapedSearchTerm, 'i')) !== -1
223
+ return child !== null && child !== undefined && comparator(child, escapedSearchTerm)
198
224
  })
199
225
  }) as ArrayOrWrapped<T>
200
226
  }
@@ -203,7 +229,40 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
203
229
  // eslint-disable-next-line vue/no-dupe-keys
204
230
  const items = computed(() => groups.value.flatMap(group => group) as T[])
205
231
 
232
+ const creatable = computed(() => {
233
+ if (!props.createItem) {
234
+ return false
235
+ }
236
+
237
+ const isModelValueCustom = props.modelValue && filterFunction((props.multiple && Array.isArray(props.modelValue) ? props.modelValue : [props.modelValue]) as ArrayOrWrapped<T>, searchTerm.value, (item, term) => String(item) === term).length === 1
238
+
239
+ if (isModelValueCustom) {
240
+ return false
241
+ }
242
+
243
+ const filteredItems = filterFunction()
244
+ const newItem = searchTerm.value && {
245
+ item: props.valueKey ? { [props.valueKey]: searchTerm.value, [props.labelKey ?? 'label']: searchTerm.value } : searchTerm.value,
246
+ position: ((typeof props.createItem === 'object' && props.createItem.placement) || 'bottom') as 'top' | 'bottom'
247
+ }
248
+
249
+ if ((typeof props.createItem === 'object' && props.createItem.when === 'always') || props.createItem === 'always') {
250
+ return (filteredItems.length === 1 && filterFunction(filteredItems, searchTerm.value, (item, term) => String(item) === term).length === 1) ? false : newItem
251
+ }
252
+
253
+ return filteredItems.length > 0 ? false : newItem
254
+ })
255
+
256
+ const rootItems = computed(() => [
257
+ ...(creatable.value && creatable.value.position === 'top' ? [creatable.value.item] : []),
258
+ ...filterFunction(),
259
+ ...(creatable.value && creatable.value.position === 'bottom' ? [creatable.value.item] : [])
260
+ ] as ArrayOrWrapped<T>)
261
+
206
262
  function onUpdate(value: any) {
263
+ if (toRaw(props.modelValue) === value) {
264
+ return
265
+ }
207
266
  // @ts-expect-error - 'target' does not exist in type 'EventInit'
208
267
  const event = new Event('change', { target: { value } })
209
268
  emits('change', event)
@@ -224,6 +283,22 @@ function onUpdateOpen(value: boolean) {
224
283
  </script>
225
284
 
226
285
  <template>
286
+ <DefineCreateItemTemplate>
287
+ <ComboboxGroup v-if="creatable" :class="ui.group({ class: props.ui?.group })">
288
+ <ComboboxItem
289
+ :class="ui.item({ class: props.ui?.item })"
290
+ :value="valueKey && typeof creatable.item === 'object' ? get(creatable.item, props.valueKey as string) : creatable.item"
291
+ @select="e => emits('create', e, (creatable as any).item as T)"
292
+ >
293
+ <span :class="ui.itemLabel({ class: props.ui?.itemLabel })">
294
+ <slot name="create-item-label" :item="(creatable.item as T)">
295
+ {{ t('selectMenu.create', { label: typeof creatable.item === 'object' ? get(creatable.item, props.labelKey as string) : creatable.item }) }}
296
+ </slot>
297
+ </span>
298
+ </ComboboxItem>
299
+ </ComboboxGroup>
300
+ </DefineCreateItemTemplate>
301
+
227
302
  <ComboboxRoot
228
303
  :id="id"
229
304
  v-slot="{ modelValue, open }"
@@ -232,8 +307,9 @@ function onUpdateOpen(value: boolean) {
232
307
  as-child
233
308
  :name="name"
234
309
  :disabled="disabled"
310
+ :multiple="multiple"
235
311
  :display-value="() => searchTerm"
236
- :filter-function="filterFunction"
312
+ :filter-function="() => rootItems"
237
313
  @update:model-value="onUpdate"
238
314
  @update:open="onUpdateOpen"
239
315
  >
@@ -267,21 +343,19 @@ function onUpdateOpen(value: boolean) {
267
343
 
268
344
  <ComboboxPortal :disabled="!portal">
269
345
  <ComboboxContent :class="ui.content({ class: props.ui?.content })" v-bind="contentProps">
270
- <ComboboxInput
271
- v-if="!!searchInput"
272
- autofocus
273
- autocomplete="off"
274
- v-bind="typeof searchInput === 'object' ? searchInput : {}"
275
- :class="ui.input({ class: props.ui?.input })"
276
- />
346
+ <ComboboxInput v-if="!!searchInput" as-child>
347
+ <UInput autofocus autocomplete="off" v-bind="searchInputProps" :class="ui.input({ class: props.ui?.input })" />
348
+ </ComboboxInput>
277
349
 
278
350
  <ComboboxEmpty :class="ui.empty({ class: props.ui?.empty })">
279
351
  <slot name="empty" :search-term="searchTerm">
280
- {{ searchTerm ? `No results for ${searchTerm}` : 'No results' }}
352
+ {{ searchTerm ? t('selectMenu.noMatch', { searchTerm }) : t('selectMenu.noData') }}
281
353
  </slot>
282
354
  </ComboboxEmpty>
283
355
 
284
356
  <ComboboxViewport :class="ui.viewport({ class: props.ui?.viewport })">
357
+ <ReuseCreateItemTemplate v-if="creatable && creatable.position === 'top'" />
358
+
285
359
  <ComboboxGroup v-for="(group, groupIndex) in groups" :key="`group-${groupIndex}`" :class="ui.group({ class: props.ui?.group })">
286
360
  <template v-for="(item, index) in group" :key="`group-${groupIndex}-${index}`">
287
361
  <ComboboxLabel v-if="item?.type === 'label'" :class="ui.label({ class: props.ui?.label })">
@@ -328,6 +402,8 @@ function onUpdateOpen(value: boolean) {
328
402
  </ComboboxItem>
329
403
  </template>
330
404
  </ComboboxGroup>
405
+
406
+ <ReuseCreateItemTemplate v-if="creatable && creatable.position === 'bottom'" />
331
407
  </ComboboxViewport>
332
408
 
333
409
  <ComboboxArrow v-if="!!arrow" v-bind="arrowProps" :class="ui.arrow({ class: props.ui?.arrow })" />
@@ -75,6 +75,7 @@ import { computed, toRef } from 'vue'
75
75
  import { DialogRoot, DialogTrigger, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose, useForwardPropsEmits } from 'radix-vue'
76
76
  import { reactivePick } from '@vueuse/core'
77
77
  import { useAppConfig } from '#imports'
78
+ import { useLocale } from '../composables/useLocale'
78
79
  import UButton from './Button.vue'
79
80
 
80
81
  const props = withDefaults(defineProps<SlideoverProps>(), {
@@ -94,14 +95,22 @@ const contentEvents = computed(() => {
94
95
  if (props.preventClose) {
95
96
  return {
96
97
  pointerDownOutside: (e: Event) => e.preventDefault(),
97
- interactOutside: (e: Event) => e.preventDefault()
98
+ interactOutside: (e: Event) => e.preventDefault(),
99
+ escapeKeyDown: (e: Event) => e.preventDefault()
98
100
  }
99
101
  }
100
102
 
101
- return {}
103
+ return {
104
+ interactOutside: (e: Event) => {
105
+ if (e.target instanceof Element && e.target.closest('[data-sonner-toaster]')) {
106
+ return e.preventDefault()
107
+ }
108
+ }
109
+ }
102
110
  })
103
111
 
104
112
  const appConfig = useAppConfig()
113
+ const { t } = useLocale()
105
114
 
106
115
  const ui = computed(() => slideover({
107
116
  transition: props.transition,
@@ -142,7 +151,7 @@ const ui = computed(() => slideover({
142
151
  size="md"
143
152
  color="neutral"
144
153
  variant="ghost"
145
- aria-label="Close"
154
+ :aria-label="t('slideover.close')"
146
155
  v-bind="typeof close === 'object' ? close : undefined"
147
156
  :class="ui.close({ class: props.ui?.close })"
148
157
  />