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
146 lines
6.4 KiB
PHP
146 lines
6.4 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. A custom control the
|
|
package does not have goes in the slot marked `data-md-field-control`. 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-md-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. The field's
|
|
icons are 24px, 20px in an `sm` field and 16px in an `xs` one.
|
|
|
|
`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.
|
|
|
|
From `medium` (600px) 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 width rule from the call site
|
|
beats that, and `full` takes it off altogether.
|
|
|
|
`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',
|
|
'label' => null,
|
|
'hint' => null,
|
|
'hintClass' => null,
|
|
'messages' => [],
|
|
'icon' => null,
|
|
'prefix' => null,
|
|
'suffix' => null,
|
|
'size' => 'md',
|
|
'variant' => null,
|
|
'floated' => false,
|
|
'mono' => false,
|
|
'counter' => null,
|
|
'full' => false,
|
|
])
|
|
|
|
@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';
|
|
$iconSize = ['md' => 24, 'sm' => 20, 'xs' => 16][$density];
|
|
// 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
|
|
data-md-field
|
|
data-md-variant="{{ $variant }}"
|
|
data-md-size="{{ $density }}"
|
|
@if ($messages !== []) data-md-invalid @endif
|
|
@if ($floated) data-md-floated @endif
|
|
@if ($mono) data-md-mono @endif
|
|
@if ($full) data-md-full @endif
|
|
{{ $attributes }}
|
|
>
|
|
<div data-md-field-box>
|
|
@if ($icon)
|
|
<x-livewire-material::icon :name="$icon" :size="$iconSize" data-md-field-icon />
|
|
@endif
|
|
|
|
@if (filled($prefix))
|
|
<span data-md-field-affix>{{ $prefix }}</span>
|
|
@endif
|
|
|
|
{{ $slot }}
|
|
|
|
@if (filled($suffix))
|
|
<span data-md-field-affix>{{ $suffix }}</span>
|
|
@endif
|
|
|
|
{{ $trailing ?? '' }}
|
|
|
|
@if ($errorIcon)
|
|
<x-livewire-material::icon name="error" :size="$iconSize" data-md-field-trailing data-md-field-error :label="__('Error')" />
|
|
@endif
|
|
|
|
<fieldset aria-hidden="true" data-md-field-outline>
|
|
<legend>@if (filled($label))<span>{{ $label }}</span>@endif</legend>
|
|
</fieldset>
|
|
|
|
@if (filled($label))
|
|
<label for="{{ $id }}" data-md-field-label>{{ $label }}</label>
|
|
@endif
|
|
</div>
|
|
|
|
@if ($messages !== [] || filled($hint) || $counter !== null)
|
|
<div data-md-field-support-row>
|
|
@if ($messages !== [])
|
|
<div id="{{ $id }}-support" data-md-field-support role="alert">
|
|
@foreach ($messages as $message)
|
|
<p>{{ $message }}</p>
|
|
@endforeach
|
|
</div>
|
|
@elseif (filled($hint))
|
|
<p id="{{ $id }}-support" data-md-field-support @if (filled($hintClass)) class="{{ $hintClass }}" @endif>{{ $hint }}</p>
|
|
@endif
|
|
|
|
@if ($counter !== null)
|
|
<span
|
|
data-md-field-counter
|
|
x-data="{
|
|
used: 0,
|
|
max: {{ $counter }},
|
|
spoken: '',
|
|
settle: null,
|
|
init() {
|
|
const control = this.$el.closest('[data-md-field]').querySelector('[data-md-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-md-over="used > max"
|
|
>
|
|
<span aria-hidden="true" x-text="`${used}/${max}`">0/{{ $counter }}</span>
|
|
<span class="md-visually-hidden" aria-live="polite" aria-atomic="true" x-text="spoken"></span>
|
|
</span>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|