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-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-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`. - `<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. - `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-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. - `<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; 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. */ /* A filled field's resting indicator line is on-surface-variant, where an outline is outline. */
.field[data-variant="filled"] { .field[data-variant="filled"] {
--field-edge: var(--md-sys-color-on-surface-variant); --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 "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. 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 `class` from the call site lands on the outermost element, never the control, so a margin or
a width is safe here. --}} a width is safe here. --}}
@@ -38,6 +42,7 @@
'floated' => false, 'floated' => false,
'mono' => false, 'mono' => false,
'counter' => null, 'counter' => null,
'full' => false,
]) ])
@php @php
@@ -58,6 +63,7 @@
@if ($messages !== []) data-invalid @endif @if ($messages !== []) data-invalid @endif
@if ($floated) data-floated @endif @if ($floated) data-floated @endif
@if ($mono) data-mono @endif @if ($mono) data-mono @endif
@if ($full) data-full @endif
> >
<div class="field-box"> <div class="field-box">
@if ($icon) @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 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 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. 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. `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
@@ -23,6 +24,7 @@
'clearable' => false, 'clearable' => false,
'copyable' => false, 'copyable' => false,
'counter' => false, 'counter' => false,
'full' => false,
'size' => 'md', 'size' => 'md',
'variant' => null, 'variant' => null,
'mono' => false, 'mono' => false,
@@ -39,7 +41,7 @@
$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 :$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 <input
{{ $attributes->except(['class', 'id', 'placeholder'])->merge(['type' => 'text']) }} {{ $attributes->except(['class', 'id', 'placeholder'])->merge(['type' => 'text']) }}
id="{{ $id }}" id="{{ $id }}"
@@ -3,8 +3,9 @@
It starts at `rows` lines (3) and grows with what is typed up to `max-rows`, then scrolls; 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 `: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`, and `counter` for M3's character counter against `maxlength`; box. `label`, `hint`, `variant`, `counter` for M3's character counter against `maxlength`, and
every other attribute reaches the `<textarea>`. --}} `full` to span the pane past the 40rem M3 bounds a field to from `medium`; every other
attribute reaches the `<textarea>`. --}}
@props([ @props([
'label' => null, 'label' => null,
@@ -15,6 +16,7 @@
'maxRows' => null, 'maxRows' => null,
'autogrow' => true, 'autogrow' => true,
'counter' => false, 'counter' => false,
'full' => false,
]) ])
@php @php
@@ -28,7 +30,7 @@
$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" :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 <textarea
{{ $attributes->except(['class', 'id', 'placeholder']) }} {{ $attributes->except(['class', 'id', 'placeholder']) }}
id="{{ $id }}" id="{{ $id }}"
@@ -41,6 +41,14 @@
</div> </div>
</div> </div>
BLADE, 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' 'Character counter' => <<<'BLADE'
<div class="grid w-full gap-6 medium:grid-cols-2"> <div class="grid w-full gap-6 medium:grid-cols-2">
<div class="grid content-start gap-4"> <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'); ->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 () { it('lays out a form with its actions under an optional divider', function () {
$html = (string) $this->blade(<<<'BLADE' $html = (string) $this->blade(<<<'BLADE'
<x-form wire:submit="save" separator> <x-form wire:submit="save" separator>