@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
@@ -35,7 +35,7 @@ export interface CommandPaletteGroup<T> {
35
35
  slot?: string
36
36
  items?: T[]
37
37
  /**
38
- * Wether to filter group items with [useFuse](https://vueuse.org/integrations/useFuse).
38
+ * Whether to filter group items with [useFuse](https://vueuse.org/integrations/useFuse).
39
39
  * When `false`, items will not be filtered which is useful for custom filtering (useAsyncData, useFetch, etc.).
40
40
  * @defaultValue true
41
41
  */
@@ -124,6 +124,7 @@ import { defu } from 'defu'
124
124
  import { reactivePick } from '@vueuse/core'
125
125
  import { useFuse } from '@vueuse/integrations/useFuse'
126
126
  import { useAppConfig } from '#imports'
127
+ import { useLocale } from '../composables/useLocale'
127
128
  import { omit, get } from '../utils'
128
129
  import { highlight } from '../utils/fuse'
129
130
  import UIcon from './Icon.vue'
@@ -144,6 +145,7 @@ const slots = defineSlots<CommandPaletteSlots<G, T>>()
144
145
  const searchTerm = defineModel<string>('searchTerm', { default: '' })
145
146
 
146
147
  const appConfig = useAppConfig()
148
+ const { t } = useLocale()
147
149
  const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'disabled', 'multiple', 'modelValue', 'defaultValue', 'selectedValue', 'resetSearchTermOnBlur'), emits)
148
150
  const inputProps = useForwardProps(reactivePick(props, 'loading', 'loadingIcon', 'placeholder'))
149
151
 
