Skip to content
Brok UI

Loading…

No results

Field

Open source Core

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.

Version
v1.5.1
Stability
stable
License
MIT
Related
Form
Fieldset
Label
Input
Native Select

Preview

We will only use this for account updates.

previews.components.field.default.blade.php 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="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

terminal
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.

  • blade resources/views/components/ui/field.blade.php
  • blade resources/views/components/ui/field/description.blade.php
  • blade 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.

field.md
# 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.blade.php Blade
@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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

field field-description field-error field-group field-label

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

Form structure and labeling

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
root label control slot description error
Theming hooks
field label field description field error

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Safe

Livewire can update this component through forwarded wire:* attributes.

Validation

Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/field.blade.php Blade
@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
resources/views/components/ui/field/description.blade.php Blade
<p
    data-slot="field-description"
    {{ $attributes->merge(['class' => 'break-words text-sm text-muted-foreground']) }}
>
    {{ $slot }}
</p>
resources/views/components/ui/field/error.blade.php Blade
<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