Skip to content

Commit 388944d

Browse files
authored
Merge branch 'v3' into feat/radio-card
2 parents 90d28f3 + 3320e04 commit 388944d

File tree

14 files changed

+176
-44
lines changed

14 files changed

+176
-44
lines changed

docs/content/3.components/form.md

+8-4
Original file line numberDiff line numberDiff line change
@@ -195,9 +195,13 @@ This will give you access to the following:
195195
| Name | Type |
196196
| ---- | ---- |
197197
| `submit()`{lang="ts-type"} | `Promise<void>`{lang="ts-type"} <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>Triggers form submission.</p> |
198-
| `validate(opts: { name?: string \| string[], silent?: boolean, nested?: boolean, transform?: boolean })`{lang="ts-type"} | `Promise<T>`{lang="ts-type"} <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>Triggers form validation. Will raise any errors unless `opts.silent` is set to true.</p> |
199-
| `clear(path?: string)`{lang="ts-type"} | `void` <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>Clears form errors associated with a specific path. If no path is provided, clears all form errors.</p> |
200-
| `getErrors(path?: string)`{lang="ts-type"} | `FormError[]`{lang="ts-type"} <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>Retrieves form errors associated with a specific path. If no path is provided, returns all form errors.</p></div> |
201-
| `setErrors(errors: FormError[], path?: string)`{lang="ts-type"} | `void` <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>Sets form errors for a given path. If no path is provided, overrides all errors.</p> |
198+
| `validate(opts: { name?: keyof T \| (keyof T)[], silent?: boolean, nested?: boolean, transform?: boolean })`{lang="ts-type"} | `Promise<T>`{lang="ts-type"} <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>Triggers form validation. Will raise any errors unless `opts.silent` is set to true.</p> |
199+
| `clear(path?: keyof T)`{lang="ts-type"} | `void` <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>Clears form errors associated with a specific path. If no path is provided, clears all form errors.</p> |
200+
| `getErrors(path?: keyof T)`{lang="ts-type"} | `FormError[]`{lang="ts-type"} <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>Retrieves form errors associated with a specific path. If no path is provided, returns all form errors.</p></div> |
201+
| `setErrors(errors: FormError[], path?: keyof T)`{lang="ts-type"} | `void` <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>Sets form errors for a given path. If no path is provided, overrides all errors.</p> |
202202
| `errors`{lang="ts-type"} | `Ref<FormError[]>`{lang="ts-type"} <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>A reference to the array containing validation errors. Use this to access or manipulate the error information.</p> |
203203
| `disabled`{lang="ts-type"} | `Ref<boolean>`{lang="ts-type"} |
204+
| `dirty`{lang="ts-type"} | `Ref<boolean>`{lang="ts-type"} `true` if at least one form field has been updated by the user.|
205+
| `dirtyFields`{lang="ts-type"} | `DeepReadonly<Set<keyof T>>`{lang="ts-type"} Tracks fields that have been modified by the user. |
206+
| `touchedFields`{lang="ts-type"} | `DeepReadonly<Set<keyof T>>`{lang="ts-type"} Tracks fields that the user interacted with. |
207+
| `blurredFields`{lang="ts-type"} | `DeepReadonly<Set<keyof T>>`{lang="ts-type"} Tracks fields blurred by the user. |

renovate.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,6 @@
2626
}, {
2727
"matchDepTypes": ["resolutions"],
2828
"enabled": false
29-
}]
29+
}],
30+
"postUpdateOptions": ["pnpmDedupe"]
3031
}

src/runtime/components/Form.vue

