GrFormField

Package: @feugene/granularitycoreGroup: forms

Combines label, control, hint and error into a single field.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • the field has a label — it is linked to the control by id automatically, with no manual for;
  • the field shows an error — the text, the red border and aria-invalid reach the control through the context;
  • the field takes part in the validation of a formname connects it to GrForm;
  • the label stands to the sidelabelPosition and labelWidth give a horizontal layout without a grid of your own.

When to take something else

NeedTake
Rules and the blocking of submissionGrForm
A control without a label and an errorthe control itself: GrInput, GrSelect, …
A block of fields with a headingGrFormSection
The error belongs to the whole form rather than to a fieldGrResponseErrorBanner

The error is declared rather than appearing

The container of the error always lives in the DOM and changes only its text, and the aria-describedby of the control always contains its id. The reason: some AT do not re-read the description after the attribute itself has changed — an error added together with a new aria-describedby could stay unread. An empty container goes into sr-only, so no empty line appears in the markup of the field.

error accepts both a string and an array: one field sometimes has several complaints (the server’s answer, the validation of a file). Inside a form the error is taken from GrForm by name, and an explicit prop overrides it.

showMessage: false leaves the field invalid for the control and for AT but does not show the text — for dense forms where the errors are explained by a summary above.

Validation on blur — only on a real departure

focusout also bubbles when the focus moves inside the field: from an input to its own clear button, between the checkboxes of a group. The field is validated only if the focus has left the bounds of the root (relatedTarget outside the field or null) — otherwise it turned red before the user had finished filling it in.

Linking the label to the control

<label for> points at the id from the context, and it is the control itself that hangs that id on itself (useGrFormFieldContext()). If there is no such element inside the field, in dev mode the component warns in the console: otherwise a click on the label silently does nothing, and for a screen reader there is no link at all.

Widgets with an ARIA role (GrCheckbox, GrRadioGroup, GrCheckboxGroup) do not support <label for> — they take their name through aria-labelledby on field.labelId.

The layout

labelPosition="start" puts the label on the left, and labelWidth sets the width of its column — otherwise the columns of controls in a form drift apart by the length of the labels. The hint and the error stay beside the control: they are about it, not about the label.

size (xs…lg) scales the label, the hint, the error and the vertical rhythm; it is taken from GrConfigProvider unless it is set locally.

The slots

#label, #hint, #error (which receives errors: string[]) and the default one — the control itself.

What is missing

validateStatus (validating/success): GrForm has no channel for the state of an asynchronous rule, and the prop would be purely manual — the status is needed on the side of the form first.

Playground 10

Loading…

Code
<GrFormField />

Install

npm i @feugene/granularity

Import

import { GrFormField } from '@feugene/granularity/components/GrFormField'

API

Props

PropTypedefaultDescription
disabledboolean | undefinedfalseThe field is unavailable. It adds to the `disabled` of the form by "or".
readonlyboolean | undefinedfalseThe whole field is read-only: the controls inside stop being edited.
requiredboolean | undefinedfalseMarks the field as required (the `*` marker plus `aria-required` on the control).
size"xs" | "sm" | "md" | "lg" | undefinedundefinedThe size of the label, the hint and the error. Unset — from `GrConfigProvider`, otherwise `md`.
namestring | undefinedundefinedThe name of the field in the model of `GrForm` (a dot path such as `address.city` included). When the field is inside a `GrForm` and `name` is set, the error and the requiredness are taken from the form by that name, and the loss of focus triggers the validation of the field.
labelPosition"start" | "top" | undefinedundefinedThe label above (the default) or to the side — for dense forms.
labelstring | undefinedundefined
errorstring | string[] | undefinedundefinedAn explicit error (or several). It overrides the error from `GrForm` — the manual mode without a form is here as well. The array is needed where the source of the errors is one and the complaints are several: the answer of the server, the validation of a file.
labelWidthstring | number | undefinedundefinedThe width of the column of the label with `labelPosition="start"`. A number is pixels.
forIdstring | undefinedundefinedAn explicit id of the control. If it is not set, one is generated automatically.
hintstring | undefinedundefinedA hint under the label or above the control (also available through the `#hint` slot).
showMessageboolean | undefinedtrueWhether to show the text of the error. `false` — the field stays invalid for the control and for AT (`aria-invalid`), but the message takes up no room: that is done in dense table-like forms, where the error is explained by a summary above.
labelClassLabelClass | undefinedundefined

