[Research] PoC: combobox as form-associated custom element (ElementInternals)#6312
[Research] PoC: combobox as form-associated custom element (ElementInternals)#6312Rajdeepc wants to merge 5 commits into
Conversation
- self-contained FACE combobox demo exercising ElementInternals for form value, validation, keyboard, and a11y patterns - documents aria-activedescendant, aria-controls, and popup ownership behavior within shadow DOM boundaries - includes IDREF matrix (light DOM, slotted, shadow, cross-root) with browser pass/fail findings matching the text-field PoC - adds QA tree inspection results confirming role, name, value, and expanded/collapsed states render correctly in the a11y tree 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 |
QA Results — Accessibility Tree, Form, and Screen ReaderTested on: Chrome 136 (macOS Sequoia 15.5) 1. Tree Inspection (role, name, value, expanded/collapsed)All 15 combobox instances on the page were verified via accessibility tree snapshots.
Expanded state details (when opened)
2. Form Submission (open vs closed listbox)
Key observation: When the listbox is open, the shadow DOM popup visually overlays the Submit button (z-index). A user would need to Tab to Submit or close the listbox first. The form value is always in sync via Additional form behaviors confirmed:
3. Screen Reader DocumentationmacOS — VoiceOver (Chromium)Tree structure confirms correct semantics for VoiceOver:
Expected VoiceOver announcement sequence:
Windows — NVDAExpected announcement based on tree structure:
Summary
Notes for reviewers
|
Sets this.#internals.role on the host element so reviewers can inspect how labelling constraints change when the host carries the combobox role via ElementInternals rather than relying solely on the shadow input's role attribute. Co-authored-by: Cursor <cursoragent@cursor.com>
|
| Labelling approach | Behavior with internals.role set |
Constraint |
|---|---|---|
<label for="host-id"> |
Improved — the host IS the combobox, so the label association is direct | delegatesFocus still needed for click-to-focus |
aria-labelledby on host |
Works — resolves in light DOM scope where IDs are visible | Standard IDREF resolution; no cross-root issue for the host itself |
internals.ariaLabel |
Works — labels the host's combobox role directly | May not be announced if SR focuses the shadow input instead of the host |
internals.ariaLabelledByElements |
Works — element-ref API labels the host | Same SR focus caveat |
Slotted <span slot="label"> |
Partial — string fallback still needed | Slotted content IDs aren't in shadow scope; same constraint as before |
The key constraint: focus target vs role-bearing element
The critical issue is focus routing:
Host element (internals.role = 'combobox')
└── Shadow root (delegatesFocus: true)
└── <input role="combobox"> ← SR focuses HERE
delegatesFocusforwards focus to the shadow<input>- Screen readers announce properties of the focused element, not necessarily the host
- If the shadow input ALSO has
role="combobox", the SR announces the input's ARIA, not the host's - If you REMOVE
rolefrom the shadow input, the input becomes a generic textbox inside the host's combobox — but thenaria-activedescendantandaria-controlson the input lose their combobox context
Recommended approach options
Option A: Host is the combobox (remove role from shadow input)
this.#internals.role = 'combobox';
// Shadow input: NO role attribute, just a plain textbox
// Pro: <label for>, aria-labelledby in light DOM "just work"
// Con: aria-activedescendant must be on a combobox/textbox/searchbox — needs validationOption B: Shadow input is the combobox (current production approach)
// No internals.role
// Shadow input: role="combobox"
// Pro: activedescendant, controls, expanded all work naturally on the focused element
// Con: labelling requires dual-write strategyOption C: Both (what this commit tests)
this.#internals.role = 'combobox';
// Shadow input: role="combobox"
// Pro: tree shows correct structure; labels resolve from both light and shadow DOM
// Con: relies on Chrome collapsing duplicates; other browsers may notWhat to inspect in DevTools
To see the full picture, open Chrome DevTools > Elements > Accessibility tab:
- Click the
<face-combobox>host element — you should seeRole: comboboxfrom internals - Expand into the shadow root and click the
<input>— you should also seeRole: combobox - Compare the "Accessible Name" on each node
- Check which node the "Name" labels actually resolve against
The key question for the team: do we want the host or the shadow input to be the canonical combobox role bearer? This determines the labelling strategy for all 2nd-gen form components.
TL;DR
Without internals.role |
With internals.role = 'combobox' |
|
|---|---|---|
| Host role in tree | generic / none | combobox |
<label for> labels host? |
Only via fallback | Yes, directly |
aria-labelledby on host |
Passes to shadow via dual-write | Labels host combobox directly |
| SR focus target | Shadow input | Shadow input (unchanged) |
| Dual-write still needed? | Yes | Yes (SR focuses input, not host) |
| Risk | None | Browser inconsistency for duplicate roles |
…unds Restructure for StackBlitz forkability: - face-combobox.js: component (pure ElementInternals, no cross-root hacks) - demo.js: form handlers, logging, axe-core audit - styles.css: page-level styles - index.html: demo scenarios Removed all JS that bridges cross-root IDREFs: - No #wireAriaRelationships / ariaLabelledByElements - No #handleSlotWiring / slotchange listeners - No #wireImplicitLabel / internals.labels text extraction - No dual-write to shadow input for labels from light DOM Added USE_INTERNALS_ROLE toggle (line 17 of face-combobox.js) so reviewers can compare tree behavior with/without internals.role set. Co-authored-by: Cursor <cursoragent@cursor.com>
| font-family: var(--font-sans); | ||
| color: var(--color-text); | ||
| background: var(--color-bg); | ||
| margin: 0; |
There was a problem hiding this comment.
🚫 [stylelint] <order/properties-order> reported by reviewdog 🐶
Expected "margin" to come before "background"
| color: var(--color-text); | ||
| background: var(--color-bg); | ||
| margin: 0; | ||
| padding: 2rem; |
There was a problem hiding this comment.
🚫 [stylelint] <order/properties-order> reported by reviewdog 🐶
Expected "padding" to come before "margin"
|
|
||
| h1 { | ||
| font-size: 1.5rem; | ||
| margin: 0 0 0.25rem; |
There was a problem hiding this comment.
🚫 [stylelint] <order/properties-order> reported by reviewdog 🐶
Expected "margin" to come before "font-size"
|
|
||
| h2 { | ||
| font-size: 1.25rem; | ||
| margin: 2rem 0 1rem; |
There was a problem hiding this comment.
🚫 [stylelint] <order/properties-order> reported by reviewdog 🐶
Expected "margin" to come before "font-size"
| h2 { | ||
| font-size: 1.25rem; | ||
| margin: 2rem 0 1rem; | ||
| padding-bottom: 0.25rem; |
There was a problem hiding this comment.
🚫 [stylelint] <order/properties-order> reported by reviewdog 🐶
Expected "padding-bottom" to come before "margin"
| font-family: var(--font-mono); | ||
| font-size: 0.8125rem; | ||
| background: #f3f4f6; | ||
| padding: 0.125rem 0.375rem; |
There was a problem hiding this comment.
🚫 [stylelint] <order/properties-order> reported by reviewdog 🐶
Expected "padding" to come before "background"
| background: #fef2f2; | ||
| border: 1px solid #fecaca; | ||
| border-radius: var(--radius); | ||
| padding: 1rem; |
There was a problem hiding this comment.
🚫 [stylelint] <order/properties-order> reported by reviewdog 🐶
Expected "padding" to come before "border-radius"
| background: #f0fdf4; | ||
| border: 1px solid #bbf7d0; | ||
| border-radius: var(--radius); | ||
| padding: 0.75rem; |
There was a problem hiding this comment.
🚫 [stylelint] <order/properties-order> reported by reviewdog 🐶
Expected "padding" to come before "border-radius"
| background: #fffbeb; | ||
| border: 1px solid #fde68a; | ||
| border-radius: var(--radius); | ||
| padding: 0.75rem; |
There was a problem hiding this comment.
🚫 [stylelint] <order/properties-order> reported by reviewdog 🐶
Expected "padding" to come before "border-radius"
| border-radius: 3px; | ||
| } | ||
|
|
||
| code { |
There was a problem hiding this comment.
🚫 [stylelint] <no-descending-specificity> reported by reviewdog 🐶
Expected selector "code" to come before selector ".config-banner code", at line 147
There was a problem hiding this comment.
🚫 [stylelint] <swc/header> reported by reviewdog 🐶
Header is missing or does not match the required format.
The big picture: what problem are we solving?
When you build a custom dropdown/combobox using Web Components (shadow DOM), browsers don't automatically treat it like a native
<select>or<input>. Forms don't see its value, screen readers don't know what it is, and keyboard navigation doesn't work out of the box.ElementInternalsis a browser API that lets you say: "Hey browser, treat my custom element like a real form control." Our PoC proves this works for a combobox (which is much more complex than a text input).Without ElementInternals: Custom combobox is invisible to native forms and screen readers.
With ElementInternals: Custom combobox participates in form submit/reset/validation, and exposes ARIA properties to assistive technology.
Section 1: "Can forms see my custom combobox?"
The question: If I put my custom
<face-combobox>inside a<form>and click Submit, does the browser know its value?The magic line that makes it work:
static formAssociated = trueis like raising your hand and saying "I belong in forms."attachInternals()gives you a toolbox to participate in form behavior.Setting the value so the form can see it:
Every time the user types or picks an option, we call
setFormValue(). This is how the form "sees" the value when you submit.HTML usage (looks just like a native element!):
When you click Submit,
FormDataincludesfruit: "Apple"just like a native<select>would.Reset and disabled behavior:
These are "lifecycle callbacks" -- the browser calls them for you. No event listeners needed. It's like the browser is saying "Hey, your form just reset" or "Hey, your fieldset just got disabled."
Section 2: "Does the keyboard work?"
The question: Can someone navigate the combobox using only a keyboard? (This matters for accessibility and power users.)
The keyboard handler:
Think of it like a contract: "ArrowDown opens the menu and moves down, Enter picks the highlighted item, Escape closes without picking." This follows the APG combobox pattern, which is the official playbook for how comboboxes should behave.
aria-activedescendant: telling the screen reader which option is highlighted:Without
aria-activedescendant, the screen reader doesn't know which option you're hovering over with the arrow keys. This is the main difference from a simple text input: the combobox has to communicate "I'm pointing at option 3 right now" to assistive technology.Section 3: "Can screen readers figure out the label?"
The question: If I have a label like "Timezone" next to my combobox, does the screen reader announce "Timezone, combo box" when you focus it?
This is trickier than it sounds because of shadow DOM boundaries. Think of shadow DOM as a one-way mirror: things inside can't see things outside, and vice versa.
Scenario A (easy): Label is a sibling in the same page
This works because both the label and the combobox are in the "light DOM" (the regular page). They can see each other's IDs. This is the recommended pattern.
Scenario B: Label is slotted inside the component
Slotted content "lives" in light DOM but "appears" inside the shadow DOM visually. The tricky part: the shadow input can't directly reference
label-3bby ID (different scope). So the code does this:Scenario C: Everything inside shadow DOM
When the label is rendered INSIDE the shadow root, same-scope IDs work fine:
Scenario D:
<label for="...">pointing at the custom elementClicking the label focuses the input (thanks to
delegatesFocus: true), but browsers don't consistently pass the label's name to the accessibility tree. We work around it:The IDREF matrix in plain English:
<label for>pointing at hostSection 4: "Combobox-specific shadow DOM challenges"
The question: The combobox has a popup (listbox) with options. Can the browser's accessibility tree "see" which option is active when everything is inside a shadow root?
Key insight: keep the input and the listbox in the SAME shadow root.
Because
inputandlistboxshare the same shadow root,aria-controls(which says "I control that listbox") resolves correctly. If you were to move the listbox to a different DOM layer (like a portal for z-index), this would break.The expanded/collapsed state (dual-write):
We write
aria-expandedin three places because screen readers focus the shadow input (not the host element), so the input must carry the state.Section 5: "
internals.ariaLabelvs hostaria-label"The question: If we set the accessible name via the new
ElementInternalsAPI vs the oldaria-labelattribute, which one do screen readers actually announce?Finding: you need BOTH.
Why? Browsers expose the shadow input as the thing the screen reader interacts with (it's what gets focus). The host's
ElementInternalsARIA goes on a separate node that screen readers may not announce. So you need the "dual-write" pattern: set it on internals for the spec, and on the input for real-world screen readers.Section 6: "axe-core audit"
The question: Does our combobox pass automated accessibility testing?
axe-core is like a robot that checks your page for accessibility problems. We load it from CDN and run it against the whole page:
Important caveat: axe-core can't always read
ElementInternalsARIA properties (it mostly inspects DOM attributes). So if you ONLY setinternals.ariaLabelwithout also puttingaria-labelon the input, axe might falsely report "this element has no accessible name." That's another reason for the dual-write pattern.The one-sentence summary
Talking points for team conversation
"Why not just use a native
<select>?" -- Because we need custom styling, filtering, and free-text entry. Native selects are almost impossible to style."Is ElementInternals ready for production?" -- Yes for form participation (Chrome 77+, Firefox 98+, Safari 16.4+). The ARIA parts work but need the dual-write workaround until tools like axe-core catch up.
"What's the non-negotiable rule?" -- The input and the listbox options must live in the same shadow root. If you portal the popup elsewhere for z-index reasons, accessibility breaks.
"What still needs manual testing?" -- Screen reader announcement quality. The automated tree inspection shows the correct structure, but a human needs to verify that VoiceOver/NVDA actually speaks the right things at the right time.
Decisions
ElementInternalsand the shadow<input>internals.ariaLabelalone is not announced. axe-core also can't inspect internals-only ARIA.document.bodyaria-activedescendantandaria-controlsresolve IDs within a single tree root. Cross-root IDREF resolution is not supported by any browser.popoverattribute on same-root element (viable future option once Popover API is stable)delegatesFocus: trueon the shadow root<label for>) forwards focus to the internal<input>. Without it, focus lands on the host and keyboard interaction doesn't start.focus()inclickhandler (fragile, doesn't cover all entry paths likelabel.click())textContentfrom slotted elements and apply asaria-labelaria-labelledby. A string copy is the only reliable cross-root bridge.aria-labelledbypointing at slotted ID (doesn't resolve cross-root); AOMariaLabelledByElements(not yet shipped)setFormValue()on every input/selection eventFormDatareads from JavaScript and break incremental validation.change(misses intermediate states); sync on submit (no API for that in FACE)setValidity()+reportValidity()for constraint validation:invalidpseudo-class and browser-default error UI (tooltip). No custom error display needed for the PoC.getByRoleall read DOM attributes more reliably thanElementInternalsARIA today. Addingroleandaria-*on the host ensures tooling works now.Manual review test cases
Open the demo and verify form submission captures the combobox value
research/form-associated-ce-combobox-poc/index.htmllocallyfruit: "Apple"and "Form participation: PASS"Verify keyboard navigation opens the listbox and cycles options
Verify constraint validation rejects empty required field
validity.valueMissing: trueand "PASS (correctly rejected)"Verify disabled combobox is excluded from FormData
Verify expanded/collapsed state in the a11y tree
aria-expanded="true", listbox with option children is visibleDevice review
Accessibility testing checklist
Keyboard (required)
Screen reader (required)
aria-activedescendant