+36-10
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import theme from '#build/ui/form'
55
import { extendDevtoolsMeta } from '../composables/extendDevtoolsMeta'
66
import { tv } from '../utils/tv'
77
import type { FormSchema, FormError, FormInputEvents, FormErrorEvent, FormSubmitEvent, FormEvent, Form, FormErrorWithId } from '../types/form'
8+
import type { DeepReadonly } from 'vue'
89
910
const appConfig = _appConfig as AppConfig & { ui: { form: Partial<typeof theme> } }
1011
@@ -52,7 +53,7 @@ defineSlots<FormSlots>()
5253
5354
const formId = props.id ?? useId() as string
5455
55-
const bus = useEventBus<FormEvent>(`form-${formId}`)
56+
const bus = useEventBus<FormEvent<T>>(`form-${formId}`)
5657
const parentBus = inject(
5758
formBusInjectionKey,
5859
undefined
@@ -68,8 +69,24 @@ onMounted(async () => {
6869
nestedForms.value.set(event.formId, { validate: event.validate })
6970
} else if (event.type === 'detach') {
7071
nestedForms.value.delete(event.formId)
71-
} else if (props.validateOn?.includes(event.type as FormInputEvents)) {
72-
await _validate({ name: event.name, silent: true, nested: false })
72+
} else if (props.validateOn?.includes(event.type)) {
73+
if (event.type !== 'input') {
74+
await _validate({ name: event.name, silent: true, nested: false })
75+
} else if (event.eager || blurredFields.has(event.name)) {
76+
await _validate({ name: event.name, silent: true, nested: false })
77+
}
78+
}
79+
80+
if (event.type === 'blur') {
81+
blurredFields.add(event.name)
82+
}
83+
84+
if (event.type === 'change' || event.type === 'input' || event.type === 'blur' || event.type === 'focus') {
85+
touchedFields.add(event.name)
86+
}
87+
88+
if (event.type === 'change' || event.type === 'input') {
89+
dirtyFields.add(event.name)
7390
}
7491
})
7592
})
@@ -94,8 +111,12 @@ onUnmounted(() => {
94111
const errors = ref<FormErrorWithId[]>([])
95112
provide('form-errors', errors)
96113
97-
const inputs = ref<Record<string, { id?: string, pattern?: RegExp }>>({})
98-
provide(formInputsInjectionKey, inputs)
114+
const inputs = ref<{ [P in keyof T]?: { id?: string, pattern?: RegExp } }>({})
115+
provide(formInputsInjectionKey, inputs as any)
116+
117+
const dirtyFields = new Set<keyof T>()
118+
const touchedFields = new Set<keyof T>()
119+
const blurredFields = new Set<keyof T>()
99120
100121
function resolveErrorIds(errs: FormError[]): FormErrorWithId[] {
101122
return errs.map(err => ({
@@ -121,8 +142,8 @@ async function getErrors(): Promise<FormErrorWithId[]> {
121142
return resolveErrorIds(errs)
122143
}
123144
124-
async function _validate(opts: { name?: string | string[], silent?: boolean, nested?: boolean, transform?: boolean } = { silent: false, nested: true, transform: false }): Promise<T | false> {
125-
const names = opts.name && !Array.isArray(opts.name) ? [opts.name] : opts.name as string[]
145+
async function _validate(opts: { name?: keyof T | (keyof T)[], silent?: boolean, nested?: boolean, transform?: boolean } = { silent: false, nested: true, transform: false }): Promise<T | false> {
146+
const names = opts.name && !Array.isArray(opts.name) ? [opts.name] : opts.name as (keyof T)[]
126147
127148
const nestedValidatePromises = !names && opts.nested
128149
? Array.from(nestedForms.value.values()).map(
@@ -203,7 +224,7 @@ defineExpose<Form<T>>({
203224
validate: _validate,
204225
errors,
205226
206-
setErrors(errs: FormError[], name?: string) {
227+
setErrors(errs: FormError[], name?: keyof T) {
207228
if (name) {
208229
errors.value = errors.value
209230
.filter(error => error.name !== name)
@@ -217,7 +238,7 @@ defineExpose<Form<T>>({
217238
await onSubmitWrapper(new Event('submit'))
218239
},
219240
220-
getErrors(name?: string) {
241+
getErrors(name?: keyof T) {
221242
if (name) {
222243
return errors.value.filter(err => err.name === name)
223244
}
@@ -232,7 +253,12 @@ defineExpose<Form<T>>({
232253
}
233254
},
234255
235-
disabled
256+
disabled,
257+
dirty: computed(() => !!dirtyFields.size),
258+
259+
dirtyFields: readonly(dirtyFields) as DeepReadonly<Set<keyof T>>,
260+
blurredFields: readonly(blurredFields) as DeepReadonly<Set<keyof T>>,
261+
touchedFields: readonly(touchedFields) as DeepReadonly<Set<keyof T>>
236262
})
237263
</script>
238264

src/runtime/components/Input.vue

+2-1
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ const slots = defineSlots<InputSlots>()
7575
7676
const [modelValue, modelModifiers] = defineModel<string | number>()
7777
78-
const { emitFormBlur, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props, { deferInputValidation: true })
78+
const { emitFormBlur, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, emitFormFocus, ariaAttrs } = useFormField<InputProps>(props, { deferInputValidation: true })
7979
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
8080
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
8181
@@ -170,6 +170,7 @@ onMounted(() => {
170170
@input="onInput"
171171
@blur="onBlur"
172172
@change="onChange"
173+
@focus="emitFormFocus"
173174
>
174175

175176
<slot />

src/runtime/components/InputMenu.vue

+2-1
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', '
178178
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8, position: 'popper' }) as ComboboxContentProps)
179179
const arrowProps = toRef(() => props.arrow as ComboboxArrowProps)
180180
181-
const { emitFormBlur, emitFormChange, emitFormInput, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
181+
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
182182
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
183183
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown })))
184184
@@ -279,6 +279,7 @@ function onBlur(event: FocusEvent) {
279279
280280
function onFocus(event: FocusEvent) {
281281
emits('focus', event)
282+
emitFormFocus()
282283
}
283284
284285
function onUpdateOpen(value: boolean) {

src/runtime/components/InputNumber.vue

+2-1
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ defineSlots<InputNumberSlots>()
9292
9393
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'min', 'max', 'step', 'formatOptions'), emits)
9494
95-
const { emitFormBlur, emitFormChange, emitFormInput, id, color, size, name, highlight, disabled, ariaAttrs } = useFormField<InputNumberProps>(props)
95+
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, id, color, size, name, highlight, disabled, ariaAttrs } = useFormField<InputNumberProps>(props)
9696
9797
const { t, code: codeLocale } = useLocale()
9898
const locale = computed(() => props.locale || codeLocale.value)
@@ -158,6 +158,7 @@ defineExpose({
158158
:required="required"
159159
:class="ui.base({ class: props.ui?.base })"
160160
@blur="onBlur"
161+
@focus="emitFormFocus"
161162
/>
162163

163164
<div :class="ui.increment({ class: props.ui?.increment })">

src/runtime/components/PinInput.vue

+2-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ const props = withDefaults(defineProps<PinInputProps>(), {
5050
const emits = defineEmits<PinInputEmits>()
5151
5252
const rootProps = useForwardPropsEmits(reactivePick(props, 'defaultValue', 'disabled', 'id', 'mask', 'modelValue', 'name', 'otp', 'placeholder', 'required', 'type'), emits)
53-
const { emitFormInput, emitFormChange, emitFormBlur, size, color, id, name, highlight, disabled, ariaAttrs } = useFormField<PinInputProps>(props)
53+
const { emitFormInput, emitFormFocus, emitFormChange, emitFormBlur, size, color, id, name, highlight, disabled, ariaAttrs } = useFormField<PinInputProps>(props)
5454
5555
const ui = computed(() => pinInput({
5656
color: color.value,
@@ -92,6 +92,7 @@ function onBlur(event: FocusEvent) {
9292
v-bind="$attrs"
9393
:disabled="disabled"
9494
@blur="onBlur"
95+
@focus="emitFormFocus"
9596
/>
9697
</PinInputRoot>
9798
</template>

src/runtime/components/Select.vue

+2-1
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ const rootProps = useForwardPropsEmits(reactivePick(props, 'open', 'defaultOpen'
133133
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8, position: 'popper' }) as SelectContentProps)
134134
const arrowProps = toRef(() => props.arrow as SelectArrowProps)
135135
136-
const { emitFormChange, emitFormInput, emitFormBlur, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
136+
const { emitFormChange, emitFormInput, emitFormBlur, emitFormFocus, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
137137
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
138138
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown })))
139139
@@ -179,6 +179,7 @@ function onUpdateOpen(value: boolean) {
179179
} else {
180180
const event = new FocusEvent('focus')
181181
emits('focus', event)
182+
emitFormFocus()
182183
}
183184
}
184185
</script>

src/runtime/components/SelectMenu.vue

+2-1
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffse
168168
const arrowProps = toRef(() => props.arrow as ComboboxArrowProps)
169169
const searchInputProps = toRef(() => defu(props.searchInput, { placeholder: t('selectMenu.search'), variant: 'none' }) as InputProps)
170170
171-
const { emitFormBlur, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
171+
const { emitFormBlur, emitFormFocus, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
172172
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
173173
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown })))
174174
@@ -272,6 +272,7 @@ function onUpdateOpen(value: boolean) {
272272
} else {
273273
const event = new FocusEvent('focus')
274274
emits('focus', event)
275+
emitFormFocus()
275276
clearTimeout(timeoutId)
276277
}
277278
}

src/runtime/components/Textarea.vue

+2-1
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ const emits = defineEmits<TextareaEmits>()
6666
6767
const [modelValue, modelModifiers] = defineModel<string | number>()
6868
69-
const { emitFormBlur, emitFormInput, emitFormChange, size, color, id, name, highlight, disabled, ariaAttrs } = useFormField<TextareaProps>(props, { deferInputValidation: true })
69+
const { emitFormFocus, emitFormBlur, emitFormInput, emitFormChange, size, color, id, name, highlight, disabled, ariaAttrs } = useFormField<TextareaProps>(props, { deferInputValidation: true })
7070
7171
const ui = computed(() => textarea({
7272
color: color.value,
@@ -189,6 +189,7 @@ onMounted(() => {
189189
@input="onInput"
190190
@blur="onBlur"
191191
@change="onChange"
192+
@focus="emitFormFocus"
192193
/>
193194

194195
<slot />

src/runtime/composables/useFormField.ts

+10-11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { inject, ref, computed, type InjectionKey, type Ref, type ComputedRef } from 'vue'
1+
import { inject, computed, type InjectionKey, type Ref, type ComputedRef } from 'vue'
22
import { type UseEventBusReturn, useDebounceFn } from '@vueuse/core'
33
import type { FormFieldProps } from '../types'
44
import type { FormEvent, FormInputEvents, FormFieldInjectedOptions, FormInjectedOptions } from '../types/form'
@@ -14,7 +14,7 @@ type Props<T> = {
1414
}
1515

1616
export const formOptionsInjectionKey: InjectionKey<ComputedRef<FormInjectedOptions>> = Symbol('nuxt-ui.form-options')
17-
export const formBusInjectionKey: InjectionKey<UseEventBusReturn<FormEvent, string>> = Symbol('nuxt-ui.form-events')
17+
export const formBusInjectionKey: InjectionKey<UseEventBusReturn<FormEvent<any>, string>> = Symbol('nuxt-ui.form-events')
1818
export const formFieldInjectionKey: InjectionKey<ComputedRef<FormFieldInjectedOptions<FormFieldProps>>> = Symbol('nuxt-ui.form-field')
1919
export const inputIdInjectionKey: InjectionKey<Ref<string | undefined>> = Symbol('nuxt-ui.input-id')
2020
export const formInputsInjectionKey: InjectionKey<Ref<Record<string, { id?: string, pattern?: RegExp }>>> = Symbol('nuxt-ui.form-inputs')
@@ -41,29 +41,27 @@ export function useFormField<T>(props?: Props<T>, opts?: { bind?: boolean, defer
4141
}
4242
}
4343

44-
const touched = ref(false)
45-
46-
function emitFormEvent(type: FormInputEvents, name?: string) {
44+
function emitFormEvent(type: FormInputEvents, name?: string, eager?: boolean) {
4745
if (formBus && formField && name) {
48-
formBus.emit({ type, name })
46+
formBus.emit({ type, name, eager })
4947
}
5048
}
5149

5250
function emitFormBlur() {
53-
touched.value = true
5451
emitFormEvent('blur', formField?.value.name)
5552
}
5653

54+
function emitFormFocus() {
55+
emitFormEvent('focus', formField?.value.name)
56+
}
57+
5758
function emitFormChange() {
58-
touched.value = true
5959
emitFormEvent('change', formField?.value.name)
6060
}
6161

6262
const emitFormInput = useDebounceFn(
6363
() => {
64-
if (!opts?.deferInputValidation || touched.value || formField?.value.eagerValidation) {
65-
emitFormEvent('input', formField?.value.name)
66-
}
64+
emitFormEvent('input', formField?.value.name, !opts?.deferInputValidation || formField?.value.eagerValidation)
6765
},
6866
formField?.value.validateOnInputDelay ?? formOptions?.value.validateOnInputDelay ?? 0
6967
)
@@ -78,6 +76,7 @@ export function useFormField<T>(props?: Props<T>, opts?: { bind?: boolean, defer
7876
emitFormBlur,
7977
emitFormInput,
8078
emitFormChange,
79+
emitFormFocus,
8180
ariaAttrs: computed(() => {
8281
if (!formField?.value) return
8382

src/runtime/locale/hu.ts

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { defineLocale } from '../composables/defineLocale'
2+
3+
export default defineLocale({
4+
name: 'Magyar',
5+
code: 'hu',
6+
messages: {
7+
inputMenu: {
8+
noMatch: 'Nincs találat',
9+
noData: 'Nincs adat',
10+
create: '"{label}" létrehozása'
11+
},
12+
calendar: {
13+
prevYear: 'Előző év',
14+
nextYear: 'Következő év',
15+
prevMonth: 'Előző hónap',
16+
nextMonth: 'Következő hónap'
17+
},
18+
inputNumber: {
19+
increment: 'Növel',
20+
decrement: 'Csökkent'
21+
},
22+
commandPalette: {
23+
placeholder: 'Írjon be egy parancsot vagy keressen...',
24+
noMatch: 'Nincs találat',
25+
noData: 'Nincs adat',
26+
close: 'Bezárás'
27+
},
28+
selectMenu: {
29+
noMatch: 'Nincs találat',
30+
noData: 'Nincs adat',
31+
create: '"{label}" létrehozása',
32+
search: 'Keresés...'
33+
},
34+
toast: {
35+
close: 'Bezárás'
36+
},
37+
carousel: {
38+
prev: 'Előző',
39+
next: 'Következő',
40+
goto: 'Ugrás ide {slide}'
41+
},
42+
modal: {
43+
close: 'Bezárás'
44+
},
45+
slideover: {
46+
close: 'Bezárás'
47+
},
48+
alert: {
49+
close: 'Bezárás'
50+
},
51+
table: {
52+
noData: 'Nincs adat'
53+
}
54+
}
55+
})

0 commit comments

Comments
 (0)