Files
livewire-material/resources/views/components/field.blade.php
T
Andreas Reinhold / reiniandClaude Fable 5.1 c8c2d2e13e 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
2026-09-14 06:21:22 +02:00

137 lines
5.7 KiB
PHP

{{-- The chrome of M3's text fields, around whichever control is in the slot.
Not used at call sites: `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`
and the pickers wrap their control in it, so a form reads as one family. The states — the
label resting or floating, the notch, focus, disabled, required — are selectors in
resources/css/components/field.css, which explains the anatomy; the only state this file
decides is `data-invalid`, because only the server knows that.
`variant` is M3's two: `outlined` (a notched outline) and `filled` (a surface-container-highest
box with an indicator line); without it, `config('livewire-material.fields.variant')`. A field
with a label is a form field, M3's 56px. A field without one is a control in a toolbar or an
editor row, and is given `size="sm"` (40px, a button's height) or `size="xs"` (32px). The
error replaces the hint rather than stacking under it, as M3 has it, and an `error` icon joins it
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.
`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
a width is safe here. --}}
@props([
'id',
'label' => null,
'hint' => null,
'hintClass' => null,
'messages' => [],
'icon' => null,
'prefix' => null,
'suffix' => null,
'size' => 'md',
'variant' => null,
'floated' => false,
'mono' => false,
'counter' => null,
])
@php
$variant = in_array($variant, ['outlined', 'filled'], true)
? $variant
: (config('livewire-material.fields.variant') === 'filled' ? 'filled' : 'outlined');
$density = in_array($size, ['sm', 'xs'], true) ? $size : 'md';
// 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.
$errorIcon = $messages !== [] && ! isset($trailing) && $density !== 'xs';
$counter = filled($counter) && (int) $counter > 0 ? (int) $counter : null;
@endphp
<div
{{ $attributes->class(['field']) }}
data-variant="{{ $variant }}"
data-size="{{ $density }}"
@if ($messages !== []) data-invalid @endif
@if ($floated) data-floated @endif
@if ($mono) data-mono @endif
>
<div class="field-box">
@if ($icon)
<x-livewire-material::icon :name="$icon" class="field-icon size-(--field-icon)" />
@endif
@if (filled($prefix))
<span class="field-affix">{{ $prefix }}</span>
@endif
{{ $slot }}
@if (filled($suffix))
<span class="field-affix">{{ $suffix }}</span>
@endif
{{ $trailing ?? '' }}
@if ($errorIcon)
<x-livewire-material::icon name="error" class="field-trailing field-error size-(--field-icon)" :label="__('Error')" />
@endif
<fieldset aria-hidden="true" class="field-outline">
<legend>@if (filled($label))<span>{{ $label }}</span>@endif</legend>
</fieldset>
@if (filled($label))
<label for="{{ $id }}" class="field-label">{{ $label }}</label>
@endif
</div>
@if ($messages !== [] || filled($hint) || $counter !== null)
<div class="field-support-row">
@if ($messages !== [])
<div id="{{ $id }}-support" class="field-support" role="alert">
@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>
@endif
</div>