Field
A form field wrapper with description and error parts. Pass a dotted `name` to opt into Laravel/Livewire validation wiring and automatic bracket-form HTML names, or override `html-name` explicitly. It reads the shared $errors bag, renders an associated label, threads old() into the control, and wires accessible error state.
Preview
We will only use this for account updates.
Please enter a valid email address.
<x-ui.field class="w-full max-w-sm text-start">
<x-ui.label for="preview-field-email" :required="true">{{ __('Email') }}</x-ui.label>
<x-ui.input id="preview-field-email" type="email" placeholder="jane@example.com" />
<x-ui.field.description>{{ __('We will only use this for account updates.') }}</x-ui.field.description>
<x-ui.field.error>{{ __('Please enter a valid email address.') }}</x-ui.field.error>
</x-ui.field>
Installation
php artisan ui:add field
Registry contract
php artisan ui:add field
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/field.blade.php -
resources/views/components/ui/field/description.blade.php -
resources/views/components/ui/field/error.blade.php
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Field (`field`)
A form field wrapper with description and error parts. Pass a dotted `name` to opt into Laravel/Livewire validation wiring and automatic bracket-form HTML names, or override `html-name` explicitly. It reads the shared $errors bag, renders an associated label, threads old() into the control, and wires accessible error state.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add field
```
## Usage
```blade
<x-ui.field class="w-full max-w-sm text-start">
<x-ui.label for="preview-field-email" :required="true">{{ __('Email') }}</x-ui.label>
<x-ui.input id="preview-field-email" type="email" placeholder="[email protected]" />
<x-ui.field.description>{{ __('We will only use this for account updates.') }}</x-ui.field.description>
<x-ui.field.error>{{ __('Please enter a valid email address.') }}</x-ui.field.error>
</x-ui.field>
```
## Props
- `name` (string|null, default `null`) — Laravel validation key and the default control name/id source.
- `htmlName` (mixed|null, default `null`) — Declared by @props in the shipped Blade source.
- `label` (string|null, default `null`) — Visible label associated with the slotted control.
- `hint` (mixed|null, default `null`) — Declared by @props in the shipped Blade source.
- `required` (bool, default `false`) — Displays required affordance and passes required state to compatible controls.
- `invalid` (bool, default `false`) — Marks the field group invalid for styling and assistive context.
## Use when
- Use to collect, group, and clearly label related input so users understand what is being asked and can recover from errors.
- A control needs a label, optional hint, and Laravel validation wiring.
- Classic Blade and Livewire forms should share the same accessible field structure.
## Avoid when
- Do not create dense unlabeled forms; start from one thing per page or one conceptual question per step when the task is unfamiliar or high stakes.
- Grouping several related controls under one legend; use fieldset.
- The child is not a form control.
## Anti-patterns
- Dense unlabeled forms
- Collecting information without a task need
## Rules
- Use the `<brok:field>` tag (or `<x-ui.field>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/field
- Registry JSON (files, props, contract): https://brokui.dev/r/open/field.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
Validation
@php
// Simulate the result of a failed $request->validate() round-trip: in a real
// app the redirect-back flashes errors into the shared $errors bag (the same
// bag Livewire populates). Here we seed a one-off ViewErrorBag and share it so
// the field below resolves it exactly as it would after validation. Scoped to
// this single preview render.
$previewErrors = new \Illuminate\Support\ViewErrorBag;
$previewErrors->put('default', new \Illuminate\Support\MessageBag([
'email' => [__('The email field must be a valid email address.')],
]));
view()->share('errors', $previewErrors);
@endphp
<form class="flex w-full max-w-sm flex-col gap-6 text-start" method="POST" action="#" onsubmit="return false">
@csrf
{{-- Laravel-native: pass a `name` and the field auto-renders the label, pulls
old('email') into the input, and shows the $errors->first('email') message
with aria-invalid / aria-describedby wired to the input. --}}
<x-ui.field name="email" label="Email" :required="true">
<x-ui.input type="email" placeholder="jane@example.com" />
</x-ui.field>
{{-- No error for this name → the hint renders instead of an error region. --}}
<x-ui.field name="display_name" label="Display name" hint="Shown on your public profile.">
<x-ui.input type="text" placeholder="jane" />
</x-ui.field>
</form>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| name | string | null | null | Laravel validation key and the default control name/id source. |
| htmlName | mixed | null | null | Declared by @props in the shipped Blade source. |
| label | string | null | null | Visible label associated with the slotted control. |
| hint | mixed | null | null | Declared by @props in the shipped Blade source. |
| required | bool | false | Displays required affordance and passes required state to compatible controls. |
| invalid | bool | false | Marks the field group invalid for styling and assistive context. |
Slots
default— The input, select, textarea, or other form control.x-ui.field.description— Standalone description part for custom compositions.x-ui.field.error— Standalone validation error part with alert semantics.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Reads the shared Laravel error bag, forwards old input context, and wires invalid/description identifiers.
- Labels, descriptions, and errors wrap long localized content without causing horizontal page overflow.
- Around a group control (choicebox group, radio group, toggle group, or loose radios and checkboxes) with no single control whose id is the name, the label gets id="{name}-label" and no for; the group root (a fieldset or role="radiogroup"/"group") gets aria-labelledby and the field description and error ids in aria-describedby, and loose inputs are wrapped in a role="group" element (data-slot=field-group). A single control keeps label for.
- Groups a form control with supporting text using consistent spacing and state styling.
- Declares registry capability flags: a11y, responsive, rtl, darkMode, localized.
Guidance
Collect and organize related user input.
Use when
- Use to collect, group, and clearly label related input so users understand what is being asked and can recover from errors.
- A control needs a label, optional hint, and Laravel validation wiring.
- Classic Blade and Livewire forms should share the same accessible field structure.
Avoid when
- Do not create dense unlabeled forms; start from one thing per page or one conceptual question per step when the task is unfamiliar or high stakes.
- Grouping several related controls under one legend; use fieldset.
- The child is not a form control.
Use instead
- Focused question pages
- A smaller set of directly labeled fields
Anti-patterns
- Dense unlabeled forms
- Collecting information without a task need
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- No component-owned keyboard interaction; native element behavior applies.
- Focus
none
- Every interactive control needs a programmatically associated label.
- Error text uses an alert region and remains linked through aria-describedby.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Livewire can update this component through forwarded wire:* attributes.
Validation
Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:field
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
// OPTIONAL — opt in to Laravel-native form wiring. When a `name` is given the
// field reads the shared `$errors` bag (populated by BOTH classic redirect-back
// validation AND Livewire), renders an associated <label for>, threads the
// server-side error into an aria-wired error region, and lets the slotted
// control inherit the name via @aware so it can pull old() + aria-invalid /
// aria-describedby. Omit `name` and the field stays the original bare wrapper.
'name' => null,
// Optional HTML form name. When omitted, controls convert a dotted Laravel /
// Livewire path such as profile.email to profile[email] for plain form posts.
'htmlName' => null,
// Visible label text. Rendered as an associated label (for=name) when both set.
'label' => null,
// Helper/hint text shown under the control while there is no error.
'hint' => null,
// Mark the label with a required asterisk.
'required' => false,
])
@php
// `$errors` is the shared ViewErrorBag every Laravel request (and Livewire)
// injects into views. It may be absent in a bare static render with no session
// middleware, so resolve defensively: an empty bag means "no error", the
// correct inert default.
$bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
$message = ($bag && filled($name)) ? $bag->first($name) : null;
$hasError = filled($message);
// When a name is given we own the layout (label + control + status region) and
// the control inherits `name` via @aware. Without a name this is the original
// pass-through wrapper — the slot is rendered verbatim, fully backward-compatible.
$managed = filled($name);
// A group control (choicebox group, radio group, toggle group, a set of
// checkboxes) has no single control with id = name, so `<label for>` would
// point at nothing. The label then names the group instead: it gets an id
// and no `for`, and the group root (a fieldset or role="radiogroup"/"group")
// gets aria-labelledby plus the field's description and error ids. Loose
// choice inputs with no group root are wrapped in a role="group" element.
// A single control keeps the plain `for` association.
$slotHtml = $managed ? (string) $slot : '';
$groupMode = false;
$labelId = null;
$groupDescribedBy = null;
$labelFor = $name;
if ($managed) {
$tagBody = '(?:"[^"]*"|\'[^\']*\'|[^\'">])*';
$idMatches = preg_match_all('/\sid=(["\'])'.preg_quote($name, '/').'\1/', $slotHtml);
$choiceInputs = preg_match_all('/<input\b'.$tagBody.'\stype=(["\'])(?:radio|checkbox)\1'.$tagBody.'>/i', $slotHtml);
$groupRoot = null;
preg_match_all('/<([a-z][a-z0-9-]*)\b'.$tagBody.'>/i', $slotHtml, $openTags, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
foreach ($openTags as $openTag) {
if (strtolower($openTag[1][0]) === 'fieldset' || preg_match('/\srole=(["\'])(?:radiogroup|group)\1/i', $openTag[0][0]) === 1) {
$groupRoot = ['tag' => $openTag[0][0], 'offset' => $openTag[0][1]];
break;
}
}
$groupMode = $idMatches !== 1 && ($groupRoot !== null || $choiceInputs >= 2);
// A lone radio takes its id from the name and the value (terms-yes):
// the label points at that id.
if (! $groupMode && $idMatches === 0 && $choiceInputs === 1
&& preg_match('/<input\b'.$tagBody.'\sid=(["\'])('.preg_quote($name, '/').'-[^"\']+)\1/i', $slotHtml, $loneControl) === 1) {
$labelFor = $loneControl[2];
}
if ($groupMode) {
$labelId = filled($label) ? $name.'-label' : null;
$groupDescribedBy = array_values(array_filter([
filled($hint) ? $name.'-description' : null,
$hasError ? $name.'-error' : null,
]));
if ($groupRoot !== null) {
$tag = $groupRoot['tag'];
$end = str_ends_with($tag, '/>') ? -2 : -1;
$additions = '';
if ($labelId !== null && preg_match('/\saria-labelledby=/i', $tag) !== 1) {
$additions .= ' aria-labelledby="'.e($labelId).'"';
}
if ($groupDescribedBy !== []) {
if (preg_match('/\saria-describedby=(["\'])(.*?)\1/i', $tag, $existing, PREG_OFFSET_CAPTURE) === 1) {
$ids = array_values(array_unique([...preg_split('/\s+/', trim($existing[2][0])) ?: [], ...$groupDescribedBy]));
$tag = substr_replace($tag, ' aria-describedby="'.e(implode(' ', array_filter($ids))).'"', $existing[0][1], strlen($existing[0][0]));
} else {
$additions .= ' aria-describedby="'.e(implode(' ', $groupDescribedBy)).'"';
}
}
$tag = substr($tag, 0, $end).$additions.substr($tag, $end);
$slotHtml = substr_replace($slotHtml, $tag, $groupRoot['offset'], strlen($groupRoot['tag']));
$groupDescribedBy = null;
}
}
}
@endphp
@if ($managed)
<div
data-slot="field"
@if ($hasError) data-invalid="true" @endif
{{ $attributes->merge(['class' => 'flex min-w-0 flex-col gap-field']) }}
>
@if (filled($label))
<label
data-slot="field-label"
@if ($groupMode)
@if ($labelId) id="{{ $labelId }}" @endif
@else
for="{{ $labelFor }}"
@endif
class="flex min-w-0 max-w-full items-center gap-1 break-all text-sm font-medium leading-none text-foreground"
>
{{ __($label) }}
@if ($required)
<span class="text-destructive" aria-hidden="true">*</span>
<span class="sr-only">{{ __('(required)') }}</span>
@endif
</label>
@endif
{{-- The consumer slots the actual control. It inherits `name` via @aware
and self-wires old() + aria from the shared error bag, so no manual
value/aria attributes are needed. --}}
@if ($groupMode && $groupDescribedBy !== null)
<div
data-slot="field-group"
role="group"
@if ($labelId) aria-labelledby="{{ $labelId }}" @endif
@if ($groupDescribedBy !== []) aria-describedby="{{ implode(' ', $groupDescribedBy) }}" @endif
class="flex min-w-0 flex-col gap-2"
>
{!! $slotHtml !!}
</div>
@else
{!! $slotHtml !!}
@endif
@if (filled($hint))
<p
data-slot="field-description"
id="{{ $name }}-description"
class="min-w-0 break-words text-sm text-muted-foreground"
>
{{ __($hint) }}
</p>
@endif
@if ($hasError)
<p
data-slot="field-error"
id="{{ $name }}-error"
role="alert"
class="min-w-0 break-words text-sm font-medium text-destructive"
>
{{ $message }}
</p>
@endif
</div>
@else
<div
data-slot="field"
{{ $attributes->merge(['class' => 'flex min-w-0 flex-col gap-field']) }}
>
{{ $slot }}
</div>
@endif
<p
data-slot="field-description"
{{ $attributes->merge(['class' => 'break-words text-sm text-muted-foreground']) }}
>
{{ $slot }}
</p>
<p
data-slot="field-error"
role="alert"
{{ $attributes->merge(['class' => 'break-words text-sm font-medium text-destructive']) }}
>
{{ $slot }}
</p>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.5.1 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0