diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 85243b34..e449690f 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -550,7 +550,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 all but `` a `hint-class`, classes added to the hint: `hint-class="text-warning"`), 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. +M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant` (and all but `` a `hint-class`, classes added to the hint: `hint-class="text-warning"`), 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, sets `aria-invalid`, and puts an `error` icon at the end of the row as M3's second indicator (not on `size="xs"`, and not when the field already trails something — `icon-right`, `clearable`, `copyable`). `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/css/components/field.css b/resources/css/components/field.css index 51be79ad..fce268eb 100644 --- a/resources/css/components/field.css +++ b/resources/css/components/field.css @@ -238,6 +238,11 @@ color: var(--md-sys-color-on-surface-variant); } + /* The error's second indicator: M3 asks that the colour change never stand alone. */ + .field-error { + color: var(--md-sys-color-error); + } + .field-icon, .field-arrow { pointer-events: none; @@ -421,11 +426,11 @@ --field-ink: var(--md-sys-color-error); } - /* Dashed for a field the caller made read-only — marked on the field, not read off the control, - because a date picker makes its own input read-only too. */ - .field[data-readonly] .field-outline { - border-style: dashed; - } + /* `data-readonly` marks a field the caller made read-only — on the field, not read off the + control, because a date picker makes its own input read-only too. Nothing here draws it + differently: M3 says a read-only field keeps "the same visual style as an editable field, but + clearly labeled read-only", and the native `readonly` attribute is what carries that. The hook + stays for an application that wants a mark of its own. */ .field:has(.field-control:disabled) { --field-edge: color-mix(in srgb, var(--md-sys-color-on-surface) 12%, transparent); diff --git a/resources/views/components/field.blade.php b/resources/views/components/field.blade.php index bc779eeb..bbe60a34 100644 --- a/resources/views/components/field.blade.php +++ b/resources/views/components/field.blade.php @@ -10,7 +10,9 @@ box with an indicator line); without it, `config('livewire-material.fields.variant')`. A field with a label is a form field, M3's 56px. A field without one is a control in a toolbar or an editor row, and is given `size="sm"` (40px, a button's height) or `size="xs"` (32px). The - error replaces the hint rather than stacking under it, as M3 has it. + error replaces the hint rather than stacking under it, as M3 has it, and an `error` icon joins it + at the end of the row so the state has two indicators and not only a colour — unless the caller + trails the field with something of its own, or the field is `xs` and has no room. `class` from the call site lands on the outermost element, never the control, so a margin or a width is safe here. --}} @@ -34,12 +36,16 @@ $variant = in_array($variant, ['outlined', 'filled'], true) ? $variant : (config('livewire-material.fields.variant') === 'filled' ? 'filled' : 'outlined'); + $density = in_array($size, ['sm', 'xs'], true) ? $size : 'md'; + // M3 pairs the error colours with a trailing error icon so the state has two indicators. It + // gives way to whatever the caller trails the field with, and to `xs`, which has no room. + $errorIcon = $messages !== [] && ! isset($trailing) && $density !== 'xs'; @endphp
class(['field']) }} data-variant="{{ $variant }}" - data-size="{{ in_array($size, ['sm', 'xs'], true) ? $size : 'md' }}" + data-size="{{ $density }}" @if ($messages !== []) data-invalid @endif @if ($floated) data-floated @endif @if ($mono) data-mono @endif @@ -61,6 +67,10 @@ {{ $trailing ?? '' }} + @if ($errorIcon) + + @endif + diff --git a/tests/Feature/Components/FieldTest.php b/tests/Feature/Components/FieldTest.php index ed7bdd9a..6f98206d 100644 --- a/tests/Feature/Components/FieldTest.php +++ b/tests/Feature/Components/FieldTest.php @@ -45,6 +45,20 @@ it('replaces the hint with the errors for its model', function () { ->not->toContain('We never share it'); }); +it('pairs the error colours with a trailing error icon', function () { + $errors = ['email' => ['Enter a valid email address.']]; + + expect((string) $this->withViewErrors($errors)->blade('')) + ->toContain('field-trailing field-error') + ->toContain('aria-label="Error"') + ->and((string) $this->withViewErrors($errors)->blade('')) + ->not->toContain('field-error') + ->and((string) $this->withViewErrors($errors)->blade('')) + ->not->toContain('field-error') + ->and((string) $this->blade('')) + ->not->toContain('field-error'); +}); + it('puts icons, affixes and the size on the field, and every other attribute on the input', function () { $html = (string) $this->blade('');