Skip to content

chore: add ElementInternals text field PoC for forms strategy#6305

Open
Rajdeepc wants to merge 14 commits into
mainfrom
rajdeepchandra/chore-face-textfield-poc-swc-2052
Open

chore: add ElementInternals text field PoC for forms strategy#6305
Rajdeepc wants to merge 14 commits into
mainfrom
rajdeepchandra/chore-face-textfield-poc-swc-2052

Conversation

@Rajdeepc
Copy link
Copy Markdown
Contributor

@Rajdeepc Rajdeepc commented May 15, 2026

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 of sp-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)

  • SWC-2052

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:

  1. 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.

  2. 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:

class FaceTextField extends HTMLElement {
  static formAssociated = true;  // "Hey browser, I'm a form field!"

  constructor() {
    super();
    this.#internals = this.attachInternals(); // Get the "control panel"
  }
}

static formAssociated = true is 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:

  • Setting the form value (what gets submitted)
  • Setting validation state (is this field valid or not?)
  • Setting accessibility info (what should screen readers say?)

How we tell the form our value:

#syncValue() {
  this.#internals.setFormValue(this.#input.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:

formResetCallback() {
  const defaultValue = this.getAttribute('value') ?? '';
  this.#input.value = defaultValue;
  this.#syncValue();
  this.#validate();
  this.#clearErrorState();
}

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:

formDisabledCallback(disabled) {
  this.#input.disabled = disabled;
  if (disabled) {
    this.setAttribute('disabled', '');
  } else {
    this.removeAttribute('disabled');
  }
}

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>). ElementInternals gives 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:

<label id="my-label">Email</label>
<input aria-labelledby="my-label" />

The screen reader sees aria-labelledby="my-label", finds the element with id="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 Analogy
A: Light DOM siblings Label and field are in the same room -- works fine
B: Slotted children Label is "visiting" the component via a slot -- still in its original room technically
C: Shadow internal Label AND input are both inside the component's room -- works fine (same room)
D: Cross-root label-for Label is outside, input is inside -- the hard one

Scenario A (the easy one) -- in the HTML:

<label id="label-2a">Email address</label>
<face-text-field
  id="field-2a"
  name="email"
  aria-labelledby="label-2a"
  aria-describedby="help-2a"
></face-text-field>
<span id="help-2a" class="help-text">We will never share your email.</span>

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:

<face-text-field id="field-2b" name="phone">
  <span slot="label" id="label-2b">Phone number</span>
  <span slot="help-text" id="help-2b">Include country code.</span>
</face-text-field>

The consumer passes label content via a <slot>. The slotted element stays in light DOM (it's still in its original room). We listen for slotchange events and grab the text:

#handleSlotWiring() {
  if (this.#slotListenersAttached) return;  // Only attach once!
  this.#slotListenersAttached = true;

  const labelSlot = this.shadowRoot.querySelector('slot[name="label"]');
  if (labelSlot) {
    labelSlot.addEventListener('slotchange', () => {
      const assigned = labelSlot.assignedElements();
      if (assigned.length > 0) {
        this.#applyLabel(assigned[0].textContent?.trim());
      }
    });
  }
}

Scenario C (shadow-internal labelling) -- the one the reviewer flagged:

#updateInternalLabel(text) {
  if (text) {
    this.#shadowLabel.textContent = text;
    this.#shadowLabel.hidden = false;
    this.#input.setAttribute('aria-labelledby', 'shadow-label');
  }
}

The <input> says aria-labelledby="shadow-label" and #shadow-label is 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":

  1. The host element (<face-text-field>) -- like the building's front door
  2. The shadow input (<input> inside shadow DOM) -- like the actual office you visit

When 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:

get #labellingStrategy() {
  return this.getAttribute('labelling-strategy') || 'dual-write';
}

#applyLabel(text) {
  if (!text) return;
  const strategy = this.#labellingStrategy;

  // Strategy 1: only put the name tag on the front door (host)
  if (strategy === 'internals-only' || strategy === 'dual-write') {
    this.#internals.ariaLabel = this.#internals.ariaLabel || text;
  }

  // Strategy 2: only put the name tag in the office (shadow input)
  if (strategy === 'input-only' || strategy === 'dual-write') {
    if (!this.#input.hasAttribute('aria-labelledby')) {
      this.#input.setAttribute('aria-label', text);
    }
  }
}

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:

<!-- This one FAILS -- only sets internals.ariaLabel -->
<face-text-field
  use-internals-aria-label="Search via internals"
  labelling-strategy="internals-only"
  placeholder="internals.ariaLabel only"
></face-text-field>

<!-- This one WORKS -- sets aria-label on the shadow input -->
<face-text-field
  use-internals-aria-label="Search via shadow input"
  labelling-strategy="input-only"
  placeholder="shadow input aria-label only"
