<x-widget.field>
Field
Use this to wrap a control of your own so it matches the rest: the same label, box, error and hint, required mark and character counter, and an error that clears the moment the value changes. It's the frame every form control here is built on, so you never need it around those; they already have it. Works in Livewire around a wire:model control.
php artisan larawell:add field
Already included with every component in Forms. Run this only to wrap a control of your own.
- Also adds
- Icon
- Used by
- Captcha, Checkbox, Date range picker, Date picker, File upload, Number, OTP, Password, Phone, Radio, Search, Select, Switch, Text input, Textarea, Time picker, Verification code
Usage
Livewire
Around your own control in a Livewire component: put wire:model on the control, and pass the frame the property's errors with :error, since it can't see your control's binding. Give the control a fixed id (the one the frame's label points at): Livewire's morph matches elements by id, and a changing one drops focus.
<x-widget.field id="volume" label="Volume" :error="$errors->get('volume')" bare>
<input id="volume" type="range" min="0" max="100" wire:model.live="volume" class="accent-primary h-12 w-full cursor-pointer">
</x-widget.field>
Examples
Custom control
Your own control, matched to the inputs. Text-like markup goes in the box; bare drops the box for a control that draws itself. Give it the id the label points at.
Charged monthly.
Show code Hide code
<div class="grid gap-6 sm:grid-cols-2">
<x-widget.field id="price" label="Price" info="Charged monthly.">
<div class="border-line relative my-3 flex shrink-0 items-center border-e">
<select name="currency" aria-label="Currency" class="text-foreground h-full cursor-pointer appearance-none bg-transparent ps-5 pe-9 outline-none">
<option>USD</option>
<option>EUR</option>
<option>GBP</option>
</select>
<x-widget.icon name="chevron-down" class="text-muted pointer-events-none absolute inset-e-3 size-4" />
</div>
<input id="price" type="text" inputmode="decimal" name="price" value="29.00" aria-describedby="price-info" class="w-full min-w-0 bg-transparent ps-4 pe-5 tabular-nums outline-none">
</x-widget.field>
<x-widget.field id="volume" label="Volume" bare>
<input id="volume" type="range" name="volume" min="0" max="100" value="60" class="accent-primary h-12 w-full cursor-pointer">
</x-widget.field>
</div>
States
An error, as from $errors->get(): one message or several. It clears the moment the value changes.
That handle is taken.
0 / 160
Show code Hide code
<div class="grid gap-6 sm:grid-cols-2">
<x-widget.field id="handle" label="Handle" error="That handle is taken." required>
<input id="handle" name="handle" value="larawell" required aria-invalid="true" aria-describedby="handle-error" class="w-full bg-transparent px-4 outline-none">
</x-widget.field>
<x-widget.field id="bio" label="Bio" box="items-start" counter="160" count="0">
<textarea id="bio" name="bio" maxlength="160" rows="3" class="w-full resize-none bg-transparent px-4 py-3 outline-none"></textarea>
</x-widget.field>
</div>
Props
Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.
<x-widget.field>
| Prop | Default | Description |
|---|---|---|
| id | Required | The control's id, which the label points at. |
| label |
null
|
Shown above the field, and its name for screen readers. |
| error |
null
|
The error message(s) to show under the box. |
| info |
null
|
A hint under the field. |
| disabled |
false
|
Greyed out: it can't be changed. |
| readonly |
false
|
Shown, and submitted, but it can't be edited. |
| box |
'h-12 items-center'
|
Classes for the field's box (its height and alignment). |
| bare |
false
|
No box, for controls that draw their own (OTP boxes, a provider's captcha). |
| required |
false
|
Marks the label as required. |
| announce-required |
false
|
Also says "required" to screen readers, for a control that can't carry required itself. |
| labels-control |
true
|
The label is a <label for> the control; false for markup that isn't yours, named by the label's id instead. |
| counter |
null
|
A count under the field, against this maximum. |
| count |
0
|
The count to start at. |
Slots
- <x-slot:before>
- Content between the label and the box (the stacked captcha's image).
- <x-slot:after>
- Content under the box but above the messages (the password's checklist).
Source
What larawell:add field writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from
the components it also adds , and the theme and base CSS.
resources/views/components/widget/field/index.blade.php Show
{{--
Shared frame for every input: label, field box, and error/info message.
The error look hangs off data-invalid on the root (group-data-invalid/field:*), not server-side
conditions, so resources/js/widget/field can clear it the moment the user edits the field.
--}}
@props([
// The control's id, which the label points at.
'id',
// Shown above the field, and its name for screen readers.
'label' => null,
// The error message(s) to show under the box.
'error' => null,
// A hint under the field.
'info' => null,
// Greyed out: it can't be changed.
'disabled' => false,
// Shown, and submitted, but it can't be edited.
'readonly' => false,
// Classes for the field's box (its height and alignment).
'box' => 'h-12 items-center',
// No box, for controls that draw their own (OTP boxes, a provider's captcha).
'bare' => false,
// Marks the label as required.
'required' => false,
// Also says "required" to screen readers, for a control that can't carry required itself.
'announceRequired' => false,
// The label is a <label for> the control; false for markup that isn't yours, named by the label's id instead.
'labelsControl' => true,
// A count under the field, against this maximum.
'counter' => null,
// The count to start at.
'count' => 0,
])
@php
// Accepts one message or several (Laravel's $errors->get() returns an array).
$messages = array_values(array_filter(\Illuminate\Support\Arr::wrap($error), 'is_string'));
$hasError = $messages !== [];
@endphp
<div data-field @if ($hasError) data-invalid @endif {{ $attributes->class(['group/field text-style-2 relative w-full min-w-44']) }}>
@if ($label)
{{-- The id lets a control whose value sits inside it (the date pickers' buttons) be named "label, value". --}}
{{-- labels-control false: the control is someone else's markup (a captcha provider's), so the text names a group
by id instead of pointing <label for> at an element this page doesn't own. --}}
<{{ $labelsControl ? 'label' : 'div' }} @if ($labelsControl) for="{{ $id }}" @endif id="{{ $id }}-label" @class(['text-style-2 mb-[11.5px] block', 'text-muted' => $disabled, 'text-foreground' => ! $disabled])>
{{ $label }}
{{-- Visual only: the control's own required (or aria-required) is what screen readers announce. A control
that can have neither, like the date pickers' buttons, sets announce-required to say it in the label. --}}
@if ($required)
<span class="text-error" aria-hidden="true">*</span>
@if ($announceRequired)
<span class="sr-only">(required)</span>
@endif
@endif
</{{ $labelsControl ? 'label' : 'div' }}>
@endif
{{-- Content between the label and the box, e.g. the stacked captcha's image. --}}
@isset($before)
{{ $before }}
@endisset
@if ($bare)
{{ $slot }}
@else
<div @class([
'bg-field text-foreground flex overflow-hidden rounded-[20px] border border-transparent transition-colors',
$box,
// Hover and focus both turn the border primary (7:1 against the page), with no ring around it: one edge,
// never two. An invalid field keeps its red border instead (below). An open popup (calendar, list) keeps it
// primary too: focus can leave the box, or never land on it (Safari doesn't focus a clicked button).
'hover:border-primary focus-within:border-primary has-aria-expanded:border-primary' => ! $disabled && ! $readonly,
'group-data-invalid/field:border-error group-data-invalid/field:bg-error/10 group-data-invalid/field:text-error',
'group-data-invalid/field:hover:border-error group-data-invalid/field:focus-within:border-error group-data-invalid/field:focus-within:bg-field group-data-invalid/field:focus-within:text-foreground',
'opacity-70' => $readonly,
])>
{{ $slot }}
</div>
@endif
{{-- Content that belongs under the box but above the messages, e.g. the password's requirements list. --}}
@isset($after)
{{ $after }}
@endisset
{{-- data-field-error: removed by the clear-on-edit script, which also brings the hint back. --}}
@if (count($messages) > 1)
<ul id="{{ $id }}-error" data-field-error class="text-error mt-1 list-disc ps-4 text-xs">
@foreach ($messages as $message)
<li>{{ $message }}</li>
@endforeach
</ul>
@elseif ($hasError)
<div data-field-error class="mt-1 flex items-start gap-1">
<x-widget.icon name="circle-alert" class="text-error mt-0.5 size-3.5" />
<p id="{{ $id }}-error" class="text-error break-words">{{ $messages[0] }}</p>
</div>
@endif
@if ($info)
<div class="mt-1 flex items-start gap-1 group-data-invalid/field:hidden">
<x-widget.icon name="info" class="text-foreground/60 mt-0.5 size-3.5" />
<p id="{{ $id }}-info" class="text-foreground/60 break-words">{{ $info }}</p>
</div>
@endif
{{-- counter="160": characters used against the limit, kept up to date by resources/js/widget/field. --}}
@if ($counter)
<p data-field-counter data-max="{{ (int) $counter }}" class="text-foreground/60 mt-1 text-end text-xs tabular-nums">{{ $count }} / {{ (int) $counter }}</p>
@endif
</div>
resources/js/widget/field/index.js Show
// Behaviour of the frame every field sits in, <x-widget.field>: the inputs, the date pickers and the file upload.
// Delegated from `document`, so fields added to the page later (fetched HTML, modals) work without re-initialising.
// Also the few helpers every field's own script uses, exported so each one doesn't carry a copy.
// Runs handler(event, element) for events on, or inside, an element matching selector.
export const on = (type, selector, handler) => {
document.addEventListener(type, (event) => {
const target = event.target instanceof Element ? event.target.closest(selector) : null;
if (target) {
handler(event, target);
}
});
};
// --- Clear a server-side error as soon as the user edits the field --------------------
// The error was about the old value; once it changes, showing it is just noise. The server
// re-validates on submit and puts it back if it still applies. The frame (widget/field)
// styles off data-invalid, so removing it drops the red look and brings any hint back.
function clearInvalid(event) {
const field = event.target.closest?.('[data-field][data-invalid]');
// Inside a popover is looking, not editing: a picker's month and year menus, a select's search box.
if (!field || event.target.closest('[popover]')) {
return;
}
field.removeAttribute('data-invalid');
field.querySelector('[data-field-error]')?.remove();
field.querySelectorAll('[aria-invalid="true"]').forEach((control) => {
control.removeAttribute('aria-invalid');
// Drop the (now removed) error from what screen readers announce, keep the rest.
const describedBy = (control.getAttribute('aria-describedby') ?? '').split(' ').filter((id) => id && document.getElementById(id));
describedBy.length ? control.setAttribute('aria-describedby', describedBy.join(' ')) : control.removeAttribute('aria-describedby');
});
}
document.addEventListener('input', clearInvalid);
document.addEventListener('change', clearInvalid);
// --- Required select, date pickers and time picker -------------------------------------------------
// Their value lives in hidden inputs, which the browser never validates, so an empty required one would
// submit. Stop the submit here instead, with the message in the frame's own error style, and focus the first
// one: its accessible description then reads the message out. Capture phase, so it runs before the submit
// guard (widget/button) and any other submit handler sees defaultPrevented. The server still validates.
const CHOICE_TRIGGER = '[data-select-trigger], [data-datepicker-trigger], [data-date-range-trigger], [data-time-picker-trigger]';
const ALERT_ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="text-error mt-0.5 size-3.5 shrink-0"><circle cx="12" cy="12" r="8.75"/><path d="M12 7.75v5"/><path d="M12 16.25h.01"/></svg>';
// Chosen: a multiple select has at least one value input; otherwise every named value input (both ends of a
// range) is filled in. A widget with no name submits nothing, so there's nothing to require.
function hasChoice(field) {
const list = field.querySelector('[data-select-values]');
if (list) {
return !list.dataset.name || list.querySelector('input') !== null;
}
return [...field.querySelectorAll('input[type="hidden"][name]')].every((input) => input.value !== '');
}
function showRequired(field) {
const trigger = field.querySelector(CHOICE_TRIGGER);
if (field.querySelector('[data-field-error]') || !trigger) {
return trigger;
}
const error = document.createElement('div');
error.dataset.fieldError = '';
error.className = 'mt-1 flex items-start gap-1';
error.innerHTML = ALERT_ICON;
const message = Object.assign(document.createElement('p'), { id: `${trigger.id}-error`, className: 'text-error break-words', textContent: field.dataset.requiredMessage });
error.append(message);
// Where the server would have put it: under the box, above any hint (which the invalid state hides).
const info = field.querySelector(`#${CSS.escape(trigger.id)}-info`)?.parentElement;
info ? info.before(error) : field.append(error);
field.setAttribute('data-invalid', '');
trigger.setAttribute('aria-invalid', 'true');
const describedBy = new Set((trigger.getAttribute('aria-describedby') ?? '').split(' ').filter(Boolean));
trigger.setAttribute('aria-describedby', [message.id, ...describedBy].join(' '));
return trigger;
}
document.addEventListener('submit', (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement) || form.noValidate || event.submitter?.formNoValidate) {
return;
}
const missing = [...form.querySelectorAll('[data-field][data-required-message]')].filter((field) => !hasChoice(field));
if (missing.length === 0) {
return;
}
event.preventDefault();
const triggers = missing.map(showRequired);
triggers[0]?.focus();
}, true);
// --- Character counter (counter + maxlength) -----------------------------------------
function updateCounter(control) {
const counter = control.closest('[data-field]').querySelector('[data-field-counter]');
if (counter) {
// UTF-16 units, as maxlength counts them, so the counter reaches the limit when typing stops, emoji and all.
counter.textContent = `${control.value.length} / ${counter.dataset.max}`;
}
}
on('input', '[data-field] input, [data-field] textarea', (event, control) => updateCounter(control));
// Swaps in a cleaned value while keeping the caret the same distance from the end,
// so stripping a character mid-string doesn't throw the cursor to the end.
export function replaceValue(input, clean) {
if (input.value === clean) {
return;
}
const fromEnd = input.value.length - (input.selectionEnd ?? input.value.length);
input.value = clean;
const caret = Math.max(0, clean.length - fromEnd);
input.setSelectionRange(caret, caret);
}
// --- Livewire renders ----------------------------------------------------------------------------------------
// A render morphs each field back to the server's markup. The value itself comes back right (FormField::old draws it
// from the bound property), but what a script works out from it (a counter, the password checklist, a grouped
// number's display, the OTP boxes) only followed it as it was typed: each field's script brings its own back in step
// through onLivewireMorph. Also run for a value the server set or cleared. Not the field being typed in: a render can
// answer an earlier keystroke, and what's in the field is newer than what came back (see typingIn).
export const typingIn = (element) => element.contains(document.activeElement);
// Calls refresh(element) after every Livewire morph. Does nothing on a page without Livewire.
export function onLivewireMorph(refresh) {
const hook = (Livewire) => Livewire.hook('morphed', ({ el }) => refresh(el));
if (window.Livewire) {
hook(window.Livewire);
} else {
document.addEventListener('livewire:init', () => hook(window.Livewire));
}
}
function refreshCounters(scope) {
scope.querySelectorAll('[data-field-counter]').forEach((counter) => {
const control = counter.closest('[data-field]').querySelector('input:not([type="hidden"]), textarea');
if (control) {
updateCounter(control);
}
});
}
onLivewireMorph(refreshCounters);
// --- Popovers that lose their field ------------------------------------------------------------------------
// Whether any of an element can still be seen: not scrolled out of a container that clips it (a table that scrolls
// inside itself, a modal's body) or the window, and not covered there (a table's sticky header). `popover` is left out
// of what counts as covering it.
export function inView(element, popover) {
const box = element.getBoundingClientRect();
let [top, right, bottom, left] = [Math.max(box.top, 0), Math.min(box.right, window.innerWidth), Math.min(box.bottom, window.innerHeight), Math.max(box.left, 0)];
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
const style = getComputedStyle(parent);
if (/auto|scroll|hidden|clip/.test(`${style.overflowX} ${style.overflowY}`)) {
const clip = parent.getBoundingClientRect();
[top, right, bottom, left] = [Math.max(top, clip.top), Math.min(right, clip.right), Math.min(bottom, clip.bottom), Math.max(left, clip.left)];
}
}
if (bottom - top < 1 || right - left < 1) {
return false;
}
const hit = document.elementsFromPoint((left + right) / 2, (top + bottom) / 2).find((node) => !popover?.contains(node));
return !hit || element.contains(hit);
}
// A popover sits in the top layer, so nothing clips it: when the field it belongs to is scrolled out of view, it would
// be left hanging, pointing at nothing. Each picker calls this as it repositions on scroll; it closes the popover then,
// and puts focus back on the field (without scrolling to it) if it was inside. True when it closed it.
export function closeIfOutOfView(field, popover) {
if (!popover.matches(':popover-open') || inView(field, popover)) {
return false;
}
const hadFocus = popover.contains(document.activeElement);
popover.hidePopover();
if (hadFocus) {
field.focus({ preventScroll: true });
}
return true;
}