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('/