GrFormField
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
idautomatically, with no manualfor; - the field shows an error — the text, the red border and
aria-invalidreach the control through the context; - the field takes part in the validation of a form —
nameconnects it toGrForm; - the label stands to the side —
labelPositionandlabelWidthgive a horizontal layout without a grid of your own.
When to take something else
| Need | Take |
|---|---|
| Rules and the blocking of submission | GrForm |
| A control without a label and an error | the control itself: GrInput, GrSelect, … |
| A block of fields with a heading | GrFormSection |
| The error belongs to the whole form rather than to a field | GrResponseErrorBanner |
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…
<GrFormField />Install
npm i @feugene/granularityImport
import { GrFormField } from '@feugene/granularity/components/GrFormField'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
disabled | boolean | undefined | false | The field is unavailable. It adds to the `disabled` of the form by "or". |
readonly | boolean | undefined | false | The whole field is read-only: the controls inside stop being edited. |
required | boolean | undefined | false | Marks the field as required (the `*` marker plus `aria-required` on the control). |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | The size of the label, the hint and the error. Unset — from `GrConfigProvider`, otherwise `md`. |
name | string | undefined | undefined | The 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" | undefined | undefined | The label above (the default) or to the side — for dense forms. |
label | string | undefined | undefined | — |
error | string | string[] | undefined | undefined | An 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. |
labelWidth | string | number | undefined | undefined | The width of the column of the label with `labelPosition="start"`. A number is pixels. |
forId | string | undefined | undefined | An explicit id of the control. If it is not set, one is generated automatically. |
hint | string | undefined | undefined | A hint under the label or above the control (also available through the `#hint` slot). |
showMessage | boolean | undefined | true | Whether 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. |
labelClass | LabelClass | undefined | undefined | — |
Slots
| Slot | Type | Description |
|---|---|---|
default | any | The control of the field. |
label | any | The label instead of the `label` prop. |
hint | any | The 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.
<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="[email protected]" />
</GrFormField>
</div>
</template>Basic label and `forId` wiring
<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
<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`
<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.
<!-- 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 базы
<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>