Skip to content

FormField

A wrapper for form elements that provides validation and error handling.

Demo

This is easier to find you in the database.

This requires the following keys to be installed:

This requires the following types to be installed:

This requires the following theme to be installed:

Component

FormField.vue
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>
FormField.vue
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

form-field.ts
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
form-field.ts
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:

FormField.test.ts
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()
  })
})
FormField.test.ts
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()
  })
})

Contributors

barbapapazes

Changelog

318c5 - fix: missing import in form-field component on 4/24/2025
98f3a - fix: add missing import in form field on 4/24/2025
7a9f6 - feat: attrs on form elements (#150) on 2/14/2025
c615c - feat: add custom eslint rule to disallow relative imports (#81) on 1/7/2025
845bd - feat: add form-field component (#58) on 12/17/2024