Skip to content

[Research] PoC: combobox as form-associated custom element (ElementInternals)#6312

Open
Rajdeepc wants to merge 5 commits into
mainfrom
rajdeepchandra/chore-face-combobox-poc
Open

[Research] PoC: combobox as form-associated custom element (ElementInternals)#6312
Rajdeepc wants to merge 5 commits into
mainfrom
rajdeepchandra/chore-face-combobox-poc

Conversation

@Rajdeepc
Copy link
Copy Markdown
Contributor

@Rajdeepc Rajdeepc commented May 17, 2026

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.

ElementInternals is 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:

class FaceCombobox extends HTMLElement {
  static formAssociated = true;  // <-- "I'm a form control!"

  constructor() {
    super();
    this.#internals = this.attachInternals(); // <-- get the internals API
  }
}

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

#syncValue() {
  this.#internals.setFormValue(this.#input.value);
}

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!):

<form id="form-1a">
  <label for="field-1a">Favorite fruit</label>
  <face-combobox
    id="field-1a"
    name="fruit"
    value="Apple"
    options='["Apple", "Banana", "Cherry", "Grape"]'
  ></face-combobox>
  <button type="submit">Submit</button>
</form>

When you click Submit, FormData includes fruit: "Apple" just like a native <select> would.

Reset and disabled behavior:

formResetCallback() {
  // Browser calls this automatically when the form resets
  const defaultValue = this.getAttribute('value') ?? '';
  this.#input.value = defaultValue;
  this.#syncValue();
}

formDisabledCallback(disabled) {
  // Browser calls this when a parent <fieldset> is disabled
  this.#input.disabled = disabled;
  this.#trigger.disabled = disabled;
}

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:

#onKeydown(e) {
  switch (e.key) {
    case 'ArrowDown':
      e.preventDefault();
      if (!this.#isOpen) {
        this.#open();  // Open the dropdown
      }
      this.#moveActive(1);  // Move highlight down
      break;

    case 'ArrowUp':
      e.preventDefault();
      this.#moveActive(-1);  // Move highlight up
      break;

    case 'Enter':
      if (this.#isOpen && this.#activeIndex >= 0) {
        e.preventDefault();
        this.#selectOption(this.#filteredOptions[this.#activeIndex]);
      }
      break;

    case 'Escape':
      if (this.#isOpen) {
        this.#close();  // Close without selecting
      }
      break;
  }
}

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:

#updateActive() {
  const options = this.#listbox.querySelectorAll('[role="option"]');
  options.forEach((opt, i) => {
    opt.classList.toggle('active', i === this.#activeIndex);
  });

  if (this.#activeIndex >= 0) {
    const activeOpt = options[this.#activeIndex];
    // This tells the screen reader: "the user is on THIS option"
    this.#input.setAttribute('aria-activedescendant', activeOpt.id);
  }
}

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

<label id="label-3a">Timezone</label>
<face-combobox
  aria-labelledby="label-3a"
  aria-describedby="help-3a"
></face-combobox>
<span id="help-3a">Select your primary timezone.</span>

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

<face-combobox id="field-3b" name="department">
  <span slot="label" id="label-3b">Department</span>
  <span slot="help-text" id="help-3b">Choose your department.</span>
</face-combobox>

Slotted content "lives" in light DOM but "appears" inside the shadow DOM visually. The tricky part: the shadow input can't directly reference label-3b by ID (different scope). So the code does this:

#handleSlotWiring() {
  const labelSlot = this.shadowRoot.querySelector('slot[name="label"]');
  labelSlot.addEventListener('slotchange', () => {
    const assigned = labelSlot.assignedElements();
    if (assigned.length > 0) {
      const labelEl = assigned[0];
      // Grab the text and set it as a string label on the input
      this.#applyLabel(labelEl.textContent?.trim());
    }
  });
}

Scenario C: Everything inside shadow DOM

<face-combobox
  internal-label="Search category"
  internal-help="Type to filter categories."
></face-combobox>

When the label is rendered INSIDE the shadow root, same-scope IDs work fine:

