chore: add ElementInternals text field PoC for forms strategy#6305
chore: add ElementInternals text field PoC for forms strategy#6305Rajdeepc wants to merge 14 commits into
Conversation
- demonstrate form-associated custom element (FACE) patterns with ElementInternals before committing to repo-wide conventions - includes IDREF matrix (4 scenarios), axe-core audit, constraint validation, and 1st-gen comparison - self-contained HTML demo and internal findings summary for the direction PR Co-authored-by: Cursor <cursoragent@cursor.com>
|
📚 Branch Preview Links🔍 First Generation Visual Regression Test ResultsWhen a visual regression test fails (or has previously failed while working on this branch), its results can be found in the following URLs:
Deployed to Azure Blob Storage: If the changes are expected, update the |
- add minimal Vite package.json so StackBlitz can serve the demo - update SUMMARY.md with live StackBlitz link Co-authored-by: Cursor <cursoragent@cursor.com>
- #validate() only ran on input/change events, so an untouched empty required field reported validity.valid: true - call #validate() in connectedCallback and before checkValidity / reportValidity so internals always reflect the shadow input state Co-authored-by: Cursor <cursoragent@cursor.com>
- add novalidate to forms 1b and 3b so the JS submit handler runs instead of native browser validation blocking the event - wrap ariaLabelledByElements/ariaDescribedByElements assignments in try-catch so the ariaLabel/ariaDescription string fallback always executes - add ariaLabel string fallback for slotted label/help-text wiring (scenario B) and implicit label association (scenario D) - add ariaDescription string fallback for host aria-describedby and slotted help-text - update SUMMARY.md with automated test results table from Chrome headless run Co-authored-by: Cursor <cursoragent@cursor.com>
- internals.ariaLabel sets the name on the host a11y node, but browsers expose the shadow input as the primary control; screen readers and axe read the input node, not the host - add #applyLabel/#applyDescription helpers that set the name on both internals (for spec compliance) and the shadow input (for actual screen reader exposure) - use the helpers for all labelling paths: host aria-labelledby, slotted content, internals aria-label, and implicit label-for Co-authored-by: Cursor <cursoragent@cursor.com>
- observe aria-label attribute and forward to shadow input via #applyLabel so the accessible name appears on the control that screen readers actually interact with - update SUMMARY.md with final automated test results and the critical finding that internals.ariaLabel alone is insufficient Co-authored-by: Cursor <cursoragent@cursor.com>
Address review feedback: - Add labelling-strategy attr (internals-only|input-only|dual-write) to prove which layer carries the accessible name - Fix Scenario C wording: shadow-local IDREF, not cross-root - Document internals.ariaInvalid open questions (SR exposure unclear) - Soften matrix headers to "DOM name source" (not a11y tree) - Guard #handleSlotWiring against duplicate listener accumulation - Remove dead field.internals reference in populateMatrix Co-authored-by: Cursor <cursoragent@cursor.com>
All SR tests pass. Key confirmations: - internals-only strategy: no label announced (proves failure) - input-only and dual-write: labels announced correctly - internals.ariaInvalid: VoiceOver announces invalid state - Scenario D (label-for + delegatesFocus): name announced Co-authored-by: Cursor <cursoragent@cursor.com>
Manual screen reader testing results (VoiceOver, macOS + Chrome)Strategy isolation (Section 3a) — the key finding
IDREF matrix scenarios (Section 2)
Validation (Section 3b)
Confirms:
|
| Question | Answer |
|---|---|
Does internals.ariaInvalid get announced? |
Yes — VoiceOver announces it |
Does <label for> + delegatesFocus produce a name? |
Yes — in VoiceOver on Chrome |
Does internals.ariaLabel alone work? |
No — confirmed; dual-write required |
Remaining
- NVDA testing not performed (expected similar results based on shared browser a11y tree)
- All findings are now recorded in
SUMMARY.md
Records confirmed adopt/avoid decisions and shared mixin spec for the direction PR (SWC-2054). Co-authored-by: Cursor <cursoragent@cursor.com>
Decisions from this PoC exerciseAdopt
Avoid
Build next: shared form mixinThe mixin should encapsulate:
Monitor for future simplification
|
Findings are captured in PR comments and description instead. Co-authored-by: Cursor <cursoragent@cursor.com>
Tests whether setting internals.role on the host changes labelling behavior. Includes: - role="textbox" with internals-only vs dual-write - role="combobox" with internals-only vs dual-write - no-delegatesFocus variant to test if host becomes focused element Answers reviewer question: what are the constraints for labelling when the host has an internals role? Co-authored-by: Cursor <cursoragent@cursor.com>
Added:
|
| Field | internals.role |
delegatesFocus |
Label strategy | Key question |
|---|---|---|---|---|
| 1 | textbox |
Yes | internals-only | Does role make internals.ariaLabel get announced? |
| 2 | textbox |
Yes | dual-write | Baseline comparison |
| 3 | textbox |
No | internals-only | Without delegatesFocus, does host receive focus and announce? |
The key question this answers
When you set internals.role = "textbox" on the host, the host now has a textbox role in the a11y tree. But does that change how labelling works?
Two possibilities:
- delegatesFocus wins — Focus still goes to the shadow
<input>, so the host's role/label is still bypassed. SR reads the shadow input. - Host role wins — The host IS the textbox now, so
internals.ariaLabelgets announced on the host.
The no-delegatesFocus variant (field 5) tests what happens when we remove delegatesFocus entirely. If the host has role="textbox" and there's no focus delegation, does the host itself become the focusable element?
How to test
- Open the StackBlitz demo (or run locally)
- Turn on VoiceOver
- Tab to section 3c
- For each field, note:
- What does VO announce? (label or just placeholder?)
- What element does DevTools show as focused? (host or shadow input?)
- Does the a11y tree show the role on the host?
Combobox will be covered in its own dedicated PoC (SWC-2053). Co-authored-by: Cursor <cursoragent@cursor.com>
Separates the single-file PoC into navigable modules for StackBlitz: - face-text-field.js: main FACE component (delegatesFocus) - face-text-field-no-delegate.js: variant without delegatesFocus - page-handlers.js: form handlers, matrix introspection, axe-core - index.html: pure HTML scenarios (no inline JS) Removes all JS that actively resolves cross-root IDREFs: - #wireAriaRelationships (ariaLabelledByElements, ariaDescribedByElements) - #handleSlotWiring (slotchange-based label forwarding) - #resolveTextFromIds (text extraction from light DOM IDs) - #wireImplicitLabel (reading internals.labels text) What remains is ElementInternals out of the box: internals.ariaLabel, internals.role, and the labelling-strategy attribute to isolate which layer (internals-only vs input-only vs dual-write) carries the name. Co-authored-by: Cursor <cursoragent@cursor.com>
Description
A proof-of-concept demonstrating a text field as a form-associated custom element (FACE) using
ElementInternals. This is a research artifact — not a migration ofsp-textfield. The goal is to learn what the platform and assistive technologies actually do before we commit repo-wide patterns for 2nd-gen form components.Demo: Open in StackBlitz
Motivation and context
Shadow DOM and ID-based ARIA (
aria-labelledby,aria-describedby) do not behave like flat HTML across roots. Patterns that pair a light-DOM label with a shadow-DOM control break unless we design around encapsulation. This PoC provides evidence for the direction PR (SWC-2054) by answering specific platform questions.Related issue(s)
The PoC explained in plain terms
The big picture: what problem are we solving?
Imagine you're building a custom text field component (
<face-text-field>) using web components. Two big problems arise:Forms don't know about your custom element. A native
<input>automatically participates in<form>submit, reset, validation. Your custom<face-text-field>is invisible to the form by default.Screen readers can't "see" inside your component. Your component has a shadow DOM (think of it as a private room). A label sitting outside the room can't point to the input inside the room using the normal "ID reference" system.
This PoC tests whether ElementInternals (a browser API) solves both problems.
Section 1: Making forms recognize our custom element
The problem: When you put
<face-text-field name="email">inside a<form>, the form doesn't know it exists. Click submit and the form data is empty.The solution: Two lines of code make the browser treat your element as a "real" form field:
static formAssociated = trueis like raising your hand and saying "Hey browser, I'm a form field!"attachInternals()gives you a "control panel" (the internals object) with buttons for:How we tell the form our value:
Every time the user types, we call
setFormValue(). Think of it as updating a nametag that the form reads when it submits.How reset works:
The browser automatically calls
formResetCallback()when someone clicks a reset button. We don't need to listen for events -- the browser just tells us "reset yourself." We go back to the original value.How disabled works:
If someone wraps our field in
<fieldset disabled>, the browser calls this automatically. No event listeners, no checking parent elements -- the browser just tells us.Analogy: It's like the difference between a restaurant with a phone (native
<input>) and one without (<div>).ElementInternalsgives your custom element a phone so the form can call it.Section 2: The labelling problem (IDREF matrix)
The problem in plain English:
In normal HTML, you connect a label to an input using IDs:
The screen reader sees
aria-labelledby="my-label", finds the element withid="my-label", reads "Email." Simple.But with Shadow DOM, your input lives in a separate "room" (the shadow root). IDs inside that room are invisible to the outside world, and vice versa. It's like two apartments -- apartment A can't see apartment B's room numbers.
The four scenarios we tested:
Scenario A (the easy one) -- in the HTML:
The label (
id="label-2a") and the custom element are in the same "room" (light DOM). The reference works. But there's a catch -- the thing the screen reader actually focuses is the<input>inside the shadow DOM. So we need to get the label text onto that inner input.Scenario B (slotted content) -- in the HTML:
The consumer passes label content via a
<slot>. The slotted element stays in light DOM (it's still in its original room). We listen forslotchangeevents and grab the text:Scenario C (shadow-internal labelling) -- the one the reviewer flagged:
The
<input>saysaria-labelledby="shadow-label"and#shadow-labelis in the same shadow root. This works because they're in the same room. This is NOT cross-root ARIA -- a reviewer caught us being sloppy with the wording here. Both elements are in the same shadow tree, so it's just normal ID resolution that happens to be inside a shadow DOM.Section 3: The critical finding -- who actually gets the label?
This is the most important discovery. Think of it this way:
Your component has two "accessibility identities":
<face-text-field>) -- like the building's front door<input>inside shadow DOM) -- like the actual office you visitWhen you use
delegatesFocus: true, clicking the building (host) automatically puts you in the office (shadow input). The screen reader focuses the office, not the front door.So if you put a name tag (label) on the front door using
internals.ariaLabel, but the screen reader is reading the office -- it won't see the name tag!This is what the strategy isolation proves:
Three modes:
internals-only: puts name tag on the front door only. Result: screen reader says nothing (it's reading the office, not the door).input-only: puts name tag in the office directly. Result: screen reader announces the name (this is where focus is).dual-write: both. Result: works -- resilient.The HTML that demonstrates this side-by-side:
The takeaway you can tell the team: "We proved that
internals.ariaLabelalone doesn't work for screen readers because focus goes to the shadow input, not the host. We need to write the label to both places (dual-write) for it to work reliably."Section 4: The slot listener guard
The problem the reviewer caught:
Every time an attribute changes (like
aria-labelledby), the code calls#wireAriaRelationships(), which calls#handleSlotWiring(), which addsaddEventListener('slotchange', ...). If 5 attributes change, you'd have 5 duplicate listeners doing the same thing.The fix -- a simple boolean flag:
First time: flag is
false, so we proceed and attach listeners, then set flag totrue.Every subsequent call: flag is
true, we bail out immediately.Analogy: It's like checking "did I already subscribe to this newsletter?" before clicking subscribe again.
Section 5: Validation and
ariaInvalidWhen validation fails, we:
internals.ariaInvalidOpen question the reviewer raised: We set
ariaInvalidon the host (front door), but focus is on the shadow input (office). Does the screen reader actually announce "invalid"? We documented this as needing manual screen reader testing -- it's the same dual-identity problem as labels.Section 6: The matrix table uses DOM state, not the actual a11y tree
The reviewer pointed out that our automated "IDREF matrix" checks DOM attributes:
This validates that the DOM wiring is correct, but it's NOT querying the actual accessibility tree. The browser's a11y tree might expose things differently. So we changed the column headers from "a11y tree name" to "DOM name source" to be honest about what we're measuring vs what we're not.
Real a11y tree validation requires either:
Summary for team conversations
static formAssociated = trueattachInternals()setFormValue()formResetCallback()formDisabledCallback()delegatesFocusinternals.ariaLabelinput.aria-labelThe key message: ElementInternals works great for forms. For accessibility labels, you can't just use the internals API alone -- you need to also label the actual focused element inside the shadow DOM.
Manual review test cases
Open the StackBlitz demo and submit form 1a
fullname: "Jane Doe"Verify strategy isolation in section 3a
internals-onlyhas NO accessible name on the focused input; the other two DOScreen reader test with VoiceOver
internals-onlydoes NOT announce a nameAccessibility testing checklist
Keyboard (required)
Screen reader (required)