Slots

SlotTypeDescription
defaultanyThe control of the field.
labelanyThe label instead of the `label` prop.
hintanyThe hint under the control instead of the `hint` prop.
error{ errors: string[]; }The text of the error instead of the standard one. It receives the already resolved list.

Examples 6

Auto id, hint, required and error linking

We'll never share your email.

Context
<script setup lang="ts">
import { computed, ref } from 'vue'

import { GrFormField, GrInput } from '@feugene/granularity'

const email = ref('john')
const error = computed(() =>
  email.value && !email.value.includes('@') ? 'Enter a valid email address' : undefined,
)
</script>

<template>
  <!--
    Контрол сам получает id (связка с label `for`), aria-describedby (hint + error),
    aria-invalid и aria-required через inject-контекст `GrFormField` — без `forId` вручную.
  -->
  <div class="grid max-w-sm gap-4">
    <GrFormField
      label="Email"
      required
      hint="We'll never share your email."
      :error="error"
    >
      <GrInput v-model="email" type="email" placeholder="you@example.com" />
    </GrFormField>
  </div>
</template>

Basic label and `forId` wiring

Basic
<script setup lang="ts">
import { ref } from 'vue'

import { GrFormField, GrInput } from '@feugene/granularity'

const name = ref('Operations dashboard')
</script>

<template>
  <GrFormField label="Workspace name" for-id="workspace-name">
    <GrInput id="workspace-name" v-model="name" placeholder="Enter workspace name" />
  </GrFormField>
</template>

Inline validation message

Error
<script setup lang="ts">
import { computed, ref } from 'vue'

import { GrFormField, GrInput } from '@feugene/granularity'

const slug = ref('')

const error = computed(() => {
  if (!slug.value)
    return 'Slug is required for deploy previews.'

  return /^[a-z0-9-]+$/.test(slug.value)
    ? undefined
    : 'Use lowercase latin letters, numbers and dashes only.'
})
</script>

<template>
  <GrFormField label="Preview slug" for-id="preview-slug" :error="error">
    <GrInput id="preview-slug" v-model="slug" :invalid="Boolean(error)" placeholder="team-dashboard" />
  </GrFormField>
</template>

Section-style labels via `labelClass`

I verified rollout steps and stakeholder approvals.
This pattern works well when the label behaves like a section heading instead of a per-input caption.

Custom Label
<script setup lang="ts">
import { ref } from 'vue'

import { GrCheckbox, GrFormField } from '@feugene/granularity'

const approvals = ref(false)
</script>

<template>
  <GrFormField
    label="Release checklist"
    label-class="font-semibold uppercase tracking-[0.12em] text-[length:var(--gr-text-xs)] leading-[var(--gr-leading-xs)] text-[var(--gr-fg)]"
  >
    <div class="grid gap-3 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
      <GrCheckbox v-model="approvals">
        I verified rollout steps and stakeholder approvals.
      </GrCheckbox>
      <div class="text-sm text-[var(--gr-muted-fg)]">
        This pattern works well when the label behaves like a section heading instead of a per-input caption.
      </div>
    </div>
  </GrFormField>
</template>

Custom control + custom rule (no GrForm)

Custom control + custom rule, without GrForm.

Custom Control
<!-- StarRatingInput.vue -->
<script setup lang="ts">
import { computed } from 'vue'

import { useGrFormFieldContext } from '@feugene/granularity'

const model = defineModel<number>({ default: 0 })