#updateInternalLabel(text) {
  this.#shadowLabel.textContent = text;
  this.#shadowLabel.hidden = false;
  // Both the label and input are in the same shadow root = works!
  this.#input.setAttribute('aria-labelledby', 'shadow-label');
}

Scenario D: <label for="..."> pointing at the custom element

<label for="field-3d">Priority level</label>
<face-combobox id="field-3d"></face-combobox>

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

#wireImplicitLabel() {
  requestAnimationFrame(() => {
    const labels = this.#internals.labels; // Browser tells us our labels
    if (labels && labels.length > 0) {
      const text = Array.from(labels)
        .map((l) => l.textContent?.trim())
        .filter(Boolean)
        .join(' ');
      if (text) this.#applyLabel(text); // Set it manually as fallback
    }
  });
}

The IDREF matrix in plain English:

Where the label lives Can the screen reader find it? Should we use this?
Same page, next to component Yes, always Yes (best option)
Slotted inside component Partially (need workaround) Yes, with fallback
Inside shadow DOM Yes (same scope) Yes, if component owns the label
<label for> pointing at host Inconsistent across browsers Avoid without fallback

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

const shadow = this.attachShadow({ mode: 'open', delegatesFocus: true });
shadow.innerHTML = `
  <div class="combobox-wrapper">
    <input id="${this.#inputId}" role="combobox"
           aria-controls="${this.#listboxId}"
           aria-expanded="false"
           aria-haspopup="listbox" />
    <button class="trigger">...</button>
  </div>
  <div id="${this.#listboxId}" role="listbox">
    <!-- options rendered here -->
  </div>
`;

Because input and listbox share 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):

#open() {
  this.#isOpen = true;
  this.setAttribute('open', '');                          // For CSS styling
  this.#input.setAttribute('aria-expanded', 'true');      // Screen reader (on the input)
  this.#trigger.setAttribute('aria-expanded', 'true');    // Screen reader (on the button)
  this.#internals.ariaExpanded = 'true';                  // Spec compliance (on the host)
}

We write aria-expanded in three places because screen readers focus the shadow input (not the host element), so the input must carry the state.


Section 5: "internals.ariaLabel vs host aria-label"

The question: If we set the accessible name via the new ElementInternals API vs the old aria-label attribute, which one do screen readers actually announce?

Finding: you need BOTH.

#applyLabel(text) {
  if (!text) return;
  // Set on internals (for spec compliance)
  this.#internals.ariaLabel = this.#internals.ariaLabel || text;
  // Also set on the actual input (for screen reader announcement)
  if (!this.#input.hasAttribute('aria-labelledby')) {
    this.#input.setAttribute('aria-label', text);
  }
}

Why? Browsers expose the shadow input as the thing the screen reader interacts with (it's what gets focus). The host's ElementInternals ARIA 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:

document.getElementById('run-axe').addEventListener('click', async () => {
  if (!window.axe) {
    await loadScript('https://cdn.jsdelivr.net/npm/axe-core@4.10.2/axe.min.js');
  }
  const results = await window.axe.run(document, {
    runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'best-practice'] },
  });
  // Display violations, passes, and incomplete checks
});

Important caveat: axe-core can't always read ElementInternals ARIA properties (it mostly inspects DOM attributes). So if you ONLY set internals.ariaLabel without also putting aria-label on 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

ElementInternals makes custom web components behave like native form controls (submit, reset, validate, disable), but for accessibility you need a "dual-write" strategy: set ARIA properties on both the internals AND the shadow input, and keep the combobox input and listbox in the same shadow root so aria-activedescendant and aria-controls can resolve their ID references.


Talking points for team conversation

  1. "Why not just use a native <select>?" -- Because we need custom styling, filtering, and free-text entry. Native selects are almost impossible to style.

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

  3. "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.

  4. "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

