From 7f92f1cb29f42b07e01253a18259e56ee27d7be8 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Sun, 13 Sep 2026 07:53:14 +0200 Subject: [PATCH] Add M3 text fields, selects and selection controls Outlined and filled text fields (input, password, auto-growing textarea, native select with the customizable select menu, file), checkbox with an indeterminate state, radio buttons, the M3 switch and form, ported from ReStride and extended. The first half of Phase 6. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy --- config/livewire-material.php | 15 + .../livewire-material-development/SKILL.md | 39 ++ resources/css/components/field.css | 516 ++++++++++++++++++ resources/css/components/menu.css | 170 ++++++ resources/css/components/selection.css | 344 ++++++++++++ resources/css/material.css | 3 + resources/js/field.js | 83 +++ resources/js/material.js | 1 + resources/views/components/checkbox.blade.php | 62 +++ resources/views/components/field.blade.php | 82 +++ resources/views/components/file.blade.php | 38 ++ resources/views/components/form.blade.php | 26 + resources/views/components/input.blade.php | 79 +++ resources/views/components/password.blade.php | 50 ++ resources/views/components/radio.blade.php | 78 +++ resources/views/components/select.blade.php | 57 ++ resources/views/components/textarea.blade.php | 39 ++ resources/views/components/toggle.blade.php | 56 ++ resources/views/showcase/index.blade.php | 1 + resources/views/showcase/layout.blade.php | 2 +- .../views/showcase/sections/fields.blade.php | 130 +++++ tests/Browser/FieldsTest.php | 185 +++++++ tests/Feature/Components/FieldTest.php | 149 +++++ tests/Feature/Components/SelectionTest.php | 92 ++++ 24 files changed, 2296 insertions(+), 1 deletion(-) create mode 100644 resources/css/components/field.css create mode 100644 resources/css/components/menu.css create mode 100644 resources/css/components/selection.css create mode 100644 resources/js/field.js create mode 100644 resources/views/components/checkbox.blade.php create mode 100644 resources/views/components/field.blade.php create mode 100644 resources/views/components/file.blade.php create mode 100644 resources/views/components/form.blade.php create mode 100644 resources/views/components/input.blade.php create mode 100644 resources/views/components/password.blade.php create mode 100644 resources/views/components/radio.blade.php create mode 100644 resources/views/components/select.blade.php create mode 100644 resources/views/components/textarea.blade.php create mode 100644 resources/views/components/toggle.blade.php create mode 100644 resources/views/showcase/sections/fields.blade.php create mode 100644 tests/Browser/FieldsTest.php create mode 100644 tests/Feature/Components/FieldTest.php create mode 100644 tests/Feature/Components/SelectionTest.php diff --git a/config/livewire-material.php b/config/livewire-material.php index 4ab8ebb3..a2a8b6a5 100644 --- a/config/livewire-material.php +++ b/config/livewire-material.php @@ -34,6 +34,21 @@ return [ 'legacy_keys' => [], ], + /* + |-------------------------------------------------------------------------- + | Fields + |-------------------------------------------------------------------------- + | + | Text fields, selects and pickers come in M3's two styles: 'outlined' (a + | notched outline) and 'filled' (a tinted box with an indicator line). This + | is the style a field takes when its `variant` is not given. + | + */ + + 'fields' => [ + 'variant' => 'outlined', + ], + /* |-------------------------------------------------------------------------- | Node diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index d306758f..49d6c7e5 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -361,6 +361,45 @@ An M3 bottom sheet, bound like ``: modal by default (scrim, inert page, A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). ``: `layout` (`multi-browse` default, `hero`, `uncontained`, `full-screen`), `item-width` (px or any CSS length; the large size multi-browse aims for, the fixed size uncontained keeps, the cap for hero; 186 by default), `height` (205px), `padding` (px at the ends, 0), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). ``: slot is an `` (fills and crops) or an element sized `size-full`; `label` overlays a line of text. A focusable `region` of `slide` groups named "n of m"; arrow keys move one item while the row has focus, Home/End to the ends. Works after a Livewire morph, in RTL and under reduced motion. Give items a `wire:key` in a loop. +### `` + +A one-column grid of fields with an `actions` slot at the foot (the slot takes its own `class`); `separator` draws a divider above the actions. + +```blade + + + + + + + + +``` + +### ``, ``, ``, ``, ``, `` + +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. + +- ``: `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`. +- ``: grows from `rows` (3) to `max-rows`, then scrolls; `:autogrow="false"` for a fixed, hand-resizable one. +- ``: native `, + diff --git a/resources/views/components/toggle.blade.php b/resources/views/components/toggle.blade.php new file mode 100644 index 00000000..f1caaea8 --- /dev/null +++ b/resources/views/components/toggle.blade.php @@ -0,0 +1,56 @@ +{{-- A switch, M3's: a 52×32 track whose handle grows and slides across when it turns on. + + On a real checkbox with `role="switch"`, so it is focusable, announced as on or off, and takes + Space from the keyboard; the handle is drawn beside it. `label` and `hint` sit beside it and are + its name — a switch without `label` needs an `aria-label`. `right` puts it at the end of the + row, where a setting's switch usually is. `icons` draws a check on the handle when on and a + cross when off; `icons="selected"` only the check. Every other attribute reaches the `` + (resources/css/components/selection.css). --}} + +@props([ + 'label' => null, + 'hint' => null, + 'right' => false, + 'icons' => false, +]) + +@php + $model = $attributes->whereStartsWith('wire:model')->first(); + $id = $attributes->get('id') ?? 'switch-'.substr(md5($model.'|'.$label), 0, 12); + $icons = $icons === 'selected' ? 'selected' : ($icons ? 'both' : null); +@endphp + +
only(['class', 'wire:key'])->class(['min-w-0']) }}> + +
diff --git a/resources/views/showcase/index.blade.php b/resources/views/showcase/index.blade.php index d9a48eb4..a5208c45 100644 --- a/resources/views/showcase/index.blade.php +++ b/resources/views/showcase/index.blade.php @@ -18,5 +18,6 @@ @include('livewire-material::showcase.sections.progress') @include('livewire-material::showcase.sections.containment') @include('livewire-material::showcase.sections.carousel') + @include('livewire-material::showcase.sections.fields') @endsection diff --git a/resources/views/showcase/layout.blade.php b/resources/views/showcase/layout.blade.php index 5918a015..d5477b6b 100644 --- a/resources/views/showcase/layout.blade.php +++ b/resources/views/showcase/layout.blade.php @@ -18,7 +18,7 @@ Livewire Material diff --git a/resources/views/showcase/sections/fields.blade.php b/resources/views/showcase/sections/fields.blade.php new file mode 100644 index 00000000..7a4defd9 --- /dev/null +++ b/resources/views/showcase/sections/fields.blade.php @@ -0,0 +1,130 @@ +@php + $examples = [ + 'Outlined and filled text fields' => <<<'BLADE' +
+
+ + + + + + +
+ +
+ + + + + + +
+
+ BLADE, + 'Errors, required and sizes' => <<<'BLADE' +
+
+ + + + +
+ +
+ + + +
+ + +
+
+
+ BLADE, + 'Textarea and select' => <<<'BLADE' +
+
+ + + +
+ +
+ + + +
+
+ BLADE, + 'Checkboxes' => <<<'BLADE' +
+
+ + + +
+
+ + + +
+
+ + +
+
+ BLADE, + 'Radio buttons' => <<<'BLADE' +
+ + +
+ BLADE, + 'Switches' => <<<'BLADE' +
+
+ + +
+
+ + + +
+
+ + + +
+
+ BLADE, + 'A form' => <<<'BLADE' + + + + + + + + + + + + + + BLADE, + ]; +@endphp + +
+

Text fields and selection

+ +

+ <x-input>, <x-password>, <x-textarea>, <x-select>, <x-file> + (all on <x-field>, outlined or filled), <x-checkbox>, <x-radio>, <x-toggle> and <x-form>. +

+ + @foreach ($examples as $title => $code) + + @endforeach +
diff --git a/tests/Browser/FieldsTest.php b/tests/Browser/FieldsTest.php new file mode 100644 index 00000000..6b907ea0 --- /dev/null +++ b/tests/Browser/FieldsTest.php @@ -0,0 +1,185 @@ + */ + public array $files = []; + + public string $audience = 'anyone'; + + public bool $public = false; + + public string $message = ''; + + public function save(): void + { + $this->validate(['name' => 'required']); + } + + public function render(): string + { + return <<<'BLADE' +
+

name: {{ $name }}

+

hours: {{ $hours }}

+

files: {{ implode(',', $files) }}

+

audience: {{ $audience }}

+

public: {{ var_export($public, true) }}

+ + + + + + + @foreach (['a', 'b', 'c'] as $file) + + @endforeach + + + + + + +
+ BLADE; + } +} + +function fieldProbe() +{ + Livewire::component('field-probe', FieldProbe::class); + + Route::middleware('web')->get('/field-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + + @livewireScripts + + + BLADE)); + + return visit('/field-probe')->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); +} + +it('floats the label while the field has focus or holds a value', function () { + $label = "getComputedStyle(document.querySelector('label[for=\"name-field\"]')).fontSize"; + + $page = fieldProbe()->assertScript("{$label} === '16px'"); + + $page->click('#name-field')->assertScript("{$label} === '12px'"); + + $page->type('#name-field', 'Holiday') + ->assertSeeIn('#name', 'Holiday'); + + $page->click('#password-field')->assertScript("{$label} === '12px'"); +}); + +it('clears a field and tells Livewire', function () { + fieldProbe() + ->type('#name-field', 'Holiday') + ->assertSeeIn('#name', 'Holiday') + ->click('[data-field-clear]') + ->assertScript("document.querySelector('#name-field').value === ''") + ->assertScript("document.querySelector('#name').textContent === ''") + ->assertScript("document.activeElement.id === 'name-field'"); +}); + +it('copies a field\'s value and says so', function () { + $page = fieldProbe()->type('#name-field', 'Holiday'); + + $page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")"); + + $page->click('[data-field-copy]') + ->assertScript("window.copied === 'Holiday'") + ->assertSee('Copied to the clipboard'); +}); + +it('shows and hides a password', function () { + fieldProbe() + ->type('#password-field', 'secret') + ->click('[data-field-reveal]') + ->assertScript("document.querySelector('#password-field').type === 'text'") + ->assertAttribute('[data-field-reveal]', 'aria-pressed', 'true') + ->click('[data-field-reveal]') + ->assertScript("document.querySelector('#password-field').type === 'password'"); +}); + +it('shows the server\'s error in place of the hint', function () { + fieldProbe() + ->click('button:has-text("Save")') + ->assertSee('The name field is required.') + ->assertAttribute('#name-field', 'aria-invalid', 'true') + ->assertScript("document.querySelector('#name-field').closest('.field').hasAttribute('data-invalid')"); +}); + +it('binds a select to Livewire', function () { + fieldProbe() + ->select('#hours-field', '1') + ->assertSeeIn('#hours', '1'); +}); + +it('keeps a partly ticked checkbox in step with the server', function () { + $all = "document.querySelector('#all')"; + + $page = fieldProbe()->assertScript("! {$all}.indeterminate"); + + $page->click('label[for="file-a"]') + ->assertSeeIn('#files', 'a') + ->assertScript("{$all}.hasAttribute('data-indeterminate') && {$all}.indeterminate"); + + $page->click('label[for="file-b"]') + ->click('label[for="file-c"]') + ->assertSeeIn('#files', 'a,b,c') + ->assertScript("! {$all}.hasAttribute('data-indeterminate') && ! {$all}.indeterminate"); +}); + +it('moves between radio buttons with the arrow keys', function () { + fieldProbe() + ->keys('input[type="radio"][value="anyone"]', 'ArrowDown') + ->assertSeeIn('#audience', 'team') + ->assertScript("document.querySelector('input[type=\"radio\"][value=\"team\"]').checked"); +}); + +it('turns a switch on from the keyboard and from its label', function () { + $page = fieldProbe() + ->keys('#public', 'Space') + ->assertSeeIn('#public-state', 'true'); + + $page->click('label[for="public"]') + ->assertSeeIn('#public-state', 'false') + ->assertAttribute('#public', 'role', 'switch'); +}); + +it('grows a textarea with its text up to its maximum', function () { + $height = "document.querySelector('#message').getBoundingClientRect().height"; + + $page = fieldProbe(); + + $rows = $page->script("({$height})"); + + $page->type('#message', "one\ntwo\nthree") + ->assertScript("({$height}) > {$rows}"); + + $three = $page->script("({$height})"); + + $page->type('#message', "one\ntwo\nthree\nfour\nfive\nsix\nseven") + ->assertScript("Math.abs(({$height}) - ({$three} + 24)) < 2") + ->assertScript("document.querySelector('#message').scrollHeight > document.querySelector('#message').clientHeight"); +}); diff --git a/tests/Feature/Components/FieldTest.php b/tests/Feature/Components/FieldTest.php new file mode 100644 index 00000000..ed7bdd9a --- /dev/null +++ b/tests/Feature/Components/FieldTest.php @@ -0,0 +1,149 @@ +blade(''); + + expect($html) + ->toContain('data-variant="outlined"') + ->toContain('Share name') + ->toMatch('/