From 1d8ced63adf88664e2f6d221839b342b8398dd15 Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Wed, 13 May 2026 17:42:10 +0530 Subject: [PATCH 01/13] chore: add ElementInternals text field PoC for forms strategy - 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 --- research/form-associated-ce-poc/SUMMARY.md | 321 +++++ research/form-associated-ce-poc/index.html | 1290 ++++++++++++++++++++ 2 files changed, 1611 insertions(+) create mode 100644 research/form-associated-ce-poc/SUMMARY.md create mode 100644 research/form-associated-ce-poc/index.html diff --git a/research/form-associated-ce-poc/SUMMARY.md b/research/form-associated-ce-poc/SUMMARY.md new file mode 100644 index 0000000000..bac60f5a5e --- /dev/null +++ b/research/form-associated-ce-poc/SUMMARY.md @@ -0,0 +1,321 @@ +# Form-associated custom element PoC: text field with ElementInternals + +> Research artifact for the 2nd-gen forms strategy. +> Ticket: SWC-2052 | Blocks: direction PR (SWC-2054) + +## Demo + +Open `index.html` in any browser (or paste into CodePen) to interact with the PoC. +No build step, no dependencies; axe-core loads from CDN on demand. + +--- + +## 1. Does native form submission and reset see the control's value? + +### FormData + +| Behavior | Result | Notes | +| -------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------ | +| `FormData` includes field name and value on submit | Pass (Chrome, Firefox, Safari) | `setFormValue()` in `input` handler is the key call | +| Multiple FACE fields in one form | Pass | Each name appears as a separate entry | +| Disabled FACE fields excluded from FormData | Pass | `formDisabledCallback` fires; matches native `` behavior | + +### Reset + +| Behavior | Result | Notes | +| --------------------------------------------- | ------ | ------------------------------------------------------------------------- | +| `formResetCallback()` fires on `
` reset | Pass | Restores to the `value` attribute (initial value), not empty | +| Internal input reflects reset value | Pass | Callback receives no arguments; read default from `getAttribute('value')` | + +### Constraint validation + +| Behavior | Result | Notes | +| ------------------------------------------------------------------ | ---------------------- | ------------------------------------------------------------------------------ | +| `required` prevents empty submit (`valueMissing`) | Pass | `setValidity()` mirrors the inner input's `ValidityState` | +| `pattern` attribute checked (`patternMismatch`) | Pass | Pattern is forwarded to the shadow input; internals mirror its state | +| Custom validity via `setValidity({ rangeUnderflow }, msg, anchor)` | Pass | Anchor (3rd arg) positions the browser tooltip near the shadow input | +| `reportValidity()` shows browser tooltip | Pass (Chrome, Firefox) | Safari shows tooltip inconsistently for custom elements; test version-specific | +| `checkValidity()` returns boolean without UI | Pass | Matches native API | + +### State restoration + +| Behavior | Result | Notes | +| -------------------------------------------------- | ------------- | -------------------------------------------------- | +| `formStateRestoreCallback()` fires on back/forward | Pass (Chrome) | Firefox behavior may vary; Safari support is newer | + +### Summary + +**Form participation works well.** `ElementInternals` + `setFormValue()` + `setValidity()` give us native-equivalent form behavior with no polyfills. The main caution is `reportValidity()` tooltip positioning in Safari, which can be version-dependent. + +--- + +## 2. IDREF matrix: does the accessibility tree resolve relationships? + +### Scenario A: light DOM label and help as siblings + +IDs on elements _outside_ the component, referenced via `aria-labelledby` / `aria-describedby` on the host. + +| Aspect | Chrome | Firefox | Safari | Notes | +| ------------------------------------------------ | --------- | ------------- | --------- | -------------------------------------------------------------------------------------- | +| `aria-labelledby` resolves to light DOM sibling | Pass | Pass | Pass | Host attributes referencing light DOM IDs work because both are in the same tree scope | +| `aria-describedby` resolves to light DOM sibling | Pass | Pass | Pass | Same tree scope; no cross-root issue | +| Screen reader announces label | Pass (VO) | Verify (NVDA) | Pass (VO) | VoiceOver reads the referenced label text | +| Screen reader announces description | Pass (VO) | Verify (NVDA) | Pass (VO) | VoiceOver reads the help text after a pause | + +**Conclusion:** This scenario works because the IDREF and the target element share the same DOM tree (light DOM). No cross-root issue. **Recommended pattern** for label and help text when the component does not own the label rendering. + +### Scenario B: IDs on slotted light DOM children + +Consumer projects content into the component via ``. IDs live on the slotted elements (which remain in light DOM despite visual projection). + +| Aspect | Chrome | Firefox | Safari | Notes | +| ------------------------------------------------- | ------------------ | ------- | ------- | ------------------------------------------------------------------------------------ | +| Slotted label ID resolvable from light DOM | Pass | Pass | Pass | Slotted elements stay in the light DOM tree; their IDs are in the host's scope | +| `ariaLabelledByElements` (element ref API) | Pass (Chrome 130+) | Partial | Not yet | Only Chrome ships the element reference APIs; others need ID fallback | +| Shadow input `aria-labelledby` → slotted ID | Fail | Fail | Fail | Shadow input cannot reference a light DOM ID; the input is in a different tree scope | +| Workaround: set `aria-labelledby` on host instead | Pass | Pass | Pass | Host is in light DOM, so it can reference slotted children's IDs | + +**Conclusion:** Slotted content keeps its light DOM identity; IDs are reachable from the host or other light DOM elements. However, the _shadow input_ cannot reference slotted IDs directly. Two approaches: + +1. Use `ariaLabelledByElements` on `ElementInternals` (Chrome only as of testing). +2. Set `aria-labelledby` on the **host** (works cross-browser) and rely on the host's role to carry the accessible name through. + +### Scenario C: IDs inside shadow DOM (internal wiring) + +Label and help text rendered _inside_ the shadow root. Component uses shadow-scoped IDs on the internal input. + +| Aspect | Chrome | Firefox | Safari | Notes | +| ------------------------------------------------- | --------- | ------------- | --------- | ------------------------------------------------------ | +| Shadow input `aria-labelledby` → shadow-scoped ID | Pass | Pass | Pass | Both elements share the same shadow root scope | +| Light DOM element referencing shadow ID | Fail | Fail | Fail | Expected: shadow IDs are encapsulated | +| `internals.ariaLabel` set from shadow label text | Pass | Pass | Pass | Fallback approach; explicit string, not IDREF | +| Screen reader announces internal label | Pass (VO) | Verify (NVDA) | Pass (VO) | Either via shadow-scoped IDREF or `ariaLabel` fallback | + +**Conclusion:** Shadow-internal IDs work fine for _within-shadow_ wiring (input and label in the same shadow root). This is a valid pattern when the component **owns** both the label and the control. External consumers cannot reference shadow IDs, which is by design. + +### Scenario D: cross-root; light DOM `