From 50431181a705f86805d24880024af41eef6e3fdc Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 13:56:44 +0200 Subject: [PATCH 01/18] Rank the material layers above Tailwind's preflight in the Workbench A rewritten component's padding, margin and border sit in material.components, which the Workbench declared below all of Tailwind's layers, so preflight's universal reset beat them. The entry's layer statement now puts material between base and components (plan step 36): the material reset already carries preflight, and every utility still outranks the package. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- tests/Feature/StylesheetsTest.php | 6 +++++- workbench/resources/css/app.css | 9 ++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/Feature/StylesheetsTest.php b/tests/Feature/StylesheetsTest.php index 2d6df214..81cefd1a 100644 --- a/tests/Feature/StylesheetsTest.php +++ b/tests/Feature/StylesheetsTest.php @@ -412,9 +412,13 @@ it('draws the interaction classes as the utilities the Tailwind components still ->and(stylesheetBlock($interaction, '.md-touch-target'))->toContain('min-width: var(--md-sys-measurement-space600);'); }); -it('declares the material layers in the Workbench before Tailwind\'s', function () { +it('declares the material layers in the Workbench between Tailwind\'s preflight and its utilities', function () { $app = stylesheetWithoutComments(File::get(__DIR__.'/../../workbench/resources/css/app.css')); + // The first statement orders the layers: above preflight, whose `* { padding: 0 }` would + // otherwise beat a rewritten component's padding, and below the utilities. + expect(trim($app))->toStartWith('@layer theme, base, material, components, utilities;'); + $foundation = strpos($app, "@import '../../../resources/css/foundation.css';"); $tailwind = strpos($app, "@import 'tailwindcss'"); $components = strpos($app, "@import '../../../resources/css/tailwind.css';"); diff --git a/workbench/resources/css/app.css b/workbench/resources/css/app.css index b0d5c678..9e7d115d 100644 --- a/workbench/resources/css/app.css +++ b/workbench/resources/css/app.css @@ -1,7 +1,10 @@ /* The Workbench builds the package as an application would, with explicit sources so the plans - in docs/ are not scanned for class names. The foundation comes before Tailwind, so the - `material` layers are declared first and sit below Tailwind's; tailwind.css brings the - components still written for Tailwind, without declaring the tokens a second time. */ + in docs/ are not scanned for class names. The layer statement puts the `material` layers above + Tailwind's preflight (`base`) and below its `components` and `utilities`: a rewritten component's + padding, margin and border would otherwise lose to preflight's `* { padding: 0 }`, which the + material reset already carries, while every utility still outranks the package. tailwind.css + brings the components still written for Tailwind, without declaring the tokens a second time. */ +@layer theme, base, material, components, utilities; @import '../../../resources/css/foundation.css'; @import '../../../resources/css/layout.css'; @import '../../../resources/css/components.css'; From a6e5a593d54f18159ece28c02435abba3bcb7c27 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 13:59:32 +0200 Subject: [PATCH 02/18] Draw the form without Tailwind renders data-md-form and data-md-form-actions, and its column and actions row move to components/form.css in material.components, on the spacing tokens (plan step 36). The actions slot's own attributes still land on the row. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components.css | 1 + resources/css/components/form.css | 37 +++++++++++++++++++++++ resources/views/components/form.blade.php | 14 ++++----- tests/Feature/Components/FieldTest.php | 14 +++++++-- 4 files changed, 56 insertions(+), 10 deletions(-) create mode 100644 resources/css/components/form.css diff --git a/resources/css/components.css b/resources/css/components.css index 50ca58df..3a6a7615 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -14,6 +14,7 @@ /* Actions and communication */ /* Inputs, selection and data */ +@import './components/form.css'; /* Containment */ diff --git a/resources/css/components/form.css b/resources/css/components/form.css new file mode 100644 index 00000000..80747a2f --- /dev/null +++ b/resources/css/components/form.css @@ -0,0 +1,37 @@ +/* + * : its fields in one column, and its actions at the foot + * (resources/views/components/form.blade.php). + * + * The column is a one-track grid whose track is `minmax(0, 1fr)`: a bare grid's implicit column + * floors at its content's min-content, so on a phone a wide field would push the form past its + * pane. Rows take their content's height. 16px (`space200`) between fields, because a text field + * floats its label half above its outline and any less lets the hint under one field run into the + * label of the next. + * + * The actions wrap, end-aligned, 8px (`space100`) apart — M3's gap between buttons in a row. A + * caller's class on the `actions` slot lands on the row and outranks this layer. + * + * [data-md-form] the
+ * [data-md-form-actions] the row of actions, under the divider when `separator` asks for one + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +/* TODO(step 36): @import './divider.css' once leaves Tailwind (containment stream). */ + +@layer material.components { + [data-md-form] { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-auto-rows: min-content; + gap: var(--md-sys-measurement-space200); + } + + [data-md-form-actions] { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: var(--md-sys-measurement-space100); + } +} diff --git a/resources/views/components/form.blade.php b/resources/views/components/form.blade.php index abab70cc..46592455 100644 --- a/resources/views/components/form.blade.php +++ b/resources/views/components/form.blade.php @@ -1,17 +1,15 @@ {{-- A form: its fields in one column, and its actions at the foot. - `gap-4` between fields, because a text field floats its label half above its outline: any less - and the hint under one field runs into the label of the next. The `actions` slot takes a class - of its own; `separator` draws a divider above it. - - `grid-cols-1`, because a bare `grid` has no columns and its implicit one floors at the - content's min-content: on a phone a wide field would push the form past its column. --}} + 16px between fields, because a text field floats its label half above its outline: any less + and the hint under one field runs into the label of the next. The `actions` slot takes + attributes of its own (a `class` from the call site lands on the row untouched); `separator` + draws a divider above it. The column is resources/css/components/form.css. --}} @props([ 'separator' => false, ]) -class(['grid grid-cols-1 auto-rows-min gap-4']) }}> + {{ $slot }} @isset($actions) @@ -19,7 +17,7 @@ @endif -
attributes->class(['flex flex-wrap items-center justify-end gap-2']) }}> +
attributes }}> {{ $actions }}
@endisset diff --git a/tests/Feature/Components/FieldTest.php b/tests/Feature/Components/FieldTest.php index 10291775..25d945a3 100644 --- a/tests/Feature/Components/FieldTest.php +++ b/tests/Feature/Components/FieldTest.php @@ -189,10 +189,20 @@ it('lays out a form with its actions under an optional divider', function () { BLADE); expect($html) - ->toContain('') + ->toContain('') ->toContain('role="separator"') - ->toContain('justify-between') + ->toContain('
') + ->not->toContain('grid-cols-1') ->toContain('') ->and((string) $this->blade('')) ->not->toContain('role="separator"'); }); + +it('draws a form\'s column and its actions from its stylesheet', function () { + $css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/form.css'); + + expect($css)->toContain('@layer material.components') + ->toContain('grid-template-columns: minmax(0, 1fr);') + ->toContain('gap: var(--md-sys-measurement-space200);') + ->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/form.css';"); +}); From 0341f9d4cabd11a00ad0909baf58969961581d68 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 14:08:22 +0200 Subject: [PATCH 03/18] Draw the text field's chrome without Tailwind renders data-md-field with its props and parts as data-md-* attributes (box, control, label, outline, support, counter, trailing buttons), and field.css moves into material.components on tokens: px geometry, spacing and state tokens, the 600px breakpoint (plan step 36). Its icons take a size prop, 24/20/16 by the field's size. Every control the field wraps now marks itself data-md-field-control, so the inputs, the pickers, choices, field.js, menu.css's select and listbox rules and timepicker.css follow the renamed hooks. Browser tests cover the error icon, the disabled field's hover, the counter and the width bound from 600px. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 2 +- resources/css/components.css | 1 + resources/css/components/field.css | 317 +++++++++--------- resources/css/components/menu.css | 46 +-- resources/css/components/timepicker.css | 2 +- resources/css/tailwind.css | 1 - resources/js/field.js | 4 +- resources/views/components/choices.blade.php | 10 +- .../views/components/datepicker.blade.php | 30 +- resources/views/components/field.blade.php | 63 ++-- resources/views/components/file.blade.php | 2 +- resources/views/components/input.blade.php | 25 +- resources/views/components/password.blade.php | 12 +- resources/views/components/select.blade.php | 5 +- resources/views/components/textarea.blade.php | 6 +- .../views/components/timepicker.blade.php | 15 +- .../views/showcase/sections/fields.blade.php | 4 +- tests/Browser/DatepickerTest.php | 10 +- tests/Browser/FieldsTest.php | 105 +++++- tests/Feature/Components/ChoicesTest.php | 2 +- tests/Feature/Components/DatepickerTest.php | 12 +- tests/Feature/Components/FieldTest.php | 110 +++--- tests/Feature/Components/TimepickerTest.php | 14 +- tests/Feature/InternalComponentsTest.php | 2 +- 24 files changed, 478 insertions(+), 322 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 56713dd2..99e21c1e 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -629,7 +629,7 @@ M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire - `counter` (on `` and ``) puts M3's character counter at the end of the supporting-text row, beside the hint or the error: `n/max`, counted on every keystroke against the field's own `maxlength`, and in the error colour once the value is past it. It needs `maxlength` — without one there is nothing to count against and nothing is drawn. It is said as "Character count, 5/20" from a polite region a second after typing stops. - ``: native `, diff --git a/resources/views/components/timepicker.blade.php b/resources/views/components/timepicker.blade.php index 81de4e7a..0098650e 100644 --- a/resources/views/components/timepicker.blade.php +++ b/resources/views/components/timepicker.blade.php @@ -135,6 +135,7 @@ $inputAttributes = $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except(['class', 'id', 'wire:key', 'placeholder']); $disabled = (bool) $attributes->get('disabled'); $described = $messages !== [] || filled($hint); + $fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24; @endphp
- + @endif @@ -184,12 +186,13 @@ type="button" tabindex="-1" x-on:click="show()" - class="field-trailing field-button" + data-md-field-trailing + data-md-field-button aria-label="{{ __('Choose time') }}" data-timepicker-open @disabled($disabled) > - + diff --git a/resources/views/showcase/sections/fields.blade.php b/resources/views/showcase/sections/fields.blade.php index 717cb59e..de789876 100644 --- a/resources/views/showcase/sections/fields.blade.php +++ b/resources/views/showcase/sections/fields.blade.php @@ -26,13 +26,13 @@
- +
- +
diff --git a/tests/Browser/DatepickerTest.php b/tests/Browser/DatepickerTest.php index aab70af2..79ad1712 100644 --- a/tests/Browser/DatepickerTest.php +++ b/tests/Browser/DatepickerTest.php @@ -183,7 +183,7 @@ it('opens the docked picker under its field and walks the grid from the keyboard ->click('[aria-controls="expires-field-picker"][data-datepicker-toggle]') ->assertScript("{$picker}.matches(':popover-open') && {$picker}.dataset.presentation === 'docked'") ->assertAttribute('#expires-field', 'aria-expanded', 'true') - ->assertScript("(() => { const field = document.querySelector('#expires-field').closest('.field-box').getBoundingClientRect(); const box = {$picker}.getBoundingClientRect(); return Math.abs(box.top - field.bottom - 4) < 2 && Math.abs(box.left - field.left) < 2; })()") + ->assertScript("(() => { const field = document.querySelector('#expires-field').closest('[data-md-field-box]').getBoundingClientRect(); const box = {$picker}.getBoundingClientRect(); return Math.abs(box.top - field.bottom - 4) < 2 && Math.abs(box.left - field.left) < 2; })()") ->assertScript(focusedDay('2026-09-13')); $page->keys(':focus', 'ArrowRight')->assertScript(focusedDay('2026-09-14')); @@ -395,15 +395,15 @@ it('opens a docked picker as a modal one on a compact window', function () { it('empties a date, or both ends of a range, with its clear button', function () { $page = dateProbe() - ->assertScript("getComputedStyle(document.querySelector('#expires-field').closest('.field').querySelector('[data-field-clear]')).display !== 'none'"); + ->assertScript("getComputedStyle(document.querySelector('#expires-field').closest('[data-md-field]').querySelector('[data-md-field-clear]')).display !== 'none'"); - $page->click('[data-datepicker]:has(#expires-field) [data-field-clear]') + $page->click('[data-datepicker]:has(#expires-field) [data-md-field-clear]') ->assertScript("document.querySelector('#expires').textContent === ''") ->assertValue('#expires-field', '') ->assertScript("document.activeElement.id === 'expires-field'") - ->assertScript("getComputedStyle(document.querySelector('[data-datepicker]:has(#expires-field) [data-field-clear]')).display === 'none'"); + ->assertScript("getComputedStyle(document.querySelector('[data-datepicker]:has(#expires-field) [data-md-field-clear]')).display === 'none'"); - $page->click('[data-datepicker]:has(#trip-field) [data-field-clear]') + $page->click('[data-datepicker]:has(#trip-field) [data-md-field-clear]') ->assertSeeIn('#trip', '{"start":null,"end":null}') ->assertValue('#trip-field', ''); }); diff --git a/tests/Browser/FieldsTest.php b/tests/Browser/FieldsTest.php index b3e6fe50..6532f1f6 100644 --- a/tests/Browser/FieldsTest.php +++ b/tests/Browser/FieldsTest.php @@ -47,6 +47,9 @@ class FieldProbe extends Component + + +
@@ -95,7 +98,7 @@ it('clears a field and tells Livewire', function () { fieldProbe() ->type('#name-field', 'Holiday') ->assertSeeIn('#name', 'Holiday') - ->click('[data-field-clear]') + ->click('[data-md-field-clear]') ->assertScript("document.querySelector('#name-field').value === ''") ->assertScript("document.querySelector('#name').textContent === ''") ->assertScript("document.activeElement.id === 'name-field'"); @@ -106,7 +109,7 @@ it('copies a field\'s value and says so', function () { $page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")"); - $page->click('[data-field-copy]') + $page->click('[data-md-field-copy]') ->assertScript("window.copied === 'Holiday'") ->assertSee('Copied to the clipboard'); }); @@ -114,10 +117,10 @@ it('copies a field\'s value and says so', function () { it('shows and hides a password', function () { fieldProbe() ->type('#password-field', 'secret') - ->click('[data-field-reveal]') + ->click('[data-md-field-reveal]') ->assertScript("document.querySelector('#password-field').type === 'text'") - ->assertAttribute('[data-field-reveal]', 'aria-label', 'Hide password') - ->click('[data-field-reveal]') + ->assertAttribute('[data-md-field-reveal]', 'aria-label', 'Hide password') + ->click('[data-md-field-reveal]') ->assertScript("document.querySelector('#password-field').type === 'password'"); }); @@ -126,7 +129,7 @@ it('shows the server\'s error in place of the hint', function () { ->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')"); + ->assertScript("document.querySelector('#name-field').closest('[data-md-field]').hasAttribute('data-md-invalid')"); }); it('binds a select to Livewire', function () { @@ -183,3 +186,93 @@ it('grows a textarea with its text up to its maximum', function () { ->assertScript("Math.abs(({$height}) - ({$three} + 24)) < 2") ->assertScript("document.querySelector('#message').scrollHeight > document.querySelector('#message').clientHeight"); }); + +it('pairs the server\'s error with a trailing error icon in the error colour', function () { + $icon = "document.querySelector('[data-md-field]:has(#plain-name-field) [data-md-field-error]')"; + + fieldProbe() + ->assertScript("{$icon} === null") + ->click('button:has-text("Save")') + ->assertSee('The name field is required.') + // A field that trails with buttons of its own gives the icon's place to them. + ->assertScript("document.querySelector('[data-md-field]:has(#name-field)').hasAttribute('data-md-invalid') && document.querySelector('[data-md-field]:has(#name-field) [data-md-field-error]') === null") + ->assertScript("{$icon}.getAttribute('aria-label') === 'Error' && {$icon}.getAttribute('role') === 'img'") + ->assertScript("{$icon}.getBoundingClientRect().width === 24") + ->assertScript("getComputedStyle({$icon}).color === getComputedStyle(document.querySelector('[data-md-field]:has(#plain-name-field) [data-md-field-support]')).color"); +}); + +it('lights an enabled field\'s outline on hover, and never a disabled one\'s', function () { + $edge = fn (string $id): string => "getComputedStyle(document.querySelector('[data-md-field]:has(#{$id}) [data-md-field-outline]')).borderTopColor"; + + $page = fieldProbe(); + + $resting = $page->script("({$edge('name-field')})"); + $disabled = $page->script("({$edge('off-field')})"); + + $page->hover('[data-md-field]:has(#name-field) [data-md-field-box]') + ->assertScript("({$edge('name-field')}) !== '{$resting}'"); + + $page->hover('[data-md-field]:has(#off-field) [data-md-field-box]') + ->assertScript("({$edge('off-field')}) === '{$disabled}'") + // The disabled edge is on-surface at 12%, which is translucent. + ->assertScript("/rgba?\\(.*,\\s*0?\\.\\d+\\)|color\\(srgb .* \\/ 0?\\.\\d+\\)/.test('{$disabled}')"); +}); + +it('counts the characters as they are typed, marks a count past the maximum and says it once typing stops', function () { + $counter = "document.querySelector('[data-md-field]:has(#bio-field) [data-md-field-counter]')"; + + $page = fieldProbe() + ->assertScript("{$counter}.querySelector('[aria-hidden]').textContent === '0/10'") + ->type('#bio-field', 'Hello') + ->assertScript("{$counter}.querySelector('[aria-hidden]').textContent === '5/10'") + ->assertScript("! {$counter}.hasAttribute('data-md-over')"); + + $within = $page->script("getComputedStyle({$counter}).color"); + + // maxlength stops typing at the maximum, but a value put in from elsewhere can pass it. + $page->script("(() => { const input = document.querySelector('#bio-field'); input.value = 'Hello there!'; input.dispatchEvent(new Event('input', { bubbles: true })); })()"); + + $page->assertScript("{$counter}.querySelector('[aria-hidden]').textContent === '12/10'") + ->assertScript("{$counter}.hasAttribute('data-md-over')") + ->assertScript("getComputedStyle({$counter}).color !== '{$within}'") + ->assertScript("{$counter}.querySelector('[aria-live]').textContent === ''") + ->wait(1.3) + ->assertScript("{$counter}.querySelector('[aria-live=\\'polite\\']').textContent === 'Character count, 12/10'"); +}); + +function fieldWidthProbe(int $width) +{ + Route::middleware('web')->get('/field-width-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + + + +
+ + + +
+ + + BLADE)); + + return visit('/field-width-probe')->resize($width, 800)->waitForEvent('networkidle'); +} + +it('bounds a field to 40rem from 600px, unless the caller\'s width rule or full says otherwise', function () { + $width = fn (string $id): string => "document.querySelector('[data-md-field]:has(#{$id})').getBoundingClientRect().width"; + + fieldWidthProbe(599) + ->assertScript("({$width('bounded')}) === 1400") + ->assertScript("({$width('narrow')}) === 200") + ->assertScript("({$width('full')}) === 1400"); + + fieldWidthProbe(600) + ->assertScript("({$width('bounded')}) === 640") + ->assertScript("({$width('narrow')}) === 200") + ->assertScript("({$width('full')}) === 1400"); +}); diff --git a/tests/Feature/Components/ChoicesTest.php b/tests/Feature/Components/ChoicesTest.php index 310832c5..d2234a4e 100644 --- a/tests/Feature/Components/ChoicesTest.php +++ b/tests/Feature/Components/ChoicesTest.php @@ -51,7 +51,7 @@ it('draws a searchable combobox with a popover list', function () { ->toContain('popover="manual"') ->toMatch('/anchor-name: (--material-choices-[a-z0-9]{10})/') ->toContain('x-modelable="value"') - ->toContain('field-arrow') + ->toContain('data-md-field-arrow') // WAI-ARIA's combobox keyboard, Home and End included. ->toContain('x-on:keydown.home="jump($event, false)"') ->toContain('x-on:keydown.end="jump($event, true)"') diff --git a/tests/Feature/Components/DatepickerTest.php b/tests/Feature/Components/DatepickerTest.php index 70dbec43..34d663a3 100644 --- a/tests/Feature/Components/DatepickerTest.php +++ b/tests/Feature/Components/DatepickerTest.php @@ -21,7 +21,7 @@ it('draws a docked date field whose combobox controls a popover picker', functio expect($html) ->toContain('data-datepicker') - ->toContain('') + ->toContain('') ->toContain('role="combobox"') ->toContain('aria-haspopup="dialog"') ->toContain('aria-controls="expires-picker"') @@ -155,8 +155,8 @@ it('puts class on the root and every other attribute on the text field', functio expect($html) ->toContain('class="w-60"') - ->toContain('data-variant="filled"') - ->toContain('data-size="sm"') + ->toContain('data-md-variant="filled"') + ->toContain('data-md-size="sm"') ->toMatch('/]*required[^>]*id="due"/s') ->toMatch('/]*disabled[^>]*id="due"/s') ->and(datepickerConfig($html)['disabled'])->toBeTrue(); @@ -225,14 +225,14 @@ it('replaces the hint with the errors for the property and its start and end', f ->not->toContain('Pick a day') ->toContain('aria-invalid="true"') ->toContain('data-datepicker-error') - ->and(substr_count($html, 'data-invalid=""'))->toBe(2); + ->and(substr_count($html, 'data-md-invalid=""'))->toBe(2); }); it('offers a clear button on request', function () { expect((string) $this->blade('')) - ->toContain('data-field-clear') + ->toContain('data-md-field-clear') ->toContain('x-on:click="clear()"') ->toContain('aria-label="Clear"') ->and((string) $this->blade('')) - ->not->toContain('data-field-clear'); + ->not->toContain('data-md-field-clear'); }); diff --git a/tests/Feature/Components/FieldTest.php b/tests/Feature/Components/FieldTest.php index 25d945a3..1997bc12 100644 --- a/tests/Feature/Components/FieldTest.php +++ b/tests/Feature/Components/FieldTest.php @@ -4,31 +4,33 @@ it('draws an outlined field with a notched label unless told otherwise', functio $html = (string) $this->blade(''); expect($html) - ->toContain('data-variant="outlined"') + ->toContain('data-md-field') + ->toContain('data-md-variant="outlined"') ->toContain('Share name') - ->toMatch('/