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
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)
once it holds one; `name` adds hidden inputs carrying `Y-m-d` for a plain form post, and every
other attribute (`required`, `disabled`, `readonly`) reaches the text field. Errors under
once it holds one; `name` adds hidden inputs carrying `Y-m-d` for a plain form post. `class` and
`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.
Month and weekday names, the first day of the week and the typed format come from `Intl` for
@@ -150,7 +151,7 @@
@endphp
<div
{{ $attributes->only(['class', 'wire:key', 'x-model']) }}
{{ $attributes->only(['class', 'style', 'wire:key', 'x-model']) }}
x-data="materialDatepicker({
@if ($model !== null) value: @entangle($attributes->wire('model')), @else value: @js($current), @endif
...@js($config),
@@ -172,7 +173,7 @@
x-bind:data-md-invalid="(fieldError !== '' || {{ $invalid ? 'true' : 'false' }}) ? '' : null"
>
<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"
id="{{ $id }}"
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
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
a width is safe here; `hint-class` lands on the hint. --}}
`class` and `style` from the call site land on the outermost element, never the control, so a
margin or a width is safe here; `hint-class` lands on the hint. --}}
@props([
'id',
+2 -2
View File
@@ -29,9 +29,9 @@
: [];
@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
{{ $attributes->except(['class', 'id', 'type']) }}
{{ $attributes->except(['class', 'style', 'id', 'type']) }}
type="file"
id="{{ $id }}"
@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.
$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-size' => $size,
'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
(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
from `medium`. 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.
from `medium`. `class` and `style` land on the field's root, which is what a width or a margin
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
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;
@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
{{ $attributes->except(['class', 'id', 'placeholder'])->merge(['type' => 'text']) }}
{{ $attributes->except(['class', 'style', 'id', 'placeholder'])->merge(['type' => 'text']) }}
id="{{ $id }}"
placeholder="{{ $placeholder }}"
@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
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.
`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
`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). --}}
@@ -27,9 +28,9 @@
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
@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
{{ $attributes->except(['class', 'id', 'type', 'placeholder']) }}
{{ $attributes->except(['class', 'style', 'id', 'type', 'placeholder']) }}
id="{{ $id }}"
type="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
`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([
'placeholder' => null,
@@ -103,7 +103,7 @@
x-bind:aria-expanded="open.toString()"
>
<input
{{ $attributes->except(['class', 'wire:key', 'id', 'placeholder', 'type']) }}
{{ $attributes->except(['class', 'style', 'wire:key', 'id', 'placeholder', 'type']) }}
x-ref="input"
id="{{ $id }}"
type="search"
+4 -3
View File
@@ -10,7 +10,8 @@
`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
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. --}}
@props([
@@ -36,9 +37,9 @@
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
@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
{{ $attributes->except(['class', 'id']) }}
{{ $attributes->except(['class', 'style', 'id']) }}
id="{{ $id }}"
@if ($messages !== []) aria-invalid="true" @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-split-button>
Attributes (wire:click, spinner…) go to the leading button, a `class` to the pair; the slot is
the menu. `variant` is `filled` (the default), `tonal`, `outlined` or `elevated` a text split
button does not exist in M3 with `color` and `size` as on `<x-button>`. The halves sit 2px
apart; the trailing one rounds fully and turns its chevron over while the menu is open.
`menu-label` names the trailing button and the menu for screen readers.
Attributes (wire:click, spinner…) go to the leading button, `class` and `style` to the pair;
the slot is the menu. `variant` is `filled` (the default), `tonal`, `outlined` or `elevated`
a text split button does not exist in M3 with `color` and `size` as on `<x-button>`. The
halves sit 2px apart; the trailing one rounds fully and turns its chevron over while the menu
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
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');
@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
{{ $attributes->except('class') }}
{{ $attributes->except(['class', 'style']) }}
:label="$label"
:icon="$icon"
:variant="$variant"
@@ -4,9 +4,10 @@
`: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
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
attribute reaches the `<textarea>`. The field's root carries `data-md-textarea`; what a
textarea changes in the field's chrome is resources/css/components/textarea.css. --}}
`full` to span the pane past the 40rem M3 bounds a field to from `medium`; `class` and `style`
land on the field's root, every other attribute reaches the `<textarea>`. The field's root
carries `data-md-textarea`; what a textarea changes in the field's chrome is
resources/css/components/textarea.css. --}}
@props([
'label' => null,
@@ -31,9 +32,9 @@
$max = $counter ? $attributes->get('maxlength') : null;
@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
{{ $attributes->except(['class', 'id', 'placeholder']) }}
{{ $attributes->except(['class', 'style', 'id', 'placeholder']) }}
id="{{ $id }}"
rows="{{ (int) $rows }}"
placeholder="{{ $placeholder }}"
@@ -24,8 +24,8 @@
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
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
field's input.
and `size` are the field's; `class` and `style` land on the root, other attributes (`required`,
`disabled`, `placeholder`) reach the field's input.
From androidx Compose Material 3 at commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326 (Apache-2.0):
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)),
];
$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');
$described = $messages !== [] || filled($hint);
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
@endphp
<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))"
@if ($model === null) x-modelable="value" @endif
>