// Даже без GrForm кастомный контрол читает контекст GrFormField, чтобы получить
// id (label `for`), aria-describedby (hint + error) и aria-invalid.
const field = useGrFormFieldContext()
const invalid = computed(() => Boolean(field?.invalid.value))
const stars = [1, 2, 3, 4, 5]
</script>

<template>
  <div
    :id="field?.id.value"
    role="radiogroup"
    :aria-describedby="field?.describedById.value"
    :aria-invalid="invalid || undefined"
    :aria-required="field?.required.value || undefined"
    class="flex gap-1"
  >
    <button
      v-for="star in stars"
      :key="star"
      type="button"
      role="radio"
      :aria-checked="model === star"
      :aria-label="`${star} stars`"
      class="text-2xl leading-none transition-transform hover:scale-110"
      :class="star <= model ? 'text-[var(--gr-warning)]' : 'text-[var(--gr-muted-fg)]'"
      @click="model = star"
    >

    </button>
  </div>
</template>

<!-- GrFormFieldCustomControlDemo.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'

import { GrButton, GrFormField } from '@feugene/granularity'

import StarRatingInput from './StarRatingInput.vue'

const rating = ref(0)
const touched = ref(false)

// Кастомное правило без GrForm: валидируем сами и отдаём текст в `:error`.
function validateRating(value: number): string | undefined {
  if (value < 1)
    return 'Please pick a rating.'
  if (value < 3)
    return 'We would love at least 3 stars 🙂'
  return undefined
}

const error = computed(() => (touched.value ? validateRating(rating.value) : undefined))

function submit() {
  touched.value = true
}
</script>

<template>
  <div class="grid max-w-sm gap-4">
    <GrFormField
      label="Satisfaction"
      required
      hint="Custom control + custom rule, without GrForm."
      :error="error"
    >
      <StarRatingInput v-model="rating" />
    </GrFormField>

    <div>
      <GrButton type="button" @click="submit">
        Send feedback
      </GrButton>
    </div>
  </div>
</template>

Dense form: inline label, several errors, size

Домен или IP базы

Inline Label
<script setup lang="ts">
import { computed, ref } from 'vue'

import { GrFormField, GrInput, GrSegmented, GrSwitch } from '@feugene/granularity'

const size = ref<'sm' | 'md'>('md')
const compact = ref(true)

const host = ref('')
const port = ref('5432')

// Несколько претензий к одному полю: массив вместо склеенной строки.
const hostErrors = computed<string[]>(() => {
  const issues: string[] = []
  if (!host.value)
    issues.push('Хост обязателен')
  else if (host.value.includes(' '))
    issues.push('Пробелы в хосте недопустимы')
  if (host.value.endsWith('.'))
    issues.push('Точка в конце — опечатка')
  return issues
})

const portError = computed(() => (Number(port.value) > 0 ? undefined : 'Порт — положительное число'))
</script>

<template>
  <div class="grid gap-4">
    <div class="flex flex-wrap items-center gap-4">
      <GrSegmented
        v-model="size"
        size="sm"
        :options="[{ value: 'sm', label: 'size=sm' }, { value: 'md', label: 'size=md' }]"
      />
      <GrSwitch v-model="compact" size="sm">
        Подпись сбоку
      </GrSwitch>
    </div>

    <div class="grid gap-3 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
      <GrFormField
        label="Хост"
        hint="Домен или IP базы"
        :error="hostErrors"
        :size="size"
        :label-position="compact ? 'start' : 'top'"
        :label-width="140"
        required
      >
        <GrInput v-model="host" :size="size" placeholder="db.internal" />
      </GrFormField>

      <!-- `showMessage: false` — поле остаётся невалидным для контрола и AT,
           но текст не занимает места: объяснение живёт в сводке формы. -->
      <GrFormField
        label="Порт"
        :error="portError"
        :show-message="false"
        :size="size"
        :label-position="compact ? 'start' : 'top'"
        :label-width="140"
      >
        <GrInput v-model="port" :size="size" />
      </GrFormField>
    </div>
  </div>
</template>

Component documentationAll components