Land a caller's style with its class on field and picker roots

Plan step 38 review: the showcase kept two unlayered width classes
because <x-input> and <x-datepicker> "don't forward style usefully".
They did not: the Phase F rule is that a caller's class and style land
on the component root, and the field family sent class to the field's
root but style to the inner control, where a width sizes nothing.
Input, password, textarea, select and file now pass style to the field
root with class; datepicker and timepicker put it on their root;
<x-group> dropped style entirely and <x-split-button> gave it to the
leading button; <x-search> rendered it twice, on the root and the
input. A textarea given a style also lost its autogrow rows, since
its own style attribute came second and the browser ignores it.
A dataset test renders each component with both and requires them on
the root, once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-15 06:14:51 +02:00
co-authored by Claude Opus 5
parent 3fb0a404db
commit cd47aa4b0f
12 changed files with 89 additions and 37 deletions
@@ -28,8 +28,9 @@
`min` and `max` (`Y-m-d` or a date object) disable the days outside them and keep the `min` and `max` (`Y-m-d` or a date object) disable the days outside them and keep the
keyboard inside them. `label`, `hint`, `icon`, `variant` (`outlined`, `filled`) and `size` keyboard inside them. `label`, `hint`, `icon`, `variant` (`outlined`, `filled`) and `size`
are the field's; `clearable` adds a button that empties it (a date, or both ends of a range) are the field's; `clearable` adds a button that empties it (a date, or both ends of a range)
once it holds one; `name` adds hidden inputs carrying `Y-m-d` for a plain form post, and every once it holds one; `name` adds hidden inputs carrying `Y-m-d` for a plain form post. `class` and
other attribute (`required`, `disabled`, `readonly`) reaches the text field. Errors under `style` land on the root; every other attribute (`required`, `disabled`, `readonly`) reaches
the text field. Errors under
the `wire:model` name replace the hint, and so does a typed date that cannot be read. the `wire:model` name replace the hint, and so does a typed date that cannot be read.
Month and weekday names, the first day of the week and the typed format come from `Intl` for Month and weekday names, the first day of the week and the typed format come from `Intl` for
@@ -150,7 +151,7 @@
@endphp @endphp
<div <div
{{ $attributes->only(['class', 'wire:key', 'x-model']) }} {{ $attributes->only(['class', 'style', 'wire:key', 'x-model']) }}
x-data="materialDatepicker({ x-data="materialDatepicker({
@if ($model !== null) value: @entangle($attributes->wire('model')), @else value: @js($current), @endif @if ($model !== null) value: @entangle($attributes->wire('model')), @else value: @js($current), @endif
...@js($config), ...@js($config),
@@ -172,7 +173,7 @@
x-bind:data-md-invalid="(fieldError !== '' || {{ $invalid ? 'true' : 'false' }}) ? '' : null" x-bind:data-md-invalid="(fieldError !== '' || {{ $invalid ? 'true' : 'false' }}) ? '' : null"
> >
<input <input
{{ $attributes->whereDoesntStartWith('wire:model')->except(['class', 'id', 'wire:key', 'x-model', 'name', 'value', 'placeholder', 'type']) }} {{ $attributes->whereDoesntStartWith('wire:model')->except(['class', 'style', 'id', 'wire:key', 'x-model', 'name', 'value', 'placeholder', 'type']) }}
x-ref="input" x-ref="input"
id="{{ $id }}" id="{{ $id }}"
type="text" type="text"
+2 -2
View File
@@ -27,8 +27,8 @@
span the full width of a large screen (§ Text Fields, Behaviour); a width rule from the call site span the full width of a large screen (§ Text Fields, Behaviour); a width rule from the call site
beats that, and `full` takes it off altogether. beats that, and `full` takes it off altogether.
`class` from the call site lands on the outermost element, never the control, so a margin or `class` and `style` from the call site land on the outermost element, never the control, so a
a width is safe here; `hint-class` lands on the hint. --}} margin or a width is safe here; `hint-class` lands on the hint. --}}
@props([ @props([
'id', 'id',
+2 -2
View File
@@ -29,9 +29,9 @@
: []; : [];
@endphp @endphp
<x-livewire-material::field :$id :$label :$hint :$messages :$variant floated :class="$attributes->get('class')" data-md-file> <x-livewire-material::field :$id :$label :$hint :$messages :$variant floated :class="$attributes->get('class')" :style="$attributes->get('style')" data-md-file>
<input <input
{{ $attributes->except(['class', 'id', 'type']) }} {{ $attributes->except(['class', 'style', 'id', 'type']) }}
type="file" type="file"
id="{{ $id }}" id="{{ $id }}"
@if ($messages !== []) aria-invalid="true" @endif @if ($messages !== []) aria-invalid="true" @endif
+1 -1
View File
@@ -63,7 +63,7 @@
// Below `medium` a segment draws under 48px, so it needs the foundation's touch target too. // Below `medium` a segment draws under 48px, so it needs the foundation's touch target too.
$segmentNeedsTouchTarget = in_array($size, ['xs', 'sm'], true); $segmentNeedsTouchTarget = in_array($size, ['xs', 'sm'], true);
$root = $attributes->only(['class', 'wire:key'])->merge(array_filter([ $root = $attributes->only(['class', 'style', 'wire:key'])->merge(array_filter([
'data-md-group' => true, 'data-md-group' => true,
'data-md-size' => $size, 'data-md-size' => $size,
'data-md-variant' => $variant, 'data-md-variant' => $variant,
+5 -4
View File
@@ -6,8 +6,9 @@
field once it holds something; `copyable` one that copies its value and says so in a snackbar field once it holds something; `copyable` one that copies its value and says so in a snackbar
(a share link, a token). `counter` counts the characters used against `maxlength`, at the end of (a share link, a token). `counter` counts the characters used against `maxlength`, at the end of
the supporting-text row; `full` lets the field span its pane past the 40rem M3 bounds it to the supporting-text row; `full` lets the field span its pane past the 40rem M3 bounds it to
from `medium`. Every other attribute reaches the `<input>`: `type`, `min`, `step`, from `medium`. `class` and `style` land on the field's root, which is what a width or a margin
`readonly`, `wire:model` and the rest. Errors are read from the bag under the `wire:model` name. sizes; every other attribute reaches the `<input>`: `type`, `min`, `step`, `readonly`,
`wire:model` and the rest. Errors are read from the bag under the `wire:model` name.
The placeholder is a single space when none is given, because the label can only tell an The placeholder is a single space when none is given, because the label can only tell an
empty field from a filled one through `:placeholder-shown`. With a label, a real placeholder empty field from a filled one through `:placeholder-shown`. With a label, a real placeholder
@@ -45,9 +46,9 @@
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24; $fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
@endphp @endphp
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :counter="$max" :$full :class="$attributes->get('class')" :data-md-readonly="$attributes->get('readonly') ? '' : null" data-md-input> <x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :counter="$max" :$full :class="$attributes->get('class')" :style="$attributes->get('style')" :data-md-readonly="$attributes->get('readonly') ? '' : null" data-md-input>
<input <input
{{ $attributes->except(['class', 'id', 'placeholder'])->merge(['type' => 'text']) }} {{ $attributes->except(['class', 'style', 'id', 'placeholder'])->merge(['type' => 'text']) }}
id="{{ $id }}" id="{{ $id }}"
placeholder="{{ $placeholder }}" placeholder="{{ $placeholder }}"
@if ($messages !== []) aria-invalid="true" @endif @if ($messages !== []) aria-invalid="true" @endif
@@ -4,7 +4,8 @@
password manager still sees a password input on load. Its label says what pressing it will do password manager still sees a password input on load. Its label says what pressing it will do
M3's own pattern for an interactive trailing icon ("Show password" / "Hide password") — and it M3's own pattern for an interactive trailing icon ("Show password" / "Hide password") — and it
carries no `aria-pressed`, which would say the state a second time and the other way round. carries no `aria-pressed`, which would say the state a second time and the other way round.
`label`, `hint`, `icon`, `variant`, `size`; every other attribute reaches the `<input>` `label`, `hint`, `icon`, `variant`, `size`; `class` and `style` land on the field's root, every
other attribute reaches the `<input>`
(`autocomplete="current-password"`, `wire:model`). The field's root carries (`autocomplete="current-password"`, `wire:model`). The field's root carries
`data-md-password`; the chrome and the eye's icon button are the field's `data-md-password`; the chrome and the eye's icon button are the field's
(resources/css/components/field.css, brought by resources/css/components/password.css). --}} (resources/css/components/field.css, brought by resources/css/components/password.css). --}}
@@ -27,9 +28,9 @@
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24; $fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
@endphp @endphp
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant :class="$attributes->get('class')" x-data="{ shown: false }" data-md-password> <x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant :class="$attributes->get('class')" :style="$attributes->get('style')" x-data="{ shown: false }" data-md-password>
<input <input
{{ $attributes->except(['class', 'id', 'type', 'placeholder']) }} {{ $attributes->except(['class', 'style', 'id', 'type', 'placeholder']) }}
id="{{ $id }}" id="{{ $id }}"
type="password" type="password"
x-bind:type="shown ? 'text' : 'password'" x-bind:type="shown ? 'text' : 'password'"
+2 -2
View File
@@ -29,7 +29,7 @@
The root renders `data-md-search` with `data-md-trigger`, and `data-md-open` and The root renders `data-md-search` with `data-md-trigger`, and `data-md-open` and
`data-md-full-screen` while they hold; the parts are `data-md-search-*`, drawn by `data-md-full-screen` while they hold; the parts are `data-md-search-*`, drawn by
resources/css/components/search.css. `class` and `wire:key` land on the root. --}} resources/css/components/search.css. `class`, `style` and `wire:key` land on the root. --}}
@props([ @props([
'placeholder' => null, 'placeholder' => null,
@@ -103,7 +103,7 @@
x-bind:aria-expanded="open.toString()" x-bind:aria-expanded="open.toString()"
> >
<input <input
{{ $attributes->except(['class', 'wire:key', 'id', 'placeholder', 'type']) }} {{ $attributes->except(['class', 'style', 'wire:key', 'id', 'placeholder', 'type']) }}
x-ref="input" x-ref="input"
id="{{ $id }}" id="{{ $id }}"
type="search" type="search"
+4 -3
View File
@@ -10,7 +10,8 @@
`options` as a list of `['id' => …, 'name' => …]` (`'disabled' => true` greys one out), `options` as a list of `['id' => …, 'name' => …]` (`'disabled' => true` greys one out),
`option-value` and `option-label` to read other keys, and a `placeholder` that becomes the `option-value` and `option-label` to read other keys, and a `placeholder` that becomes the
first option, valued `placeholder-value` (empty by default). Or pass `<option>`s in the slot. first option, valued `placeholder-value` (empty by default). Or pass `<option>`s in the slot.
`label`, `hint`, `icon`, `variant`, `size`. The field's root carries `data-md-select`; what a `label`, `hint`, `icon`, `variant`, `size`; `class` and `style` land on the field's root, every
other attribute on the `<select>`. The field's root carries `data-md-select`; what a
select changes in the field's chrome is resources/css/components/select.css. --}} select changes in the field's chrome is resources/css/components/select.css. --}}
@props([ @props([
@@ -36,9 +37,9 @@
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24; $fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
@endphp @endphp
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant floated :class="$attributes->get('class')" data-md-select> <x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant floated :class="$attributes->get('class')" :style="$attributes->get('style')" data-md-select>
<select <select
{{ $attributes->except(['class', 'id']) }} {{ $attributes->except(['class', 'style', 'id']) }}
id="{{ $id }}" id="{{ $id }}"
@if ($messages !== []) aria-invalid="true" @endif @if ($messages !== []) aria-invalid="true" @endif
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif @if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
@@ -5,11 +5,11 @@
<x-menu-item label="Download files one by one" wire:click="downloadEach" /> <x-menu-item label="Download files one by one" wire:click="downloadEach" />
</x-split-button> </x-split-button>
Attributes (wire:click, spinner…) go to the leading button, a `class` to the pair; the slot is Attributes (wire:click, spinner…) go to the leading button, `class` and `style` to the pair;
the menu. `variant` is `filled` (the default), `tonal`, `outlined` or `elevated` a text split the slot is the menu. `variant` is `filled` (the default), `tonal`, `outlined` or `elevated`
button does not exist in M3 with `color` and `size` as on `<x-button>`. The halves sit 2px a text split button does not exist in M3 with `color` and `size` as on `<x-button>`. The
apart; the trailing one rounds fully and turns its chevron over while the menu is open. halves sit 2px apart; the trailing one rounds fully and turns its chevron over while the menu
`menu-label` names the trailing button and the menu for screen readers. is open. `menu-label` names the trailing button and the menu for screen readers.
Padding from SplitButton*Tokens (androidx Compose Material 3, Apache-2.0): the leading button Padding from SplitButton*Tokens (androidx Compose Material 3, Apache-2.0): the leading button
keeps less room on its inner side at the two smallest sizes, and the trailing button is 48px keeps less room on its inner side at the two smallest sizes, and the trailing button is 48px
@@ -37,9 +37,9 @@
$menuLabel ??= __('More options'); $menuLabel ??= __('More options');
@endphp @endphp
<div data-md-split-button data-md-size="{{ $size }}" {{ $attributes->only('class') }}> <div data-md-split-button data-md-size="{{ $size }}" {{ $attributes->only(['class', 'style']) }}>
<x-livewire-material::button <x-livewire-material::button
{{ $attributes->except('class') }} {{ $attributes->except(['class', 'style']) }}
:label="$label" :label="$label"
:icon="$icon" :icon="$icon"
:variant="$variant" :variant="$variant"
@@ -4,9 +4,10 @@
`:autogrow="false"` keeps it at `rows` and lets it be resized by hand, vertically only, so a `:autogrow="false"` keeps it at `rows` and lets it be resized by hand, vertically only, so a
form's column never moves. The label rests on the first line rather than in the middle of the form's column never moves. The label rests on the first line rather than in the middle of the
box. `label`, `hint`, `variant`, `counter` for M3's character counter against `maxlength`, and box. `label`, `hint`, `variant`, `counter` for M3's character counter against `maxlength`, and
`full` to span the pane past the 40rem M3 bounds a field to from `medium`; every other `full` to span the pane past the 40rem M3 bounds a field to from `medium`; `class` and `style`
attribute reaches the `<textarea>`. The field's root carries `data-md-textarea`; what a land on the field's root, every other attribute reaches the `<textarea>`. The field's root
textarea changes in the field's chrome is resources/css/components/textarea.css. --}} carries `data-md-textarea`; what a textarea changes in the field's chrome is
resources/css/components/textarea.css. --}}
@props([ @props([
'label' => null, 'label' => null,
@@ -31,9 +32,9 @@
$max = $counter ? $attributes->get('maxlength') : null; $max = $counter ? $attributes->get('maxlength') : null;
@endphp @endphp
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :counter="$max" :$full :class="$attributes->get('class')" :data-md-readonly="$attributes->get('readonly') ? '' : null" data-md-textarea> <x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :counter="$max" :$full :class="$attributes->get('class')" :style="$attributes->get('style')" :data-md-readonly="$attributes->get('readonly') ? '' : null" data-md-textarea>
<textarea <textarea
{{ $attributes->except(['class', 'id', 'placeholder']) }} {{ $attributes->except(['class', 'style', 'id', 'placeholder']) }}
id="{{ $id }}" id="{{ $id }}"
rows="{{ (int) $rows }}" rows="{{ (int) $rows }}"
placeholder="{{ $placeholder }}" placeholder="{{ $placeholder }}"
@@ -24,8 +24,8 @@
a `min` later than `max` spans midnight) grey out and skip what lies outside them and turn typed a `min` later than `max` spans midnight) grey out and skip what lies outside them and turn typed
values outside them into errors validate on the server as well. `clearable` adds a button that values outside them into errors validate on the server as well. `clearable` adds a button that
empties the field; `name` posts the value from a hidden input. `label`, `hint`, `icon`, `variant` empties the field; `name` posts the value from a hidden input. `label`, `hint`, `icon`, `variant`
and `size` are the field's; other attributes (`required`, `disabled`, `placeholder`) reach the and `size` are the field's; `class` and `style` land on the root, other attributes (`required`,
field's input. `disabled`, `placeholder`) reach the field's input.
From androidx Compose Material 3 at commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326 (Apache-2.0): From androidx Compose Material 3 at commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326 (Apache-2.0):
TimePickerTokens and TimeInputTokens a surface-container-high dialog with an extra-large TimePickerTokens and TimeInputTokens a surface-container-high dialog with an extra-large
@@ -132,14 +132,14 @@
'minute' => array_map(fn (int $index): array => ['value' => $index * 5, 'text' => $index === 0 ? '00' : $index * 5, 'index' => $index, 'inner' => false, 'allowed' => 'minuteAllowed('.($index * 5).')'], range(0, 11)), 'minute' => array_map(fn (int $index): array => ['value' => $index * 5, 'text' => $index === 0 ? '00' : $index * 5, 'index' => $index, 'inner' => false, 'allowed' => 'minuteAllowed('.($index * 5).')'], range(0, 11)),
]; ];
$inputAttributes = $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except(['class', 'id', 'wire:key', 'placeholder']); $inputAttributes = $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except(['class', 'style', 'id', 'wire:key', 'placeholder']);
$disabled = (bool) $attributes->get('disabled'); $disabled = (bool) $attributes->get('disabled');
$described = $messages !== [] || filled($hint); $described = $messages !== [] || filled($hint);
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24; $fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
@endphp @endphp
<div <div
{{ $attributes->only(['class', 'wire:key', 'x-model']) }} {{ $attributes->only(['class', 'style', 'wire:key', 'x-model']) }}
x-data="materialTimepicker(@if ($model !== null) @entangle($attributes->wire('model')) @else @js($current) @endif, @js($config))" x-data="materialTimepicker(@if ($model !== null) @entangle($attributes->wire('model')) @else @js($current) @endif, @js($config))"
@if ($model === null) x-modelable="value" @endif @if ($model === null) x-modelable="value" @endif
> >
@@ -0,0 +1,47 @@
<?php
/**
* A caller's `class` and `style` land on a component's root, untouched and once (the Phase F rule
* in docs/plans/material-3-alignment-brief.md): an application's width or margin sizes the whole
* component, whichever element its other attributes reach. The field family, the pickers and the
* components that split their attributes between a root and an inner control are the ones that
* can get it wrong, so each is rendered here with both.
*/
it('lands a caller\'s class and style on the root, and only there', function (string $blade) {
$html = (string) $this->blade($blade);
preg_match('/<([a-z]+)((?:[^>"\']|"[^"]*"|\'[^\']*\')*)>/', ltrim($html), $root);
expect($root[2] ?? '')
->toContain('class="caller-class')
->toContain('--caller-width: 192px')
->and(substr_count($html, 'caller-class'))->toBe(1, 'the class lands more than once')
->and(substr_count($html, '--caller-width'))->toBe(1, 'the style lands more than once');
})->with([
'input' => '<x-input label="Name" class="caller-class" style="--caller-width: 192px" />',
'password' => '<x-password label="Password" class="caller-class" style="--caller-width: 192px" />',
'textarea' => '<x-textarea label="Message" class="caller-class" style="--caller-width: 192px" />',
'textarea, fixed height' => '<x-textarea label="Message" :autogrow="false" class="caller-class" style="--caller-width: 192px" />',
'select' => '<x-select label="Expiry" :options="[]" class="caller-class" style="--caller-width: 192px" />',
'file' => '<x-file label="Attachments" class="caller-class" style="--caller-width: 192px" />',
'datepicker' => '<x-datepicker label="From" class="caller-class" style="--caller-width: 192px" />',
'timepicker' => '<x-timepicker label="At" class="caller-class" style="--caller-width: 192px" />',
'choices' => '<x-choices label="Days" :options="[]" class="caller-class" style="--caller-width: 192px" />',
'search' => '<x-search placeholder="Search" class="caller-class" style="--caller-width: 192px" />',
'group' => '<x-group label="Theme" name="theme" :options="[[\'id\' => 1, \'name\' => \'One\']]" class="caller-class" style="--caller-width: 192px" />',
'split button' => '<x-split-button label="Save" class="caller-class" style="--caller-width: 192px" />',
'checkbox' => '<x-checkbox label="Tick" class="caller-class" style="--caller-width: 192px" />',
'radio' => '<x-radio label="Pick" name="pick" :options="[[\'id\' => 1, \'name\' => \'One\']]" class="caller-class" style="--caller-width: 192px" />',
'toggle' => '<x-toggle label="On" class="caller-class" style="--caller-width: 192px" />',
'slider' => '<x-slider label="Volume" class="caller-class" style="--caller-width: 192px" />',
]);
it('keeps a textarea\'s own row count beside a caller\'s style', function () {
$html = (string) $this->blade('<x-textarea label="Message" rows="2" max-rows="6" style="max-width: 320px" />');
// A second style attribute on the <textarea> would be ignored by the browser, rows and all.
preg_match('/<textarea\b(?:[^>"]|"[^"]*")*>/', $html, $control);
expect(substr_count($control[0] ?? '', 'style="'))->toBe(1)
->and($control[0] ?? '')->toMatch('/style="--field-rows: 2;\s*--field-max-rows: 6;"/');
});