Count a text field's characters against its maximum

M3 lists "supporting text + character counter" as a configuration of both
text-field variants; nothing in the package drew one (plan step 24, audit
docs/audits/m3-alignment/inputs.md § Missing). `counter` on `<x-input>` and
`<x-textarea>` now puts `n/max` at the end of the supporting-text row, counted
from the control on every input, in the error colour past the maximum, and said
as M3's own "Character count, 5/20" from a polite region once typing settles.

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:21:22 +02:00
co-authored by Claude Fable 5.1
parent 71442831ff
commit c8c2d2e13e
7 changed files with 127 additions and 14 deletions
@@ -552,9 +552,10 @@ A one-column grid of fields with an `actions` slot at the foot (the slot takes i
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant` (and all but `<x-file>` a `hint-class`, classes added to the hint: `hint-class="text-warning"`), and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]``photos`, `address[city]``address.city`); the error replaces the hint, sets `aria-invalid`, and puts an `error` icon at the end of the row as M3's second indicator (not on `size="xs"`, and not when the field already trails something — `icon-right`, `clearable`, `copyable`). `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus. M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant` (and all but `<x-file>` a `hint-class`, classes added to the hint: `hint-class="text-warning"`), and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]``photos`, `address[city]``address.city`); the error replaces the hint, sets `aria-invalid`, and puts an `error` icon at the end of the row as M3's second indicator (not on `size="xs"`, and not when the field already trails something — `icon-right`, `clearable`, `copyable`). `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
- `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `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. - `<x-textarea>`: grows from `rows` (3) to `max-rows`, then scrolls; `:autogrow="false"` for a fixed, hand-resizable one; `counter`.
- `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.
- `<x-field id="…" label="…" :messages="$messages">` wraps a custom control given `class="field-control"`; only for controls the package does not have. - `<x-field id="…" label="…" :messages="$messages">` wraps a custom control given `class="field-control"`; only for controls the package does not have.
+28 -1
View File
@@ -20,7 +20,9 @@
* error state's second indicator * error state's second indicator
* .field-outline the fieldset and its legend (the filled field's indicator line) * .field-outline the fieldset and its legend (the filled field's indicator line)
* .field-label the visible label * .field-label the visible label
* .field-support the hint, or the error in its place * .field-support-row the row under the field
* .field-support the hint, or the error in its place
* .field-counter M3's character counter, `n/max`, at the end of that row
* *
* A select's open list is not part of the field: it is the dropdown menu in components/menu.css. * A select's open list is not part of the field: it is the dropdown menu in components/menu.css.
*/ */
@@ -450,7 +452,16 @@
} }
} }
/* The hint or the error, and — when the field has a maximum — M3's character counter at the far
end of the same row (specs: "Padding between supporting text and counter | 16dp", which the
two paddings between them already make). */
.field-support-row {
display: flex;
align-items: baseline;
}
.field-support { .field-support {
min-width: 0;
padding: 0.25rem var(--field-pad) 0; padding: 0.25rem var(--field-pad) 0;
color: var(--md-sys-color-on-surface-variant); color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm); font: var(--md-sys-typescale-body-sm);
@@ -461,6 +472,22 @@
color: var(--md-sys-color-error); color: var(--md-sys-color-error);
} }
.field-counter {
flex: none;
margin-inline-start: auto;
padding-block-start: 0.25rem;
padding-inline-end: var(--field-pad);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variant-numeric: tabular-nums;
}
/* Past the maximum the count is an error, and says so in the error colour. */
.field-counter[data-over] {
color: var(--md-sys-color-error);
}
/* ---- The filled text field ---------------------------------------------------------------- */ /* ---- The filled text field ---------------------------------------------------------------- */
.field[data-variant="filled"] .field-box { .field[data-variant="filled"] .field-box {
+51 -7
View File
@@ -14,6 +14,13 @@
at the end of the row so the state has two indicators and not only a colour unless the caller at the end of the row so the state has two indicators and not only a colour unless the caller
trails the field with something of its own, or the field is `xs` and has no room. trails the field with something of its own, or the field is `xs` and has no room.
`counter` is the maximum number of characters, and puts M3's character counter at the end of the
supporting-text row: `n/max`, counted from the control on every `input`, in the error colour once
the value is past the maximum
(docs/reference/m3/components-navigation-selection-inputs.md § Text Fields). It is said as
"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.
`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. --}}
@@ -30,6 +37,7 @@
'variant' => null, 'variant' => null,
'floated' => false, 'floated' => false,
'mono' => false, 'mono' => false,
'counter' => null,
]) ])
@php @php
@@ -40,6 +48,7 @@
// M3 pairs the error colours with a trailing error icon so the state has two indicators. It // M3 pairs the error colours with a trailing error icon so the state has two indicators. It
// gives way to whatever the caller trails the field with, and to `xs`, which has no room. // gives way to whatever the caller trails the field with, and to `xs`, which has no room.
$errorIcon = $messages !== [] && ! isset($trailing) && $density !== 'xs'; $errorIcon = $messages !== [] && ! isset($trailing) && $density !== 'xs';
$counter = filled($counter) && (int) $counter > 0 ? (int) $counter : null;
@endphp @endphp
<div <div
@@ -80,13 +89,48 @@
@endif @endif
</div> </div>
@if ($messages !== []) @if ($messages !== [] || filled($hint) || $counter !== null)
<div id="{{ $id }}-support" class="field-support" role="alert"> <div class="field-support-row">
@foreach ($messages as $message) @if ($messages !== [])
<p>{{ $message }}</p> <div id="{{ $id }}-support" class="field-support" role="alert">
@endforeach @foreach ($messages as $message)
<p>{{ $message }}</p>
@endforeach
</div>
@elseif (filled($hint))
<p id="{{ $id }}-support" @class(array_filter(['field-support', $hintClass]))>{{ $hint }}</p>
@endif
@if ($counter !== null)
<span
class="field-counter"
data-field-counter
x-data="{
used: 0,
max: {{ $counter }},
spoken: '',
settle: null,
init() {
const control = this.$el.closest('.field').querySelector('.field-control');
this.used = control.value.length;
control.addEventListener('input', () => (this.used = control.value.length));
this.$watch('used', () => {
clearTimeout(this.settle);
this.settle = setTimeout(() => (this.spoken = @js(__('Character count, :used/:max')).replace(':used', this.used).replace(':max', this.max)), 1000);
});
},
destroy() {
clearTimeout(this.settle);
},
}"
x-bind:data-over="used > max"
>
<span aria-hidden="true" x-text="`${used}/${max}`">0/{{ $counter }}</span>
<span class="sr-only" aria-live="polite" aria-atomic="true" x-text="spoken"></span>
</span>
@endif
</div> </div>
@elseif (filled($hint))
<p id="{{ $id }}-support" @class(array_filter(['field-support', $hintClass]))>{{ $hint }}</p>
@endif @endif
</div> </div>
+6 -2
View File
@@ -4,7 +4,8 @@
value, such as a currency or a unit), `variant`, `size` for unlabelled controls in toolbars, value, such as a currency or a unit), `variant`, `size` for unlabelled controls in toolbars,
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). Every other attribute reaches the `<input>`: `type`, `min`, `step`, (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`,
`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
@@ -21,6 +22,7 @@
'suffix' => null, 'suffix' => null,
'clearable' => false, 'clearable' => false,
'copyable' => false, 'copyable' => false,
'counter' => false,
'size' => 'md', 'size' => 'md',
'variant' => null, 'variant' => null,
'mono' => false, 'mono' => false,
@@ -33,9 +35,11 @@
// A plain form's field is named, not bound: its errors are under its name (`files[]` `files`, `a[b]` `a.b`). // A plain form's field is named, not bound: its errors are under its name (`files[]` `files`, `a[b]` `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null); $errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : []; $messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
// M3's counter needs a maximum to count against: without `maxlength` there is nothing to show.
$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 :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" :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,7 +3,8 @@
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`; every other attribute reaches the `<textarea>`. --}} box. `label`, `hint`, `variant`, and `counter` for M3's character counter against `maxlength`;
every other attribute reaches the `<textarea>`. --}}
@props([ @props([
'label' => null, 'label' => null,
@@ -13,6 +14,7 @@
'rows' => 3, 'rows' => 3,
'maxRows' => null, 'maxRows' => null,
'autogrow' => true, 'autogrow' => true,
'counter' => false,
]) ])
@php @php
@@ -22,9 +24,11 @@
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`). // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null); $errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : []; $messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
// M3's counter needs a maximum to count against: without `maxlength` there is nothing to show.
$max = $counter ? $attributes->get('maxlength') : null;
@endphp @endphp
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null"> <x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :counter="$max" :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,16 @@
</div> </div>
</div> </div>
BLADE, BLADE,
'Character counter' => <<<'BLADE'
<div class="grid w-full gap-6 medium:grid-cols-2">
<div class="grid content-start gap-4">
<x-input label="Share name" maxlength="40" counter hint="Recipients see this name" />
<x-input variant="filled" label="Subject" maxlength="60" counter />
</div>
<x-textarea label="Message for recipients" maxlength="180" counter rows="3" hint="Sent with the link" />
</div>
BLADE,
'Textarea and select' => <<<'BLADE' 'Textarea and select' => <<<'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">
+23
View File
@@ -147,6 +147,29 @@ it('collects a file field\'s own errors and each file\'s', function () {
->toContain('photo.jpg is larger than 2 GB.'); ->toContain('photo.jpg is larger than 2 GB.');
}); });
it('counts the characters of a field that has a maximum', function () {
$html = (string) $this->blade('<x-input id="bio" label="Bio" hint="Shown on your profile" maxlength="60" counter />');
expect($html)
->toContain('class="field-support-row"')
->toContain('class="field-counter"')
->toContain('maxlength="60"')
->toContain('max: 60,')
->toContain('>0/60</span>')
->toContain('aria-live="polite"')
->toContain('Shown on your profile');
expect((string) $this->blade('<x-textarea id="note" label="Note" maxlength="140" counter />'))
->toContain('class="field-counter"')
->toContain('>0/140</span>');
});
it('shows no counter without `counter` or without a maximum to count against', function () {
expect((string) $this->blade('<x-input label="Bio" maxlength="60" />'))->not->toContain('field-counter')
->and((string) $this->blade('<x-input label="Bio" counter />'))->not->toContain('field-counter')
->and((string) $this->blade('<x-textarea label="Note" counter />'))->not->toContain('field-counter');
});
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>