@@ -245,7 +247,7 @@ const groups = computed(() => {
245
247
  size="md"
246
248
  color="neutral"
247
249
  variant="ghost"
248
- aria-label="Close"
250
+ :aria-label="t('commandPalette.close')"
249
251
  v-bind="typeof close === 'object' ? close : undefined"
250
252
  :class="ui.close({ class: props.ui?.close })"
251
253
  @click="emits('update:open', false)"
@@ -259,7 +261,7 @@ const groups = computed(() => {
259
261
  <ComboboxContent :class="ui.content({ class: props.ui?.content })" :dismissable="false">
260
262
  <ComboboxEmpty :class="ui.empty({ class: props.ui?.empty })">
261
263
  <slot name="empty" :search-term="searchTerm">
262
- {{ searchTerm ? `No results for ${searchTerm}` : 'No results' }}
264
+ {{ searchTerm ? t('commandPalette.noMatch', { searchTerm }) : t('commandPalette.noData') }}
263
265
  </slot>
264
266
  </ComboboxEmpty>
265
267
 
@@ -34,6 +34,8 @@ import ULink from './Link.vue'
34
34
  import UAvatar from './Avatar.vue'
35
35
  import UIcon from './Icon.vue'
36
36
  import UKbd from './Kbd.vue'
37
+ // eslint-disable-next-line import/no-self-import
38
+ import UContextMenuContent from './ContextMenuContent.vue'
37
39
 
38
40
  const props = defineProps<ContextMenuContentProps<T>>()
39
41
  const emits = defineEmits<ContextMenuContentEmits>()
@@ -40,6 +40,8 @@ import ULink from './Link.vue'
40
40
  import UAvatar from './Avatar.vue'
41
41
  import UIcon from './Icon.vue'
42
42
  import UKbd from './Kbd.vue'
43
+ // eslint-disable-next-line import/no-self-import
44
+ import UDropdownMenuContent from './DropdownMenuContent.vue'
43
45
 
44
46
  const props = defineProps<DropdownMenuContentProps<T>>()
45
47
  const emits = defineEmits<DropdownMenuContentEmits>()
@@ -38,7 +38,7 @@ extendDevtoolsMeta({ example: 'FormExample' })
38
38
  import { provide, inject, nextTick, ref, onUnmounted, onMounted, computed, useId, readonly } from 'vue'
39
39
  import { useEventBus } from '@vueuse/core'
40
40
  import { formOptionsInjectionKey, formInputsInjectionKey, formBusInjectionKey, formLoadingInjectionKey } from '../composables/useFormField'
41
- import { getYupErrors, isYupSchema, getValibotErrors, isValibotSchema, getZodErrors, isZodSchema, getJoiErrors, isJoiSchema, getStandardErrors, isStandardSchema, getSuperStructErrors, isSuperStructSchema } from '../utils/form'
41
+ import { parseSchema } from '../utils/form'
42
42
  import { FormValidationException } from '../types/form'
43
43
 
44
44
  const props = withDefaults(defineProps<FormProps<T>>(), {
@@ -94,13 +94,13 @@ onUnmounted(() => {
94
94
  const errors = ref<FormErrorWithId[]>([])
95
95
  provide('form-errors', errors)
96
96
 
97
- const inputs = ref<Record<string, string>>({})
97
+ const inputs = ref<Record<string, { id?: string, pattern?: RegExp }>>({})
98
98
  provide(formInputsInjectionKey, inputs)
99
99
 
100
100
  function resolveErrorIds(errs: FormError[]): FormErrorWithId[] {
101
101
  return errs.map(err => ({
102
102
  ...err,
103
- id: inputs.value[err.name]
103
+ id: inputs.value[err.name]?.id
104
104
  }))
105
105
  }
106
106
 
@@ -108,20 +108,11 @@ async function getErrors(): Promise<FormErrorWithId[]> {
108
108
  let errs = props.validate ? (await props.validate(props.state)) ?? [] : []
109
109
 
110
110
  if (props.schema) {
111
- if (isZodSchema(props.schema)) {
112
- errs = errs.concat(await getZodErrors(props.state, props.schema))
113
- } else if (isYupSchema(props.schema)) {
114
- errs = errs.concat(await getYupErrors(props.state, props.schema))
115
- } else if (isJoiSchema(props.schema)) {
116
- errs = errs.concat(await getJoiErrors(props.state, props.schema))
117
- } else if (isValibotSchema(props.schema)) {
118
- errs = errs.concat(await getValibotErrors(props.state, props.schema))
119
- } else if (isSuperStructSchema(props.schema)) {
120
- errs = errs.concat(await getSuperStructErrors(props.state, props.schema))
121
- } else if (isStandardSchema(props.schema)) {
122
- errs = errs.concat(await getStandardErrors(props.state, props.schema))
111
+ const { errors, result } = await parseSchema(props.state, props.schema as FormSchema<typeof props.state>)
112
+ if (errors) {
113
+ errs = errs.concat(errors)
123
114
  } else {
124
- throw new Error('Form validation failed: Unsupported form schema')
115
+ Object.assign(props.state, result)
125
116
  }
126
117
  }
127
118
 
@@ -129,7 +120,7 @@ async function getErrors(): Promise<FormErrorWithId[]> {
129
120
  }
130
121
 
131
122
  async function _validate(opts: { name?: string | string[], silent?: boolean, nested?: boolean } = { silent: false, nested: true }): Promise<T | false> {
132
- const names = opts.name && !Array.isArray(opts.name) ? [opts.name] : opts.name
123
+ const names = opts.name && !Array.isArray(opts.name) ? [opts.name] : opts.name as string[]
133
124
 
134
125
  const nestedValidatePromises = !names && opts.nested
135
126
  ? Array.from(nestedForms.value.values()).map(
@@ -143,9 +134,16 @@ async function _validate(opts: { name?: string | string[], silent?: boolean, nes
143
134
  : []
144
135
 
145
136
  if (names) {
146
- const otherErrors = errors.value.filter(error => !names!.includes(error.name))
147
- const pathErrors = (await getErrors()).filter(error => names!.includes(error.name)
148
- )
137
+ const otherErrors = errors.value.filter(error => !names.some((name) => {
138
+ const pattern = inputs.value?.[name]?.pattern
139
+ return name === error.name || (pattern && error.name.match(pattern))
140
+ }))
141
+
142
+ const pathErrors = (await getErrors()).filter(error => names.some((name) => {
143
+ const pattern = inputs.value?.[name]?.pattern
144
+ return name === error.name || (pattern && error.name.match(pattern))
145
+ }))
146
+
149
147
  errors.value = otherErrors.concat(pathErrors)
150
148
  } else {
151
149
  errors.value = await getErrors()
@@ -12,7 +12,10 @@ const formField = tv({ extend: tv(theme), ...(appConfig.ui?.formField || {}) })
12
12
  type FormFieldVariants = VariantProps<typeof formField>
13
13
 
14
14
  export interface FormFieldProps {
15
+ /** The name of the FormField. Also used to match form errors. */
15
16
  name?: string
17
+ /** A regular expression to match form error names. */
18
+ errorPattern?: RegExp
16
19
  label?: string
17
20
  description?: string
18
21
  help?: string
@@ -54,7 +57,7 @@ const ui = computed(() => formField({
54
57
 
55
58
  const formErrors = inject<Ref<FormError[]> | null>('form-errors', null)
56
59
 
57
- const error = computed(() => props.error || formErrors?.value?.find(error => error.name === props.name)?.message)
60
+ const error = computed(() => props.error || formErrors?.value?.find(error => error.name === props.name || (props.errorPattern && error.name.match(props.errorPattern)))?.message)
58
61
 
59
62
  const id = ref(useId())
60
63
 
@@ -65,7 +68,8 @@ provide(formFieldInjectionKey, computed(() => ({
65
68
  name: props.name,
66
69
  size: props.size,
67
70
  eagerValidation: props.eagerValidation,
68
- validateOnInputDelay: props.validateOnInputDelay
71
+ validateOnInputDelay: props.validateOnInputDelay,
72
+ errorPattern: props.errorPattern
69
73
  }) as FormFieldInjectedOptions<FormFieldProps>))
70
74
  </script>
71
75
 
@@ -92,7 +96,7 @@ provide(formFieldInjectionKey, computed(() => ({
92
96
  </p>
93
97
  </div>
94
98
 
95
- <div :class="[label && ui.container({ class: props.ui?.container })]">
99
+ <div :class="[(label || !!slots.label || description || !!slots.description) && ui.container({ class: props.ui?.container })]">
96
100
  <slot :error="error" />
97
101
 
98
102
  <p v-if="(typeof error === 'string' && error) || !!slots.error" :class="ui.error({ class: props.ui?.error })">
@@ -78,9 +78,10 @@ export interface InputMenuProps<T extends MaybeArrayOfArrayItem<I>, I extends Ma
78
78
  */
79
79
  portal?: boolean
80
80
  /**
81
- * Whether to filter items or not, can be an array of fields to filter.
82
- * When `false`, items will not be filtered which is useful for custom filtering.
83
- * @defaultValue ['label']
81
+ * Whether to filter items or not, can be an array of fields to filter. Defaults to `[labelKey]`.
82
+ * When `false`, items will not be filtered which is useful for custom filtering (useAsyncData, useFetch, etc.).
83
+ * `['label']`{lang="ts-type"}
84
+ * @defaultValue true
84
85
  */
85
86
  filter?: boolean | string[]
86
87
  /**
@@ -92,10 +93,15 @@ export interface InputMenuProps<T extends MaybeArrayOfArrayItem<I>, I extends Ma
92
93
  * When `items` is an array of objects, select the field to use as the label.
93
94
  * @defaultValue 'label'
94
95
  */
95
- labelKey?: keyof T
96
+ labelKey?: V
96
97
  items?: I
97
98
  /** Highlight the ring color like a focus state. */
98
99
  highlight?: boolean
100
+ /**
101
+ * Determines if custom user input that does not exist in options can be added.
102
+ * @defaultValue false
103
+ */
104
+ createItem?: boolean | 'always' | { placement?: 'top' | 'bottom', when?: 'empty' | 'always' }
99
105
  class?: any
100
106
  ui?: PartialString<typeof inputMenu.slots>
101
107
  /** The controlled value of the Combobox. Can be binded-with with `v-model`. */
@@ -108,6 +114,7 @@ export type InputMenuEmits<T, V, M extends boolean> = Omit<ComboboxRootEmits<T>,
108
114
  change: [payload: Event]
109
115
  blur: [payload: FocusEvent]
110
116
  focus: [payload: FocusEvent]
117
+ create: [payload: Event, item: T]
111
118
  } & SelectModelValueEmits<T, V, M>
112
119
 
113
120
  type SlotProps<T> = (props: { item: T, index: number }) => any
@@ -122,21 +129,23 @@ export interface InputMenuSlots<T> {
122
129
  'item-trailing': SlotProps<T>
123
130
  'tags-item-text': SlotProps<T>
124
131
  'tags-item-delete': SlotProps<T>
132
+ 'create-item-label'(props: { item: T }): any
125
133
  }
126
134
 
127
135
  extendDevtoolsMeta({ defaultProps: { items: ['Option 1', 'Option 2', 'Option 3'] } })
128
136
  </script>
129
137
 
130
138
  <script setup lang="ts" generic="T extends MaybeArrayOfArrayItem<I>, I extends MaybeArrayOfArray<InputMenuItem | AcceptableValue> = MaybeArrayOfArray<InputMenuItem | AcceptableValue>, V extends SelectItemKey<T> | undefined = undefined, M extends boolean = false">
131
- import { computed, ref, toRef, onMounted } from 'vue'
139
+ import { computed, ref, toRef, onMounted, toRaw } from 'vue'
132
140
  import { ComboboxRoot, ComboboxArrow, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxPortal, ComboboxContent, ComboboxViewport, ComboboxEmpty, ComboboxGroup, ComboboxLabel, ComboboxSeparator, ComboboxItem, ComboboxItemIndicator, TagsInputRoot, TagsInputItem, TagsInputItemText, TagsInputItemDelete, TagsInputInput, useForwardPropsEmits } from 'radix-vue'
133
141
  import { defu } from 'defu'
134
- import * as isEqual from 'fast-deep-equal'
135
- import { reactivePick } from '@vueuse/core'
142
+ import { isEqual } from 'ohash'
143
+ import { reactivePick, createReusableTemplate } from '@vueuse/core'
136
144
  import { useAppConfig } from '#imports'
137
145
  import { useButtonGroup } from '../composables/useButtonGroup'
138
146
  import { useComponentIcons } from '../composables/useComponentIcons'
139
147
  import { useFormField } from '../composables/useFormField'
148
+ import { useLocale } from '../composables/useLocale'
140
149
  import { get, escapeRegExp } from '../utils'
141
150
  import UIcon from './Icon.vue'
142
151
  import UAvatar from './Avatar.vue'
@@ -148,8 +157,8 @@ const props = withDefaults(defineProps<InputMenuProps<T, I, V, M>>(), {
148
157
  type: 'text',
149
158
  autofocusDelay: 0,
150
159
  portal: true,
151
- filter: () => ['label'],
152
- labelKey: 'label' as keyof T
160
+ filter: true,
161
+ labelKey: 'label' as never
153
162
  })
154
163
  const emits = defineEmits<InputMenuEmits<T, V, M>>()
155
164
  const slots = defineSlots<InputMenuSlots<T>>()
@@ -157,14 +166,19 @@ const slots = defineSlots<InputMenuSlots<T>>()
157
166
  const searchTerm = defineModel<string>('searchTerm', { default: '' })
158
167
 
159
168
  const appConfig = useAppConfig()
160
- const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'selectedValue', 'open', 'defaultOpen', 'multiple', 'resetSearchTermOnBlur'), emits)
169
+ const { t } = useLocale()
170
+ const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'selectedValue', 'open', 'defaultOpen', 'resetSearchTermOnBlur'), emits)
161
171
  const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, position: 'popper' }) as ComboboxContentProps)
162
172
  const arrowProps = toRef(() => props.arrow as ComboboxArrowProps)
173
+ // This is a hack due to generic boolean casting (see https://github.com/nuxt/ui/issues/2541)
174
+ const multiple = toRef(() => typeof props.multiple === 'string' ? true : props.multiple)
163
175
 
164
176
  const { emitFormBlur, emitFormChange, emitFormInput, size: formGroupSize, color, id, name, highlight, disabled } = useFormField<InputProps>(props)
165
177
  const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
166
178
  const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown })))
167
179
 
180
+ const [DefineCreateItemTemplate, ReuseCreateItemTemplate] = createReusableTemplate()
181
+
168
182
  const inputSize = computed(() => buttonGroupSize.value || formGroupSize.value)
169
183
 
170
184
  const ui = computed(() => inputMenu({
@@ -175,33 +189,41 @@ const ui = computed(() => inputMenu({
175
189
  highlight: highlight.value,
176
190
  leading: isLeading.value || !!props.avatar || !!slots.leading,
177
191
  trailing: isTrailing.value || !!slots.trailing,
178
- multiple: props.multiple,
192
+ multiple: multiple.value,
179
193
  buttonGroup: orientation.value
180
194
  }))
181
195
 
182
- function displayValue(value: AcceptableValue): string {
183
- 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))
196
+ function displayValue(value: T): string {
197
+ if (!props.valueKey) {
198
+ return value && (typeof value === 'object' ? get(value, props.labelKey as string) : value)
199
+ }
200
+
201
+ const item = items.value.find(item => isEqual(get(item as Record<string, any>, props.valueKey as string), value))
184
202
 
185
203
  return item && (typeof item === 'object' ? get(item, props.labelKey as string) : item)
186
204
  }
187
205
 
188
- function filterFunction(items: ArrayOrWrapped<AcceptableValue>, searchTerm: string): ArrayOrWrapped<AcceptableValue> {
206
+ function filterFunction(
207
+ inputItems: ArrayOrWrapped<T> = items.value as ArrayOrWrapped<T>,
208
+ filterSearchTerm: string = searchTerm.value,
209
+ comparator = (item: any, term: string) => String(item).search(new RegExp(term, 'i')) !== -1
210
+ ): ArrayOrWrapped<T> {
189
211
  if (props.filter === false) {
190
- return items
212
+ return inputItems
191
213
  }
192
214
 
193
215
  const fields = Array.isArray(props.filter) ? props.filter : [props.labelKey]
194
- const escapedSearchTerm = escapeRegExp(searchTerm)
216
+ const escapedSearchTerm = escapeRegExp(filterSearchTerm ?? '')
195
217
 
196
- return items.filter((item) => {
218
+ return inputItems.filter((item) => {
197
219
  if (typeof item !== 'object') {
198
- return String(item).search(new RegExp(escapedSearchTerm, 'i')) !== -1
220
+ return comparator(item, escapedSearchTerm)
199
221
  }
200
222
 
201
223
  return fields.some((field) => {
202
224
  const child = get(item, field as string)
203
225
 
204
- return child !== null && child !== undefined && String(child).search(new RegExp(escapedSearchTerm, 'i')) !== -1
226
+ return child !== null && child !== undefined && comparator(child, escapedSearchTerm)
205
227
  })
206
228
  }) as ArrayOrWrapped<T>
207
229
  }
@@ -210,6 +232,36 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
210
232
  // eslint-disable-next-line vue/no-dupe-keys
211
233
  const items = computed(() => groups.value.flatMap(group => group) as T[])
212
234
 
235
+ const creatable = computed(() => {
236
+ if (!props.createItem) {
237
+ return false
238
+ }
239
+
240
+ 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
241
+
242
+ if (isModelValueCustom) {
243
+ return false
244
+ }
245
+
246
+ const filteredItems = filterFunction()
247
+ const newItem = searchTerm.value && {
248
+ item: props.valueKey ? { [props.valueKey]: searchTerm.value, [props.labelKey ?? 'label']: searchTerm.value } : searchTerm.value,
249
+ position: ((typeof props.createItem === 'object' && props.createItem.placement) || 'bottom') as 'top' | 'bottom'
250
+ }
251
+
252
+ if ((typeof props.createItem === 'object' && props.createItem.when === 'always') || props.createItem === 'always') {
253
+ return (filteredItems.length === 1 && filterFunction(filteredItems, searchTerm.value, (item, term) => String(item) === term).length === 1) ? false : newItem
254
+ }
255
+
256
+ return filteredItems.length > 0 ? false : newItem
257
+ })
258
+
259
+ const rootItems = computed(() => [
260
+ ...(creatable.value && creatable.value.position === 'top' ? [creatable.value.item] : []),
261
+ ...filterFunction(),
262
+ ...(creatable.value && creatable.value.position === 'bottom' ? [creatable.value.item] : [])
263
+ ] as ArrayOrWrapped<T>)
264
+
213
265
  const inputRef = ref<InstanceType<typeof ComboboxInput> | null>(null)
214
266
 
215
267
  function autoFocus() {
@@ -225,6 +277,9 @@ onMounted(() => {
225
277
  })
226
278
 
227
279
  function onUpdate(value: any) {
280
+ if (toRaw(props.modelValue) === value) {
281
+ return
282
+ }
228
283
  // @ts-expect-error - 'target' does not exist in type 'EventInit'
229
284
  const event = new Event('change', { target: { value } })
230
285
  emits('change', event)
@@ -258,6 +313,22 @@ defineExpose({
258
313
  </script>
259
314
 
260
315
  <template>
316
+ <DefineCreateItemTemplate>
317
+ <ComboboxGroup v-if="creatable" :class="ui.group({ class: props.ui?.group })">
318
+ <ComboboxItem
319
+ :class="ui.item({ class: props.ui?.item })"
320
+ :value="valueKey && typeof creatable.item === 'object' ? get(creatable.item, props.valueKey as string) : creatable.item"
321
+ @select="e => emits('create', e, (creatable as any).item as T)"
322
+ >
323
+ <span :class="ui.itemLabel({ class: props.ui?.itemLabel })">
324
+ <slot name="create-item-label" :item="(creatable.item as T)">
325
+ {{ t('inputMenu.create', { label: typeof creatable.item === 'object' ? get(creatable.item, props.labelKey as string) : creatable.item }) }}
326
+ </slot>
327
+ </span>
328
+ </ComboboxItem>
329
+ </ComboboxGroup>
330
+ </DefineCreateItemTemplate>
331
+
261
332
  <ComboboxRoot
262
333
  :id="id"
263
334
  v-slot="{ modelValue, open }"
@@ -265,8 +336,9 @@ defineExpose({
265
336
  v-model:search-term="searchTerm"
266
337
  :name="name"
267
338
  :disabled="disabled"
339
+ :multiple="multiple"
268
340
  :display-value="displayValue"
269
- :filter-function="filterFunction"
341
+ :filter-function="() => rootItems"
270
342
  :class="ui.root({ class: [props.class, props.ui?.root] })"
271
343
  :as-child="!!multiple"
272
344
  @update:model-value="onUpdate"
@@ -340,11 +412,13 @@ defineExpose({
340
412
  <ComboboxContent :class="ui.content({ class: props.ui?.content })" v-bind="contentProps">
341
413
  <ComboboxEmpty :class="ui.empty({ class: props.ui?.empty })">
342
414
  <slot name="empty" :search-term="searchTerm">
343
- {{ searchTerm ? `No results for ${searchTerm}` : 'No results' }}
415
+ {{ searchTerm ? t('inputMenu.noMatch', { searchTerm }) : t('inputMenu.noData') }}
344
416
  </slot>
345
417
  </ComboboxEmpty>
346
418
 
347
419
  <ComboboxViewport :class="ui.viewport({ class: props.ui?.viewport })">
420
+ <ReuseCreateItemTemplate v-if="creatable && creatable.position === 'top'" />
421
+
348
422
  <ComboboxGroup v-for="(group, groupIndex) in groups" :key="`group-${groupIndex}`" :class="ui.group({ class: props.ui?.group })">
349
423
  <template v-for="(item, index) in group" :key="`group-${groupIndex}-${index}`">
350
424
  <ComboboxLabel v-if="item?.type === 'label'" :class="ui.label({ class: props.ui?.label })">
@@ -391,6 +465,8 @@ defineExpose({
391
465
  </ComboboxItem>
392
466
  </template>
393
467
  </ComboboxGroup>
468
+
469
+ <ReuseCreateItemTemplate v-if="creatable && creatable.position === 'bottom'" />
394
470
  </ComboboxViewport>
395
471
 
396
472
  <ComboboxArrow v-if="!!arrow" v-bind="arrowProps" :class="ui.arrow({ class: props.ui?.arrow })" />
@@ -0,0 +1,192 @@
1
+ <script lang="ts">
2
+ import { tv, type VariantProps } from 'tailwind-variants'
3
+ import type { NumberFieldRootProps } from 'radix-vue'
4
+ import type { AppConfig } from '@nuxt/schema'
5
+ import _appConfig from '#build/app.config'
6
+ import theme from '#build/ui/input-number'
7
+ import type { ButtonProps } from '../types'
8
+
9
+ const appConfig = _appConfig as AppConfig & { ui: { inputNumber: Partial<typeof theme> } }
10
+
11
+ const inputNumber = tv({ extend: tv(theme), ...(appConfig.ui?.inputNumber || {}) })
12
+
13
+ type InputNumberVariants = VariantProps<typeof inputNumber>
14
+
15
+ export interface InputNumberProps extends Pick<NumberFieldRootProps, 'modelValue' | 'defaultValue' | 'min' | 'max' | 'step' | 'disabled' | 'required' | 'id' | 'name' | 'formatOptions'> {
16
+ /**
17
+ * The element or component this component should render as.
18
+ * @defaultValue 'div'
19
+ */
20
+ as?: any
21
+ class?: any
22
+ /** The placeholder text when the input is empty. */
23
+ placeholder?: string
24
+ ui?: Partial<typeof inputNumber.slots>
25
+ color?: InputNumberVariants['color']
26
+ variant?: InputNumberVariants['variant']
27
+ size?: InputNumberVariants['size']
28
+ /** Highlight the ring color like a focus state. */
29
+ highlight?: boolean
30
+ /**
31
+ * The orientation of the input menu.
32
+ * @defaultValue 'horizontal'
33
+ */
34
+ orientation?: 'vertical' | 'horizontal'
35
+ /**
36
+ * Configure the increment button. The `color` and `size` are inherited.
37
+ * @defaultValue { variant: 'link' }
38
+ */
39
+ increment?: ButtonProps
40
+ /**
41
+ * The icon displayed to increment the value.
42
+ * @defaultValue appConfig.ui.icons.plus
43
+ */
44
+ incrementIcon?: string
45
+ /**
46
+ * Configure the decrement button. The `color` and `size` are inherited.
47
+ * @defaultValue { variant: 'link' }
48
+ */
49
+ decrement?: ButtonProps
50
+ /**
51
+ * The icon displayed to decrement the value.
52
+ * @defaultValue appConfig.ui.icons.minus
53
+ */
54
+ decrementIcon?: string
55
+ autofocus?: boolean
56
+ autofocusDelay?: number
57
+ /**
58
+ * The locale to use for formatting and parsing numbers.
59
+ * @defaultValue UApp.locale.code
60
+ */
61
+ locale?: string
62
+ }
63
+
64
+ export interface InputNumberEmits {
65
+ (e: 'update:modelValue', payload: number): void
66
+ (e: 'blur', event: FocusEvent): void
67
+ (e: 'change', payload: Event): void
68
+ }
69
+
70
+ export interface InputNumberSlots {
71
+ increment(props?: {}): any
72
+ decrement(props?: {}): any
73
+ }
74
+ </script>
75
+
76
+ <script setup lang="ts">
77
+ import { onMounted, ref, computed } from 'vue'
78
+ import { NumberFieldRoot, NumberFieldInput, NumberFieldDecrement, NumberFieldIncrement, useForwardPropsEmits } from 'radix-vue'
79
+ import { reactivePick } from '@vueuse/core'
80
+ import { useFormField } from '../composables/useFormField'
81
+ import { useLocale } from '../composables/useLocale'
82
+ import UButton from './Button.vue'
83
+
84
+ defineOptions({ inheritAttrs: false })
85
+
86
+ const props = withDefaults(defineProps<InputNumberProps>(), {
87
+ orientation: 'horizontal'
88
+ })
89
+ const emits = defineEmits<InputNumberEmits>()
90
+ defineSlots<InputNumberSlots>()
91
+
92
+ const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'min', 'max', 'step', 'formatOptions'), emits)
93
+
94
+ const { emitFormBlur, emitFormChange, emitFormInput, id, color, size, name, highlight, disabled } = useFormField<InputNumberProps>(props)
95
+
96
+ const { t, code: codeLocale } = useLocale()
97
+ const locale = computed(() => props.locale || codeLocale.value)
98
+
99
+ const ui = computed(() => inputNumber({
100
+ color: color.value,
101
+ variant: props.variant,
102
+ size: size.value,
103
+ highlight: highlight.value,
104
+ orientation: props.orientation
105
+ }))
106
+
107
+ const incrementIcon = computed(() => props.incrementIcon || (props.orientation === 'horizontal' ? appConfig.ui.icons.plus : appConfig.ui.icons.chevronUp))
108
+ const decrementIcon = computed(() => props.decrementIcon || (props.orientation === 'horizontal' ? appConfig.ui.icons.minus : appConfig.ui.icons.chevronDown))
109
+
110
+ const inputRef = ref<InstanceType<typeof NumberFieldInput> | null>(null)
111
+
112
+ function autoFocus() {
113
+ if (props.autofocus) {
114
+ inputRef.value?.$el?.focus()
115
+ }
116
+ }
117
+
118
+ onMounted(() => {
119
+ setTimeout(() => {
120
+ autoFocus()
121
+ }, props.autofocusDelay)
122
+ })
123
+
124
+ function onUpdate(value: number) {
125
+ // @ts-expect-error - 'target' does not exist in type 'EventInit'
126
+ const event = new Event('change', { target: { value } })
127
+ emits('change', event)
128
+
129
+ emitFormChange()
130
+ emitFormInput()
131
+ }
132
+
133
+ function onBlur(event: FocusEvent) {
134
+ emitFormBlur()
135
+ emits('blur', event)
136
+ }
137
+
138
+ defineExpose({
139
+ inputRef
140
+ })
141
+ </script>
142
+
143
+ <template>
144
+ <NumberFieldRoot
145
+ v-bind="rootProps"
146
+ :id="id"
147
+ :class="ui.root({ class: [props.class, props.ui?.root] })"
148
+ :name="name"
149
+ :disabled="disabled"
150
+ :locale="locale"
151
+ @update:model-value="onUpdate"
152
+ >
153
+ <NumberFieldInput
154
+ v-bind="$attrs"
155
+ ref="inputRef"
156
+ :placeholder="placeholder"
157
+ :required="required"
158
+ :class="ui.base({ class: props.ui?.base })"
159
+ @blur="onBlur"
160
+ />
161
+
162
+ <div :class="ui.increment({ class: props.ui?.increment })">
163
+ <NumberFieldIncrement as-child :disabled="disabled">
164
+ <slot name="increment">
165
+ <UButton
166
+ :icon="incrementIcon"
167
+ :color="color"
168
+ :size="size"
169
+ variant="link"
170
+ :aria-label="t('inputNumber.increment')"
171
+ v-bind="typeof increment === 'object' ? increment : undefined"
172
+ />
173
+ </slot>
174
+ </NumberFieldIncrement>
175
+ </div>
176
+
177
+ <div :class="ui.decrement({ class: props.ui?.decrement })">
178
+ <NumberFieldDecrement as-child :disabled="disabled">
179
+ <slot name="decrement">
180
+ <UButton
181
+ :icon="decrementIcon"
182
+ :color="color"
183
+ :size="size"
184
+ variant="link"
185
+ :aria-label="t('inputNumber.decrement')"
186
+ v-bind="typeof decrement === 'object' ? decrement : undefined"
187
+ />
188
+ </slot>
189
+ </NumberFieldDecrement>
190
+ </div>
191
+ </NumberFieldRoot>
192
+ </template>