# Decision Rationale Alternatives considered
1 Dual-write ARIA — set ARIA properties on both ElementInternals and the shadow <input> Screen readers focus the shadow input, not the host. internals.ariaLabel alone is not announced. axe-core also can't inspect internals-only ARIA. Internals-only (spec-pure but screen readers miss it); host attribute-only (works today but diverges from the intended ElementInternals direction)
2 Input + listbox in the same shadow root — no portaling the popup to document.body aria-activedescendant and aria-controls resolve IDs within a single tree root. Cross-root IDREF resolution is not supported by any browser. Portal to body for z-index (breaks a11y); use popover attribute on same-root element (viable future option once Popover API is stable)
3 delegatesFocus: true on the shadow root Ensures that clicking anywhere on the host (including <label for>) forwards focus to the internal <input>. Without it, focus lands on the host and keyboard interaction doesn't start. Manual focus() in click handler (fragile, doesn't cover all entry paths like label.click())
4 String fallback for slotted labels — read textContent from slotted elements and apply as aria-label Slotted elements live in light DOM; their IDs are invisible to the shadow input's aria-labelledby. A string copy is the only reliable cross-root bridge. aria-labelledby pointing at slotted ID (doesn't resolve cross-root); AOM ariaLabelledByElements (not yet shipped)
5 setFormValue() on every input/selection event Keeps the form's view of the value always in sync. Deferring to submit-time would miss FormData reads from JavaScript and break incremental validation. Sync only on change (misses intermediate states); sync on submit (no API for that in FACE)
6 setValidity() + reportValidity() for constraint validation Native-feeling validation that integrates with :invalid pseudo-class and browser-default error UI (tooltip). No custom error display needed for the PoC. Custom error rendering inside shadow DOM (more control, but heavier); skip validation (insufficient for the research question)
7 Host attributes as a testing-tool fallback axe-core, Chrome Accessibility Inspector, and Playwright's getByRole all read DOM attributes more reliably than ElementInternals ARIA today. Adding role and aria-* on the host ensures tooling works now. Rely purely on internals (correct per spec, but breaks current automated testing tools)
8 No external dependencies — single-file vanilla custom element Isolates platform behavior without framework opinions. Ensures the findings apply universally to any component library (Lit, Stencil, etc.). Lit-based PoC (closer to production stack, but harder to isolate what's platform vs framework)

Manual review test cases

  • Open the demo and verify form submission captures the combobox value

    1. Go to StackBlitz demo or open research/form-associated-ce-combobox-poc/index.html locally
    2. In section 1a, click Submit
    3. Expect output shows fruit: "Apple" and "Form participation: PASS"
  • Verify keyboard navigation opens the listbox and cycles options

    1. In section 2a, focus the "Programming language" combobox via Tab
    2. Press ArrowDown to open, then ArrowDown/ArrowUp to cycle
    3. Expect the active option changes and is logged in the output area
  • Verify constraint validation rejects empty required field

    1. In section 1b, leave the Country field empty and click Submit
    2. Expect output shows validity.valueMissing: true and "PASS (correctly rejected)"
  • Verify disabled combobox is excluded from FormData

    1. In section 1c, click Submit
    2. Expect output shows "FormData includes field: NO (correct)"
  • Verify expanded/collapsed state in the a11y tree

    1. In section 1d, click the chevron trigger to open the Color combobox
    2. Inspect the a11y tree (DevTools > Accessibility)
    3. Expect combobox shows aria-expanded="true", listbox with option children is visible

Device review

  • Did it pass in Desktop?
  • Did it pass in (emulated) Mobile?
  • Did it pass in (emulated) iPad?

Accessibility testing checklist

  • Keyboard (required)

    1. Tab to any combobox field
    2. Press ArrowDown to open the listbox; expect focus ring on input, listbox appears
    3. Press ArrowDown/ArrowUp to navigate options; expect visual highlight moves
    4. Press Enter to select; expect value populates, listbox closes
    5. Press Escape to dismiss without selecting; expect listbox closes, value unchanged
    6. Press Home/End to jump to first/last option
    7. Press Tab to move focus to next element; expect listbox closes
  • Screen reader (required)

    1. Focus a combobox with VoiceOver (macOS) or NVDA (Windows)
    2. Expect: role "combo box" and accessible name are announced
    3. Open the listbox (ArrowDown or trigger); expect "expanded" state announced
    4. Navigate options; expect each option name announced via aria-activedescendant
    5. Select an option (Enter); expect selected value and "collapsed" announced
    6. Navigate to scenario 4c (required + error); submit empty; expect error state announced

- 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>
@Rajdeepc Rajdeepc requested a review from a team as a code owner May 17, 2026 11:12
@Rajdeepc Rajdeepc added the a11y Issues or PRs related to accessibility label May 17, 2026
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 17, 2026

⚠️ No Changeset found

Latest commit: d854879

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

@Rajdeepc Rajdeepc added Status:WIP PR is a work in progress or draft Form Patterns labels May 17, 2026
@Rajdeepc Rajdeepc marked this pull request as draft May 17, 2026 11:13
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 17, 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-6312

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.

@Rajdeepc Rajdeepc self-assigned this May 18, 2026
@Rajdeepc
Copy link
Copy Markdown
Contributor Author

Rajdeepc commented May 18, 2026

QA Results — Accessibility Tree, Form, and Screen Reader

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

Scenario Role Accessible Name Value/Placeholder States Verdict
1a: FormData combobox Favorite fruit Apple collapsed PASS
1b: Required combobox Country (required) placeholder: "Start typing..." required, collapsed PASS
1c: Disabled combobox Disabled combobox Cannot change disabled, readonly, collapsed PASS
1d: Open/closed combobox Color placeholder: "Pick a color..." collapsed / expanded PASS
2a: Keyboard combobox Programming language placeholder: "Type or use arrow keys..." collapsed PASS
3a: Light DOM siblings combobox Timezone placeholder: "Search timezones..." collapsed PASS
3b: Slotted children combobox Department collapsed PASS
3c: Shadow internal combobox Search category placeholder: "Search..." collapsed PASS
3d: Cross-root label-for combobox Priority level placeholder: "e.g. High" collapsed PASS
4a: activedescendant combobox Font family placeholder: "Type to search fonts..." collapsed PASS
4b: aria-controls combobox City placeholder: "Search cities..." collapsed PASS
4c: Validation combobox Project status (required) placeholder: "Select a status..." required, collapsed PASS
5a: internals.ariaLabel combobox Search via internals placeholder: "internals.ariaLabel" collapsed PASS
5a: host aria-label combobox Search via host attribute placeholder: "host aria-label" collapsed PASS
5b: ariaExpanded combobox Test expanded state placeholder: "Open to test..." collapsed PASS

Expanded state details (when opened)

  • Combobox transitions from collapsedexpanded
  • Trigger button reflects states: [expanded] in sync ✅
  • role: listbox with name: "Options" appears in tree ✅
  • Child elements expose role: option with correct text names ✅
  • Selected option shows states: [selected]

2. Form Submission (open vs closed listbox)

Test Listbox state FormData captured Result
1a: Submit with default value Closed fruit: "Apple" PASS
1c: Submit disabled field Closed Field excluded from FormData PASS
1d: Submit with list open Open Value captured correctly (setFormValue synced on every keystroke/selection) PASS

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 setFormValue() regardless of popup state — this matches native <select> behavior.

Additional form behaviors confirmed:

  • formResetCallback: resets input to initial attribute value ✅
  • formDisabledCallback: fires from <fieldset disabled>, excludes from FormData ✅
  • setValidity() + reportValidity(): constraint validation for required fields ✅

3. Screen Reader Documentation

macOS — VoiceOver (Chromium)

Tree structure confirms correct semantics for VoiceOver:

  • role: combobox → VoiceOver announces "combo box"
  • Accessible name (e.g., "Favorite fruit") → announced before role
  • states: [expanded] / [collapsed] → "expanded" / "collapsed" announced
  • aria-activedescendant → option names announced on navigation (same-root IDREF resolves correctly)
  • required → "required" announced
  • disabled → "dimmed" announced

Expected VoiceOver announcement sequence:

  1. Focus combobox: "Favorite fruit, combo box, Apple, collapsed"
  2. Open (ArrowDown): "expanded" + first option announced
  3. Navigate: "Apricot", "Avocado", etc.
  4. Select (Enter): "Cherry, collapsed"

Status: Tree structure confirmed correct via automated inspection. Full manual VoiceOver pass recommended for announcement quality and timing verification.

Windows — NVDA

Expected announcement based on tree structure:

  • role: combobox → "combo box editable"
  • Value → spoken after name
  • expanded / collapsed → state change announced
  • aria-activedescendant → option name announced on arrow keys

Status: Requires manual testing on a Windows machine. The a11y tree exposes all necessary properties for NVDA to construct correct announcements. Documented test procedure is in SUMMARY.md section 7.


Summary

Category Result
Tree: role exposure 15/15 PASS
Tree: accessible names 15/15 PASS (all wiring scenarios)
Tree: expanded/collapsed states PASS
Tree: listbox + option children PASS
Form: closed submit PASS
Form: open submit PASS (value in sync)
Form: disabled exclusion PASS
SR: VoiceOver (tree-validated) Structure PASS (manual announcement quality TBD)
SR: NVDA Pending manual test

Notes for reviewers

  1. Shadow DOM event routing: Browser automation tools (Playwright, Puppeteer, Cursor MCP) cannot reliably route keyboard events into shadow DOM inputs or click through shadow DOM listbox overlays. This is a test tooling limitation, not a component bug. Manual keyboard testing is unaffected.
  2. Dual-write confirmation: Both internals.ariaLabel (scenario 5a first combobox) and host aria-label (scenario 5a second combobox) correctly expose the accessible name in the tree — validating the dual-write strategy works.
  3. Cross-root <label for> (scenario 3d): Correctly exposes "Priority level" as the name, confirming the delegatesFocus + internals.labels fallback strategy works in Chrome.

@Rajdeepc Rajdeepc marked this pull request as ready for review May 19, 2026 09:31
@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 19, 2026
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>
@Rajdeepc
Copy link
Copy Markdown
Contributor Author

Rajdeepc commented May 19, 2026

internals.role = 'combobox' — findings and labelling constraints

What internals.role does

When you set this.#internals.role = 'combobox', the host element (<face-combobox>) itself becomes a combobox node in the accessibility tree. Without it, the host is a generic element and only the shadow <input role="combobox"> is the combobox.

What I observed in the tree (Chrome 136)

With both internals.role = 'combobox' on the host AND role="combobox" on the shadow input:

  • Chrome collapses the two into a single combobox node in the flattened tree (no "combobox inside combobox" duplication visible)
  • The accessible name resolves correctly for all 15 scenarios (same results as before)
  • states: [expanded], [collapsed], [required], [disabled] all still work
  • The trigger button still reflects [expanded] in sync

Labelling constraints when host has internals.role

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
  • delegatesFocus forwards 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 role from the shadow input, the input becomes a generic textbox inside the host's combobox — but then aria-activedescendant and aria-controls on 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 validation

Option 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 strategy

Option 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 not

What to inspect in DevTools

To see the full picture, open Chrome DevTools > Elements > Accessibility tab:

  1. Click the <face-combobox> host element — you should see Role: combobox from internals
  2. Expand into the shadow root and click the <input> — you should also see Role: combobox
  3. Compare the "Accessible Name" on each node
  4. 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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [stylelint] <order/properties-order> reported by reviewdog 🐶
Expected "padding" to come before "margin"


h1 {
font-size: 1.5rem;
margin: 0 0 0.25rem;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [stylelint] <order/properties-order> reported by reviewdog 🐶
Expected "margin" to come before "font-size"


h2 {
font-size: 1.25rem;
margin: 2rem 0 1rem;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [stylelint] <order/properties-order> reported by reviewdog 🐶
Expected "padding" to come before "border-radius"

border-radius: 3px;
}

code {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [stylelint] <no-descending-specificity> reported by reviewdog 🐶
Expected selector "code" to come before selector ".config-banner code", at line 147

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Rajdeepc Rajdeepc added do-not-merge NO MERGE-Y! Peer review experiment and removed Status:Ready for review PR ready for review or re-review. labels May 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a11y Issues or PRs related to accessibility do-not-merge NO MERGE-Y! experiment Form Patterns Peer review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant