* fix(a11y): associate input and textarea labels with their controls
<atom:label> rendered a real <label> but with no for, and the control had no
id, so the label was decoration: assistive tech announced every field as an
unnamed "edit text". Reported against smgdms (#65), where 17 of 18 inputs on
one form were unnamed and Playwright's getByLabel could not resolve any.
The id is minted per render rather than derived from the field name, so two
components on one page cannot collide; label and control render together, so
they always carry the same value. A caller-supplied id is kept.
Covers <atom:input> (all four field call sites) and <atom:textarea>. NOT yet
covered: select, date-picker, time-picker, radio.group and tiptap — select's
listbox variant is a custom Alpine widget rather than a <select>, so it needs
aria-labelledby and a real ARIA pass, not an id.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(a11y): derive the field id, and route it onto the control
The id minted in 6e4d84f was random per render, and Livewire's morph falls back
to `id` as its key when nothing else keys the element:
key: (el) => el.hasAttribute('wire:id') ? ...
: el.hasAttribute('wire:key') ? ...
: el.id // livewire.esm.js
so every <atom:input> and <atom:textarea> got a key that never matched the
previous render and was replaced rather than patched. On a wire:model.live field
the user typed, the debounce fired, focus was torn away and every keystroke after
that was discarded. `clearable` captures the input node once in x-init, so it kept
rendering while clearing a node no longer in the document. Measured: node survived
false, focus false, "Acme" + " Holdings" -> "Acme".
The id is now derived from the field (name|label|type) through a shared
ComponentAttributeBag::fieldId() macro, so it is identical across renders and the
morph patches as before. It is minted only where there is a label to carry it, so
an unlabelled field keeps exactly the markup it had. A caller-supplied id still
wins, including the legal-but-falsy "0".
tel, color and multi-value email hand the attribute bag to their Alpine wrapper
and the real control gets only an allow-list, so the id landed on a <div>: the
label named nothing while looking correct to anything that only checks that `for`
resolves. The id is now routed past the wrapper onto the control. As a wrapper id
it was also a morph key, which is why a re-render reset the tel dial code the user
had picked.
`file` is now excluded rather than half-covered: the uploader's real input is
class="hidden", so it is out of the accessibility tree and a <label for> computes
no name from it. Naming the visible button needs aria-labelledby — a separate ARIA
pass, along with select, date-picker, time-picker, radio.group and tiptap.
Guards: per-type association (the `for` target must be a labelable control, not a
wrapper), id stability across two renders, no id without a label, no `for` on file;
and tests/e2e/input-morph.spec.js for the browser-level properties Pest cannot see,
since renderBlade() renders once and a per-render id looks correct in one render by
construction. Each new assertion was mutation-tested.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>