From 648a4cfe0f9232f8b89708b3b5fba58dabe0b866 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Sun, 13 Sep 2026 10:41:37 +0200 Subject: [PATCH] Show a plain form field's errors under its name Fields read their errors only under the wire:model name, so a Fortify login form (name="email", no wire:model) never showed "These credentials do not match". Without wire:model they now read the name, turning brackets into dots. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy --- .../livewire-material-development/SKILL.md | 2 +- resources/views/components/checkbox.blade.php | 4 +++- resources/views/components/choices.blade.php | 6 +++-- .../views/components/datepicker.blade.php | 6 +++-- resources/views/components/file.blade.php | 6 +++-- resources/views/components/group.blade.php | 4 +++- resources/views/components/input.blade.php | 4 +++- resources/views/components/password.blade.php | 4 +++- resources/views/components/radio.blade.php | 4 +++- resources/views/components/select.blade.php | 4 +++- resources/views/components/textarea.blade.php | 4 +++- .../views/components/timepicker.blade.php | 4 +++- .../Components/PlainFormErrorsTest.php | 24 +++++++++++++++++++ 13 files changed, 61 insertions(+), 15 deletions(-) create mode 100644 tests/Feature/Components/PlainFormErrorsTest.php diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 60682208..60f44f28 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -467,7 +467,7 @@ A one-column grid of fields with an `actions` slot at the foot (the slot takes i ### ``, ``, ``, ``, ``, `` -M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant`, and read their errors from the bag under the `wire:model` name (the error replaces the hint, sets `aria-invalid`). `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus. +M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant`, and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]` → `photos`, `address[city]` → `address.city`); the error replaces the hint and sets `aria-invalid`. `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus. - ``: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`. - ``: a reveal button; `icon`, `size`. diff --git a/resources/views/components/checkbox.blade.php b/resources/views/components/checkbox.blade.php index 5cfd818a..6b09cfe7 100644 --- a/resources/views/components/checkbox.blade.php +++ b/resources/views/components/checkbox.blade.php @@ -18,7 +18,9 @@ @php $model = $attributes->whereStartsWith('wire:model')->first(); $id = $attributes->get('id') ?? 'check-'.substr(md5($model.'|'.$label.'|'.$attributes->get('value')), 0, 12); - $messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : []; + // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`). + $errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null); + $messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : []; @endphp
only(['class', 'wire:key'])->class(['min-w-0']) }}> diff --git a/resources/views/components/choices.blade.php b/resources/views/components/choices.blade.php index 9c695d16..b485af4f 100644 --- a/resources/views/components/choices.blade.php +++ b/resources/views/components/choices.blade.php @@ -35,8 +35,10 @@ @php $model = $attributes->wire('model')->value() ?: null; - $messages = $model !== null && isset($errors) - ? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')]))) + // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`). + $errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null); + $messages = $errorKey !== null && isset($errors) + ? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')]))) : []; $choices = collect($options)->map(fn ($option): array => [ 'value' => data_get($option, $optionValue), diff --git a/resources/views/components/datepicker.blade.php b/resources/views/components/datepicker.blade.php index d13a5e39..b8985a7a 100644 --- a/resources/views/components/datepicker.blade.php +++ b/resources/views/components/datepicker.blade.php @@ -57,8 +57,10 @@ $mode = in_array($mode, ['docked', 'modal', 'input'], true) ? $mode : 'docked'; $id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|datepicker'), 0, 12); $anchor = '--material-datepicker-'.preg_replace('/[^A-Za-z0-9_-]/', '-', $id); - $messages = $model !== null && isset($errors) - ? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')]))) + // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`). + $errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null); + $messages = $errorKey !== null && isset($errors) + ? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')]))) : []; $locale = str_replace('_', '-', app()->getLocale()); diff --git a/resources/views/components/file.blade.php b/resources/views/components/file.blade.php index ca7803fe..f6aa60fb 100644 --- a/resources/views/components/file.blade.php +++ b/resources/views/components/file.blade.php @@ -21,8 +21,10 @@ @php $model = $attributes->whereStartsWith('wire:model')->first(); $id = $attributes->get('id') ?? 'field-'.substr(md5('file|'.$model.'|'.$label), 0, 12); - $messages = $model !== null && isset($errors) - ? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')]))) + // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`). + $errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null); + $messages = $errorKey !== null && isset($errors) + ? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')]))) : []; @endphp diff --git a/resources/views/components/group.blade.php b/resources/views/components/group.blade.php index d03eb74f..11d2848f 100644 --- a/resources/views/components/group.blade.php +++ b/resources/views/components/group.blade.php @@ -33,7 +33,9 @@ @php $model = $attributes->whereStartsWith('wire:model')->first(); $name ??= $model; - $messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : []; + // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`). + $errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null); + $messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : []; $size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm'; $segment = [ diff --git a/resources/views/components/input.blade.php b/resources/views/components/input.blade.php index 9f8c4a42..9cf9ed51 100644 --- a/resources/views/components/input.blade.php +++ b/resources/views/components/input.blade.php @@ -30,7 +30,9 @@ $model = $attributes->whereStartsWith('wire:model')->first(); $placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' '; $id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|'.$placeholder), 0, 12); - $messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : []; + // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`). + $errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null); + $messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : []; @endphp diff --git a/resources/views/components/password.blade.php b/resources/views/components/password.blade.php index f21fec62..b3fc66ac 100644 --- a/resources/views/components/password.blade.php +++ b/resources/views/components/password.blade.php @@ -17,7 +17,9 @@ @php $model = $attributes->whereStartsWith('wire:model')->first(); $id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|password'), 0, 12); - $messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : []; + // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`). + $errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null); + $messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : []; @endphp diff --git a/resources/views/components/radio.blade.php b/resources/views/components/radio.blade.php index fe72372f..0b7a6652 100644 --- a/resources/views/components/radio.blade.php +++ b/resources/views/components/radio.blade.php @@ -23,7 +23,9 @@ $model = $attributes->whereStartsWith('wire:model')->first(); $name = $attributes->get('name') ?? $model ?? 'radio-'.substr(md5($label.'|'.json_encode($options)), 0, 12); $id = 'radio-'.substr(md5($name.'|'.$label), 0, 12); - $messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : []; + // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`). + $errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null); + $messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : []; @endphp
whereStartsWith('wire:model')->first(); $id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|select'), 0, 12); - $messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : []; + // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`). + $errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null); + $messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : []; @endphp diff --git a/resources/views/components/textarea.blade.php b/resources/views/components/textarea.blade.php index 4225ff6b..2789afcc 100644 --- a/resources/views/components/textarea.blade.php +++ b/resources/views/components/textarea.blade.php @@ -19,7 +19,9 @@ $model = $attributes->whereStartsWith('wire:model')->first(); $placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' '; $id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|textarea'), 0, 12); - $messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : []; + // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`). + $errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null); + $messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : []; @endphp diff --git a/resources/views/components/timepicker.blade.php b/resources/views/components/timepicker.blade.php index e3765e9a..14fa9b11 100644 --- a/resources/views/components/timepicker.blade.php +++ b/resources/views/components/timepicker.blade.php @@ -58,7 +58,9 @@ @php $model = $attributes->wire('model')->value() ?: null; - $messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : []; + // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`). + $errorKey = $model ?? (filled($name) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $name) : null); + $messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : []; $id = $attributes->get('id') ?? 'timepicker-'.substr(md5($model.'|'.$label.'|'.$name.'|timepicker'), 0, 12); $cycle = in_array((string) $format, ['12', '24'], true) ? (int) $format : null; diff --git a/tests/Feature/Components/PlainFormErrorsTest.php b/tests/Feature/Components/PlainFormErrorsTest.php new file mode 100644 index 00000000..2eae880e --- /dev/null +++ b/tests/Feature/Components/PlainFormErrorsTest.php @@ -0,0 +1,24 @@ +withViewErrors([$key => ['Something is wrong here.']])->blade($blade); + + expect($html) + ->toContain('Something is wrong here.') + ->toContain('aria-invalid="true"'); +})->with([ + 'input' => ['', 'email'], + 'password' => ['', 'password'], + 'textarea' => ['', 'message'], + 'select' => ['', 'hours'], + 'checkbox' => ['', 'terms'], + 'radio' => ['', 'audience'], + 'file with brackets' => ['', 'photos'], + 'nested name' => ['', 'address.city'], + 'timepicker' => ['', 'starts_at'], +]); + +it('keeps reading the wire:model name when both are there', function () { + expect((string) $this->withViewErrors(['form.email' => ['Taken.']])->blade('')) + ->toContain('Taken.'); +});