Bound a text field's width from medium

M3 § Text Fields, Behaviour: compact may let a field span full width, but
medium and expanded must bound it — "never let it span the full width of a
large screen". Nothing in the package capped a field (plan step 24, audit
docs/audits/m3-alignment/inputs.md § Missing). From `medium` a field now stops
at 40rem — the site names no number, so the header says where this one comes
from — which a `max-w-*` class beats and `full` takes off. The search bar
already carries M3's own 720px.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:23:08 +02:00
co-authored by Claude Fable 5.1
parent c8c2d2e13e
commit 72a3e22fbc
7 changed files with 48 additions and 5 deletions
@@ -555,6 +555,7 @@ M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire
- `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `counter`, `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
- `<x-password>`: a reveal button; `icon`, `size`.
- `<x-textarea>`: grows from `rows` (3) to `max-rows`, then scrolls; `:autogrow="false"` for a fixed, hand-resizable one; `counter`.
- Width: M3 asks that a text field never span the full width of a large screen, so from `medium` (600px) every field stops at **40rem**; below that it fills its pane. A `max-w-*` class on the component narrows or widens it, and `full` (on `<x-field>`, `<x-input>`, `<x-textarea>`) takes the bound off for a field that really is the width of its pane — a search row, an editor. `<x-search>`'s bar carries M3's own bound, 720px.
- `counter` (on `<x-input>` and `<x-textarea>`) 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.
- `<x-select>`: native `<select>` (M3 menu where the browser supports customizable selects). `options` as `['id' => …, 'name' => …, 'disabled' => bool]`, `option-value`, `option-label`, `placeholder` + `placeholder-value`, or `<option>`s in the slot; `icon`, `size`.
- `<x-file>`: native file input; errors from `photos` and `photos.*`. Show previews of what was chosen yourself.
+14
View File
@@ -42,6 +42,20 @@
min-width: 0;
}
/* M3 § Text Fields, Behaviour: "compact breakpoints can let a text field span full width;
* medium/expanded should bound it with flexible margins/other containers — never let it span the
* full width of a large screen." The site names no number, so 40rem is this package's: it is
* under half the `large` class's 1200px, a little over the `expanded` class's half, and holds
* about the 70 characters body-large reads best at. It is a ceiling, not a width — a narrower
* pane still gets a narrower field. A `max-w-*` class from the call site beats it, because
* utilities come after this layer, and `full` takes it off for a field that really is the width
* of its pane (a search-and-filter row, an editor). Below `medium` nothing is bounded. */
@media (width >= 37.5rem) {
.field:not([data-full]) {
max-width: 40rem;
}
}
/* A filled field's resting indicator line is on-surface-variant, where an outline is outline. */
.field[data-variant="filled"] {
--field-edge: var(--md-sys-color-on-surface-variant);
@@ -21,6 +21,10 @@
"Character count, 5/20", M3's own label, from a polite region a second after typing stops rather
than on every keystroke, which would talk over the typing.
From `medium` the field is no wider than 40rem, because M3 asks that a text field never span
the full width of a large screen (§ Text Fields, Behaviour); a `max-w-*` class 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. --}}
@@ -38,6 +42,7 @@
'floated' => false,
'mono' => false,
'counter' => null,
'full' => false,
])
@php
@@ -58,6 +63,7 @@
@if ($messages !== []) data-invalid @endif
@if ($floated) data-floated @endif
@if ($mono) data-mono @endif
@if ($full) data-full @endif
>
<div class="field-box">
@if ($icon)
+4 -2
View File
@@ -5,7 +5,8 @@
and `mono` for a field that holds code. `clearable` adds a trailing button that empties the
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. Every other attribute reaches the `<input>`: `type`, `min`, `step`,
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.
The placeholder is a single space when none is given, because the label can only tell an
@@ -23,6 +24,7 @@
'clearable' => false,
'copyable' => false,
'counter' => false,
'full' => false,
'size' => 'md',
'variant' => null,
'mono' => false,
@@ -39,7 +41,7 @@
$max = $counter ? $attributes->get('maxlength') : null;
@endphp
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :counter="$max" :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
<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-readonly="$attributes->get('readonly') ? '' : null">
<input
{{ $attributes->except(['class', 'id', 'placeholder'])->merge(['type' => 'text']) }}
id="{{ $id }}"
@@ -3,8 +3,9 @@
It starts at `rows` lines (3) and grows with what is typed up to `max-rows`, then scrolls;
`: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`, and `counter` for M3's character counter against `maxlength`;
every other attribute reaches the `<textarea>`. --}}
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>`. --}}
@props([
'label' => null,
@@ -15,6 +16,7 @@
'maxRows' => null,
'autogrow' => true,
'counter' => false,
'full' => false,
])
@php
@@ -28,7 +30,7 @@
$max = $counter ? $attributes->get('maxlength') : null;
@endphp
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :counter="$max" :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :counter="$max" :$full :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
<textarea
{{ $attributes->except(['class', 'id', 'placeholder']) }}
id="{{ $id }}"
@@ -41,6 +41,14 @@
</div>
</div>
BLADE,
'Width at medium and above' => <<<'BLADE'
<div class="w-full space-y-4">
{{-- M3: never let a text field span the full width of a large screen. From medium it stops at 40rem. --}}
<x-input label="Bounded, the default" hint="No wider than 40rem from medium, however wide the pane is" />
<x-input label="Narrower" class="max-w-xs" hint="A max-w-* class from the call site wins" />
<x-input label="The width of the pane" full icon="search" hint="`full` takes the bound off — for a search row or an editor" />
</div>
BLADE,
'Character counter' => <<<'BLADE'
<div class="grid w-full gap-6 medium:grid-cols-2">
<div class="grid content-start gap-4">
+10
View File
@@ -170,6 +170,16 @@ it('shows no counter without `counter` or without a maximum to count against', f
->and((string) $this->blade('<x-textarea label="Note" counter />'))->not->toContain('field-counter');
});
it('bounds a field\'s width from medium unless it is told to fill its pane', function () {
expect(file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))
->toContain('@media (width >= 37.5rem)')
->toMatch('/\.field:not\(\[data-full\]\)\s*\{\s*max-width: 40rem;/');
expect((string) $this->blade('<x-input label="Share name" full />'))->toContain('data-full')
->and((string) $this->blade('<x-textarea label="Message" full />'))->toContain('data-full')
->and((string) $this->blade('<x-input label="Share name" />'))->not->toContain('data-full');
});
it('lays out a form with its actions under an optional divider', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-form wire:submit="save" separator>