chore(FormGroup): simplify bindings between input and form group p… (#704)

Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
Romain Hamel
2023-09-21 23:22:55 +02:00
committed by GitHub
parent a94782d94b
commit 46879dc1b7
14 changed files with 81 additions and 65 deletions

View File

@@ -80,7 +80,7 @@ async function submit (event: FormSubmitEvent<Schema>) {
</UFormGroup> </UFormGroup>
<UFormGroup name="checkbox" label="Checkbox"> <UFormGroup name="checkbox" label="Checkbox">
<UCheckbox v-model="state.checkbox" /> <UCheckbox v-model="state.checkbox" label="Check me" />
</UFormGroup> </UFormGroup>
<UFormGroup name="radio" label="Radio"> <UFormGroup name="radio" label="Radio">

View File

@@ -2,7 +2,7 @@
<div :class="ui.wrapper"> <div :class="ui.wrapper">
<div class="flex items-center h-5"> <div class="flex items-center h-5">
<input <input
:id="name" :id="inputId"
v-model="toggle" v-model="toggle"
:name="name" :name="name"
:required="required" :required="required"
@@ -18,7 +18,7 @@
> >
</div> </div>
<div v-if="label || $slots.label" class="ms-3 text-sm"> <div v-if="label || $slots.label" class="ms-3 text-sm">
<label :for="name" :class="ui.label"> <label :for="inputId" :class="ui.label">
<slot name="label">{{ label }}</slot> <slot name="label">{{ label }}</slot>
<span v-if="required" :class="ui.required">*</span> <span v-if="required" :class="ui.required">*</span>
</label> </label>
@@ -36,6 +36,7 @@ import { twMerge, twJoin } from 'tailwind-merge'
import { useUI } from '../../composables/useUI' import { useUI } from '../../composables/useUI'
import { useFormGroup } from '../../composables/useFormGroup' import { useFormGroup } from '../../composables/useFormGroup'
import { mergeConfig } from '../../utils' import { mergeConfig } from '../../utils'
import { uid } from '../../utils/uid'
import type { Strategy } from '../../types' import type { Strategy } from '../../types'
// @ts-expect-error // @ts-expect-error
import appConfig from '#build/app.config' import appConfig from '#build/app.config'
@@ -47,6 +48,11 @@ const config = mergeConfig<typeof checkbox>(appConfig.ui.strategy, appConfig.ui.
export default defineComponent({ export default defineComponent({
inheritAttrs: false, inheritAttrs: false,
props: { props: {
id: {
type: String,
// A default value is needed here to bind the label
default: () => uid()
},
value: { value: {
type: [String, Number, Boolean, Object], type: [String, Number, Boolean, Object],
default: null default: null
@@ -103,8 +109,7 @@ export default defineComponent({
setup (props, { emit }) { setup (props, { emit }) {
const { ui, attrs } = useUI('checkbox', props.ui, config, { mergeWrapper: true }) const { ui, attrs } = useUI('checkbox', props.ui, config, { mergeWrapper: true })
const { emitFormChange, formGroup } = useFormGroup() const { emitFormChange, color, name, inputId } = useFormGroup(props)
const color = computed(() => formGroup?.error?.value ? 'red' : props.color)
const toggle = computed({ const toggle = computed({
get () { get () {
@@ -136,6 +141,9 @@ export default defineComponent({
ui, ui,
attrs, attrs,
toggle, toggle,
inputId,
// eslint-disable-next-line vue/no-dupe-keys
name,
// eslint-disable-next-line vue/no-dupe-keys // eslint-disable-next-line vue/no-dupe-keys
inputClass, inputClass,
onChange onChange

View File

@@ -12,6 +12,7 @@ import type { ValidationError as JoiError, Schema as JoiSchema } from 'joi'
import type { ObjectSchema as YupObjectSchema, ValidationError as YupError } from 'yup' import type { ObjectSchema as YupObjectSchema, ValidationError as YupError } from 'yup'
import type { ObjectSchemaAsync as ValibotObjectSchema } from 'valibot' import type { ObjectSchemaAsync as ValibotObjectSchema } from 'valibot'
import type { FormError, FormEvent, FormEventType, FormSubmitEvent, Form } from '../../types/form' import type { FormError, FormEvent, FormEventType, FormSubmitEvent, Form } from '../../types/form'
import { uid } from '../../utils/uid'
export default defineComponent({ export default defineComponent({
props: { props: {
@@ -40,8 +41,7 @@ export default defineComponent({
}, },
emits: ['submit'], emits: ['submit'],
setup (props, { expose, emit }) { setup (props, { expose, emit }) {
const seed = Math.random().toString(36).substring(7) const bus = useEventBus<FormEvent>(`form-${uid()}`)
const bus = useEventBus<FormEvent>(`form-${seed}`)
bus.on(async (event) => { bus.on(async (event) => {
if (event.type !== 'submit' && props.validateOn?.includes(event.type)) { if (event.type !== 'submit' && props.validateOn?.includes(event.type)) {

View File

@@ -1,7 +1,7 @@
<template> <template>
<div :class="ui.wrapper" v-bind="attrs"> <div :class="ui.wrapper" v-bind="attrs">
<div v-if="label" :class="[ui.label.wrapper, size]"> <div v-if="label" :class="[ui.label.wrapper, size]">
<label :for="labelFor" :class="[ui.label.base, required ? ui.label.required : '']">{{ label }}</label> <label :for="inputId" :class="[ui.label.base, required ? ui.label.required : '']">{{ label }}</label>
<span v-if="hint" :class="[ui.hint]">{{ hint }}</span> <span v-if="hint" :class="[ui.hint]">{{ hint }}</span>
</div> </div>
<p v-if="description" :class="[ui.description, size]"> <p v-if="description" :class="[ui.description, size]">
@@ -28,11 +28,10 @@ import type { FormError, InjectedFormGroupValue, Strategy } from '../../types'
// @ts-expect-error // @ts-expect-error
import appConfig from '#build/app.config' import appConfig from '#build/app.config'
import { formGroup } from '#ui/ui.config' import { formGroup } from '#ui/ui.config'
import { uid } from '../../utils/uid'
const config = mergeConfig<typeof formGroup>(appConfig.ui.strategy, appConfig.ui.formGroup, formGroup) const config = mergeConfig<typeof formGroup>(appConfig.ui.strategy, appConfig.ui.formGroup, formGroup)
let increment = 0
export default defineComponent({ export default defineComponent({
inheritAttrs: false, inheritAttrs: false,
props: { props: {
@@ -88,11 +87,11 @@ export default defineComponent({
}) })
const size = computed(() => ui.value.size[props.size ?? config.default.size]) const size = computed(() => ui.value.size[props.size ?? config.default.size])
const labelFor = ref(`${props.name || 'lf'}-${increment = increment < 1000000 ? increment + 1 : 0}`) const inputId = ref(uid())
provide<InjectedFormGroupValue>('form-group', { provide<InjectedFormGroupValue>('form-group', {
error, error,
labelFor, inputId,
name: computed(() => props.name), name: computed(() => props.name),
size: computed(() => props.size) size: computed(() => props.size)
}) })
@@ -101,7 +100,7 @@ export default defineComponent({
// eslint-disable-next-line vue/no-dupe-keys // eslint-disable-next-line vue/no-dupe-keys
ui, ui,
attrs, attrs,
labelFor, inputId,
// eslint-disable-next-line vue/no-dupe-keys // eslint-disable-next-line vue/no-dupe-keys
size, size,
// eslint-disable-next-line vue/no-dupe-keys // eslint-disable-next-line vue/no-dupe-keys

View File

@@ -1,7 +1,7 @@
<template> <template>
<div :class="ui.wrapper"> <div :class="ui.wrapper">
<input <input
:id="id" :id="inputId"
ref="input" ref="input"
:name="name" :name="name"
:value="modelValue" :value="modelValue"
@@ -119,7 +119,7 @@ export default defineComponent({
}, },
size: { size: {
type: String as PropType<keyof typeof config.size>, type: String as PropType<keyof typeof config.size>,
default: () => config.default.size, default: null,
validator (value: string) { validator (value: string) {
return Object.keys(config.size).includes(value) return Object.keys(config.size).includes(value)
} }
@@ -154,10 +154,7 @@ export default defineComponent({
setup (props, { emit, slots }) { setup (props, { emit, slots }) {
const { ui, attrs } = useUI('input', props.ui, config, { mergeWrapper: true }) const { ui, attrs } = useUI('input', props.ui, config, { mergeWrapper: true })
const { emitFormBlur, emitFormInput, formGroup } = useFormGroup(props) const { emitFormBlur, emitFormInput, size, color, inputId, name } = useFormGroup(props, config)
const color = computed(() => formGroup?.error?.value ? 'red' : props.color)
const size = computed(() => formGroup?.size?.value ?? props.size)
const id = formGroup?.labelFor
const input = ref<HTMLInputElement | null>(null) const input = ref<HTMLInputElement | null>(null)
@@ -261,7 +258,8 @@ export default defineComponent({
ui, ui,
attrs, attrs,
// eslint-disable-next-line vue/no-dupe-keys // eslint-disable-next-line vue/no-dupe-keys
id, name,
inputId,
input, input,
isLeading, isLeading,
isTrailing, isTrailing,

View File

@@ -2,7 +2,7 @@
<div :class="ui.wrapper"> <div :class="ui.wrapper">
<div class="flex items-center h-5"> <div class="flex items-center h-5">
<input <input
:id="`${name}-${value}`" :id="inputId"
v-model="pick" v-model="pick"
:name="name" :name="name"
:required="required" :required="required"
@@ -15,7 +15,7 @@
> >
</div> </div>
<div v-if="label || $slots.label" class="ms-3 text-sm"> <div v-if="label || $slots.label" class="ms-3 text-sm">
<label :for="`${name}-${value}`" :class="ui.label"> <label :for="inputId" :class="ui.label">
<slot name="label">{{ label }}</slot> <slot name="label">{{ label }}</slot>
<span v-if="required" :class="ui.required">*</span> <span v-if="required" :class="ui.required">*</span>
</label> </label>
@@ -38,12 +38,18 @@ import type { Strategy } from '../../types'
import appConfig from '#build/app.config' import appConfig from '#build/app.config'
import { radio } from '#ui/ui.config' import { radio } from '#ui/ui.config'
import colors from '#ui-colors' import colors from '#ui-colors'
import { uid } from '../../utils/uid'
const config = mergeConfig<typeof radio>(appConfig.ui.strategy, appConfig.ui.radio, radio) const config = mergeConfig<typeof radio>(appConfig.ui.strategy, appConfig.ui.radio, radio)
export default defineComponent({ export default defineComponent({
inheritAttrs: false, inheritAttrs: false,
props: { props: {
id: {
type: String,
// A default value is needed here to bind the label
default: () => uid()
},
value: { value: {
type: [String, Number, Boolean], type: [String, Number, Boolean],
default: null default: null
@@ -92,8 +98,7 @@ export default defineComponent({
setup (props, { emit }) { setup (props, { emit }) {
const { ui, attrs } = useUI('radio', props.ui, config, { mergeWrapper: true }) const { ui, attrs } = useUI('radio', props.ui, config, { mergeWrapper: true })
const { emitFormChange, formGroup } = useFormGroup() const { emitFormChange, color, name, inputId } = useFormGroup(props)
const color = computed(() => formGroup?.error?.value ? 'red' : props.color)
const pick = computed({ const pick = computed({
get () { get () {
@@ -120,9 +125,12 @@ export default defineComponent({
return { return {
// eslint-disable-next-line vue/no-dupe-keys // eslint-disable-next-line vue/no-dupe-keys
ui, ui,
inputId,
attrs, attrs,
pick, pick,
// eslint-disable-next-line vue/no-dupe-keys // eslint-disable-next-line vue/no-dupe-keys
name,
// eslint-disable-next-line vue/no-dupe-keys
inputClass inputClass
} }
} }

View File

@@ -1,7 +1,7 @@
<template> <template>
<div :class="wrapperClass"> <div :class="wrapperClass">
<input <input
:id="id" :id="inputId"
ref="input" ref="input"
v-model.number="value" v-model.number="value"
:name="name" :name="name"
@@ -67,7 +67,7 @@ export default defineComponent({
}, },
size: { size: {
type: String as PropType<keyof typeof config.size>, type: String as PropType<keyof typeof config.size>,
default: () => config.default.size, default: null,
validator (value: string) { validator (value: string) {
return Object.keys(config.size).includes(value) return Object.keys(config.size).includes(value)
} }
@@ -92,10 +92,7 @@ export default defineComponent({
setup (props, { emit }) { setup (props, { emit }) {
const { ui, attrs, attrsClass } = useUI('range', props.ui, config) const { ui, attrs, attrsClass } = useUI('range', props.ui, config)
const { emitFormChange, formGroup } = useFormGroup(props) const { emitFormChange, inputId, color, size, name } = useFormGroup(props, config)
const color = computed(() => formGroup?.error?.value ? 'red' : props.color)
const size = computed(() => formGroup?.size?.value ?? props.size)
const id = formGroup?.labelFor
const value = computed({ const value = computed({
get () { get () {
@@ -171,7 +168,8 @@ export default defineComponent({
ui, ui,
attrs, attrs,
// eslint-disable-next-line vue/no-dupe-keys // eslint-disable-next-line vue/no-dupe-keys
id, name,
inputId,
value, value,
wrapperClass, wrapperClass,
// eslint-disable-next-line vue/no-dupe-keys // eslint-disable-next-line vue/no-dupe-keys

View File

@@ -1,7 +1,7 @@
<template> <template>
<div :class="ui.wrapper"> <div :class="ui.wrapper">
<select <select
:id="id" :id="inputId"
:name="name" :name="name"
:value="modelValue" :value="modelValue"
:required="required" :required="required"
@@ -137,7 +137,7 @@ export default defineComponent({
}, },
size: { size: {
type: String as PropType<keyof typeof config.size>, type: String as PropType<keyof typeof config.size>,
default: () => config.default.size, default: null,
validator (value: string) { validator (value: string) {
return Object.keys(config.size).includes(value) return Object.keys(config.size).includes(value)
} }
@@ -180,10 +180,7 @@ export default defineComponent({
setup (props, { emit, slots }) { setup (props, { emit, slots }) {
const { ui, attrs } = useUI('select', props.ui, config, { mergeWrapper: true }) const { ui, attrs } = useUI('select', props.ui, config, { mergeWrapper: true })
const { emitFormChange, formGroup } = useFormGroup(props) const { emitFormChange, inputId, color, size, name } = useFormGroup(props, config)
const color = computed(() => formGroup?.error?.value ? 'red' : props.color)
const size = computed(() => formGroup?.size?.value ?? props.size)
const id = formGroup?.labelFor
const onInput = (event: InputEvent) => { const onInput = (event: InputEvent) => {
emit('update:modelValue', (event.target as HTMLInputElement).value) emit('update:modelValue', (event.target as HTMLInputElement).value)
@@ -323,7 +320,8 @@ export default defineComponent({
ui, ui,
attrs, attrs,
// eslint-disable-next-line vue/no-dupe-keys // eslint-disable-next-line vue/no-dupe-keys
id, name,
inputId,
normalizedOptionsWithPlaceholder, normalizedOptionsWithPlaceholder,
normalizedValue, normalizedValue,
isLeading, isLeading,

View File

@@ -28,7 +28,7 @@
class="inline-flex w-full" class="inline-flex w-full"
> >
<slot :open="open" :disabled="disabled" :loading="loading"> <slot :open="open" :disabled="disabled" :loading="loading">
<button :id="id" :class="selectClass" :disabled="disabled || loading" type="button" v-bind="attrs"> <button :id="inputId" :class="selectClass" :disabled="disabled || loading" type="button" v-bind="attrs">
<span v-if="(isLeading && leadingIconName) || $slots.leading" :class="leadingWrapperIconClass"> <span v-if="(isLeading && leadingIconName) || $slots.leading" :class="leadingWrapperIconClass">
<slot name="leading" :disabled="disabled" :loading="loading"> <slot name="leading" :disabled="disabled" :loading="loading">
<UIcon :name="leadingIconName" :class="leadingIconClass" /> <UIcon :name="leadingIconName" :class="leadingIconClass" />
@@ -254,7 +254,7 @@ export default defineComponent({
}, },
size: { size: {
type: String as PropType<keyof typeof config.size>, type: String as PropType<keyof typeof config.size>,
default: () => config.default.size, default: null,
validator (value: string) { validator (value: string) {
return Object.keys(config.size).includes(value) return Object.keys(config.size).includes(value)
} }
@@ -314,10 +314,7 @@ export default defineComponent({
const popper = computed<PopperOptions>(() => defu({}, props.popper, uiMenu.value.popper as PopperOptions)) const popper = computed<PopperOptions>(() => defu({}, props.popper, uiMenu.value.popper as PopperOptions))
const [trigger, container] = usePopper(popper.value) const [trigger, container] = usePopper(popper.value)
const { emitFormBlur, emitFormChange, formGroup } = useFormGroup(props) const { emitFormBlur, emitFormChange, inputId, color, size, name } = useFormGroup(props, config)
const color = computed(() => formGroup?.error?.value ? 'red' : props.color)
const size = computed(() => formGroup?.size?.value ?? props.size)
const id = formGroup?.labelFor
const query = ref('') const query = ref('')
const searchInput = ref<ComponentPublicInstance<HTMLElement>>() const searchInput = ref<ComponentPublicInstance<HTMLElement>>()
@@ -446,7 +443,8 @@ export default defineComponent({
uiMenu, uiMenu,
attrs, attrs,
// eslint-disable-next-line vue/no-dupe-keys // eslint-disable-next-line vue/no-dupe-keys
id, name,
inputId,
trigger, trigger,
container, container,
isLeading, isLeading,

View File

@@ -1,7 +1,7 @@
<template> <template>
<div :class="ui.wrapper"> <div :class="ui.wrapper">
<textarea <textarea
:id="id" :id="inputId"
ref="textarea" ref="textarea"
:value="modelValue" :value="modelValue"
:name="name" :name="name"
@@ -82,7 +82,7 @@ export default defineComponent({
}, },
size: { size: {
type: String as PropType<keyof typeof config.size>, type: String as PropType<keyof typeof config.size>,
default: () => config.default.size, default: null,
validator (value: string) { validator (value: string) {
return Object.keys(config.size).includes(value) return Object.keys(config.size).includes(value)
} }
@@ -117,10 +117,7 @@ export default defineComponent({
setup (props, { emit }) { setup (props, { emit }) {
const { ui, attrs } = useUI('textarea', props.ui, config, { mergeWrapper: true }) const { ui, attrs } = useUI('textarea', props.ui, config, { mergeWrapper: true })
const { emitFormBlur, emitFormInput, formGroup } = useFormGroup(props) const { emitFormBlur, emitFormInput, inputId, color, size, name } = useFormGroup(props, config)
const color = computed(() => formGroup?.error?.value ? 'red' : props.color)
const size = computed(() => formGroup?.size?.value ?? props.size)
const id = formGroup?.labelFor
const textarea = ref<HTMLTextAreaElement | null>(null) const textarea = ref<HTMLTextAreaElement | null>(null)
@@ -200,7 +197,8 @@ export default defineComponent({
ui, ui,
attrs, attrs,
// eslint-disable-next-line vue/no-dupe-keys // eslint-disable-next-line vue/no-dupe-keys
id, name,
inputId,
textarea, textarea,
// eslint-disable-next-line vue/no-dupe-keys // eslint-disable-next-line vue/no-dupe-keys
textareaClass, textareaClass,

View File

@@ -1,6 +1,6 @@
<template> <template>
<HSwitch <HSwitch
:id="id" :id="inputId"
v-model="active" v-model="active"
:name="name" :name="name"
:disabled="disabled" :disabled="disabled"
@@ -82,9 +82,7 @@ export default defineComponent({
setup (props, { emit }) { setup (props, { emit }) {
const { ui, attrs, attrsClass } = useUI('toggle', props.ui, config) const { ui, attrs, attrsClass } = useUI('toggle', props.ui, config)
const { emitFormChange, formGroup } = useFormGroup(props) const { emitFormChange, color, inputId, name } = useFormGroup(props)
const color = computed(() => formGroup?.error?.value ? 'red' : props.color)
const id = formGroup?.labelFor
const active = computed({ const active = computed({
get () { get () {
@@ -122,7 +120,8 @@ export default defineComponent({
ui, ui,
attrs, attrs,
// eslint-disable-next-line vue/no-dupe-keys // eslint-disable-next-line vue/no-dupe-keys
id, name,
inputId,
active, active,
switchClass, switchClass,
onIconClass, onIconClass,

View File

@@ -1,17 +1,21 @@
import { inject, ref } from 'vue' import { inject, ref, computed } from 'vue'
import { type UseEventBusReturn, useDebounceFn } from '@vueuse/core' import { type UseEventBusReturn, useDebounceFn } from '@vueuse/core'
import type { FormEvent, FormEventType, InjectedFormGroupValue } from '../types/form' import type { FormEvent, FormEventType, InjectedFormGroupValue } from '../types/form'
type InputAttrs = { type InputProps = {
id?: string id?: string
size?: string
color?: string
name?: string
} }
export const useFormGroup = (inputAttrs?: InputAttrs) => { export const useFormGroup = (inputProps?: InputProps, config?: any) => {
const formBus = inject<UseEventBusReturn<FormEvent, string> | undefined>('form-events', undefined) const formBus = inject<UseEventBusReturn<FormEvent, string> | undefined>('form-events', undefined)
const formGroup = inject<InjectedFormGroupValue>('form-group', undefined) const formGroup = inject<InjectedFormGroupValue>('form-group', undefined)
if (formGroup) { if (formGroup) {
formGroup.labelFor.value = inputAttrs?.id ?? formGroup?.labelFor.value // Updates for="..." attribute on label if inputProps.id is provided
formGroup.inputId.value = inputProps?.id ?? formGroup?.inputId.value
} }
const blurred = ref(false) const blurred = ref(false)
@@ -38,9 +42,12 @@ export const useFormGroup = (inputAttrs?: InputAttrs) => {
}, 300) }, 300)
return { return {
inputId: computed(() => inputProps.id ?? formGroup?.inputId.value),
name: computed(() => inputProps?.name ?? formGroup?.name.value),
size: computed(() => inputProps?.size ?? formGroup?.size.value ?? config?.default?.size),
color: computed(() => formGroup?.error?.value ? 'red' : inputProps?.color),
emitFormBlur, emitFormBlur,
emitFormInput, emitFormInput,
emitFormChange, emitFormChange
formGroup
} }
} }

View File

@@ -21,8 +21,8 @@ export interface FormEvent {
} }
export interface InjectedFormGroupValue { export interface InjectedFormGroupValue {
labelFor: Ref<string> inputId: Ref<string>
name: Ref<string> name: Ref<string>
size: Ref<string> size: Ref<string>
error: Ref<string | boolean> error: Ref<string | boolean>
} }

5
src/runtime/utils/uid.ts Normal file
View File

@@ -0,0 +1,5 @@
let _id = 0
export function uid () {
return `nuid-${_id++}`
}