></face-text-field>

<!-- This one WORKS -- sets both (recommended) -->
<face-text-field
  use-internals-aria-label="Search via dual write"
  labelling-strategy="dual-write"
  placeholder="both internals + shadow input"
></face-text-field>

The takeaway you can tell the team: "We proved that internals.ariaLabel alone 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 adds addEventListener('slotchange', ...). If 5 attributes change, you'd have 5 duplicate listeners doing the same thing.

The fix -- a simple boolean flag:

#slotListenersAttached = false;

#handleSlotWiring() {
  if (this.#slotListenersAttached) return;  // Already done? Bail.
  this.#slotListenersAttached = true;
  // ... attach listeners only once ...
}

First time: flag is false, so we proceed and attach listeners, then set flag to true.
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 ariaInvalid

#showErrorState() {
  this.#internals.ariaInvalid = 'true';
  const errorId = this.getAttribute('aria-errormessage');
  if (errorId) {
    const errorEl = document.getElementById(errorId);
    if (errorEl) errorEl.hidden = false;
  }
}

When validation fails, we:

  1. Tell the browser "this field is invalid" via internals.ariaInvalid
  2. Show the error message element (unhide it)

Open question the reviewer raised: We set ariaInvalid on 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:

const ariaLabel = field.getAttribute('aria-label');

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:

  • DevTools Accessibility panel (manual)
  • Screen reader testing (manual)

Summary for team conversations

Concept What does this mean?
static formAssociated = true "Browser, treat me like a real form field"
attachInternals() Get the control panel for form + accessibility APIs
setFormValue() Tell the form what our current value is
formResetCallback() Browser tells us "reset yourself" (automatic)
formDisabledCallback() Browser tells us "you're disabled now" (automatic)
delegatesFocus Clicking the outer element auto-focuses the inner input
internals.ariaLabel Puts a name tag on the front door (host)
input.aria-label Puts a name tag in the office (shadow input)
Dual-write Put name tags on BOTH -- because SRs read the office
Shadow-local IDREF IDs work fine when both elements are in the same room
Cross-root ARIA IDs DON'T work across room boundaries -- no fix yet

The 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

    1. Go to StackBlitz demo
    2. Click "Submit" on form 1a
    3. Expect: FormData shows fullname: "Jane Doe"
  • Verify strategy isolation in section 3a

    1. Open DevTools → Accessibility panel
    2. Focus each of the 3a fields (internals-only, input-only, dual-write)
    3. Expect: internals-only has NO accessible name on the focused input; the other two DO
  • Screen reader test with VoiceOver

    1. Enable VoiceOver, Tab to each field in section 2 (A, B, C, D)
    2. Expect: VoiceOver announces the label for A, B, C. D may be partial.
    3. Tab to section 3a fields — confirm internals-only does NOT announce a name

Accessibility testing checklist

  • Keyboard (required)

    1. Open the demo, press Tab repeatedly
    2. Expect: Focus moves through all fields in logical order; focus indicator is visible on each
    3. Expect: No focus traps; Tab moves past the last field to the buttons
  • Screen reader (required)

    1. Enable VoiceOver (Cmd+F5 on Mac) or NVDA (Windows)
    2. Tab to field 2A (Email) — Expect: "Email address, edit text" announced
    3. Tab to field 2B (Phone) — Expect: "Phone number, edit text" announced
    4. Tab to field 3a-internals — Expect: NO label announced (just "edit text" or placeholder)
    5. Tab to field 3a-input — Expect: "Search via shadow input, edit text" announced
    6. Tab to field 3a-dual — Expect: "Search via dual write, edit text" announced
    7. Submit form 3b with value "15" — Expect: error state announced

- 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>
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 15, 2026

⚠️ No Changeset found

Latest commit: c67fd9e

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 15, 2026

📚 Branch Preview Links

🔍 First Generation Visual Regression Test Results

When 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: pr-6305

If the changes are expected, update the current_golden_images_cache hash in the circleci config to accept the new images. Instructions are included in that file.
If the changes are unexpected, you can investigate the cause of the differences and update the code accordingly.

Rajdeep Chandra and others added 5 commits May 15, 2026 17:05
- 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>
@Rajdeepc Rajdeepc added Status:WIP PR is a work in progress or draft Form Patterns labels May 17, 2026
Rajdeep Chandra and others added 2 commits May 18, 2026 13:34
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>
@Rajdeepc
Copy link
Copy Markdown
Contributor Author

Rajdeepc commented May 18, 2026

Manual screen reader testing results (VoiceOver, macOS + Chrome)

Strategy isolation (Section 3a) — the key finding

Strategy Label announced on focus? Verdict
internals-only No — VO reads placeholder or "edit text" only Proves internals.ariaLabel alone fails
input-only Yes — VO reads "Search via shadow input" Shadow input aria-label is what SRs read
dual-write Yes — VO reads "Search via dual write" Resilient; recommended pattern

IDREF matrix scenarios (Section 2)

Scenario Name announced Description announced Result
A (Light DOM siblings) "Email address" "We will never share your email" Pass
B (Slotted children) "Phone number" "Include country code" Pass
C (Shadow internal) "Search query" "Type keywords to search" Pass
D (Cross-root label-for) "Favorite color" N/A Pass

Validation (Section 3b)

  • Typed 15 into the age field (min-value="18"), submitted
  • VoiceOver announced the invalid state
  • Error message became visible

Confirms: internals.ariaInvalid = 'true' IS announced by VoiceOver even when focus is on the shadow input.

<label for> + delegatesFocus (Scenario D)

  • Clicking the label text correctly focused the shadow input ✓
  • VoiceOver announced the label name ✓

This was previously marked as "partial" from automated testing — now confirmed working in VoiceOver.


Closed open questions

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>
@Rajdeepc
Copy link
Copy Markdown
Contributor Author

Rajdeepc commented May 18, 2026

Decisions from this PoC exercise


Adopt

Decision Evidence
Use ElementInternals + FACE for all form-participating 2nd-gen components Form value, reset, disabled, validation all work natively. No polyfills. Browser support solid.
Always dual-write accessible names (internals + shadow input) internals-only fails to announce in VoiceOver. Only the shadow input's aria-label is read by SRs.
Primary labelling: host aria-labelledby referencing light DOM IDs Cross-browser, axe-compatible. Consumers provide labels in light DOM; component resolves and forwards.
Support slot-based labels via slotchange wiring Passes; component reads slotted text and dual-writes. Standardize label and help-text slot names.
Use delegatesFocus: true for focus management Replaces 1st-gen Focusable mixin. <label for> click-to-focus confirmed working in VoiceOver.
internals.ariaInvalid is sufficient for SR invalid announcements VoiceOver announces invalid state when set via internals.
axe policy: story-level exclusions, never global disables Dual-write mitigates most axe issues. Document exclusions with upstream Deque links.

Avoid

Decision Reason
Do NOT rely on internals.ariaLabel alone SRs do not announce it on the focused shadow input
Do NOT assume cross-root ARIA works No production solution; referenceTarget is not cross-browser
Do NOT use ariaLabelledByElements as sole mechanism Chrome 130+ only; progressive enhancement only
Do NOT disable axe rules globally Masks real violations

Build next: shared form mixin

The mixin should encapsulate:

  1. static formAssociated = true
  2. attachInternals() in constructor
  3. setFormValue() on every input change
  4. setValidity() mirroring inner input's ValidityState
  5. formResetCallback() — restore to default value
  6. formDisabledCallback() — forward disabled state
  7. formStateRestoreCallback() — browser back/forward
  8. Dual-write ARIA labelling (#applyLabel / #applyDescription)
  9. Slot wiring with slotchange (with deduplication guard)
  10. delegatesFocus: true on shadow root

Monitor for future simplification

API What it replaces when shipped
referenceTarget Eliminates text-based dual-write; direct IDREF across roots
ariaLabelledByElements (Firefox + Safari) Eliminates text resolution from IDs
axe-core ElementInternals support (Deque) Removes need for dual-write for axe

@Rajdeepc Rajdeepc marked this pull request as ready for review May 18, 2026 13:15
@Rajdeepc Rajdeepc requested a review from a team as a code owner May 18, 2026 13:15
Rajdeepc and others added 2 commits May 18, 2026 18:45
Findings are captured in PR comments and description instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Rajdeepc Rajdeepc self-assigned this May 18, 2026
@Rajdeepc Rajdeepc added Status:Ready for review PR ready for review or re-review. and removed Status:WIP PR is a work in progress or draft labels May 18, 2026
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>
@Rajdeepc
Copy link
Copy Markdown
Contributor Author

Rajdeepc commented May 19, 2026

Added: internals.role scenarios (Section 3c)

What's been added

Five fields, each isolating a different combination:

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:

  1. delegatesFocus wins — Focus still goes to the shadow <input>, so the host's role/label is still bypassed. SR reads the shadow input.
  2. Host role wins — The host IS the textbox now, so internals.ariaLabel gets 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

  1. Open the StackBlitz demo (or run locally)
  2. Turn on VoiceOver
  3. Tab to section 3c
  4. 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?

Rajdeep Chandra and others added 2 commits May 19, 2026 21:55
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>
@Rajdeepc Rajdeepc removed the Status:Ready for review PR ready for review or re-review. label May 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant