FormField
A wrapper for form elements that provides validation and error handling.
Demo
This is easier to find you in the database.
Related Keys
This requires the following keys to be installed:
Related Types
This requires the following types to be installed:
Related Theme
This requires the following theme to be installed:
Component
vue
<script lang="ts">
import type { FormFieldInjectedOptions } from '@/ui/keys/form-field'
import type { ComponentConfig } from '@/ui/utils/utils'
import { formFieldInjectionKey, inputIdInjectionKey } from '@/ui/keys/form-field'
import theme from '@/ui/theme/form-field'
import { Label, Primitive } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, provide, ref, useId } from 'vue'
type FormField = ComponentConfig<typeof theme>
export interface FormFieldProps {
as?: any
name?: string
label?: string
description?: string
help?: string
error?: string | boolean
hint?: string
size?: FormField['variants']['size']
required?: boolean
eagerValidation?: boolean
validateOnInputDelay?: number
class?: any
ui?: FormField['slots']
}
export interface FormFieldSlots {
label: (props: { label?: string }) => any
hint: (props: { hint?: string }) => any
description: (props: { description?: string }) => any
help: (props: { help?: string }) => any
error: (props: { error?: string | boolean }) => any
default: (props: { error?: string | boolean }) => any
}
</script>
<script setup lang="ts">
const props = defineProps<FormFieldProps>()
const slots = defineSlots<FormFieldSlots>()
const ui = computed(() => tv(theme)({
size: props.size,
required: props.required,
}))
const error = computed(() => props.error)
const id = ref(useId())
// Copies id's initial value to bind aria-attributes such as aria-describedby.
// This is required for the RadioGroup component which unsets the id value.
const ariaId = id.value
provide(inputIdInjectionKey, id)
provide(formFieldInjectionKey, computed(() => ({
error: error.value,
name: props.name,
size: props.size,
eagerValidation: props.eagerValidation,
validateOnInputDelay: props.validateOnInputDelay,
hint: props.hint,
description: props.description,
help: props.help,
ariaId,
}) as FormFieldInjectedOptions<FormFieldProps>))
</script>
<template>
<Primitive :as="as" :class="ui.root({ class: [props.class, props.ui?.root] })">
<div :class="ui.wrapper({ class: props.ui?.wrapper })">
<div v-if="label || !!slots.label" :class="ui.labelWrapper({ class: props.ui?.labelWrapper })">
<Label :for="id" :class="ui.label({ class: props.ui?.label })">
<slot name="label" :label="label">
{{ label }}
</slot>
</Label>
<span v-if="hint || !!slots.hint" :id="`${ariaId}-hint`" :class="ui.hint({ class: props.ui?.hint })">
<slot name="hint" :hint="hint">
{{ hint }}
</slot>
</span>
</div>
<p v-if="description || !!slots.description" :id="`${ariaId}-description`" :class="ui.description({ class: props.ui?.description })">
<slot name="description" :description="description">
{{ description }}
</slot>
</p>
</div>
<div :class="[(label || !!slots.label || description || !!slots.description) && ui.container({ class: props.ui?.container })]">
<slot :error="error" />
<p v-if="(typeof error === 'string' && error) || !!slots.error" :id="`${ariaId}-error`" :class="ui.error({ class: props.ui?.error })">
<slot name="error" :error="error">
{{ error }}
</slot>
</p>
<p v-else-if="help || !!slots.help" :class="ui.help({ class: props.ui?.help })">
<slot name="help" :help="help">
{{ help }}
</slot>
</p>
</div>
</Primitive>
</template>vue
<script lang="ts">
import type { FormFieldInjectedOptions } from '@/UI/Keys/form-field'
import type { ComponentConfig } from '@/UI/Utils/utils'
import { formFieldInjectionKey, inputIdInjectionKey } from '@/UI/Keys/form-field'
import theme from '@/UI/Theme/form-field'
import { Label, Primitive } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, provide, ref, useId } from 'vue'
type FormField = ComponentConfig<typeof theme>
export interface FormFieldProps {
as?: any
name?: string
label?: string
description?: string
help?: string
error?: string | boolean
hint?: string
size?: FormField['variants']['size']
required?: boolean
eagerValidation?: boolean
validateOnInputDelay?: number
class?: any
ui?: FormField['slots']
}
export interface FormFieldSlots {
label: (props: { label?: string }) => any
hint: (props: { hint?: string }) => any
description: (props: { description?: string }) => any
help: (props: { help?: string }) => any
error: (props: { error?: string | boolean }) => any
default: (props: { error?: string | boolean }) => any
}
</script>
<script setup lang="ts">
const props = defineProps<FormFieldProps>()
const slots = defineSlots<FormFieldSlots>()
const ui = computed(() => tv(theme)({
size: props.size,
required: props.required,
}))
const error = computed(() => props.error)
const id = ref(useId())
// Copies id's initial value to bind aria-attributes such as aria-describedby.
// This is required for the RadioGroup component which unsets the id value.
const ariaId = id.value
provide(inputIdInjectionKey, id)
provide(formFieldInjectionKey, computed(() => ({
error: error.value,
name: props.name,
size: props.size,
eagerValidation: props.eagerValidation,
validateOnInputDelay: props.validateOnInputDelay,
hint: props.hint,
description: props.description,
help: props.help,
ariaId,
}) as FormFieldInjectedOptions<FormFieldProps>))
</script>
<template>
<Primitive :as="as" :class="ui.root({ class: [props.class, props.ui?.root] })">
<div :class="ui.wrapper({ class: props.ui?.wrapper })">
<div v-if="label || !!slots.label" :class="ui.labelWrapper({ class: props.ui?.labelWrapper })">
<Label :for="id" :class="ui.label({ class: props.ui?.label })">
<slot name="label" :label="label">
{{ label }}
</slot>
</Label>
<span v-if="hint || !!slots.hint" :id="`${ariaId}-hint`" :class="ui.hint({ class: props.ui?.hint })">
<slot name="hint" :hint="hint">
{{ hint }}
</slot>
</span>
</div>
<p v-if="description || !!slots.description" :id="`${ariaId}-description`" :class="ui.description({ class: props.ui?.description })">
<slot name="description" :description="description">
{{ description }}
</slot>
</p>
</div>
<div :class="[(label || !!slots.label || description || !!slots.description) && ui.container({ class: props.ui?.container })]">
<slot :error="error" />
<p v-if="(typeof error === 'string' && error) || !!slots.error" :id="`${ariaId}-error`" :class="ui.error({ class: props.ui?.error })">
<slot name="error" :error="error">
{{ error }}
</slot>
</p>
<p v-else-if="help || !!slots.help" :class="ui.help({ class: props.ui?.help })">
<slot name="help" :help="help">
{{ help }}
</slot>
</p>
</div>
</Primitive>
</template>Theme
ts
export default {
slots: {
root: '',
wrapper: '',
labelWrapper: '',
label: '',
container: '',
description: '',
error: '',
hint: '',
help: '',
},
variants: {
size: {
xs: { root: '' },
sm: { root: '' },
md: { root: '' },
lg: { root: '' },
xl: { root: '' },
},
required: {
true: {
label: '',
},
},
},
defaultVariants: {
size: 'md',
} as const,
}View Nuxt UI theme
ts
export default {
slots: {
root: '',
wrapper: '',
labelWrapper: 'flex content-center items-center justify-between',
label: 'block font-medium text-default',
container: 'mt-1 relative',
description: 'text-muted',
error: 'mt-2 text-error',
hint: 'text-muted',
help: 'mt-2 text-muted',
},
variants: {
size: {
xs: { root: 'text-xs' },
sm: { root: 'text-xs' },
md: { root: 'text-sm' },
lg: { root: 'text-sm' },
xl: { root: 'text-base' },
},
required: {
true: {
label: `after:content-['*'] after:ms-0.5 after:text-error`,
},
},
},
defaultVariants: {
size: 'md',
} as const,
}Test
To test this component, you can use the following test file:
ts
import type { RenderOptions } from '@testing-library/vue'
import FormField from '@/ui/components/FormField.vue'
import Input from '@/ui/components/Input.vue'
import theme from '@/ui/theme/form-field.js'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { h } from 'vue'
describe('form-field', () => {
const sizes = Object.keys(theme.variants.size) as any
it.each<[string, RenderOptions<typeof FormField>]>([
// Props
['with label and description', { props: { label: 'Username', description: 'Enter your username' } }],
['with required', { props: { label: 'Username', required: true } }],
['with help', { props: { help: 'Username must be unique' } }],
['with error', { props: { error: 'Username is already taken' } }],
['with hint', { props: { hint: 'Use letters, numbers, and special characters' } }],
...sizes.map((size: string) => [`with size ${size}`, { props: { label: 'Username', description: 'Enter your username', size } }]),
['with as', { props: { as: 'section' } }],
['with class', { props: { class: 'relative' } }],
['with ui', { props: { ui: { label: 'text-(--ui-text-highlighted)' } } }],
// Slots
['with default slot', { slots: { default: () => 'Default slot' } }],
['with label slot', { slots: { label: () => 'Label slot' } }],
['with description slot', { slots: { description: () => 'Description slot' } }],
['with error slot', { slots: { error: () => 'Error slot' } }],
['with hint slot', { slots: { hint: () => 'Hint slot' } }],
['with help slot', { slots: { help: () => 'Help slot' } }],
])('renders %s correctly', (name, options) => {
render(FormField, {
attrs: {
'data-testid': 'form-field',
},
...options,
})
expect(screen.getByTestId('form-field')).toMatchSnapshot()
})
it('provides the id to the input', () => {
render(FormField, {
attrs: { 'data-testid': 'form-field' },
props: { label: 'Form Field' },
slots: { default: () => h(Input, { label: 'Name' }) },
})
expect(screen.getByTestId('form-field')).toMatchSnapshot()
})
})ts
import type { RenderOptions } from '@testing-library/vue'
import FormField from '@/UI/Components/FormField.vue'
import Input from '@/UI/Components/Input.vue'
import theme from '@/UI/Theme/form-field.js'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { h } from 'vue'
describe('form-field', () => {
const sizes = Object.keys(theme.variants.size) as any
it.each<[string, RenderOptions<typeof FormField>]>([
// Props
['with label and description', { props: { label: 'Username', description: 'Enter your username' } }],
['with required', { props: { label: 'Username', required: true } }],
['with help', { props: { help: 'Username must be unique' } }],
['with error', { props: { error: 'Username is already taken' } }],
['with hint', { props: { hint: 'Use letters, numbers, and special characters' } }],
...sizes.map((size: string) => [`with size ${size}`, { props: { label: 'Username', description: 'Enter your username', size } }]),
['with as', { props: { as: 'section' } }],
['with class', { props: { class: 'relative' } }],
['with ui', { props: { ui: { label: 'text-(--ui-text-highlighted)' } } }],
// Slots
['with default slot', { slots: { default: () => 'Default slot' } }],
['with label slot', { slots: { label: () => 'Label slot' } }],
['with description slot', { slots: { description: () => 'Description slot' } }],
['with error slot', { slots: { error: () => 'Error slot' } }],
['with hint slot', { slots: { hint: () => 'Hint slot' } }],
['with help slot', { slots: { help: () => 'Help slot' } }],
])('renders %s correctly', (name, options) => {
render(FormField, {
attrs: {
'data-testid': 'form-field',
},
...options,
})
expect(screen.getByTestId('form-field')).toMatchSnapshot()
})
it('provides the id to the input', () => {
render(FormField, {
attrs: { 'data-testid': 'form-field' },
props: { label: 'Form Field' },
slots: { default: () => h(Input, { label: 'Name' }) },
})
expect(screen.getByTestId('form-field')).toMatchSnapshot()
})
})