From 5910ad12395d3c8fd0f81b90cbc105e469819f08 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Thu, 13 Aug 2026 14:32:18 -0400 Subject: [PATCH 1/4] docs(combobox): a11y migration analysis docs --- .../03_components/README.md | 2 + .../accessibility-migration-analysis.md | 242 ++++++++++++++++++ .../03_project-planning/README.md | 1 + 3 files changed, 245 insertions(+) create mode 100644 CONTRIBUTOR-DOCS/03_project-planning/03_components/combobox/accessibility-migration-analysis.md diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md index f482c7a3121..6c4f0d31832 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md @@ -69,6 +69,8 @@ - [Color loupe accessibility migration analysis](color-loupe/accessibility-migration-analysis.md) - [Color loupe migration checklist](color-loupe/migration-checklist.md) - [Color loupe migration analysis](color-loupe/rendering-and-styling-migration-analysis.md) +- Combobox + - [Combobox accessibility migration analysis](combobox/accessibility-migration-analysis.md) - Divider - [Divider accessibility migration analysis](divider/accessibility-migration-analysis.md) - [Divider migration roadmap](divider/rendering-and-styling-migration-analysis.md) diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/combobox/accessibility-migration-analysis.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/combobox/accessibility-migration-analysis.md new file mode 100644 index 00000000000..39cf6b7ec11 --- /dev/null +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/combobox/accessibility-migration-analysis.md @@ -0,0 +1,242 @@ + + +[CONTRIBUTOR-DOCS](../../../README.md) / [Project planning](../../README.md) / [Components](../README.md) / Combobox / Combobox accessibility migration analysis + + + +# Combobox accessibility migration analysis + + + +
+In this doc + +- [Overview](#overview) + - [Also read](#also-read) + - [What it is](#what-it-is) + - [When to use something else](#when-to-use-something-else) + - [What it is not](#what-it-is-not) + - [Related](#related) +- [ARIA and WCAG context](#aria-and-wcag-context) + - [Pattern in the APG](#pattern-in-the-apg) + - [Guidelines that apply](#guidelines-that-apply) +- [Related 1st-gen accessibility (Jira)](#related-1st-gen-accessibility-jira) +- [Recommendations: ``](#recommendations-swc-combobox) + - [ARIA roles, states, and properties](#aria-roles-states-and-properties) + - [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues) + - [Accessibility tree expectations](#accessibility-tree-expectations) + - [Keyboard and focus](#keyboard-and-focus) +- [Testing](#testing) + - [Automated tests](#automated-tests) +- [Summary checklist](#summary-checklist) +- [References](#references) + +
+ + + +## Overview + +This doc tells you how **`swc-combobox`** should work for **accessibility**. It matches the goal of **WCAG 2.2 Level AA**. `swc-combobox` is the 2nd-gen replacement for 1st-gen `sp-combobox`: a single-line text input paired with a filterable listbox of options, where the user can type a value, pick a suggestion, or both. + +A combobox is a text field with a popup listbox bolted on, and 1st-gen models that literally — `Combobox` extends `Textfield`. So the label, help text, error message, and form-participation guidance for `swc-combobox` is the **same** as [`swc-text-field`](../text-field/accessibility-migration-analysis.md)'s, driven by the same shared controllers. This doc does not re-derive that shared guidance; it references the text-field doc and concentrates on what a combobox adds on top of a text field: the `combobox`/`listbox`/`option` role trio, the expanded/active-descendant states, the cross-root wiring between the input and its options, filtering/autocomplete behavior, and the keyboard model for moving through suggestions. + +### Also read + +- [Text field accessibility migration analysis](../text-field/accessibility-migration-analysis.md) — the base component `sp-combobox` extends. Its label, help-text, error, placeholder, `autocomplete`/`inputmode`, validation-icon, and form-association guidance apply to `swc-combobox` unchanged and are not repeated here. +- [Forms strategy: 2nd-gen proposal](../../05_strategies/forms-strategy-rfc.md) — the canonical direction for form participation (ElementInternals/FACE), role placement, the `accessible-label`/`accessible-labelledby`/`accessible-describedby` naming table, and the axe-core policy this doc follows. +- A combobox rendering-and-styling migration analysis is forthcoming; layout, CSS, and DOM-structure changes belong there, not here. + +### What it is + +- An **editable combobox with a list-autocomplete popup**: a real `` in the component's shadow root, a `listbox` of `option`s that filters as the user types, and a trigger button that toggles the popup. The value-bearing role (`combobox`) is supplied by the inner ``, exactly as `swc-text-field`'s `textbox` role is — `swc-combobox` does not set a host-level ARIA role (see [ARIA roles, states, and properties](#aria-roles-states-and-properties)). +- Its label, help text, and error message are rendered inside `swc-combobox`'s own shadow root by the **`LabellingController`**, the same way `swc-text-field` renders them — not by an externally associated `swc-field-label`/`swc-help-text`. +- Its options are author-supplied elements (menu-item-like children), the same content model `sp-combobox` accepts through its default slot. The listbox and its options do **not** have to live in the same shadow root as the input; see [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues). + +### When to use something else + +- A closed set of choices where **freeform typing is not wanted** — use a picker (`select`-like) rather than a combobox. A combobox is specifically for "type **or** pick"; if custom text can never be valid, the extra textbox affordance is a liability, not a feature. +- A short, always-visible set of mutually exclusive choices — use a radio group. +- Plain open-ended text with no suggestions — use [`swc-text-field`](../text-field/accessibility-migration-analysis.md). Note that `swc-text-field` deliberately does **not** carry combobox behavior: its `autocomplete` property must not be widened to the combobox-only `'list'`/`'none'` tokens 1st-gen shares between the two. + +### What it is not + +- Not a menu or a menu button. A combobox's popup is a `listbox` of selectable values that fills a form field, not a `menu` of commands that perform actions. Do not reach for menu semantics (`role="menu"`/`menuitem`) to build the popup even though 1st-gen composes it from `sp-menu`/`sp-menu-item` internally. +- Not a multi-select control. `swc-combobox` selects a single value. Multi-value entry (token/tag input) is a different pattern and out of scope for this migration. + +### Related + +- [`swc-text-field`](../text-field/accessibility-migration-analysis.md) — the base class; shared label/help/error/form guidance lives there. +- The shared controllers this component composes: **`LabellingController`** ([SWC-2466](https://jira.corp.adobe.com/browse/SWC-2466)) and **`FieldAssociationController`** ([SWC-2467](https://jira.corp.adobe.com/browse/SWC-2467)) — the same two `swc-text-field` depends on — plus two the text field does not need: the [`FocusgroupNavigationController`](../../../../2nd-gen/packages/core/controllers/focusgroup-navigation-controller/focusgroup-navigation-controller.mdx) and the [`LiveSelectionController`](../../../../2nd-gen/packages/core/controllers/live-selection-controller/live-selection-controller.mdx) for option traversal and single-selection enforcement (see [Recommendations](#recommendations-swc-combobox)). +- 1st-gen `sp-combobox` renders its options a second time inside its own shadow root (see [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues)); the duplicate-rendering hack is a direct cause of option-announcement bugs such as [SWC-592](https://jira.corp.adobe.com/browse/SWC-592). + +--- + +## ARIA and WCAG context + +### Pattern in the APG + +- The APG **does** name this widget: the [combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/), specifically the [editable combobox with list autocomplete](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list/) example. Unlike `swc-text-field` (which has no named APG pattern because a native `` covers it), a combobox is a genuine composite widget: a `combobox` role on the input, a `listbox` popup it controls via `aria-controls`, and `option` children whose active one is pointed at by `aria-activedescendant`. There is no single native HTML element that supplies all of this, so the ARIA wiring is real work, not a native-semantics fallback. +- Even so, the base of the widget is still a native ``. Keep it: the input supplies the editable text, the caret, text selection, IME composition, and the `combobox` role by way of the explicit `role="combobox"` on a real text input. `swc-combobox` layers `aria-expanded`, `aria-controls`, `aria-activedescendant`, and `aria-autocomplete` on top of that native input rather than reconstructing a textbox from a non-editable element. +- **Single host role holds.** The combobox exposes exactly one host-facing role, `combobox`, and it lives on the inner input — never conditionally swapped, never set on the host. The `listbox` and `option` roles belong to *different elements* (the popup and its children), not to alternate configurations of one host, so the project's single-host-role policy is satisfied without a dual-role decision. The one composition question the migration must settle — whether options are their own custom element and where the listbox lives — is answered in [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues); it does not change the host's role. + +### Guidelines that apply + +| Idea | Plain meaning | +| --- | --- | +| [Info and relationships (1.3.1)](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html) | The `combobox`/`listbox`/`option` relationships (`aria-controls`, `aria-activedescendant`, `aria-selected`) must be real programmatic associations, not just visual adjacency — and they must resolve even when the options are in a different shadow root than the input (see [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues)). | +| [Name, role, value (4.1.2)](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) | Every state the widget can be in — collapsed/expanded, which option is active, what the current value is, disabled, invalid — must be exposed to AT, not conveyed visually alone. 1st-gen has an open sev1 here ([SWC-1195](https://jira.corp.adobe.com/browse/SWC-1195): missing accessible name in the disabled state). | +| [Labels or instructions (3.3.2)](https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html) and [Label in name (2.5.3)](https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html) | The field needs one real, stable accessible name — the same requirement as `swc-text-field`. 1st-gen has an open issue here ([SWC-1152](https://jira.corp.adobe.com/browse/SWC-1152)), made worse by its double-labelling (see [ARIA roles, states, and properties](#aria-roles-states-and-properties)). | +| [Use of color (1.4.1)](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html) | Disabled and invalid states must not be signalled by color alone. 1st-gen has an open sev2 here ([SWC-1127](https://jira.corp.adobe.com/browse/SWC-1127)). | +| [Error identification (3.3.1)](https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html) | When `invalid`, the error text must be visible and exposed via `aria-describedby`/`aria-errormessage` — inherited unchanged from the text-field guidance. | +| [Language of parts (3.1.2)](https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html) | When an option's text is in a different language than the surrounding page, its `lang` must be exposed on the option element so AT pronounces it correctly. 1st-gen loses this today because it re-renders options as shadow-DOM copies that do not carry the author's `lang` ([SWC-2359](https://jira.corp.adobe.com/browse/SWC-2359)); using the real author elements as the options (see [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues)) fixes it structurally. | +| [Focus visible (2.4.7)](https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html) | DOM focus stays on the input the entire time the popup is open; the "focused" option is indicated by `aria-activedescendant`, not by moving focus. The active option needs a visible active-style indicator distinct from the input's own focus ring. | +| [Status messages (4.1.3)](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) | Result-count and pending/loading changes happen without the user moving focus. Any announcement of "N results" or "loading" must be measured, not a flood — see [ARIA roles, states, and properties](#aria-roles-states-and-properties); do not default help text or the listbox to an assertive live region. | + +**Bottom line:** the text-field layer (label, help, error, form participation, native input behavior) is already solved by the shared controllers and documented in the text-field doc. The combobox-specific accessibility work is (1) getting the `combobox`/`listbox`/`option` roles and their `aria-controls`/`aria-activedescendant`/`aria-expanded`/`aria-selected` wiring correct, (2) making that wiring resolve **across shadow roots** so options can be the author's real elements instead of shadow-DOM duplicates, and (3) keeping DOM focus on the input while the `FocusgroupNavigationController` drives which option is *active*. + +--- + +## Related 1st-gen accessibility (Jira) + +| Jira | Type | Status (snapshot) | Resolution (snapshot) | Summary | +| --- | --- | --- | --- | --- | +| [SWC-1195](https://jira.corp.adobe.com/browse/SWC-1195) | Bug | Blocked | Unresolved | Form field is missing an accessible name — `sp-combobox` (disabled tab) ([WCAG 4.1.2](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html)), sev1 | +| [SWC-1152](https://jira.corp.adobe.com/browse/SWC-1152) | Bug | To Do | Unresolved | Accessible name missing — `sp-combobox` (disabled, invalid tab) ([WCAG 2.5.3](https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html)) | +| [SWC-1127](https://jira.corp.adobe.com/browse/SWC-1127) | Bug | To Do | Unresolved | Color alone conveys the state of a control — `sp-combobox` (disabled, invalid) ([WCAG 1.4.1](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html)) | +| [SWC-592](https://jira.corp.adobe.com/browse/SWC-592) | Bug | To Do | Unresolved | Combobox a11y issues speaking the options | +| [SWC-2359](https://jira.corp.adobe.com/browse/SWC-2359) | Bug | In Progress | Unresolved | Combobox should support `lang` on menu item ([WCAG 3.1.2](https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html)) | +| [SWC-874](https://jira.corp.adobe.com/browse/SWC-874) | Epic | Blocked | Unresolved | Refactor combobox (accessibility) | +| [SWC-710](https://jira.corp.adobe.com/browse/SWC-710) | Story | To Do | Unresolved | Refactor `sp-combobox` for accessibility | +| [SWC-711](https://jira.corp.adobe.com/browse/SWC-711) | Story | To Do | Unresolved | Fix CSS in the refactored combobox PR, sev1 | +| [SWC-712](https://jira.corp.adobe.com/browse/SWC-712) | Story | To Do | Unresolved | Update unit tests as needed based on the WAI-ARIA APG, sev1 | +| [SWC-616](https://jira.corp.adobe.com/browse/SWC-616) | Story | Done | Fixed | RFC: combobox — refactor for accessibility | +| [SWC-1045](https://jira.corp.adobe.com/browse/SWC-1045) | Story | Done | Fixed | Migrate combobox to `FormFieldMixin`, adding a `placeholder` property | +| [SWC-1320](https://jira.corp.adobe.com/browse/SWC-1320) | Story | Done | Fixed | Align slotted field-label CSS with the standalone field-label component | +| [SWC-1255](https://jira.corp.adobe.com/browse/SWC-1255) | Bug | Done | Fixed | Pending-state controller uses a semantically incorrect progress circle with accessibility issues | +| [SWC-534](https://jira.corp.adobe.com/browse/SWC-534) | Story | Done | Fixed | Extend `ComboboxOption` to support the disabled state of menu items | +| [SWC-23](https://jira.corp.adobe.com/browse/SWC-23) | Story | To Do | Unresolved | `sp-combobox` value is not equivalent to the currently selected menu item | +| [SWC-634](https://jira.corp.adobe.com/browse/SWC-634) | Bug | Done | Cannot Reproduce | Escape/delete key doesn't work in Japanese (IME) input | +| [SWC-1373](https://jira.corp.adobe.com/browse/SWC-1373) | Story | To Do | Unresolved | Spike: compare semantics across menu, action menu, combobox, and picker | +| [SWC-1346](https://jira.corp.adobe.com/browse/SWC-1346) | Epic | Done | Deferred | Implement `FormFieldMixin` across components | +| [SWC-772](https://jira.corp.adobe.com/browse/SWC-772) | Story | Done | Fixed | RFC: form element patterns | +| [SWC-320](https://jira.corp.adobe.com/browse/SWC-320) | Story | Done | Deferred | Improve form association for input elements | +| [SWC-196](https://jira.corp.adobe.com/browse/SWC-196) | Epic | Done | Duplicate | Loosening the API for form input elements | +| [SWC-48](https://jira.corp.adobe.com/browse/SWC-48) | Epic | Done | Deferred | RFC: recommendations for form-associated custom elements (`ElementInternals`) | +| [SWC-372](https://jira.corp.adobe.com/browse/SWC-372) | Story | Done | Fixed | docs(combobox): documentation audit | +| [SWC-123](https://jira.corp.adobe.com/browse/SWC-123) | Story | Done | Deferred | test(combobox): refactor test structure | + +--- + +## Recommendations: `` + +Component tag may change until API freeze. Shared label/help/error/form-association guidance is inherited from [`swc-text-field`](../text-field/accessibility-migration-analysis.md#recommendations-swc-text-field) and is not repeated in full here; the rows below cover what a combobox adds or changes. + +### ARIA roles, states, and properties + +| Topic | What to do | +| --- | --- | +| **Host role** | None. The host element sets no `role`. The inner, real `` carries an explicit `role="combobox"`, which is the widget's single value-bearing role — placed on the shadow-DOM control for the same reason `swc-text-field`'s `textbox` role is: a combobox exposes a **live value** and an expanded state to AT, and per the [forms strategy §3.2](../../05_strategies/forms-strategy-rfc.md#32-where-aria-roles-live) a value-bearing role must live on the same node as its value, so it stays on the inner control rather than being hoisted to the host via `ElementInternals`. Do not add a `role` to the host. | +| **Accessible name — one writer** | Same three-source model as `swc-text-field`, wired by the **`LabellingController`**: `accessible-labelledby` (highest precedence) → `accessible-label` → slotted visible label. Fix 1st-gen's **double-labelling bug** in the process: 1st-gen sets both `aria-label` **and** `aria-labelledby` on the same input (`aria-labelledby="label applied-label pending-label"` alongside `aria-label`), which is a conflicting, two-writer setup and a direct contributor to [SWC-1152](https://jira.corp.adobe.com/browse/SWC-1152)/[SWC-1195](https://jira.corp.adobe.com/browse/SWC-1195). 2nd-gen must set exactly one naming mechanism, from one render pass. The same name is applied to the `listbox` (via `aria-labelledby`/`aria-label` in the same root as the listbox) so the popup is named too. | +| **`aria-expanded`** | Set `aria-expanded="true"` on the input when the listbox is open and `"false"` when closed. This is the collapsed/expanded state and must always be present (both values), unlike `aria-invalid` which is only set when true. 1st-gen already does this; keep it, but drive it from the same `open` state the popup visibility uses so they can never disagree. | +| **`aria-controls` → the listbox** | The input's `aria-controls` must reference the `listbox` element. When the listbox lives in a **different shadow root** than the input (the recommended structure — see [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues)), set it through the **`ariaControlsElements`** element-reference property rather than an IDREF string, which cannot cross a shadow boundary. 1st-gen only sets `aria-controls` while open and via a same-root IDREF (`aria-controls="listbox-menu"`); the element-reference form is what makes a cross-root listbox possible. | +| **`aria-activedescendant` → the active option** | While the popup is open and the user arrows through options, the input's `aria-activedescendant` points at the currently active `option`. DOM focus never leaves the input. Set this through the single-element reflection property **`ariaActiveDescendantElement`** (not the pluralized name; `aria-activedescendant` references exactly one element) so it resolves to the real option element even across a shadow boundary. This is the linchpin of the hybrid approach and replaces 1st-gen's shadow-DOM-`id` lookup (`shadowRoot.getElementById(activeDescendant.value)`). Clear it (no active option) when the caret moves within the text (Home/End/ArrowLeft/ArrowRight) or the popup closes. | +| **`aria-autocomplete`** | Set `aria-autocomplete="list"` when the popup offers filtered suggestions and `"none"` when it does not, on the input. Keep this decoupled from the native `autocomplete` attribute (which must be `off` on the input so the browser's own autofill/history UI does not compete with the listbox). This mirrors 1st-gen's split but should use a combobox-owned property type, not the widened text-field type. | +| **Options: `role="option"` + `aria-selected`** | Each option exposes `role="option"`. The **selected** option (the one matching the field's value) carries `aria-selected="true"`; the **active** option (arrow-key focus) is indicated by `aria-activedescendant` from the input, not by `aria-selected`. 1st-gen conflates these — it writes `aria-selected` to track the *active* descendant during arrow navigation (`el.setAttribute('aria-selected', … === activeDescendant …)`), which mis-reports selection to AT. Keep the two concepts distinct: `aria-selected` = chosen value; `aria-activedescendant` = keyboard-active row. | +| **Selection enforcement** | Use the **`LiveSelectionController`** in `'single'` mode: options own their own `selected` state and dispatch a change event, and the controller deselects the others when one becomes selected. This is the correct controller for menu-item-like children that flip their own state (as `sp-menu-item` does), rather than a host-authoritative `SelectionController`. The field's value updates from the selected option's text/value on selection. | +| **Option traversal** | Use the **`FocusgroupNavigationController`** to compute which option is next/previous under the arrow keys, but **do not let it move DOM focus** — see [Keyboard and focus](#keyboard-and-focus). The combobox uses the controller's `setActiveItem()` / active-change event to track the active option and reflect it through `ariaActiveDescendantElement`; focus stays in the input for typing. Set `skipDisabled: true` so disabled options are skipped during arrow traversal, matching 1st-gen's `do…while` that steps over `disabled` options. | +| **Trigger button** | The chevron/picker button that toggles the popup is a pointer affordance only. It is **not** a separate tab stop (`tabindex="-1"`) and its state duplicates the input's, so it must not introduce a second, competing set of `aria-expanded`/`aria-controls` announcements the way 1st-gen's `sp-picker-button` does (it currently carries its own `aria-controls`, `aria-expanded`, `aria-label`, and `aria-labelledby`). Keep it out of the accessibility tree as an interactive control, or expose it only as a decorative toggle; the input is the combobox. | +| **Pending / loading** | The pending (loading) state must be announced without flooding. Do not reuse 1st-gen's approach wholesale: [SWC-1255](https://jira.corp.adobe.com/browse/SWC-1255) flagged its progress circle as semantically incorrect. Expose "loading" as a bounded status tied to the field (a labelled busy state), not an assertive live region, and never `aria-live="assertive"`. The pending spinner itself is decorative (`aria-hidden`); the pending *label* carries the text. | +| **Placeholder, `aria-invalid`, required, validation icon, `autocomplete`/`inputmode`** | Inherited unchanged from [`swc-text-field`](../text-field/accessibility-migration-analysis.md#aria-roles-states-and-properties): placeholder is never the accessible name (drop the placeholder→`aria-label` fallback and dev-warn instead); `aria-invalid` only when actually invalid; native `required` rather than `aria-required`; the validation icon stays `aria-hidden="true"` and is never the sole signal of invalid state (addresses [SWC-1127](https://jira.corp.adobe.com/browse/SWC-1127)); `autocomplete` keeps its input-purpose value and `inputmode` is added. | +| **Result count (optional, measured)** | If the design calls for announcing how many suggestions match, associate a concise, debounced status ("3 results") tied to the combobox — never an assertive region, and never per-keystroke without debouncing. Treat this as opt-in, not default, to avoid over-announcing under [WCAG 4.1.3](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html). | + +### Shadow DOM and cross-root ARIA Issues + +**This is the defining accessibility problem for the combobox migration, and it is the opposite of the text field's.** `swc-text-field` resolves its cross-root concern by *removing* the boundary — label, input, and help text all render in one shadow root. `swc-combobox` cannot do that, because a combobox is inherently a **three-role, multi-element** widget: the `combobox` input, the `listbox`, and the `option`s. The APG's model, and every IDREF that connects these (`aria-controls`, `aria-activedescendant`, and the listbox's `aria-labelledby`), assumes all three share one DOM tree so the IDREFs resolve. + +**1st-gen's workaround, and why it hurts.** To keep the IDREFs resolvable, 1st-gen renders the options **twice**: the author's slotted `sp-menu-item`s feed a hidden `` (read into `optionEls`), and a *second* copy is rendered inside the component's own shadow root via `repeat(availableOptions …)` with `id=option.value`. `aria-activedescendant` then points at those shadow-DOM copies, resolved with `shadowRoot.getElementById(...)`. This duplicate-rendering hack is a recurring source of real defects: + +- The shadow copies do not carry the author's element state, so per-option `lang` is lost ([SWC-2359](https://jira.corp.adobe.com/browse/SWC-2359), [WCAG 3.1.2](https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html)). +- Two element sets representing one logical option list is a structural cause of the option-announcement bugs tracked in [SWC-592](https://jira.corp.adobe.com/browse/SWC-592). +- The `aria-selected` bookkeeping has to be mirrored across both copies, which is where 1st-gen conflates *active* and *selected* (see [ARIA roles, states, and properties](#aria-roles-states-and-properties)). + +**2nd-gen's fix: element references instead of IDREFs, so the options can stay put.** Use the **hybrid model** from the [web-component form-strategy demos](https://nikkimk.github.io/web-component-form-strategy-demos/demo-hybrid.html): the `combobox` input and the `listbox` may live in `swc-combobox`'s shadow root, while the **`option`s remain the author's real elements** (in the light DOM, or in a separate popover component's root) — they no longer need to share the input's root. The IDREF relationships that would normally require a shared tree are set instead through the ARIA **element-reference reflection** properties, which resolve across shadow boundaries: + +- `aria-controls` (input → listbox): **`ariaControlsElements`** = `[listboxElement]`. +- `aria-activedescendant` (input → active option): **`ariaActiveDescendantElement`** = `activeOptionElement` (single element; note the singular property name). +- The listbox's name and the options' `aria-labelledby`, where needed, use the same element-reference pattern rather than IDREF strings. + +Because these are element references, the active option can be the **author's own element** — carrying its own `lang`, text, and `role="option"` — with no shadow-DOM duplicate to keep in sync. This removes the double-render, fixes the `lang` loss structurally, and lets `aria-selected` be written once, on the real option. Element reflection is the same capability `swc-text-field` uses for `accessible-labelledby`/`accessible-describedby`; browser support baseline is the same (Chromium 135+, Safari 16.4+, Firefox 136+). Verify option and active-descendant exposure manually where support lags, per the [forms strategy §3.4 axe policy](../../05_strategies/forms-strategy-rfc.md#34-axe-core-policy). + +**axe-core will report expected false positives here.** Two from the forms strategy's [known-false-positives list](../../05_strategies/forms-strategy-rfc.md#34-axe-core-policy) apply directly: `aria-required-children` fires on the `listbox` because axe does not traverse a slot or a cross-root reference to find the `option` children, and `label` fires on the roleless host. Add story- or test-level exclusions **with a written `// reason:` comment**, not a silent global disable, and lean on manual AT testing for the relationships axe cannot follow across roots. + +### Accessibility tree expectations + +- **Collapsed, labeled, empty:** role `combobox` on the input; name from `accessible-labelledby`/`accessible-label`/slotted label (one source); `aria-expanded="false"`; value empty; no `aria-activedescendant`. Description from same-root `aria-describedby` when help text is slotted. +- **Expanded, arrowing through options:** input still has role `combobox` and DOM focus; `aria-expanded="true"`; `aria-controls` resolves to the `listbox`; `aria-activedescendant` resolves to the active `option`. The listbox exposes role `listbox` with its own accessible name; each option exposes role `option`, its own text (and its own `lang` when set), and `aria-selected="true"` only on the option matching the field value — *not* on the merely-active option. +- **Value selected:** the input's value reflects the chosen option's text; the matching option is `aria-selected="true"`; the popup collapses (`aria-expanded="false"`) and `aria-activedescendant` is cleared. Exactly one option is selected (`LiveSelectionController`, single mode). +- **Pending / loading:** a bounded, labelled busy state associated with the field; the spinner adds no separate interactive node (it is `aria-hidden`); no assertive live region. +- **Invalid:** `aria-invalid="true"`; error text visible and reachable via `aria-describedby`/`aria-errormessage`; the alert icon stays `aria-hidden="true"` and is not the only signal of the state. +- **Disabled vs. readonly:** `disabled` removes the field from the tab order and the popup cannot open; `readonly` keeps the field focusable and its value selectable but non-editable and non-expandable. These must be programmatically distinct outcomes, not just CSS — and the disabled state must still expose the field's accessible name (the specific 1st-gen gap in [SWC-1195](https://jira.corp.adobe.com/browse/SWC-1195)). + +### Keyboard and focus + +DOM focus stays on the input for the entire interaction; this is an **active-descendant** widget, not a roving-tabindex one. That distinction is the single most important keyboard note for the migration, because the combobox composes the `FocusgroupNavigationController` — whose default job is roving tabindex — and must deliberately **not** use it that way. + +- **One tab stop.** The input is the only Tab stop. Attach the shadow root with `delegatesFocus: true` so focus lands on the real ``. The trigger button is `tabindex="-1"` and the options are never in the tab order. +- **Active option ≠ focused option.** As the user presses ArrowDown/ArrowUp, the `FocusgroupNavigationController` computes the next/previous eligible option via `setActiveItem()` (which by design does **not** call `focus()`), the combobox listens to its active-change event, and reflects the new active option through `ariaActiveDescendantElement`. Focus never moves to the option. If the controller ever moved DOM focus into the listbox, the user could no longer type — so the combobox uses the controller for traversal math and active-item tracking only, not for focus movement. +- **Keys** (matching the [APG list-autocomplete combobox](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list/) and preserving 1st-gen behavior where it already conforms): + - ArrowDown — open the popup if closed and move the active option to the next (first, if none active); Alt + ArrowDown opens without moving. + - ArrowUp — open the popup if closed and move the active option to the previous (last, if none active). + - Enter — select the active option and close the popup; if no option is active, submit the enclosing form as a plain text field would. + - Escape — close the popup if open; if already closed, clear the input value (preserve 1st-gen's behavior). + - Home / End — move the caret to the start/end of the input text and clear the active option (do not treat these as first/last-option jumps; the caret takes priority in an editable combobox). + - ArrowLeft / ArrowRight — move the caret and clear the active option. + - Printable characters — type into the input and refilter the list; a new keystroke clears the active option (the user is typing, not choosing). +- **`skipDisabled`.** Configure the `FocusgroupNavigationController` with `skipDisabled: true` so arrow traversal steps over disabled options, matching 1st-gen. Disabled options remain in the listbox for screen reader context but are not selectable. +- **IME / composition.** Do not let Escape or Enter handling swallow composition events. 1st-gen has a related report ([SWC-634](https://jira.corp.adobe.com/browse/SWC-634), Cannot Reproduce) for Japanese input; when the combobox intercepts Escape to clear/close, it must yield to IME composition cancellation first, mirroring the text field's [SWC-1870](https://jira.corp.adobe.com/browse/SWC-1870) concern. +- **Focus-visible on the active option.** The active option needs a visible active indicator that is distinct from the input's focus ring and from the *selected* option's styling, so sighted keyboard users can tell "the row I'm on" from "the value I've chosen." + +--- + +## Testing + +### Automated tests + +| Kind of test | What to check | +| --- | --- | +| **Unit** | `aria-expanded` toggles with `open` and is always present (both `true`/`false`); `ariaControlsElements` resolves to the listbox and `ariaActiveDescendantElement` resolves to the active option element (not a stale shadow-DOM copy) across a shadow boundary; `aria-activedescendant` clears on caret-move keys and on close; `aria-selected="true"` appears only on the option matching the value, never on the merely-active option; `LiveSelectionController` keeps exactly one option selected in single mode; `FocusgroupNavigationController` traversal skips disabled options and never moves DOM focus off the input; exactly one accessible-name mechanism is set on the input (no `aria-label` + `aria-labelledby` together); `FieldAssociationController` submits the value, resets, and disables via an ancestor `
`. | +| **aXe + Storybook** | Stories for collapsed, expanded, selected, pending, invalid, disabled, and readonly. Document the expected `aria-required-children` and `label` false positives with `// reason:` exclusions per the [forms strategy axe policy](../../05_strategies/forms-strategy-rfc.md#34-axe-core-policy) rather than disabling axe globally. A per-option `lang` story asserting the author's `lang` survives onto the real option element (regression guard for [SWC-2359](https://jira.corp.adobe.com/browse/SWC-2359)). A no-accessible-name story that dev-warns rather than silently borrowing the placeholder. | +| **Playwright ARIA snapshots** | `role=combobox` with the correct name, value, `expanded` state, and (when open) `controls`/`activedescendant`; the `listbox` with its name and `option` children carrying correct `selected` state; snapshots across top/side label positions and default/error/disabled/readonly/pending states from the design spec's state matrix. | +| **Manual screen reader** | Cross-root `aria-activedescendant`/`aria-controls` exposure verified in NVDA, JAWS, and VoiceOver — and specifically in **Firefox**, where `ElementInternals`/element-reflection ARIA is least consistent (per the forms strategy). Confirm: opening announces the listbox and count sensibly (not floodily); arrowing announces each option once, including its `lang`; selecting announces the chosen value; the disabled field still announces its name. | + +--- + +## Summary checklist + +- [ ] Host sets no `role`; the inner `` carries the single `combobox` role; no host-role hoist via `ElementInternals`. +- [ ] Exactly one accessible-name mechanism on the input — 1st-gen's simultaneous `aria-label` + `aria-labelledby` double-labelling is removed (addresses [SWC-1152](https://jira.corp.adobe.com/browse/SWC-1152)/[SWC-1195](https://jira.corp.adobe.com/browse/SWC-1195)). +- [ ] `aria-expanded` is always present and driven from the same `open` state as popup visibility. +- [ ] `aria-controls` and `aria-activedescendant` are set via `ariaControlsElements` / `ariaActiveDescendantElement` element references, so the listbox and options resolve across shadow roots — no shadow-DOM duplicate of the option list. +- [ ] Options are the author's real elements; per-option `lang` survives (fixes [SWC-2359](https://jira.corp.adobe.com/browse/SWC-2359)); no double-rendering. +- [ ] `aria-selected` marks the chosen value only; the keyboard-active option is conveyed by `aria-activedescendant`, not `aria-selected` (fixes 1st-gen's active/selected conflation). +- [ ] `LiveSelectionController` enforces single selection; `FocusgroupNavigationController` drives arrow traversal with `skipDisabled: true` and **never** moves DOM focus off the input. +- [ ] DOM focus stays on the input throughout; one Tab stop with `delegatesFocus: true`; trigger button is `tabindex="-1"`. +- [ ] Keyboard model matches the APG list-autocomplete combobox; Home/End/arrow-left/right move the caret and clear the active option; Escape yields to IME composition before clearing/closing. +- [ ] Pending/loading is a bounded, labelled busy state (not an assertive live region); the spinner is `aria-hidden`; addresses [SWC-1255](https://jira.corp.adobe.com/browse/SWC-1255). +- [ ] Disabled vs. readonly are programmatically distinct; the disabled field still exposes its accessible name (fixes [SWC-1195](https://jira.corp.adobe.com/browse/SWC-1195)). +- [ ] Disabled/invalid state is not conveyed by color alone (fixes [SWC-1127](https://jira.corp.adobe.com/browse/SWC-1127)). +- [ ] Shared label/help/error/form guidance is inherited from `swc-text-field` via `LabellingController` + `FieldAssociationController`, not reimplemented. +- [ ] Expected axe false positives (`aria-required-children`, `label`) are excluded with written `// reason:` rationales, and cross-root exposure is verified by manual AT testing including Firefox. + +## References + +- [Web component form strategy demos](https://github.com/nikkimk/web-component-form-strategy-demos/tree/main) and the [hybrid demo](https://nikkimk.github.io/web-component-form-strategy-demos/demo-hybrid.html) — the cross-root element-reference model (`ariaControlsElements`, `ariaActiveDescendantElement`) this doc's listbox/option wiring follows. +- [Forms strategy: 2nd-gen proposal (this repo)](../../05_strategies/forms-strategy-rfc.md) — role placement, the `accessible-label`/`accessible-labelledby`/`accessible-describedby` naming table, and the axe-core policy. +- [Text field accessibility migration analysis (this repo)](../text-field/accessibility-migration-analysis.md) — the base component; shared label/help/error/form guidance. +- [WAI-ARIA](https://www.w3.org/TR/wai-aria-1.2/), [WCAG 2.2](https://www.w3.org/TR/WCAG22/), [APG: read me first](https://www.w3.org/WAI/ARIA/apg/practices/read-me-first/) +- [APG: combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) and the [editable combobox with list autocomplete example](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list/) +- [`FocusgroupNavigationController` (this repo)](../../../../2nd-gen/packages/core/controllers/focusgroup-navigation-controller/focusgroup-navigation-controller.mdx) and [`LiveSelectionController` (this repo)](../../../../2nd-gen/packages/core/controllers/live-selection-controller/live-selection-controller.mdx) — option traversal and single-selection enforcement. +- [React Spectrum: ComboBox](https://react-spectrum.adobe.com/ComboBox) — S2 API reference (selection vs. input value, `menuTrigger`, loading state, sections). +- 1st-gen: [`sp-combobox`](../../../../1st-gen/packages/combobox/README.md), [`sp-textfield`](../../../../1st-gen/packages/textfield/README.md) +- Jira: [SWC-2452](https://jira.corp.adobe.com/browse/SWC-2452) (gen2 combobox epic), [SWC-2453](https://jira.corp.adobe.com/browse/SWC-2453) (this a11y analysis), [SWC-2455](https://jira.corp.adobe.com/browse/SWC-2455) (file structure, API, TypeScript, accessibility), [SWC-1888](https://jira.corp.adobe.com/browse/SWC-1888) (RFC: form field strategy for 2nd-gen migration) diff --git a/CONTRIBUTOR-DOCS/03_project-planning/README.md b/CONTRIBUTOR-DOCS/03_project-planning/README.md index 42d1d2ec534..5454e63b6a7 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/README.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/README.md @@ -42,6 +42,7 @@ - Color Field - Color Handle - Color Loupe + - Combobox - Divider - Dropzone - Field Group From 6351937ac356e5d595c99a180733c759335e6af0 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Thu, 13 Aug 2026 15:01:58 -0400 Subject: [PATCH 2/4] docs(combobox): added option and option-group --- .claude/settings.json | 7 + .../03_components/README.md | 4 + .../accessibility-migration-analysis.md | 33 ++- .../accessibility-migration-analysis.md | 177 ++++++++++++++++ .../accessibility-migration-analysis.md | 197 ++++++++++++++++++ .../03_project-planning/README.md | 2 + 6 files changed, 411 insertions(+), 9 deletions(-) create mode 100644 .claude/settings.json create mode 100644 CONTRIBUTOR-DOCS/03_project-planning/03_components/option-group/accessibility-migration-analysis.md create mode 100644 CONTRIBUTOR-DOCS/03_project-planning/03_components/option/accessibility-migration-analysis.md diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000000..58c0beb386e --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(node update-nav.js)" + ] + } +} diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md index 6c4f0d31832..8fced149f69 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md @@ -122,6 +122,10 @@ - [Opacity checkerboard accessibility migration analysis](opacity-checkerboard/accessibility-migration-analysis.md) - [Opacity Checkerboard migration plan](opacity-checkerboard/migration-plan.md) - [Opacity Checkerboard migration roadmap](opacity-checkerboard/rendering-and-styling-migration-analysis.md) +- Option + - [Option accessibility migration analysis](option/accessibility-migration-analysis.md) +- Option Group + - [Option group accessibility migration analysis](option-group/accessibility-migration-analysis.md) - Picker Button - [Picker button migration roadmap](picker-button/rendering-and-styling-migration-analysis.md) - Popover diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/combobox/accessibility-migration-analysis.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/combobox/accessibility-migration-analysis.md index 39cf6b7ec11..8cbd234689a 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/combobox/accessibility-migration-analysis.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/combobox/accessibility-migration-analysis.md @@ -41,6 +41,8 @@ This doc tells you how **`swc-combobox`** should work for **accessibility**. It A combobox is a text field with a popup listbox bolted on, and 1st-gen models that literally — `Combobox` extends `Textfield`. So the label, help text, error message, and form-participation guidance for `swc-combobox` is the **same** as [`swc-text-field`](../text-field/accessibility-migration-analysis.md)'s, driven by the same shared controllers. This doc does not re-derive that shared guidance; it references the text-field doc and concentrates on what a combobox adds on top of a text field: the `combobox`/`listbox`/`option` role trio, the expanded/active-descendant states, the cross-root wiring between the input and its options, filtering/autocomplete behavior, and the keyboard model for moving through suggestions. +Several of the 2nd-gen decisions below are motivated by concrete limitations consumers reported using the 1st-gen combobox in search-filter UI (internal design discussion, Slack — SSO-gated): (1) **no way to separate the key from the displayed text**, so duplicate display text cannot be disambiguated — addressed by giving each [`swc-option`](../option/accessibility-migration-analysis.md) a `value` distinct from its label, plus the distinct-sibling-label dev warning; (2) **long option values force the popup very wide with no way to customize** — an a11y-adjacent reflow/readability concern captured below and deferred to the rendering doc for the width API; and (3) **placeholder text that reliably works** — inherited from the text-field placeholder handling (placeholder is supported but is never the accessible name). + ### Also read - [Text field accessibility migration analysis](../text-field/accessibility-migration-analysis.md) — the base component `sp-combobox` extends. Its label, help-text, error, placeholder, `autocomplete`/`inputmode`, validation-icon, and form-association guidance apply to `swc-combobox` unchanged and are not repeated here. @@ -51,7 +53,8 @@ A combobox is a text field with a popup listbox bolted on, and 1st-gen models th - An **editable combobox with a list-autocomplete popup**: a real `` in the component's shadow root, a `listbox` of `option`s that filters as the user types, and a trigger button that toggles the popup. The value-bearing role (`combobox`) is supplied by the inner ``, exactly as `swc-text-field`'s `textbox` role is — `swc-combobox` does not set a host-level ARIA role (see [ARIA roles, states, and properties](#aria-roles-states-and-properties)). - Its label, help text, and error message are rendered inside `swc-combobox`'s own shadow root by the **`LabellingController`**, the same way `swc-text-field` renders them — not by an externally associated `swc-field-label`/`swc-help-text`. -- Its options are author-supplied elements (menu-item-like children), the same content model `sp-combobox` accepts through its default slot. The listbox and its options do **not** have to live in the same shadow root as the input; see [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues). +- Its options are author-supplied [`swc-option`](../option/accessibility-migration-analysis.md) elements — a new 2nd-gen component that owns `role="option"` on its own host, rather than the `sp-menu-item` (`role="menuitem"`) 1st-gen borrows. Options may sit directly in the combobox or be grouped under a labeled [`swc-option-group`](../option-group/accessibility-migration-analysis.md) (`role="group"`), implementing the APG [grouped listbox](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-grouped/) pattern. The listbox and its options do **not** have to live in the same shadow root as the input; see [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues). +- Each option carries a **`value`** that is separate from its displayed label. Every option's `value` must be unique across the combobox, and sibling options must have distinct labels; the combobox **dev-warns** otherwise (see [ARIA roles, states, and properties](#aria-roles-states-and-properties)). ### When to use something else @@ -61,13 +64,15 @@ A combobox is a text field with a popup listbox bolted on, and 1st-gen models th ### What it is not -- Not a menu or a menu button. A combobox's popup is a `listbox` of selectable values that fills a form field, not a `menu` of commands that perform actions. Do not reach for menu semantics (`role="menu"`/`menuitem`) to build the popup even though 1st-gen composes it from `sp-menu`/`sp-menu-item` internally. +- Not a menu or a menu button. A combobox's popup is a `listbox` of selectable values that fills a form field, not a `menu` of commands that perform actions. Do not reach for menu semantics (`role="menu"`/`menuitem`) to build the popup even though 1st-gen composes it from `sp-menu`/`sp-menu-item` internally; 2nd-gen uses [`swc-option`](../option/accessibility-migration-analysis.md) (`role="option"`) for the rows instead. - Not a multi-select control. `swc-combobox` selects a single value. Multi-value entry (token/tag input) is a different pattern and out of scope for this migration. ### Related - [`swc-text-field`](../text-field/accessibility-migration-analysis.md) — the base class; shared label/help/error/form guidance lives there. - The shared controllers this component composes: **`LabellingController`** ([SWC-2466](https://jira.corp.adobe.com/browse/SWC-2466)) and **`FieldAssociationController`** ([SWC-2467](https://jira.corp.adobe.com/browse/SWC-2467)) — the same two `swc-text-field` depends on — plus two the text field does not need: the [`FocusgroupNavigationController`](../../../../2nd-gen/packages/core/controllers/focusgroup-navigation-controller/focusgroup-navigation-controller.mdx) and the [`LiveSelectionController`](../../../../2nd-gen/packages/core/controllers/live-selection-controller/live-selection-controller.mdx) for option traversal and single-selection enforcement (see [Recommendations](#recommendations-swc-combobox)). +- [`swc-option`](../option/accessibility-migration-analysis.md) — the new 2nd-gen `role="option"` element that replaces `sp-menu-item` as the combobox's row, letting the combobox own the option role and reference the author's real element across shadow roots. +- [`swc-option-group`](../option-group/accessibility-migration-analysis.md) — the labeled `role="group"` container for grouping options (APG grouped listbox); the combobox's other accepted child. - 1st-gen `sp-combobox` renders its options a second time inside its own shadow root (see [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues)); the duplicate-rendering hack is a direct cause of option-announcement bugs such as [SWC-592](https://jira.corp.adobe.com/browse/SWC-592). --- @@ -142,12 +147,16 @@ Component tag may change until API freeze. Shared label/help/error/form-associat | **`aria-controls` → the listbox** | The input's `aria-controls` must reference the `listbox` element. When the listbox lives in a **different shadow root** than the input (the recommended structure — see [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues)), set it through the **`ariaControlsElements`** element-reference property rather than an IDREF string, which cannot cross a shadow boundary. 1st-gen only sets `aria-controls` while open and via a same-root IDREF (`aria-controls="listbox-menu"`); the element-reference form is what makes a cross-root listbox possible. | | **`aria-activedescendant` → the active option** | While the popup is open and the user arrows through options, the input's `aria-activedescendant` points at the currently active `option`. DOM focus never leaves the input. Set this through the single-element reflection property **`ariaActiveDescendantElement`** (not the pluralized name; `aria-activedescendant` references exactly one element) so it resolves to the real option element even across a shadow boundary. This is the linchpin of the hybrid approach and replaces 1st-gen's shadow-DOM-`id` lookup (`shadowRoot.getElementById(activeDescendant.value)`). Clear it (no active option) when the caret moves within the text (Home/End/ArrowLeft/ArrowRight) or the popup closes. | | **`aria-autocomplete`** | Set `aria-autocomplete="list"` when the popup offers filtered suggestions and `"none"` when it does not, on the input. Keep this decoupled from the native `autocomplete` attribute (which must be `off` on the input so the browser's own autofill/history UI does not compete with the listbox). This mirrors 1st-gen's split but should use a combobox-owned property type, not the widened text-field type. | -| **Options: `role="option"` + `aria-selected`** | Each option exposes `role="option"`. The **selected** option (the one matching the field's value) carries `aria-selected="true"`; the **active** option (arrow-key focus) is indicated by `aria-activedescendant` from the input, not by `aria-selected`. 1st-gen conflates these — it writes `aria-selected` to track the *active* descendant during arrow navigation (`el.setAttribute('aria-selected', … === activeDescendant …)`), which mis-reports selection to AT. Keep the two concepts distinct: `aria-selected` = chosen value; `aria-activedescendant` = keyboard-active row. | -| **Selection enforcement** | Use the **`LiveSelectionController`** in `'single'` mode: options own their own `selected` state and dispatch a change event, and the controller deselects the others when one becomes selected. This is the correct controller for menu-item-like children that flip their own state (as `sp-menu-item` does), rather than a host-authoritative `SelectionController`. The field's value updates from the selected option's text/value on selection. | +| **Options: `swc-option`, `role="option"` + `aria-selected`** | Rows are [`swc-option`](../option/accessibility-migration-analysis.md) elements, each exposing `role="option"` on its own host (owned by the component, not re-rendered as a shadow copy). The **selected** option (the one matching the field's value) carries `aria-selected="true"`; the **active** option (arrow-key focus) is indicated by `aria-activedescendant` from the input, not by `aria-selected`. 1st-gen conflates these — it writes `aria-selected` to track the *active* descendant during arrow navigation (`el.setAttribute('aria-selected', … === activeDescendant …)`), which mis-reports selection to AT. Keep the two concepts distinct: `aria-selected` = chosen value; `aria-activedescendant` = keyboard-active row. See the [`swc-option` doc](../option/accessibility-migration-analysis.md) for the option side. | +| **Option grouping** | The combobox also accepts [`swc-option-group`](../option-group/accessibility-migration-analysis.md) children (`role="group"` with a name), each wrapping a set of `swc-option`s — the APG [grouped listbox](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-grouped/) pattern. The active-descendant reference still targets the option, not the group; grouping is structural context AT reads around the options. | +| **Unique option `value` (dev warning)** | Every option's **`value`** must be unique across the entire combobox, including options nested in groups. `value` is decoupled from the displayed label — the key the combobox submits and keys selection on, so duplicate text can still be disambiguated. Dev-warn (using the project's shared validation/`window.__swc.warn` pattern) on a duplicate or missing `value`; a duplicate `value` makes selection ambiguous ([SWC-23](https://jira.corp.adobe.com/browse/SWC-23)). | +| **Distinct sibling labels (dev warning)** | Sibling options — those sharing a parent (the combobox directly, or the same `swc-option-group`) — must have distinct computed labels, where the computed label is the option's text content **including the alt text of icons or images** that visually distinguish it. Identical siblings are indistinguishable to both screen reader and sighted users, so **dev-warn** when two siblings compute to the same label. Options in different groups may repeat a label because the group name disambiguates. Every story except the dedicated dev-warning story must satisfy this rule, and consumer docs must explain it. See [`swc-option`](../option/accessibility-migration-analysis.md#aria-roles-states-and-properties). | +| **Selection enforcement** | Use the **`LiveSelectionController`** in `'single'` mode: `swc-option`s own their own `selected` state and dispatch a change event, and the controller deselects the others when one becomes selected. This is the correct controller for option children that flip their own state, rather than a host-authoritative `SelectionController`. The field's value updates from the selected option's text/value on selection. | | **Option traversal** | Use the **`FocusgroupNavigationController`** to compute which option is next/previous under the arrow keys, but **do not let it move DOM focus** — see [Keyboard and focus](#keyboard-and-focus). The combobox uses the controller's `setActiveItem()` / active-change event to track the active option and reflect it through `ariaActiveDescendantElement`; focus stays in the input for typing. Set `skipDisabled: true` so disabled options are skipped during arrow traversal, matching 1st-gen's `do…while` that steps over `disabled` options. | | **Trigger button** | The chevron/picker button that toggles the popup is a pointer affordance only. It is **not** a separate tab stop (`tabindex="-1"`) and its state duplicates the input's, so it must not introduce a second, competing set of `aria-expanded`/`aria-controls` announcements the way 1st-gen's `sp-picker-button` does (it currently carries its own `aria-controls`, `aria-expanded`, `aria-label`, and `aria-labelledby`). Keep it out of the accessibility tree as an interactive control, or expose it only as a decorative toggle; the input is the combobox. | | **Pending / loading** | The pending (loading) state must be announced without flooding. Do not reuse 1st-gen's approach wholesale: [SWC-1255](https://jira.corp.adobe.com/browse/SWC-1255) flagged its progress circle as semantically incorrect. Expose "loading" as a bounded status tied to the field (a labelled busy state), not an assertive live region, and never `aria-live="assertive"`. The pending spinner itself is decorative (`aria-hidden`); the pending *label* carries the text. | | **Placeholder, `aria-invalid`, required, validation icon, `autocomplete`/`inputmode`** | Inherited unchanged from [`swc-text-field`](../text-field/accessibility-migration-analysis.md#aria-roles-states-and-properties): placeholder is never the accessible name (drop the placeholder→`aria-label` fallback and dev-warn instead); `aria-invalid` only when actually invalid; native `required` rather than `aria-required`; the validation icon stays `aria-hidden="true"` and is never the sole signal of invalid state (addresses [SWC-1127](https://jira.corp.adobe.com/browse/SWC-1127)); `autocomplete` keeps its input-purpose value and `inputmode` is added. | +| **Long option text / popup width** | Consumers report long option values forcing the popup very wide with no way to customize it. The width/customization API (compare [React Spectrum's `menuWidth`](https://react-spectrum.adobe.com/ComboBox)) belongs in the rendering-and-styling doc, but two a11y constraints apply here: option text that truncates must keep its full text available to AT (the option's accessible name is the full label, never the visually clipped string — same principle as the text field's truncated value), and the popup must still satisfy [reflow (1.4.10)](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html) and text-spacing without clipping content or forcing two-dimensional scrolling. | | **Result count (optional, measured)** | If the design calls for announcing how many suggestions match, associate a concise, debounced status ("3 results") tied to the combobox — never an assertive region, and never per-keystroke without debouncing. Treat this as opt-in, not default, to avoid over-announcing under [WCAG 4.1.3](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html). | ### Shadow DOM and cross-root ARIA Issues @@ -160,13 +169,13 @@ Component tag may change until API freeze. Shared label/help/error/form-associat - Two element sets representing one logical option list is a structural cause of the option-announcement bugs tracked in [SWC-592](https://jira.corp.adobe.com/browse/SWC-592). - The `aria-selected` bookkeeping has to be mirrored across both copies, which is where 1st-gen conflates *active* and *selected* (see [ARIA roles, states, and properties](#aria-roles-states-and-properties)). -**2nd-gen's fix: element references instead of IDREFs, so the options can stay put.** Use the **hybrid model** from the [web-component form-strategy demos](https://nikkimk.github.io/web-component-form-strategy-demos/demo-hybrid.html): the `combobox` input and the `listbox` may live in `swc-combobox`'s shadow root, while the **`option`s remain the author's real elements** (in the light DOM, or in a separate popover component's root) — they no longer need to share the input's root. The IDREF relationships that would normally require a shared tree are set instead through the ARIA **element-reference reflection** properties, which resolve across shadow boundaries: +**2nd-gen's fix: element references instead of IDREFs, so the options can stay put.** Use the **hybrid model** from the [web-component form-strategy demos](https://nikkimk.github.io/web-component-form-strategy-demos/demo-hybrid.html): the `combobox` input and the `listbox` may live in `swc-combobox`'s shadow root, while the options remain the author's real [`swc-option`](../option/accessibility-migration-analysis.md) elements (in the light DOM, or in a separate popover component's root) — they no longer need to share the input's root. Using a dedicated `swc-option` that owns `role="option"` on its own host is what makes this work: the element the combobox references is already correctly roled, so there is nothing to re-render into the input's shadow tree. The IDREF relationships that would normally require a shared tree are set instead through the ARIA **element-reference reflection** properties, which resolve across shadow boundaries: - `aria-controls` (input → listbox): **`ariaControlsElements`** = `[listboxElement]`. - `aria-activedescendant` (input → active option): **`ariaActiveDescendantElement`** = `activeOptionElement` (single element; note the singular property name). - The listbox's name and the options' `aria-labelledby`, where needed, use the same element-reference pattern rather than IDREF strings. -Because these are element references, the active option can be the **author's own element** — carrying its own `lang`, text, and `role="option"` — with no shadow-DOM duplicate to keep in sync. This removes the double-render, fixes the `lang` loss structurally, and lets `aria-selected` be written once, on the real option. Element reflection is the same capability `swc-text-field` uses for `accessible-labelledby`/`accessible-describedby`; browser support baseline is the same (Chromium 135+, Safari 16.4+, Firefox 136+). Verify option and active-descendant exposure manually where support lags, per the [forms strategy §3.4 axe policy](../../05_strategies/forms-strategy-rfc.md#34-axe-core-policy). +Because these are element references, the active option can be the **author's own `swc-option`** — carrying its own `lang`, text, and host `role="option"` — with no shadow-DOM duplicate to keep in sync. This removes the double-render, fixes the `lang` loss structurally, and lets `aria-selected` be written once, on the real option. Element reflection is the same capability `swc-text-field` uses for `accessible-labelledby`/`accessible-describedby`; browser support baseline is the same (Chromium 135+, Safari 16.4+, Firefox 136+). Verify option and active-descendant exposure manually where support lags, per the [forms strategy §3.4 axe policy](../../05_strategies/forms-strategy-rfc.md#34-axe-core-policy). **axe-core will report expected false positives here.** Two from the forms strategy's [known-false-positives list](../../05_strategies/forms-strategy-rfc.md#34-axe-core-policy) apply directly: `aria-required-children` fires on the `listbox` because axe does not traverse a slot or a cross-root reference to find the `option` children, and `label` fires on the roleless host. Add story- or test-level exclusions **with a written `// reason:` comment**, not a silent global disable, and lean on manual AT testing for the relationships axe cannot follow across roots. @@ -206,7 +215,7 @@ DOM focus stays on the input for the entire interaction; this is an **active-des | Kind of test | What to check | | --- | --- | | **Unit** | `aria-expanded` toggles with `open` and is always present (both `true`/`false`); `ariaControlsElements` resolves to the listbox and `ariaActiveDescendantElement` resolves to the active option element (not a stale shadow-DOM copy) across a shadow boundary; `aria-activedescendant` clears on caret-move keys and on close; `aria-selected="true"` appears only on the option matching the value, never on the merely-active option; `LiveSelectionController` keeps exactly one option selected in single mode; `FocusgroupNavigationController` traversal skips disabled options and never moves DOM focus off the input; exactly one accessible-name mechanism is set on the input (no `aria-label` + `aria-labelledby` together); `FieldAssociationController` submits the value, resets, and disables via an ancestor `
`. | -| **aXe + Storybook** | Stories for collapsed, expanded, selected, pending, invalid, disabled, and readonly. Document the expected `aria-required-children` and `label` false positives with `// reason:` exclusions per the [forms strategy axe policy](../../05_strategies/forms-strategy-rfc.md#34-axe-core-policy) rather than disabling axe globally. A per-option `lang` story asserting the author's `lang` survives onto the real option element (regression guard for [SWC-2359](https://jira.corp.adobe.com/browse/SWC-2359)). A no-accessible-name story that dev-warns rather than silently borrowing the placeholder. | +| **aXe + Storybook** | Stories for collapsed, expanded, selected, pending, invalid, disabled, and readonly. Document the expected `aria-required-children` and `label` false positives with `// reason:` exclusions per the [forms strategy axe policy](../../05_strategies/forms-strategy-rfc.md#34-axe-core-policy) rather than disabling axe globally. A per-option `lang` story asserting the author's `lang` survives onto the real option element (regression guard for [SWC-2359](https://jira.corp.adobe.com/browse/SWC-2359)). A no-accessible-name story that dev-warns rather than silently borrowing the placeholder. A grouped story with `swc-option-group`s. **Every story uses distinct sibling labels and globally-unique option `value`s;** exactly one dedicated dev-warning story pairs two identical sibling options (and a duplicate `value`) to assert the warning fires, and the consumer docs explain the requirement. | | **Playwright ARIA snapshots** | `role=combobox` with the correct name, value, `expanded` state, and (when open) `controls`/`activedescendant`; the `listbox` with its name and `option` children carrying correct `selected` state; snapshots across top/side label positions and default/error/disabled/readonly/pending states from the design spec's state matrix. | | **Manual screen reader** | Cross-root `aria-activedescendant`/`aria-controls` exposure verified in NVDA, JAWS, and VoiceOver — and specifically in **Firefox**, where `ElementInternals`/element-reflection ARIA is least consistent (per the forms strategy). Confirm: opening announces the listbox and count sensibly (not floodily); arrowing announces each option once, including its `lang`; selecting announces the chosen value; the disabled field still announces its name. | @@ -218,7 +227,11 @@ DOM focus stays on the input for the entire interaction; this is an **active-des - [ ] Exactly one accessible-name mechanism on the input — 1st-gen's simultaneous `aria-label` + `aria-labelledby` double-labelling is removed (addresses [SWC-1152](https://jira.corp.adobe.com/browse/SWC-1152)/[SWC-1195](https://jira.corp.adobe.com/browse/SWC-1195)). - [ ] `aria-expanded` is always present and driven from the same `open` state as popup visibility. - [ ] `aria-controls` and `aria-activedescendant` are set via `ariaControlsElements` / `ariaActiveDescendantElement` element references, so the listbox and options resolve across shadow roots — no shadow-DOM duplicate of the option list. -- [ ] Options are the author's real elements; per-option `lang` survives (fixes [SWC-2359](https://jira.corp.adobe.com/browse/SWC-2359)); no double-rendering. +- [ ] Options are author-supplied [`swc-option`](../option/accessibility-migration-analysis.md) elements that own `role="option"` on their host; per-option `lang` survives (fixes [SWC-2359](https://jira.corp.adobe.com/browse/SWC-2359)); no double-rendering into the input's shadow root. +- [ ] The combobox accepts [`swc-option-group`](../option-group/accessibility-migration-analysis.md) children (`role="group"` with a name) for the APG grouped-listbox pattern; the active-descendant reference still targets the option, not the group. +- [ ] Each option's `value` is decoupled from its label and unique across the combobox; sibling options have distinct computed labels (including image/icon alt); the combobox dev-warns on a duplicate/missing `value` or identical sibling labels. +- [ ] All stories satisfy the unique-value / distinct-sibling-label rule except one dedicated dev-warning story; consumer docs explain the rule. +- [ ] Truncated long option text keeps its full accessible name; popup width still satisfies [reflow (1.4.10)](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html) (width customization API tracked in the rendering doc). - [ ] `aria-selected` marks the chosen value only; the keyboard-active option is conveyed by `aria-activedescendant`, not `aria-selected` (fixes 1st-gen's active/selected conflation). - [ ] `LiveSelectionController` enforces single selection; `FocusgroupNavigationController` drives arrow traversal with `skipDisabled: true` and **never** moves DOM focus off the input. - [ ] DOM focus stays on the input throughout; one Tab stop with `delegatesFocus: true`; trigger button is `tabindex="-1"`. @@ -234,8 +247,10 @@ DOM focus stays on the input for the entire interaction; this is an **active-des - [Web component form strategy demos](https://github.com/nikkimk/web-component-form-strategy-demos/tree/main) and the [hybrid demo](https://nikkimk.github.io/web-component-form-strategy-demos/demo-hybrid.html) — the cross-root element-reference model (`ariaControlsElements`, `ariaActiveDescendantElement`) this doc's listbox/option wiring follows. - [Forms strategy: 2nd-gen proposal (this repo)](../../05_strategies/forms-strategy-rfc.md) — role placement, the `accessible-label`/`accessible-labelledby`/`accessible-describedby` naming table, and the axe-core policy. - [Text field accessibility migration analysis (this repo)](../text-field/accessibility-migration-analysis.md) — the base component; shared label/help/error/form guidance. +- [Option accessibility migration analysis (this repo)](../option/accessibility-migration-analysis.md) — the `swc-option` (`role="option"`) rows the combobox references. +- [Option group accessibility migration analysis (this repo)](../option-group/accessibility-migration-analysis.md) — the `swc-option-group` (`role="group"`) container for grouped options. - [WAI-ARIA](https://www.w3.org/TR/wai-aria-1.2/), [WCAG 2.2](https://www.w3.org/TR/WCAG22/), [APG: read me first](https://www.w3.org/WAI/ARIA/apg/practices/read-me-first/) -- [APG: combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) and the [editable combobox with list autocomplete example](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list/) +- [APG: combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/), the [editable combobox with list autocomplete example](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list/), and the [grouped listbox example](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-grouped/) - [`FocusgroupNavigationController` (this repo)](../../../../2nd-gen/packages/core/controllers/focusgroup-navigation-controller/focusgroup-navigation-controller.mdx) and [`LiveSelectionController` (this repo)](../../../../2nd-gen/packages/core/controllers/live-selection-controller/live-selection-controller.mdx) — option traversal and single-selection enforcement. - [React Spectrum: ComboBox](https://react-spectrum.adobe.com/ComboBox) — S2 API reference (selection vs. input value, `menuTrigger`, loading state, sections). - 1st-gen: [`sp-combobox`](../../../../1st-gen/packages/combobox/README.md), [`sp-textfield`](../../../../1st-gen/packages/textfield/README.md) diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/option-group/accessibility-migration-analysis.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/option-group/accessibility-migration-analysis.md new file mode 100644 index 00000000000..5d8dd483d15 --- /dev/null +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/option-group/accessibility-migration-analysis.md @@ -0,0 +1,177 @@ + + +[CONTRIBUTOR-DOCS](../../../README.md) / [Project planning](../../README.md) / [Components](../README.md) / Option Group / Option group accessibility migration analysis + + + +# Option group accessibility migration analysis + + + +
+In this doc + +- [Overview](#overview) + - [Also read](#also-read) + - [What it is](#what-it-is) + - [When to use something else](#when-to-use-something-else) + - [What it is not](#what-it-is-not) + - [Related](#related) +- [ARIA and WCAG context](#aria-and-wcag-context) + - [Pattern in the APG](#pattern-in-the-apg) + - [Guidelines that apply](#guidelines-that-apply) +- [Related 1st-gen accessibility (Jira)](#related-1st-gen-accessibility-jira) +- [Recommendations: ``](#recommendations-swc-option-group) + - [ARIA roles, states, and properties](#aria-roles-states-and-properties) + - [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues) + - [Accessibility tree expectations](#accessibility-tree-expectations) + - [Keyboard and focus](#keyboard-and-focus) +- [Testing](#testing) + - [Automated tests](#automated-tests) + - [Manual screen reader testing](#manual-screen-reader-testing) +- [Summary checklist](#summary-checklist) +- [References](#references) + +
+ + + +## Overview + +This doc tells you how **`swc-option-group`** should work for **accessibility**. It matches the goal of **WCAG 2.2 Level AA**. `swc-option-group` is a new 2nd-gen component — there is no 1st-gen `sp-option-group`, and 1st-gen `sp-combobox` had no grouping at all. It is a labeled container that groups related [`swc-option`](../option/accessibility-migration-analysis.md) elements inside a listbox, implementing the APG [grouped listbox](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-grouped/) pattern for [`swc-combobox`](../combobox/accessibility-migration-analysis.md). + +Like `swc-option`, its reason for existing is **role ownership**: it carries `role="group"` on its own host and names that group, so a combobox in a different shadow root can present grouped options with correct structure and correct group labels, without re-rendering shadow-DOM copies. + +### Also read + +- [Combobox accessibility migration analysis](../combobox/accessibility-migration-analysis.md) — the consumer; options and option groups are its accepted children. +- [Option accessibility migration analysis](../option/accessibility-migration-analysis.md) — the `swc-option` rows this component groups; the sibling-label and unique-`value` rules are shared and defined there and in the combobox doc. +- [Menu group accessibility migration analysis](../menu-group/accessibility-migration-analysis.md) — the `role="group"` sibling in the menu family; same grouping idea, different (menu) context. + +### What it is + +- A **labeled group of options** inside a listbox: `role="group"` on the host, an accessible name for the group (from a slotted label or a label property), and one or more `swc-option` children in its default slot. +- **The role lives on the `swc-option-group` host**, set via `ElementInternals` (`internals.role = 'group'`), for the same reason `swc-option` owns `role="option"`: the real, author-supplied element carries the correct role, so grouping survives across the shadow boundary between the combobox input and its options. +- A structural container only. It groups and labels; it is never itself selectable, focusable, or the target of `aria-activedescendant`. + +### When to use something else + +- A **flat list of options** with no categories — put `swc-option`s directly in the combobox; no group is needed. +- A **command menu** with sections — use [`swc-menu-group`](../menu-group/accessibility-migration-analysis.md) (`role="group"` among `menuitem`s), not `swc-option-group`. Groups of options and groups of commands are different patterns. +- A **visual divider** with no group semantics — use a separator element, not an empty group. + +### What it is not + +- Not an option. `swc-option-group` is never selectable and never carries `aria-selected`; its label is not a choosable value. Do not give it a `value` or let it become an `aria-activedescendant` target. +- Not a nesting container for more groups. The APG grouped-listbox pattern is one level deep: `listbox` → `group` → `option`. Do not nest `swc-option-group` inside another `swc-option-group`. +- Not a menu group. It must not carry `menu`/`menuitem`-family semantics. + +### Related + +- [`swc-combobox`](../combobox/accessibility-migration-analysis.md) and [`swc-option`](../option/accessibility-migration-analysis.md) — the container and the leaf this component sits between. +- [`swc-menu-group`](../menu-group/accessibility-migration-analysis.md) — the menu-family analogue. + +--- + +## ARIA and WCAG context + +### Pattern in the APG + +- `swc-option-group` implements the group layer of the APG [grouped listbox example](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-grouped/): the `listbox` contains `group` elements, each `group` is named (via `aria-labelledby` pointing at its label, or `aria-label`), and each group's `option` children are its selectable rows. The group label itself is **not** an option and is not part of the selectable set. +- Grouping is presentational structure plus a name; it does not change how selection or active-descendant movement works. Arrowing moves from option to option across group boundaries as one linear sequence; the group only adds a spoken "group, [name]" context and a heading in the reading order. +- **Single host role.** `swc-option-group` exposes exactly one role, `group`, on its host — never conditional, never an option. This satisfies the single-host-role policy; the group is not a value-bearing control, so (like `swc-option`) the role sitting on the host is correct and is what lets grouping resolve across the shadow boundary. + +### Guidelines that apply + +| Idea | Plain meaning | +| --- | --- | +| [Info and relationships (1.3.1)](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html) | The group and its label must be programmatically associated (`role="group"` + `aria-labelledby`/`aria-label`), and the options must be exposed as children of the group, so AT conveys the category structure — not just visual spacing or a bold heading. | +| [Name, role, value (4.1.2)](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) | The group exposes `role="group"` and an accessible name. The label is a name for the group, not a selectable node. | +| [Language of parts (3.1.2)](https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html) | A group label (and its option children) in another language must carry `lang` on the real element so AT pronounces the category name and rows correctly. | +| [Headings and labels (2.4.6)](https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html) | Group labels must be descriptive and distinct, so a listener can tell one category from another. | + +**Bottom line:** `swc-option-group` adds one thing — a named `role="group"` wrapper around a subset of options — and its accessibility job is to make that name and the group→option containment real and cross-root-safe, without becoming selectable itself. + +--- + +## Related 1st-gen accessibility (Jira) + +| Jira | Type | Status (snapshot) | Resolution (snapshot) | Summary | Notes | +| --- | --- | --- | --- | --- | --- | +| [SWC-592](https://jira.corp.adobe.com/browse/SWC-592) | Bug | To Do | Unresolved | Combobox a11y issues speaking the options | Grouping adds category context to option announcements; net-new in 2nd-gen (1st-gen combobox had no groups) | +| [SWC-1373](https://jira.corp.adobe.com/browse/SWC-1373) | Story | To Do | Unresolved | Spike: compare semantics across menu, action menu, combobox, and picker | Informs how option groups relate to menu groups | + +--- + +## Recommendations: `` + +Component tag may change until API freeze. `swc-option-group` is new in 2nd-gen; there is no 1st-gen equivalent to preserve compatibility with. + +### ARIA roles, states, and properties + +| Topic | What to do | +| --- | --- | +| **Host role** | `group`, set on the host via `ElementInternals` (`internals.role = 'group'`). Keep it on the real, author-supplied element so grouping survives across the boundary between the combobox input and its options. Do not use `menu`/`listbox` roles, and do not make the group itself an `option`. | +| **Group name** | Every group must be named. Provide the name via a slotted label rendered inside the group's shadow root (associated same-root with `aria-labelledby`) or an `accessible-label`-style string property that sets `aria-label`. An unnamed group defeats the purpose; dev-warn on a group with no accessible name (matching the project's `window.__swc.warn` pattern). | +| **Label is not selectable** | The group label element is presentational: it must not be an `option`, must not be focusable, and must not be an `aria-activedescendant` target. It participates in the reading order as the group's name only. | +| **Options are the children** | The group's default slot holds `swc-option` children. The group does not re-render or proxy them; they remain the author's real elements (owning their own `value`, `lang`, `aria-selected`, `aria-disabled`), so the combobox references them directly. See [`swc-option`](../option/accessibility-migration-analysis.md). | +| **Sibling-label distinctness within the group** | Options within one group are siblings for the distinct-label rule: two options in the same group must not have identical computed label text. Options in *different* groups may legitimately repeat a label because the group name disambiguates them. The combobox owns value-uniqueness across the whole widget and sibling-label distinctness within each option-parent scope (the combobox itself and each group); see [`swc-combobox`](../combobox/accessibility-migration-analysis.md#aria-roles-states-and-properties). | +| **No nested groups** | Do not support a `swc-option-group` inside another; the grouped-listbox pattern is one level deep. | +| **`lang` passthrough** | Author `lang` on the group or on any option child must remain on the real element carrying the role, per [WCAG 3.1.2](https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html). | + +### Shadow DOM and cross-root ARIA Issues + +The group's own label renders in its shadow root and associates same-root (`aria-labelledby`/`aria-label`), so the group has no internal cross-root problem. The cross-root relationship is the same one the option has with the combobox: the combobox input, which may live in a different shadow root, references the **options** (not the group) via `ariaActiveDescendantElement`, and those references resolve because the options are the author's real elements. The group adds a containment layer that AT reads structurally; it does not need an IDREF from the input. + +What `swc-option-group` must guarantee: `role="group"` and the group name live on the referenceable host, and the option children are not re-rendered as shadow copies (which would strip their `value`/`lang`/state and break the combobox's references). Expected axe-core note: the same `aria-required-children` false positive the combobox and listbox trigger applies through the group when options are slotted or cross-root; document it as a written exclusion on the composed story per the [forms strategy axe policy](../../05_strategies/forms-strategy-rfc.md#34-axe-core-policy), not a silent disable. + +### Accessibility tree expectations + +- **Named group:** role `group` with an accessible name; contains `option` children; not selectable; not the active descendant. +- **Grouped options:** each option exposes role `option` and its own name/state as usual; AT conveys it as within the named group. +- **Localized group:** the group label (and any localized option) carries its `lang`, so AT pronounces the category name and rows in the right language. +- **Arrowing across groups:** the active option moves linearly across group boundaries; the group name is announced as context when focus enters a new group, and the group itself is never the active descendant. + +### Keyboard and focus + +`swc-option-group` is **not focusable** and handles no keys. It is a structural container: DOM focus stays on the combobox input, and the parent moves the active option (via `aria-activedescendant`) linearly across group boundaries. The group label is never a Tab stop, never an active descendant, and never selectable. If `swc-option-group` is later reused inside a standalone roving-`tabindex` listbox, the parent listbox — not the group — would manage option `tabindex`; the group still holds no focus and handles no keys. + +--- + +## Testing + +### Automated tests + +| Kind of test | What to check | +| --- | --- | +| **Unit** | `internals.role` is `group` on the host; the group has an accessible name (and dev-warns when it does not); the group label is not exposed as an `option` and is not focusable; option children keep their own `value`/`lang`/`aria-selected`/`aria-disabled` (not re-rendered by the group); no nested-group support. | +| **aXe + Storybook** | A grouped combobox story asserting `role="group"` with a name and `option` children within it, carrying the composed `aria-required-children` exclusion with a written `// reason:`. A localized-group story asserting `lang` survives on the group label and options. All grouped stories use distinct sibling labels within each group and globally-unique option `value`s; one dedicated dev-warning story pairs two identical sibling options within a group to assert the warning fires. | +| **Playwright ARIA snapshots** | Within a composed `swc-combobox`: named `group` nodes each containing their `option` children with correct names and states, and correct `activedescendant` targeting as the user arrows across group boundaries. | + +### Manual screen reader testing + +Test `swc-option-group` through its composed parent (`swc-combobox`), using [Screen reader testing](../../../../2nd-gen/packages/swc/.storybook/guides/accessibility-guides/screen_reader_testing.mdx) in NVDA, JAWS, and VoiceOver. Confirm that: entering a group announces the group name as context, arrowing moves across group boundaries without getting stuck on a label, each option is announced within its group, and a localized group name and rows are spoken in the correct language. Verify cross-root exposure in **Firefox**, where element-reference ARIA is least consistent. + +--- + +## Summary checklist + +- [ ] `swc-option-group` sets `role="group"` on its host via `ElementInternals`; grouping survives across the combobox/option shadow boundary. +- [ ] Every group has an accessible name; an unnamed group dev-warns. +- [ ] The group label is presentational — never an `option`, never focusable, never an `aria-activedescendant` target. +- [ ] Option children keep their own `value`, `lang`, and selected/disabled state; the group does not re-render them. +- [ ] Sibling-label distinctness is scoped within the group; options in different groups may repeat a label because the group name disambiguates; globally-unique option `value`s and the dev warning are enforced by the combobox. +- [ ] No nested option groups (grouped listbox is one level deep). +- [ ] Group and option `lang` survive on the real elements ([WCAG 3.1.2](https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html)). +- [ ] Stories use distinct sibling labels and unique values; one dev-warning story deliberately violates the rule; consumer docs explain the requirement. +- [ ] Manual SR testing is done through the composed `swc-combobox`, including a localized group and Firefox. + +## References + +- [Combobox accessibility migration analysis (this repo)](../combobox/accessibility-migration-analysis.md) and [Option accessibility migration analysis (this repo)](../option/accessibility-migration-analysis.md) — the container and leaf this component sits between. +- [Menu group accessibility migration analysis (this repo)](../menu-group/accessibility-migration-analysis.md) — the menu-family `role="group"` analogue. +- [Forms strategy: 2nd-gen proposal (this repo)](../../05_strategies/forms-strategy-rfc.md) — role placement and cross-root ARIA policy. +- [WAI-ARIA](https://www.w3.org/TR/wai-aria-1.2/), [WCAG 2.2](https://www.w3.org/TR/WCAG22/), [APG: read me first](https://www.w3.org/WAI/ARIA/apg/practices/read-me-first/) +- [APG: listbox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) and [grouped listbox example](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-grouped/) +- [React Spectrum: ComboBox — sections](https://react-spectrum.adobe.com/ComboBox) (`ComboBoxSection` / `Header` grouping). +- [2nd-gen Storybook: Screen reader testing](../../../../2nd-gen/packages/swc/.storybook/guides/accessibility-guides/screen_reader_testing.mdx) diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/option/accessibility-migration-analysis.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/option/accessibility-migration-analysis.md new file mode 100644 index 00000000000..d0a6853add4 --- /dev/null +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/option/accessibility-migration-analysis.md @@ -0,0 +1,197 @@ + + +[CONTRIBUTOR-DOCS](../../../README.md) / [Project planning](../../README.md) / [Components](../README.md) / Option / Option accessibility migration analysis + + + +# Option accessibility migration analysis + + + +
+In this doc + +- [Overview](#overview) + - [Also read](#also-read) + - [What it is](#what-it-is) + - [When to use something else](#when-to-use-something-else) + - [What it is not](#what-it-is-not) + - [Related](#related) +- [ARIA and WCAG context](#aria-and-wcag-context) + - [Pattern in the APG](#pattern-in-the-apg) + - [Guidelines that apply](#guidelines-that-apply) +- [Related 1st-gen accessibility (Jira)](#related-1st-gen-accessibility-jira) +- [Recommendations: ``](#recommendations-swc-option) + - [ARIA roles, states, and properties](#aria-roles-states-and-properties) + - [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues) + - [Accessibility tree expectations](#accessibility-tree-expectations) + - [Keyboard and focus](#keyboard-and-focus) +- [Testing](#testing) + - [Automated tests](#automated-tests) + - [Manual screen reader testing](#manual-screen-reader-testing) +- [Summary checklist](#summary-checklist) +- [References](#references) + +
+ + + +## Overview + +This doc tells you how **`swc-option`** should work for **accessibility**. It matches the goal of **WCAG 2.2 Level AA**. `swc-option` is a new 2nd-gen component — it has no 1st-gen `sp-option` predecessor. It is the selectable row inside a `listbox`: a single value the user can choose. Its first consumer is [`swc-combobox`](../combobox/accessibility-migration-analysis.md), which replaces 1st-gen's practice of borrowing `sp-menu-item` for combobox options. + +The reason `swc-option` exists is **role ownership**. 1st-gen `sp-combobox` builds its popup from `sp-menu` / `sp-menu-item`, which carry `menu`/`menuitem` semantics, and then works around that mismatch by re-rendering shadow-DOM copies of the options so it can attach the `listbox`/`option` roles it actually needs. A dedicated `swc-option` that **owns `role="option"` on its own host** lets the combobox point `aria-activedescendant` straight at the author's real element, across shadow roots, with the correct role already in place — no menu-to-listbox impedance mismatch and no duplicate rendering. See [`swc-combobox`'s Shadow DOM section](../combobox/accessibility-migration-analysis.md#shadow-dom-and-cross-root-aria-issues) for the parent side of this relationship. + +### Also read + +- [Combobox accessibility migration analysis](../combobox/accessibility-migration-analysis.md) — the first consumer; explains the cross-root `aria-activedescendant` / `aria-controls` wiring that references `swc-option` elements. +- [Menu item accessibility migration analysis](../menu-item/accessibility-migration-analysis.md) — the `menuitem` sibling `swc-option` is deliberately **not** a variant of (different role, different pattern). +- [Forms strategy: 2nd-gen proposal](../../05_strategies/forms-strategy-rfc.md) — role-placement and cross-root ARIA direction. + +### What it is + +- A **selectable option in a listbox**: `role="option"`, a visible label, an optional description, and optional decorative icon/avatar content. Its selected state is exposed through `aria-selected`; its disabled state through `aria-disabled`. +- It carries a **`value`** property that identifies the option to the combobox for selection and form submission. Every option in a combobox must have a **unique `value`**, and sibling options must have **distinct labels** (see [ARIA roles, states, and properties](#aria-roles-states-and-properties)). +- It may be a direct child of a [`swc-combobox`](../combobox/accessibility-migration-analysis.md) or a child of a [`swc-option-group`](../option-group/accessibility-migration-analysis.md); the group adds a labeled category around a set of options. +- **The role lives on the `swc-option` host**, set via `ElementInternals` (`internals.role = 'option'`). This is deliberate and is what makes the component worth creating: because the option's own element carries the role, a combobox (or listbox) in a different shadow root can reference it with `aria-activedescendant` via the `ariaActiveDescendantElement` element-reference property and land on a node that is already an `option`. +- Its accessible name comes from its slotted content (label text), with an optional `textValue`-style string for typeahead when the visible content is not plain text (icon-only or richly formatted rows), mirroring [React Spectrum's `textValue` on `ComboBoxItem`](https://react-spectrum.adobe.com/ComboBox). + +### When to use something else + +- A **command** that performs an action in a menu — use [`swc-menu-item`](../menu-item/accessibility-migration-analysis.md) (`role="menuitem"`), not `swc-option`. Options are *values you select*; menu items are *commands you run*. This is the core distinction and the reason `swc-option` is a separate component rather than a mode of `swc-menu-item`. +- A **checkbox or radio** choice — use the appropriate form control; `aria-selected` on an option is not the same semantic as `aria-checked`. +- A **static, non-selectable label or divider** inside a listbox — use a group label or separator element, not an `option` with selection disabled. + +### What it is not + +- Not a menu item. Even though 1st-gen composes combobox options from `sp-menu-item`, `swc-option` must not inherit `menuitem` semantics, menu-button keyboard behavior, or a `submenu` slot. It is a leaf `option`. +- Not an independently focusable control in its default (combobox) use. In the active-descendant model, DOM focus stays on the combobox input and never moves to the option (see [Keyboard and focus](#keyboard-and-focus)). +- Not a container for interactive children. Per [React Spectrum's guidance](https://react-spectrum.adobe.com/ComboBox), buttons or other interactive elements inside an option break keyboard and screen reader navigation; `swc-option` should hold only text and decorative graphics. + +### Related + +- [`swc-combobox`](../combobox/accessibility-migration-analysis.md) — the parent that references `swc-option` elements as active descendants. +- [`swc-option-group`](../option-group/accessibility-migration-analysis.md) — a labeled `role="group"` container that groups a set of `swc-option`s inside the combobox. +- [`swc-menu-item`](../menu-item/accessibility-migration-analysis.md) — the `menuitem` counterpart; same visual family, different role and pattern. +- The [`LiveSelectionController`](../../../../2nd-gen/packages/core/controllers/live-selection-controller/live-selection-controller.mdx) and [`FocusgroupNavigationController`](../../../../2nd-gen/packages/core/controllers/focusgroup-navigation-controller/focusgroup-navigation-controller.mdx) — driven by the *parent* listbox/combobox over a set of `swc-option` children, not by the option itself. + +--- + +## ARIA and WCAG context + +### Pattern in the APG + +- `swc-option` is the `option` half of the [listbox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) and, in its first use, the [combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/). The APG requires that every child of a `listbox` be an `option` (or a `group` of options), that the selected option carry `aria-selected="true"`, and that a disabled option carry `aria-disabled="true"` while remaining perceivable. +- The APG describes two focus models for options: roving `tabindex` (DOM focus moves to the option) and active-descendant (DOM focus stays on a container, and the active option is referenced by `aria-activedescendant`). `swc-option`'s first consumer, `swc-combobox`, uses the **active-descendant** model, so `swc-option` is designed to be *referenced while not focused*. A future roving-tabindex listbox could reuse `swc-option` with parent-managed `tabindex`; that is out of scope here (see [Keyboard and focus](#keyboard-and-focus)). +- **Single host role.** `swc-option` exposes exactly one role, `option`, on its host. This does not conflict with the project's single-host-role policy or the forms strategy's "value-bearing role stays in shadow DOM" rule: an option is **not** a value-bearing form control. It has no live editable value to mirror; its entire state is `aria-selected` plus its name. That is precisely why the role can safely sit on the host (the same reasoning the forms strategy applies to button-like and radio-like controls), which is required for a cross-root `aria-activedescendant` reference to resolve to a node that is already an `option`. + +### Guidelines that apply + +| Idea | Plain meaning | +| --- | --- | +| [Name, role, value (4.1.2)](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) | Each option exposes `role="option"`, an accessible name from its content, and its `aria-selected` state. Disabled options expose `aria-disabled="true"` and stay perceivable. | +| [Info and relationships (1.3.1)](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html) | The option is a child of a `listbox` and the target of the combobox's `aria-activedescendant`; that relationship must resolve programmatically even across a shadow boundary. | +| [Language of parts (3.1.2)](https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html) | When an option's text is in a different language than the page, its `lang` must be present on the real option element so AT pronounces it correctly. Owning the option element (rather than re-rendering a shadow copy) is what preserves this — the structural fix for combobox's [SWC-2359](https://jira.corp.adobe.com/browse/SWC-2359). | +| [Use of color (1.4.1)](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html) | Selected and disabled states must not be signalled by color alone; pair color with `aria-selected` / `aria-disabled` and a non-color visual cue (checkmark, dimming plus reduced affordance). | +| [Non-text contrast (1.4.11)](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html) | The active-option indicator and the selected-option indicator must each meet 3:1 against adjacent colors and be distinguishable from one another. | + +**Bottom line:** `swc-option` is a small component with one job — be a correctly-roled, correctly-named, correctly-stated `option` that a parent listbox or combobox can reference and select. Its accessibility value is concentrated in owning `role="option"` on a real, author-provided element so the parent's cross-root ARIA resolves and per-option `lang` survives. + +--- + +## Related 1st-gen accessibility (Jira) + +| Jira | Type | Status (snapshot) | Resolution (snapshot) | Summary | +| --- | --- | --- | --- | --- | +| [SWC-2359](https://jira.corp.adobe.com/browse/SWC-2359) | Bug | In Progress | Unresolved | Combobox should support `lang` on the option element ([WCAG 3.1.2](https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html)); owning the option element is the structural fix | +| [SWC-592](https://jira.corp.adobe.com/browse/SWC-592) | Bug | To Do | Unresolved | Combobox a11y issues speaking the options — rooted in re-rendered shadow-copy options rather than real option elements | +| [SWC-534](https://jira.corp.adobe.com/browse/SWC-534) | Story | Done | Fixed | Extend the combobox option type to support a disabled state — `swc-option` owns `aria-disabled` | + +--- + +## Recommendations: `` + +Component tag may change until API freeze. `swc-option` is new in 2nd-gen; there is no 1st-gen `sp-option` to preserve compatibility with. Where behavior is inherited from a parent, this doc points at [`swc-combobox`](../combobox/accessibility-migration-analysis.md) rather than restating it. + +### ARIA roles, states, and properties + +| Topic | What to do | +| --- | --- | +| **Host role** | `option`, set on the host via `ElementInternals` (`internals.role = 'option'`). This is the whole point of the component: the role lives on the real, author-supplied element so a parent in a different shadow root can reference it. Do not put `menuitem` or any other role on `swc-option`, and do not move the role into the shadow root — a cross-root `aria-activedescendant` reference must land on a node that itself carries `role="option"`. | +| **Accessible name** | From the option's slotted label content by default. Provide a `textValue`-style string property for typeahead and for cases where the visible content is not plain readable text (icon-only, or label-plus-description), matching [React Spectrum's `textValue`](https://react-spectrum.adobe.com/ComboBox). Do not rely on a description slot for the name; the name is the label. | +| **`value` — separate from the label, always unique** | Each option carries a **`value`** property that identifies it for selection and form submission, **decoupled from the displayed label**. This separation is a deliberate fix for a real 1st-gen limitation: consumers with duplicate display text have no key to disambiguate on. `value` is what the combobox submits and what selection is keyed on; the label is what the user reads. Every option in a combobox must have a **unique `value`** across the whole widget (including options inside any [`swc-option-group`](../option-group/accessibility-migration-analysis.md)); the combobox **dev-warns** on a duplicate or missing `value`. A duplicate `value` makes selection ambiguous (the 1st-gen root of [SWC-23](https://jira.corp.adobe.com/browse/SWC-23)). | +| **Distinct label among siblings** | Sibling options — options sharing one parent (the combobox directly, or the same `swc-option-group`) — must have **distinct computed labels**, where the computed label is the option's text content **including the alt text of any icon or image** that visually distinguishes it. Two siblings that read identically to a screen reader (and look identical to a sighted user) cannot be told apart, so the combobox **dev-warns** when two sibling options compute to the same label. Options in *different* groups may repeat a label because the group name disambiguates them. Give near-identical options a distinguishing suffix, description, or image alt so the computed labels differ. | +| **`aria-selected`** | Reflects whether this option is the chosen value. Exactly one option per single-select listbox is `aria-selected="true"` at a time; the parent enforces that via the `LiveSelectionController`. Keep `aria-selected` distinct from *active* state: being the keyboard-active row (the combobox's `aria-activedescendant` target) is **not** selection and must not set `aria-selected`. This separation is the fix for 1st-gen conflating the two. | +| **`aria-disabled`, not native `disabled`** | Disabled options use `aria-disabled="true"` and stay in the listbox so screen reader users can perceive them and the parent can skip them during arrow traversal (the `FocusgroupNavigationController`'s `skipDisabled` reads `aria-disabled`). Do not use native `disabled` (it is not valid on the host and would risk removing the element from the accessibility tree). | +| **Active state (owned by the parent)** | The option does **not** set its own active state. The parent combobox marks the active option through `aria-activedescendant` / `ariaActiveDescendantElement`. `swc-option` should expose a hook (attribute or property) the parent can toggle purely for the *visual* active indicator, but that hook must not write `aria-selected` or any role-bearing ARIA — it is presentation only. | +| **`lang` passthrough** | Whatever `lang` the author sets on `` must remain on the element that carries `role="option"`, so AT pronounces the option in the right language ([WCAG 3.1.2](https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html), [SWC-2359](https://jira.corp.adobe.com/browse/SWC-2359)). This is automatic when the host is the option element and there is no shadow-copy re-render. | +| **Decorative icon / avatar** | Icons and avatars inside an option are decorative reinforcement of the label and must be `aria-hidden="true"` (or otherwise excluded from the name) unless they carry information the label does not — in which case that information belongs in the accessible name via `textValue`, not in a separately-announced graphic. | +| **No interactive children** | Do not support buttons, links, or other interactive controls as option content; they break the single-activation, active-descendant model. Keep option content to text and decorative graphics. | + +### Shadow DOM and cross-root ARIA Issues + +`swc-option`'s own label and description render in its shadow root, and its accessible name computes from that content same-root, so the option has no internal cross-root problem. The cross-root relationship is **between the option and its parent**, and it is resolved on the parent's side: the combobox (or listbox) references the option via the `ariaActiveDescendantElement` and `ariaControlsElements` element-reference properties, which resolve across shadow boundaries — see [`swc-combobox`'s Shadow DOM section](../combobox/accessibility-migration-analysis.md#shadow-dom-and-cross-root-aria-issues). + +What `swc-option` must guarantee for that to work: it carries `role="option"` **on the referenceable host** (not on an inner shadow node the parent cannot reach), and its `lang`, name, and `aria-selected`/`aria-disabled` state are all on that same host node. Because the option is the author's real element (slotted into the combobox, or placed in a listbox), there is no duplicate shadow-DOM copy to keep in sync — the defect class 1st-gen's re-render created ([SWC-592](https://jira.corp.adobe.com/browse/SWC-592), [SWC-2359](https://jira.corp.adobe.com/browse/SWC-2359)) does not exist here. + +Expected axe-core note: a `listbox` whose `option` children are slotted or cross-root can trip `aria-required-children`, because axe does not traverse the slot or element reference to find them. That is the **parent's** exclusion to document (per the [forms strategy axe policy](../../05_strategies/forms-strategy-rfc.md#34-axe-core-policy)); `swc-option` itself, scanned in isolation, simply exposes `role="option"` with a name and state. + +### Accessibility tree expectations + +- **Default option:** role `option`; name from label content (or `textValue`); `aria-selected` absent or `false`; not the active descendant. +- **Selected option:** role `option`; `aria-selected="true"`; exactly one per single-select listbox. +- **Active option (combobox open, arrowing):** role `option`; referenced by the combobox input's `aria-activedescendant`; visually indicated as active, but **not** `aria-selected` unless it is also the chosen value. +- **Disabled option:** role `option`; `aria-disabled="true"`; still present in the tree and perceivable; skipped by parent arrow traversal. +- **Localized option:** role `option`; carries its author `lang`, so AT switches pronunciation for that row. + +### Keyboard and focus + +In its default `swc-combobox` use, `swc-option` follows the **active-descendant** model: it is **not** a Tab stop, it does not receive DOM focus, and it does not handle its own arrow keys. DOM focus stays on the combobox input; the parent moves the *active* option (via the `FocusgroupNavigationController`'s active-item tracking) and reflects it through `aria-activedescendant`. Activation (choosing the option) is driven by the parent's Enter handling and by pointer clicks, which the option turns into a selection change the `LiveSelectionController` observes. + +This is not the divider-style "permanently non-focusable decoration" case: the option is an interactive part of a composite widget, just one whose focus and key handling are owned by the parent rather than the option itself. If `swc-option` is later reused inside a standalone roving-`tabindex` listbox (not the combobox), that parent would manage each option's `tabindex` and move DOM focus onto the option; `swc-option` should not hard-code `tabindex` in a way that prevents either model. Pointer users can click an option to select it in every model. + +--- + +## Testing + +### Automated tests + +| Kind of test | What to check | +| --- | --- | +| **Unit** | `internals.role` is `option` on the host; `aria-selected` reflects selected state and is set on exactly the chosen option (never on the merely-active option); `aria-disabled="true"` is used for disabled options and native `disabled` is not; author `lang` remains on the host that carries the role; the visual active hook set by the parent writes no `aria-selected` or role ARIA; `textValue` feeds the accessible name for icon-only content. | +| **aXe + Storybook** | An option in isolation exposes `role="option"` with a name and no violations. A localized-option story asserting `lang` survives on the option element (regression guard for [SWC-2359](https://jira.corp.adobe.com/browse/SWC-2359)). A disabled-option story asserting `aria-disabled` and perceivability. Composed listbox/combobox stories carry the parent's documented `aria-required-children` exclusion, not a silent global disable. **All stories use distinct sibling labels and globally-unique `value`s;** exactly one dedicated dev-warning story deliberately pairs two identical sibling options (and a duplicate `value`) to assert the warning fires. Consumer docs explain the unique-`value` and distinct-sibling-label requirement. | +| **Playwright ARIA snapshots** | Within a composed `swc-combobox`: `option` children with correct names, `selected` state on only the chosen option, `disabled` state on disabled options, and correct `activedescendant` targeting from the input as the user arrows. | + +### Manual screen reader testing + +Test `swc-option` through its **composed** parent (`swc-combobox`), not in isolation, using [Screen reader testing](../../../../2nd-gen/packages/swc/.storybook/guides/accessibility-guides/screen_reader_testing.mdx) in NVDA, JAWS, and VoiceOver. Confirm that: arrowing through the open listbox announces each option's name once (including a localized option in its own language), the selected option is announced as selected, disabled options are announced as dimmed/unavailable and are skipped by arrow keys, and the active option announced by the combobox matches the visible active indicator. Verify cross-root exposure in **Firefox**, where element-reference ARIA is least consistent. + +--- + +## Summary checklist + +- [ ] `swc-option` sets `role="option"` on its host via `ElementInternals`, so a cross-root `aria-activedescendant` reference lands on a real, correctly-roled element. +- [ ] `swc-option` is a distinct component from [`swc-menu-item`](../menu-item/accessibility-migration-analysis.md) — `option` (a value you select), not `menuitem` (a command you run); no menu semantics or `submenu` slot. +- [ ] Each option has a `value` decoupled from its label; `value` is unique across the combobox; the combobox dev-warns on a duplicate or missing `value`. +- [ ] Sibling options have distinct computed labels (text content including image/icon alt); the combobox dev-warns on identical sibling labels; options in different groups may repeat a label. +- [ ] Stories use distinct sibling labels and unique values; one dedicated dev-warning story violates the rule; consumer docs explain it. +- [ ] `aria-selected` marks only the chosen value; the keyboard-active option is conveyed by the parent's `aria-activedescendant`, never by the option setting its own `aria-selected`. +- [ ] Disabled options use `aria-disabled="true"` and stay perceivable and skippable; native `disabled` is not used. +- [ ] Author `lang` survives on the option host that carries the role (fixes [SWC-2359](https://jira.corp.adobe.com/browse/SWC-2359)); no shadow-DOM duplicate of the option. +- [ ] Accessible name comes from label content, with a `textValue`-style property for typeahead / icon-only rows; decorative icons/avatars are `aria-hidden`. +- [ ] No interactive children inside an option. +- [ ] In the combobox active-descendant model the option is not a Tab stop and does not receive DOM focus; it does not hard-code `tabindex` in a way that blocks a future roving-tabindex listbox. +- [ ] Selected/disabled/active states are not conveyed by color alone; active and selected indicators are visually distinct and meet non-text contrast. +- [ ] Manual SR testing is done through the composed `swc-combobox`, including a localized option and Firefox. + +## References + +- [Combobox accessibility migration analysis (this repo)](../combobox/accessibility-migration-analysis.md) — the first consumer and the cross-root reference model. +- [Option group accessibility migration analysis (this repo)](../option-group/accessibility-migration-analysis.md) — the `role="group"` container for a set of options. +- [Menu item accessibility migration analysis (this repo)](../menu-item/accessibility-migration-analysis.md) — the `menuitem` sibling `swc-option` is deliberately not. +- [Forms strategy: 2nd-gen proposal (this repo)](../../05_strategies/forms-strategy-rfc.md) — role placement and cross-root ARIA policy. +- [WAI-ARIA](https://www.w3.org/TR/wai-aria-1.2/), [WCAG 2.2](https://www.w3.org/TR/WCAG22/), [APG: read me first](https://www.w3.org/WAI/ARIA/apg/practices/read-me-first/) +- [APG: listbox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) and [combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) +- [`LiveSelectionController` (this repo)](../../../../2nd-gen/packages/core/controllers/live-selection-controller/live-selection-controller.mdx) and [`FocusgroupNavigationController` (this repo)](../../../../2nd-gen/packages/core/controllers/focusgroup-navigation-controller/focusgroup-navigation-controller.mdx) +- [React Spectrum: ComboBox](https://react-spectrum.adobe.com/ComboBox) — `ComboBoxItem` slots (`label`, `description`, icon, avatar) and `textValue`. +- [2nd-gen Storybook: Screen reader testing](../../../../2nd-gen/packages/swc/.storybook/guides/accessibility-guides/screen_reader_testing.mdx) diff --git a/CONTRIBUTOR-DOCS/03_project-planning/README.md b/CONTRIBUTOR-DOCS/03_project-planning/README.md index 5454e63b6a7..02f7b69b0a0 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/README.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/README.md @@ -61,6 +61,8 @@ - Meter - Number Field - Opacity Checkerboard + - Option + - Option Group - Picker Button - Popover - Progress Bar From 8cdeeba412f739e60f454bf588cedd7b4a6f000f Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Thu, 13 Aug 2026 15:20:44 -0400 Subject: [PATCH 3/4] docs(combobox): added listbox component --- .../03_components/README.md | 2 + .../accessibility-migration-analysis.md | 25 ++- .../accessibility-migration-analysis.md | 194 ++++++++++++++++++ .../accessibility-migration-analysis.md | 7 +- .../accessibility-migration-analysis.md | 9 +- .../03_project-planning/README.md | 1 + 6 files changed, 223 insertions(+), 15 deletions(-) create mode 100644 CONTRIBUTOR-DOCS/03_project-planning/03_components/listbox/accessibility-migration-analysis.md diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md index 8fced149f69..b6f59f4b0d1 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md @@ -103,6 +103,8 @@ - [Link accessibility migration analysis](link/accessibility-migration-analysis.md) - [Link migration plan](link/migration-plan.md) - [Link migration roadmap](link/rendering-and-styling-migration-analysis.md) +- Listbox + - [Listbox accessibility migration analysis](listbox/accessibility-migration-analysis.md) - Menu - [Menu accessibility migration analysis](menu/accessibility-migration-analysis.md) - [Menu migration roadmap](menu/rendering-and-styling-migration-analysis.md) diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/combobox/accessibility-migration-analysis.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/combobox/accessibility-migration-analysis.md index 8cbd234689a..aebf218c34f 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/combobox/accessibility-migration-analysis.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/combobox/accessibility-migration-analysis.md @@ -53,7 +53,8 @@ Several of the 2nd-gen decisions below are motivated by concrete limitations con - An **editable combobox with a list-autocomplete popup**: a real `` in the component's shadow root, a `listbox` of `option`s that filters as the user types, and a trigger button that toggles the popup. The value-bearing role (`combobox`) is supplied by the inner ``, exactly as `swc-text-field`'s `textbox` role is — `swc-combobox` does not set a host-level ARIA role (see [ARIA roles, states, and properties](#aria-roles-states-and-properties)). - Its label, help text, and error message are rendered inside `swc-combobox`'s own shadow root by the **`LabellingController`**, the same way `swc-text-field` renders them — not by an externally associated `swc-field-label`/`swc-help-text`. -- Its options are author-supplied [`swc-option`](../option/accessibility-migration-analysis.md) elements — a new 2nd-gen component that owns `role="option"` on its own host, rather than the `sp-menu-item` (`role="menuitem"`) 1st-gen borrows. Options may sit directly in the combobox or be grouped under a labeled [`swc-option-group`](../option-group/accessibility-migration-analysis.md) (`role="group"`), implementing the APG [grouped listbox](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-grouped/) pattern. The listbox and its options do **not** have to live in the same shadow root as the input; see [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues). +- Its options are author-supplied [`swc-option`](../option/accessibility-migration-analysis.md) elements — a new 2nd-gen component that owns `role="option"` on its own host, rather than the `sp-menu-item` (`role="menuitem"`) 1st-gen borrows. Options may sit directly in the combobox or be grouped under a labeled [`swc-option-group`](../option-group/accessibility-migration-analysis.md) (`role="group"`), implementing the APG [grouped listbox](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-grouped/) pattern. +- The `listbox` itself is a shared [`swc-listbox`](../listbox/accessibility-migration-analysis.md) component that the combobox renders in **its own shadow DOM**; the options and option groups the author slots into `swc-combobox` are projected into that listbox's default slot. Because the input and the listbox share the combobox's shadow root while the options remain light-DOM elements, only the input-to-active-option relationship is cross-root; see [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues). - Each option carries a **`value`** that is separate from its displayed label. Every option's `value` must be unique across the combobox, and sibling options must have distinct labels; the combobox **dev-warns** otherwise (see [ARIA roles, states, and properties](#aria-roles-states-and-properties)). ### When to use something else @@ -73,6 +74,7 @@ Several of the 2nd-gen decisions below are motivated by concrete limitations con - The shared controllers this component composes: **`LabellingController`** ([SWC-2466](https://jira.corp.adobe.com/browse/SWC-2466)) and **`FieldAssociationController`** ([SWC-2467](https://jira.corp.adobe.com/browse/SWC-2467)) — the same two `swc-text-field` depends on — plus two the text field does not need: the [`FocusgroupNavigationController`](../../../../2nd-gen/packages/core/controllers/focusgroup-navigation-controller/focusgroup-navigation-controller.mdx) and the [`LiveSelectionController`](../../../../2nd-gen/packages/core/controllers/live-selection-controller/live-selection-controller.mdx) for option traversal and single-selection enforcement (see [Recommendations](#recommendations-swc-combobox)). - [`swc-option`](../option/accessibility-migration-analysis.md) — the new 2nd-gen `role="option"` element that replaces `sp-menu-item` as the combobox's row, letting the combobox own the option role and reference the author's real element across shadow roots. - [`swc-option-group`](../option-group/accessibility-migration-analysis.md) — the labeled `role="group"` container for grouping options (APG grouped listbox); the combobox's other accepted child. +- [`swc-listbox`](../listbox/accessibility-migration-analysis.md) — the shared `role="listbox"` component the combobox renders in its shadow DOM and projects slotted options into; the same component a picker embeds and that can be used standalone. - 1st-gen `sp-combobox` renders its options a second time inside its own shadow root (see [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues)); the duplicate-rendering hack is a direct cause of option-announcement bugs such as [SWC-592](https://jira.corp.adobe.com/browse/SWC-592). --- @@ -83,7 +85,7 @@ Several of the 2nd-gen decisions below are motivated by concrete limitations con - The APG **does** name this widget: the [combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/), specifically the [editable combobox with list autocomplete](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list/) example. Unlike `swc-text-field` (which has no named APG pattern because a native `` covers it), a combobox is a genuine composite widget: a `combobox` role on the input, a `listbox` popup it controls via `aria-controls`, and `option` children whose active one is pointed at by `aria-activedescendant`. There is no single native HTML element that supplies all of this, so the ARIA wiring is real work, not a native-semantics fallback. - Even so, the base of the widget is still a native ``. Keep it: the input supplies the editable text, the caret, text selection, IME composition, and the `combobox` role by way of the explicit `role="combobox"` on a real text input. `swc-combobox` layers `aria-expanded`, `aria-controls`, `aria-activedescendant`, and `aria-autocomplete` on top of that native input rather than reconstructing a textbox from a non-editable element. -- **Single host role holds.** The combobox exposes exactly one host-facing role, `combobox`, and it lives on the inner input — never conditionally swapped, never set on the host. The `listbox` and `option` roles belong to *different elements* (the popup and its children), not to alternate configurations of one host, so the project's single-host-role policy is satisfied without a dual-role decision. The one composition question the migration must settle — whether options are their own custom element and where the listbox lives — is answered in [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues); it does not change the host's role. +- **Single host role holds.** The combobox exposes exactly one host-facing role, `combobox`, and it lives on the inner input — never conditionally swapped, never set on the host. The `listbox` and `option` roles belong to *different elements* (the popup and its children), not to alternate configurations of one host, so the project's single-host-role policy is satisfied without a dual-role decision. The composition — a shared `swc-listbox` rendered in the combobox's shadow DOM, with `swc-option`/`swc-option-group` children projected into it — is settled in [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues); it does not change the host's role. ### Guidelines that apply @@ -144,7 +146,7 @@ Component tag may change until API freeze. Shared label/help/error/form-associat | **Host role** | None. The host element sets no `role`. The inner, real `` carries an explicit `role="combobox"`, which is the widget's single value-bearing role — placed on the shadow-DOM control for the same reason `swc-text-field`'s `textbox` role is: a combobox exposes a **live value** and an expanded state to AT, and per the [forms strategy §3.2](../../05_strategies/forms-strategy-rfc.md#32-where-aria-roles-live) a value-bearing role must live on the same node as its value, so it stays on the inner control rather than being hoisted to the host via `ElementInternals`. Do not add a `role` to the host. | | **Accessible name — one writer** | Same three-source model as `swc-text-field`, wired by the **`LabellingController`**: `accessible-labelledby` (highest precedence) → `accessible-label` → slotted visible label. Fix 1st-gen's **double-labelling bug** in the process: 1st-gen sets both `aria-label` **and** `aria-labelledby` on the same input (`aria-labelledby="label applied-label pending-label"` alongside `aria-label`), which is a conflicting, two-writer setup and a direct contributor to [SWC-1152](https://jira.corp.adobe.com/browse/SWC-1152)/[SWC-1195](https://jira.corp.adobe.com/browse/SWC-1195). 2nd-gen must set exactly one naming mechanism, from one render pass. The same name is applied to the `listbox` (via `aria-labelledby`/`aria-label` in the same root as the listbox) so the popup is named too. | | **`aria-expanded`** | Set `aria-expanded="true"` on the input when the listbox is open and `"false"` when closed. This is the collapsed/expanded state and must always be present (both values), unlike `aria-invalid` which is only set when true. 1st-gen already does this; keep it, but drive it from the same `open` state the popup visibility uses so they can never disagree. | -| **`aria-controls` → the listbox** | The input's `aria-controls` must reference the `listbox` element. When the listbox lives in a **different shadow root** than the input (the recommended structure — see [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues)), set it through the **`ariaControlsElements`** element-reference property rather than an IDREF string, which cannot cross a shadow boundary. 1st-gen only sets `aria-controls` while open and via a same-root IDREF (`aria-controls="listbox-menu"`); the element-reference form is what makes a cross-root listbox possible. | +| **`aria-controls` → the listbox** | The input's `aria-controls` references the [`swc-listbox`](../listbox/accessibility-migration-analysis.md). Because the combobox renders the listbox in **its own shadow root** (the same root as the input), this is a **same-root IDREF** — a plain, well-supported relationship, and a benefit of making the listbox a shadow-DOM child rather than a separately-rooted popup. 1st-gen also uses a same-root IDREF (`aria-controls="listbox-menu"`) but only while open; keep the reference stable and drive its presence from the `open` state. | | **`aria-activedescendant` → the active option** | While the popup is open and the user arrows through options, the input's `aria-activedescendant` points at the currently active `option`. DOM focus never leaves the input. Set this through the single-element reflection property **`ariaActiveDescendantElement`** (not the pluralized name; `aria-activedescendant` references exactly one element) so it resolves to the real option element even across a shadow boundary. This is the linchpin of the hybrid approach and replaces 1st-gen's shadow-DOM-`id` lookup (`shadowRoot.getElementById(activeDescendant.value)`). Clear it (no active option) when the caret moves within the text (Home/End/ArrowLeft/ArrowRight) or the popup closes. | | **`aria-autocomplete`** | Set `aria-autocomplete="list"` when the popup offers filtered suggestions and `"none"` when it does not, on the input. Keep this decoupled from the native `autocomplete` attribute (which must be `off` on the input so the browser's own autofill/history UI does not compete with the listbox). This mirrors 1st-gen's split but should use a combobox-owned property type, not the widened text-field type. | | **Options: `swc-option`, `role="option"` + `aria-selected`** | Rows are [`swc-option`](../option/accessibility-migration-analysis.md) elements, each exposing `role="option"` on its own host (owned by the component, not re-rendered as a shadow copy). The **selected** option (the one matching the field's value) carries `aria-selected="true"`; the **active** option (arrow-key focus) is indicated by `aria-activedescendant` from the input, not by `aria-selected`. 1st-gen conflates these — it writes `aria-selected` to track the *active* descendant during arrow navigation (`el.setAttribute('aria-selected', … === activeDescendant …)`), which mis-reports selection to AT. Keep the two concepts distinct: `aria-selected` = chosen value; `aria-activedescendant` = keyboard-active row. See the [`swc-option` doc](../option/accessibility-migration-analysis.md) for the option side. | @@ -169,11 +171,13 @@ Component tag may change until API freeze. Shared label/help/error/form-associat - Two element sets representing one logical option list is a structural cause of the option-announcement bugs tracked in [SWC-592](https://jira.corp.adobe.com/browse/SWC-592). - The `aria-selected` bookkeeping has to be mirrored across both copies, which is where 1st-gen conflates *active* and *selected* (see [ARIA roles, states, and properties](#aria-roles-states-and-properties)). -**2nd-gen's fix: element references instead of IDREFs, so the options can stay put.** Use the **hybrid model** from the [web-component form-strategy demos](https://nikkimk.github.io/web-component-form-strategy-demos/demo-hybrid.html): the `combobox` input and the `listbox` may live in `swc-combobox`'s shadow root, while the options remain the author's real [`swc-option`](../option/accessibility-migration-analysis.md) elements (in the light DOM, or in a separate popover component's root) — they no longer need to share the input's root. Using a dedicated `swc-option` that owns `role="option"` on its own host is what makes this work: the element the combobox references is already correctly roled, so there is nothing to re-render into the input's shadow tree. The IDREF relationships that would normally require a shared tree are set instead through the ARIA **element-reference reflection** properties, which resolve across shadow boundaries: +**2nd-gen's fix: a shadow-DOM `swc-listbox` with slotted options, and a single cross-root reference.** The `combobox` input and a shared [`swc-listbox`](../listbox/accessibility-migration-analysis.md) both live in `swc-combobox`'s shadow root; the author's real [`swc-option`](../option/accessibility-migration-analysis.md) and [`swc-option-group`](../option-group/accessibility-migration-analysis.md) elements stay in the combobox's light DOM and are **projected into the listbox's default slot** rather than re-rendered. Using dedicated `swc-option`/`swc-option-group`/`swc-listbox` elements that own their roles (`option`/`group`/`listbox`) on their own hosts is what makes this work: every element the combobox references is already correctly roled, and the flattened accessibility tree shows the listbox containing its options through slot projection — no shadow-DOM duplicate to keep in sync. This splits the widget's IDREF relationships into two cases: -- `aria-controls` (input → listbox): **`ariaControlsElements`** = `[listboxElement]`. -- `aria-activedescendant` (input → active option): **`ariaActiveDescendantElement`** = `activeOptionElement` (single element; note the singular property name). -- The listbox's name and the options' `aria-labelledby`, where needed, use the same element-reference pattern rather than IDREF strings. +- `aria-controls` (input → listbox): **same-root IDREF**. The input and the `swc-listbox` are both in the combobox's shadow root, so a plain IDREF resolves; no element-reference API is needed. +- `aria-activedescendant` (input → active option): **cross-root element reference**. The active option is a light-DOM `swc-option` (projected into the shadow listbox but still rooted in the document), so this uses the ARIA element-reflection property **`ariaActiveDescendantElement`** = `activeOptionElement` (single element; note the singular property name), which resolves across the shadow boundary — the "light DOM siblings via properties" half of the hybrid model in the [web-component form-strategy demos](https://nikkimk.github.io/web-component-form-strategy-demos/demo-hybrid.html). +- Where the listbox or an option needs a name from an element it does not own, use the same element-reference pattern (`ariaLabelledByElements`) rather than an IDREF string. + +**Verification required — does `ariaActiveDescendantElement` resolve to a slotted option?** This is the load-bearing assumption of the whole structure and must be proven before the API freezes, not assumed. The reference points from the combobox `` (inside `swc-combobox`'s shadow root) to a `swc-option` that lives in the combobox's light DOM and is *projected by slot* into the shadow `swc-listbox` — and, when grouped, projected through a `swc-option-group` as well. Two things have to hold: (1) the element reference is **valid to set** — referencing outward from a shadow root to an element in a shadow-including ancestor tree is permitted by ARIA element reflection, so this is expected to be allowed; and (2) assistive technology actually **honors** the `aria-activedescendant` when the option is related to the focused input only through `aria-controls` → listbox and slot projection, rather than being a light-DOM descendant of the input. The second point is the risk, because AT resolution of "active descendant" across slot projection — especially with the **extra option-group nesting level** — is not uniformly guaranteed. Test this specific topology (option directly in the combobox, and option inside an `swc-option-group`) in NVDA, JAWS, and VoiceOver across Chromium, WebKit, and Firefox before relying on it; if an AT/browser combination does not follow the projection, fall back for that case (for example an `aria-owns` relationship establishing the listbox→option ownership in the flattened tree, or a documented limitation) rather than silently shipping a broken reference. Capture the result in this doc when known. Because these are element references, the active option can be the **author's own `swc-option`** — carrying its own `lang`, text, and host `role="option"` — with no shadow-DOM duplicate to keep in sync. This removes the double-render, fixes the `lang` loss structurally, and lets `aria-selected` be written once, on the real option. Element reflection is the same capability `swc-text-field` uses for `accessible-labelledby`/`accessible-describedby`; browser support baseline is the same (Chromium 135+, Safari 16.4+, Firefox 136+). Verify option and active-descendant exposure manually where support lags, per the [forms strategy §3.4 axe policy](../../05_strategies/forms-strategy-rfc.md#34-axe-core-policy). @@ -217,7 +221,7 @@ DOM focus stays on the input for the entire interaction; this is an **active-des | **Unit** | `aria-expanded` toggles with `open` and is always present (both `true`/`false`); `ariaControlsElements` resolves to the listbox and `ariaActiveDescendantElement` resolves to the active option element (not a stale shadow-DOM copy) across a shadow boundary; `aria-activedescendant` clears on caret-move keys and on close; `aria-selected="true"` appears only on the option matching the value, never on the merely-active option; `LiveSelectionController` keeps exactly one option selected in single mode; `FocusgroupNavigationController` traversal skips disabled options and never moves DOM focus off the input; exactly one accessible-name mechanism is set on the input (no `aria-label` + `aria-labelledby` together); `FieldAssociationController` submits the value, resets, and disables via an ancestor `
`. | | **aXe + Storybook** | Stories for collapsed, expanded, selected, pending, invalid, disabled, and readonly. Document the expected `aria-required-children` and `label` false positives with `// reason:` exclusions per the [forms strategy axe policy](../../05_strategies/forms-strategy-rfc.md#34-axe-core-policy) rather than disabling axe globally. A per-option `lang` story asserting the author's `lang` survives onto the real option element (regression guard for [SWC-2359](https://jira.corp.adobe.com/browse/SWC-2359)). A no-accessible-name story that dev-warns rather than silently borrowing the placeholder. A grouped story with `swc-option-group`s. **Every story uses distinct sibling labels and globally-unique option `value`s;** exactly one dedicated dev-warning story pairs two identical sibling options (and a duplicate `value`) to assert the warning fires, and the consumer docs explain the requirement. | | **Playwright ARIA snapshots** | `role=combobox` with the correct name, value, `expanded` state, and (when open) `controls`/`activedescendant`; the `listbox` with its name and `option` children carrying correct `selected` state; snapshots across top/side label positions and default/error/disabled/readonly/pending states from the design spec's state matrix. | -| **Manual screen reader** | Cross-root `aria-activedescendant`/`aria-controls` exposure verified in NVDA, JAWS, and VoiceOver — and specifically in **Firefox**, where `ElementInternals`/element-reflection ARIA is least consistent (per the forms strategy). Confirm: opening announces the listbox and count sensibly (not floodily); arrowing announces each option once, including its `lang`; selecting announces the chosen value; the disabled field still announces its name. | +| **Manual screen reader** | Cross-root `aria-activedescendant`/`aria-controls` exposure verified in NVDA, JAWS, and VoiceOver — and specifically in **Firefox**, where `ElementInternals`/element-reflection ARIA is least consistent (per the forms strategy). **Explicitly verify the slotted-option topology:** that `ariaActiveDescendantElement` from the input resolves to a `swc-option` projected into the shadow `swc-listbox`, both when the option is a direct child of the combobox and when it is nested inside an `swc-option-group` (see [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues)). Confirm: opening announces the listbox and count sensibly (not floodily); arrowing announces each option once, including its `lang` and its group context; selecting announces the chosen value; the disabled field still announces its name. | --- @@ -226,7 +230,9 @@ DOM focus stays on the input for the entire interaction; this is an **active-des - [ ] Host sets no `role`; the inner `` carries the single `combobox` role; no host-role hoist via `ElementInternals`. - [ ] Exactly one accessible-name mechanism on the input — 1st-gen's simultaneous `aria-label` + `aria-labelledby` double-labelling is removed (addresses [SWC-1152](https://jira.corp.adobe.com/browse/SWC-1152)/[SWC-1195](https://jira.corp.adobe.com/browse/SWC-1195)). - [ ] `aria-expanded` is always present and driven from the same `open` state as popup visibility. -- [ ] `aria-controls` and `aria-activedescendant` are set via `ariaControlsElements` / `ariaActiveDescendantElement` element references, so the listbox and options resolve across shadow roots — no shadow-DOM duplicate of the option list. +- [ ] The `listbox` is a shared [`swc-listbox`](../listbox/accessibility-migration-analysis.md) rendered in the combobox's shadow DOM; slotted `swc-option`/`swc-option-group` children are projected into it, not re-rendered. +- [ ] `aria-controls` (input → listbox) is a same-root IDREF; `aria-activedescendant` (input → active option) uses the `ariaActiveDescendantElement` element reference for the cross-root, light-DOM option. +- [ ] **Verified** that `ariaActiveDescendantElement` resolves to a slotted option — both directly in the combobox and nested inside an `swc-option-group` — across NVDA/JAWS/VoiceOver and Chromium/WebKit/Firefox; fallback documented for any combo that does not honor the projection. - [ ] Options are author-supplied [`swc-option`](../option/accessibility-migration-analysis.md) elements that own `role="option"` on their host; per-option `lang` survives (fixes [SWC-2359](https://jira.corp.adobe.com/browse/SWC-2359)); no double-rendering into the input's shadow root. - [ ] The combobox accepts [`swc-option-group`](../option-group/accessibility-migration-analysis.md) children (`role="group"` with a name) for the APG grouped-listbox pattern; the active-descendant reference still targets the option, not the group. - [ ] Each option's `value` is decoupled from its label and unique across the combobox; sibling options have distinct computed labels (including image/icon alt); the combobox dev-warns on a duplicate/missing `value` or identical sibling labels. @@ -249,6 +255,7 @@ DOM focus stays on the input for the entire interaction; this is an **active-des - [Text field accessibility migration analysis (this repo)](../text-field/accessibility-migration-analysis.md) — the base component; shared label/help/error/form guidance. - [Option accessibility migration analysis (this repo)](../option/accessibility-migration-analysis.md) — the `swc-option` (`role="option"`) rows the combobox references. - [Option group accessibility migration analysis (this repo)](../option-group/accessibility-migration-analysis.md) — the `swc-option-group` (`role="group"`) container for grouped options. +- [Listbox accessibility migration analysis (this repo)](../listbox/accessibility-migration-analysis.md) — the shared `swc-listbox` (`role="listbox"`) the combobox renders in its shadow DOM. - [WAI-ARIA](https://www.w3.org/TR/wai-aria-1.2/), [WCAG 2.2](https://www.w3.org/TR/WCAG22/), [APG: read me first](https://www.w3.org/WAI/ARIA/apg/practices/read-me-first/) - [APG: combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/), the [editable combobox with list autocomplete example](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list/), and the [grouped listbox example](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-grouped/) - [`FocusgroupNavigationController` (this repo)](../../../../2nd-gen/packages/core/controllers/focusgroup-navigation-controller/focusgroup-navigation-controller.mdx) and [`LiveSelectionController` (this repo)](../../../../2nd-gen/packages/core/controllers/live-selection-controller/live-selection-controller.mdx) — option traversal and single-selection enforcement. diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/listbox/accessibility-migration-analysis.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/listbox/accessibility-migration-analysis.md new file mode 100644 index 00000000000..8785fd137d6 --- /dev/null +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/listbox/accessibility-migration-analysis.md @@ -0,0 +1,194 @@ + + +[CONTRIBUTOR-DOCS](../../../README.md) / [Project planning](../../README.md) / [Components](../README.md) / Listbox / Listbox accessibility migration analysis + + + +# Listbox accessibility migration analysis + + + +
+In this doc + +- [Overview](#overview) + - [Also read](#also-read) + - [What it is](#what-it-is) + - [When to use something else](#when-to-use-something-else) + - [What it is not](#what-it-is-not) + - [Related](#related) +- [ARIA and WCAG context](#aria-and-wcag-context) + - [Pattern in the APG](#pattern-in-the-apg) + - [Guidelines that apply](#guidelines-that-apply) +- [Related 1st-gen accessibility (Jira)](#related-1st-gen-accessibility-jira) +- [Recommendations: ``](#recommendations-swc-listbox) + - [ARIA roles, states, and properties](#aria-roles-states-and-properties) + - [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues) + - [Accessibility tree expectations](#accessibility-tree-expectations) + - [Keyboard and focus](#keyboard-and-focus) +- [Testing](#testing) + - [Automated tests](#automated-tests) + - [Manual screen reader testing](#manual-screen-reader-testing) +- [Summary checklist](#summary-checklist) +- [References](#references) + +
+ + + +## Overview + +This doc tells you how **`swc-listbox`** should work for **accessibility**. It matches the goal of **WCAG 2.2 Level AA**. `swc-listbox` is a new 2nd-gen component — there is no 1st-gen `sp-listbox` (1st-gen `sp-combobox` borrowed `sp-menu` with `role="listbox"` internally). It is the container that presents a list of selectable [`swc-option`](../option/accessibility-migration-analysis.md) rows, optionally organized into [`swc-option-group`](../option-group/accessibility-migration-analysis.md)s, implementing the APG [listbox](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) pattern. + +`swc-listbox` exists so that the `listbox` half of a combobox or a picker is **one shared component** instead of being re-implemented in each, and so that a listbox can also be used **standalone**. It carries `role="listbox"` on its own host and holds `swc-option`/`swc-option-group` children in its default slot. In [`swc-combobox`](../combobox/accessibility-migration-analysis.md) and picker, `swc-listbox` is rendered inside the parent's **shadow DOM**, and the options and option groups the author slots into the combobox or picker are projected into the listbox's default slot. That means an option or option group can have three kinds of parent — a `swc-listbox` (standalone), a `swc-combobox`, or a picker — and the listbox is the element that actually carries `role="listbox"` in every case. + +### Also read + +- [Combobox accessibility migration analysis](../combobox/accessibility-migration-analysis.md) — the first embedded consumer; explains the `combobox`-to-`listbox` wiring and the cross-root active-descendant reference to options. +- [Option accessibility migration analysis](../option/accessibility-migration-analysis.md) and [Option group accessibility migration analysis](../option-group/accessibility-migration-analysis.md) — the children this component holds; the unique-`value` and distinct-sibling-label rules live there and in the combobox doc. +- [Forms strategy: 2nd-gen proposal](../../05_strategies/forms-strategy-rfc.md) — role-placement and cross-root ARIA direction. + +### What it is + +- A **list of selectable options**: `role="listbox"` on the host, holding `swc-option` rows (and optional `swc-option-group`s) in its default slot, with single-select by default and optional multi-select (`aria-multiselectable`). +- **The role lives on the `swc-listbox` host**, set via `ElementInternals` (`internals.role = 'listbox'`). Because the real element carries the role, the same component works standalone (author places it in the light DOM) and embedded (a combobox or picker renders it in its shadow DOM and projects slotted options into it). +- **Shared, two-mode component.** Standalone, it is a focusable widget with its own keyboard model and accessible name. Embedded in a combobox or picker, it is the popup surface: it keeps `role="listbox"` but is not the focusable element — the combobox input or the picker button owns focus, `aria-expanded`, and the active-descendant reference into the list. + +### When to use something else + +- A **single value chosen from a field that also allows typing** — use [`swc-combobox`](../combobox/accessibility-migration-analysis.md), which composes `swc-listbox` internally; do not hand-wire a bare listbox next to a text input. +- A **single value chosen from a collapsed trigger** (no typing) — use a picker, which also composes `swc-listbox`. +- A **menu of commands that perform actions** — use [`swc-menu`](../menu/accessibility-migration-analysis.md) with [`swc-menu-item`](../menu-item/accessibility-migration-analysis.md) (`role="menu"`/`menuitem`), not a listbox of options. +- A set of **independently toggleable options that are all always visible** and behave like form controls — consider a checkbox or radio group instead; a listbox is a single composite widget with one Tab stop, not a set of separate fields. + +### What it is not + +- Not a menu. `listbox` is for *selecting values*; `menu` is for *running commands*. Do not give `swc-listbox` `menu`/`menuitem`-family semantics even though 1st-gen composes the combobox popup from `sp-menu`. +- Not a form field on its own. When it participates in a form it does so through its embedding parent (combobox, picker), which owns form association; a standalone `swc-listbox` exposes selection but is not itself a form-associated custom element in this migration's scope. +- Not the owner of option identity or option content. Options own their own `value`, label, `lang`, and selected/disabled state; the listbox coordinates selection across them, it does not re-render them. + +### Related + +- [`swc-option`](../option/accessibility-migration-analysis.md) and [`swc-option-group`](../option-group/accessibility-migration-analysis.md) — the children. +- [`swc-combobox`](../combobox/accessibility-migration-analysis.md) and picker — the embedding consumers that render `swc-listbox` in their shadow DOM. +- The [`FocusgroupNavigationController`](../../../../2nd-gen/packages/core/controllers/focusgroup-navigation-controller/focusgroup-navigation-controller.mdx) and [`LiveSelectionController`](../../../../2nd-gen/packages/core/controllers/live-selection-controller/live-selection-controller.mdx) — the shared controllers the listbox uses for option traversal and selection enforcement (and lends to its embedding parents). + +--- + +## ARIA and WCAG context + +### Pattern in the APG + +- `swc-listbox` implements the APG [listbox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/): a `listbox` container whose children are `option`s (or `group`s of options), with `aria-selected` marking chosen options and `aria-multiselectable="true"` when more than one may be selected. Grouping follows the [grouped listbox example](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-grouped/). +- The APG describes two focus models: **roving `tabindex`** (DOM focus moves to the active option) and **active-descendant** (DOM focus stays on the listbox container, which carries `aria-activedescendant`). A standalone `swc-listbox` may use either, driven by the `FocusgroupNavigationController`. When **embedded in a combobox**, the active-descendant model is required and the reference lives on the combobox input, not on the listbox — see [`swc-combobox`](../combobox/accessibility-migration-analysis.md#keyboard-and-focus). +- **Single host role.** `swc-listbox` exposes exactly one role, `listbox`, on its host — fixed, never conditional, never overridable. It is not a value-bearing text control, so the role sitting on the host is correct and lets the same element be reused across standalone, combobox, and picker contexts. + +### Guidelines that apply + +| Idea | Plain meaning | +| --- | --- | +| [Name, role, value (4.1.2)](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) | The listbox exposes `role="listbox"`, an accessible name, its multi-selectable state, and (through its options) which options are selected. | +| [Info and relationships (1.3.1)](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html) | The listbox → option (and listbox → group → option) containment must be a real programmatic relationship. When options are slotted into an embedded listbox across the shadow boundary, the flattened accessibility tree must still show them as the listbox's children. | +| [Keyboard (2.1.1)](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html) | Every option is reachable and selectable by keyboard; one Tab stop for the whole widget (standalone), with arrows/Home/End/typeahead inside. | +| [Focus visible (2.4.7)](https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html) | The active option has a visible indicator distinct from the selected-option indicator, whether focus is on the option (roving) or on the container (active-descendant). | +| [Use of color (1.4.1)](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html) | Selected and disabled options are not signalled by color alone; pair with `aria-selected`/`aria-disabled` and a non-color cue. | + +**Bottom line:** `swc-listbox` centralizes the `listbox` role, option traversal, and selection so combobox and picker do not each re-implement them, and so a standalone listbox is possible. Its accessibility job is to own `role="listbox"` and a name, coordinate selection across real option children, and behave correctly in both the standalone (focusable) and embedded (parent-driven, active-descendant) modes. + +--- + +## Related 1st-gen accessibility (Jira) + +| Jira | Type | Status (snapshot) | Resolution (snapshot) | Summary | Notes | +| --- | --- | --- | --- | --- | --- | +| [SWC-592](https://jira.corp.adobe.com/browse/SWC-592) | Bug | To Do | Unresolved | Combobox a11y issues speaking the options | 1st-gen had no shared listbox; the popup was `sp-menu` with `role="listbox"` re-rendering options | +| [SWC-1373](https://jira.corp.adobe.com/browse/SWC-1373) | Story | To Do | Unresolved | Spike: compare semantics across menu, action menu, combobox, and picker | Motivates a shared listbox distinct from menu semantics | +| [SWC-1377](https://jira.corp.adobe.com/browse/SWC-1377) | Story | To Do | Unresolved | RFC(menu): align menu and listbox components with WAI-ARIA APG patterns | Directly informs the listbox/menu split | + +--- + +## Recommendations: `` + +Component tag may change until API freeze. `swc-listbox` is new in 2nd-gen; there is no 1st-gen equivalent to preserve compatibility with. + +### ARIA roles, states, and properties + +| Topic | What to do | +| --- | --- | +| **Host role** | `listbox`, set on the host via `ElementInternals` (`internals.role = 'listbox'`). Fixed and not author-overridable; if a menu is needed, use `swc-menu`, not a role override. | +| **Accessible name** | A standalone listbox must have an accessible name via an `accessible-label`/`accessible-labelledby`-style API (the project's cross-root-safe naming, per the [forms strategy](../../05_strategies/forms-strategy-rfc.md#33-idref-strategy-label-help-text-and-errors)); do not expose raw `aria-label` on the host. When embedded, the parent supplies the name — the combobox/picker field label names the listbox — so the listbox does not need its own visible label in that mode. | +| **Children** | `swc-option` and `swc-option-group` only, in the default slot. In embedded mode these arrive by slot projection from the combobox/picker light DOM; the listbox does not re-render or clone them, so each option keeps its own `value`, `lang`, and state. | +| **Selection: single vs multiple** | Single-select by default (one `aria-selected="true"` at a time). For multi-select, set `aria-multiselectable="true"` on the host and allow multiple `aria-selected` options. Use the **`LiveSelectionController`** in `'single'` mode for single-select; in `'multiple'` mode it imposes no constraint. Selection state (`aria-selected`) lives on the options, not the listbox. | +| **Active option** | The active (keyboard-focused) option is conveyed by `aria-activedescendant`. In standalone active-descendant mode, `aria-activedescendant` (or its element-reference form) lives on the `swc-listbox` host. In embedded combobox mode, it lives on the **combobox input**, not the listbox, and targets the slotted option across the shadow boundary — see [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues). Active state is never the same as `aria-selected`. | +| **Option traversal** | Use the **`FocusgroupNavigationController`** with `direction: 'vertical'` and `skipDisabled: true`. Standalone, it can move DOM focus (roving tabindex) or track the active item for active-descendant. Embedded in a combobox, it must **not** move DOM focus (focus stays on the input); the parent uses the controller's active-item tracking to drive `aria-activedescendant`. | +| **Grouping** | Accept `swc-option-group` (`role="group"`) children; the active-descendant reference targets the option, not the group. See [`swc-option-group`](../option-group/accessibility-migration-analysis.md). | +| **Unique `value` and distinct sibling labels** | The unique-`value`-per-widget and distinct-sibling-label rules (with dev warnings) are owned by the embedding parent for combobox/picker, and by the listbox itself when standalone: a standalone `swc-listbox` should run the same validation over its own options. See [`swc-option`](../option/accessibility-migration-analysis.md#aria-roles-states-and-properties). | +| **Empty / no matches** | When the list has no options to show (for example a combobox filter with no matches), do not present an empty `listbox` as if it had content; the embedding parent manages `aria-expanded` and any "no results" messaging (measured, not an assertive live region — see [`swc-combobox`](../combobox/accessibility-migration-analysis.md#aria-roles-states-and-properties)). | + +### Shadow DOM and cross-root ARIA Issues + +`swc-listbox` is the component that makes the combobox/picker cross-root story tractable, so its two modes have different — and mostly favorable — cross-root properties. + +**Standalone: no cross-root issue.** The listbox and its slotted options are in the same document (light DOM), so listbox → option containment and any `aria-activedescendant` on the listbox host resolve same-root. The listbox's own name and the group labels associate same-root. There is nothing cross-root to solve. + +**Embedded in a combobox/picker: the boundary moves, and mostly simplifies.** The `swc-listbox` is rendered in the parent's shadow DOM, and the author's `swc-option`/`swc-option-group` elements — written in the parent's light DOM — are projected into the listbox's default slot. Two relationships result: + +- **Parent focus element → listbox (`aria-controls`):** the combobox input (or picker button) and the `swc-listbox` are now in the **same** shadow root (the parent's), so `aria-controls` can be a plain same-root IDREF. This is simpler than referencing a listbox that lives in yet another root, and is a direct benefit of making the listbox a shadow-DOM child of the parent. +- **Parent focus element → active option (`aria-activedescendant`):** the option is a **light-DOM** element of the parent (projected into the shadow listbox, but still rooted in the document), while the input is in the parent's shadow root — so this reference is still cross-root and must use the **`ariaActiveDescendantElement`** element-reference property, not an IDREF string. This is the one genuinely cross-root relationship. It is *expected* to resolve because the option is a real, correctly-roled `swc-option`, but that resolution across slot projection — and across the extra nesting level a `swc-option-group` adds — is an **open verification item** owned by the embedding parent; see [`swc-combobox`'s Shadow DOM section](../combobox/accessibility-migration-analysis.md#shadow-dom-and-cross-root-aria-issues). Do not treat it as guaranteed until tested in real AT. + +Because options are projected rather than re-rendered, the listbox → option containment is preserved in the flattened accessibility tree without any duplicate copies — the 1st-gen double-render (and its `lang`-loss and option-announcement defects, [SWC-2359](https://jira.corp.adobe.com/browse/SWC-2359)/[SWC-592](https://jira.corp.adobe.com/browse/SWC-592)) does not occur. Expected axe-core note: `aria-required-children` can still fire on the listbox because axe does not traverse the slot projection to find the `option` children; document it as a written exclusion on the composed story per the [forms strategy axe policy](../../05_strategies/forms-strategy-rfc.md#34-axe-core-policy), and verify child exposure with manual AT testing, especially in Firefox. + +### Accessibility tree expectations + +- **Standalone, named:** role `listbox` with an accessible name; `aria-multiselectable="true"` when multi-select; `option` children (and `group`s) with correct names and `aria-selected` state; the active option referenced by `aria-activedescendant` on the listbox (active-descendant mode) or focused directly (roving mode). +- **Embedded in a combobox:** role `listbox` named by the field label; not focusable itself; contains the projected `option`/`group` children; the active option is referenced from the combobox input's `aria-activedescendant`, not from the listbox. +- **Grouped:** `group` nodes each with a name, containing their `option` children; arrowing moves across group boundaries; the group is never the active descendant. +- **Selected options:** `aria-selected="true"` on the chosen option(s) — exactly one in single-select, any number in multi-select; carried by the options, not the listbox. +- **Disabled options:** `aria-disabled="true"`, present and perceivable, skipped in traversal. + +### Keyboard and focus + +Behavior depends on the mode: + +- **Standalone.** One Tab stop for the whole widget. Inside, the `FocusgroupNavigationController` (`direction: 'vertical'`, `skipDisabled: true`, `wrap` per design) handles ArrowUp/ArrowDown, Home/End, and optional typeahead. Selection is made with Enter/Space; multi-select adds Shift + arrows and select-all where the design calls for it, following the [APG listbox keyboard model](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/#keyboardinteraction). The listbox may use roving tabindex (focus on the option) or active-descendant (focus on the container). +- **Embedded in a combobox/picker.** The listbox is **not** a Tab stop and does not hold DOM focus; the combobox input or picker button is the focusable element and owns the keyboard model. The listbox contributes its option-traversal logic through the shared `FocusgroupNavigationController`, but the parent keeps DOM focus on its own control and drives `aria-activedescendant` — see [`swc-combobox`'s Keyboard and focus](../combobox/accessibility-migration-analysis.md#keyboard-and-focus). Pointer users can click an option to select it in every mode. + +--- + +## Testing + +### Automated tests + +| Kind of test | What to check | +| --- | --- | +| **Unit** | `internals.role` is `listbox` on the host; `aria-multiselectable` reflects multi-select; single-select keeps exactly one `aria-selected` option via the `LiveSelectionController`; slotted `swc-option`/`swc-option-group` children are projected (not re-rendered) and keep their `value`/`lang`/state; standalone listbox validates unique `value`s and distinct sibling labels; embedded mode does not put a Tab stop on the listbox or move DOM focus off the parent control. | +| **aXe + Storybook** | A standalone named listbox (single and multi-select) with no violations; a grouped listbox; a listbox embedded in a combobox story carrying the documented `aria-required-children` exclusion with a written `// reason:`. Localized-option and disabled-option coverage. | +| **Playwright ARIA snapshots** | Standalone: `role=listbox` with name, `multiselectable`, `option`/`group` children and `selected` state, and correct `activedescendant`/roving focus. Embedded: the listbox named by the field label, not focusable, with the active option referenced from the parent control. | + +### Manual screen reader testing + +Test both modes with [Screen reader testing](../../../../2nd-gen/packages/swc/.storybook/guides/accessibility-guides/screen_reader_testing.mdx) in NVDA, JAWS, and VoiceOver. Standalone: confirm the listbox is announced with its name and multi-selectable state, arrowing announces each option and its selected state, and grouping is announced as context. Embedded: confirm the composed combobox/picker announces the listbox and its options correctly and that focus never leaves the parent control. Verify cross-root active-descendant exposure in **Firefox**, where element-reference ARIA is least consistent. + +--- + +## Summary checklist + +- [ ] `swc-listbox` sets `role="listbox"` on its host via `ElementInternals`; fixed, not author-overridable. +- [ ] Works in two modes: standalone (focusable, own keyboard model and name) and embedded (rendered in a combobox/picker shadow DOM, not focusable, parent-driven). +- [ ] Accepts `swc-option` and `swc-option-group` in the default slot; embedded options arrive by slot projection and are not re-rendered (no duplicate copies; `value`/`lang`/state preserved). +- [ ] Single-select by default (one `aria-selected`); multi-select via `aria-multiselectable="true"` and the `LiveSelectionController`. +- [ ] Active option conveyed by `aria-activedescendant` — on the listbox host standalone, on the combobox input when embedded; never conflated with `aria-selected`. +- [ ] Embedded: parent-focus → listbox `aria-controls` is same-root; parent-focus → active option `aria-activedescendant` uses `ariaActiveDescendantElement` cross-root. +- [ ] Standalone listbox validates unique option `value`s and distinct sibling labels; combobox/picker own that validation when embedded. +- [ ] `aria-required-children` axe false positive documented with a written `// reason:`, not a silent disable; child exposure verified manually including Firefox. +- [ ] Menu semantics are not used; `swc-menu`/`swc-menu-item` remain the command-menu components. + +## References + +- [Combobox accessibility migration analysis (this repo)](../combobox/accessibility-migration-analysis.md), [Option accessibility migration analysis (this repo)](../option/accessibility-migration-analysis.md), [Option group accessibility migration analysis (this repo)](../option-group/accessibility-migration-analysis.md) — the consumer and children. +- [Menu accessibility migration analysis (this repo)](../menu/accessibility-migration-analysis.md) — the command-menu counterpart the listbox is deliberately distinct from. +- [Forms strategy: 2nd-gen proposal (this repo)](../../05_strategies/forms-strategy-rfc.md) — role placement, naming, and cross-root ARIA policy. +- [WAI-ARIA](https://www.w3.org/TR/wai-aria-1.2/), [WCAG 2.2](https://www.w3.org/TR/WCAG22/), [APG: read me first](https://www.w3.org/WAI/ARIA/apg/practices/read-me-first/) +- [APG: listbox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) and [grouped listbox example](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-grouped/) +- [`FocusgroupNavigationController` (this repo)](../../../../2nd-gen/packages/core/controllers/focusgroup-navigation-controller/focusgroup-navigation-controller.mdx) and [`LiveSelectionController` (this repo)](../../../../2nd-gen/packages/core/controllers/live-selection-controller/live-selection-controller.mdx) +- [2nd-gen Storybook: Screen reader testing](../../../../2nd-gen/packages/swc/.storybook/guides/accessibility-guides/screen_reader_testing.mdx) diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/option-group/accessibility-migration-analysis.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/option-group/accessibility-migration-analysis.md index 5d8dd483d15..da21b3120e4 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/option-group/accessibility-migration-analysis.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/option-group/accessibility-migration-analysis.md @@ -44,13 +44,14 @@ Like `swc-option`, its reason for existing is **role ownership**: it carries `ro ### Also read +- [Listbox accessibility migration analysis](../listbox/accessibility-migration-analysis.md) — the `role="listbox"` container that holds option groups (standalone, or inside a combobox/picker). - [Combobox accessibility migration analysis](../combobox/accessibility-migration-analysis.md) — the consumer; options and option groups are its accepted children. - [Option accessibility migration analysis](../option/accessibility-migration-analysis.md) — the `swc-option` rows this component groups; the sibling-label and unique-`value` rules are shared and defined there and in the combobox doc. - [Menu group accessibility migration analysis](../menu-group/accessibility-migration-analysis.md) — the `role="group"` sibling in the menu family; same grouping idea, different (menu) context. ### What it is -- A **labeled group of options** inside a listbox: `role="group"` on the host, an accessible name for the group (from a slotted label or a label property), and one or more `swc-option` children in its default slot. +- A **labeled group of options** inside a listbox: `role="group"` on the host, an accessible name for the group (from a slotted label or a label property), and one or more `swc-option` children in its default slot. Like an option, its parent may be a [`swc-listbox`](../listbox/accessibility-migration-analysis.md) (standalone), a [`swc-combobox`](../combobox/accessibility-migration-analysis.md), or a picker; in a combobox or picker it is authored in the parent's light DOM and projected by slot into the `swc-listbox` the parent renders in its shadow DOM. - **The role lives on the `swc-option-group` host**, set via `ElementInternals` (`internals.role = 'group'`), for the same reason `swc-option` owns `role="option"`: the real, author-supplied element carries the correct role, so grouping survives across the shadow boundary between the combobox input and its options. - A structural container only. It groups and labels; it is never itself selectable, focusable, or the target of `aria-activedescendant`. @@ -68,7 +69,7 @@ Like `swc-option`, its reason for existing is **role ownership**: it carries `ro ### Related -- [`swc-combobox`](../combobox/accessibility-migration-analysis.md) and [`swc-option`](../option/accessibility-migration-analysis.md) — the container and the leaf this component sits between. +- [`swc-listbox`](../listbox/accessibility-migration-analysis.md), [`swc-combobox`](../combobox/accessibility-migration-analysis.md), and [`swc-option`](../option/accessibility-migration-analysis.md) — the containers and the leaf this component sits between. - [`swc-menu-group`](../menu-group/accessibility-migration-analysis.md) — the menu-family analogue. --- @@ -123,7 +124,7 @@ Component tag may change until API freeze. `swc-option-group` is new in 2nd-gen; The group's own label renders in its shadow root and associates same-root (`aria-labelledby`/`aria-label`), so the group has no internal cross-root problem. The cross-root relationship is the same one the option has with the combobox: the combobox input, which may live in a different shadow root, references the **options** (not the group) via `ariaActiveDescendantElement`, and those references resolve because the options are the author's real elements. The group adds a containment layer that AT reads structurally; it does not need an IDREF from the input. -What `swc-option-group` must guarantee: `role="group"` and the group name live on the referenceable host, and the option children are not re-rendered as shadow copies (which would strip their `value`/`lang`/state and break the combobox's references). Expected axe-core note: the same `aria-required-children` false positive the combobox and listbox trigger applies through the group when options are slotted or cross-root; document it as a written exclusion on the composed story per the [forms strategy axe policy](../../05_strategies/forms-strategy-rfc.md#34-axe-core-policy), not a silent disable. +Because `swc-option-group` adds **one more nesting level** between the listbox and the option, it is the specific case that most needs verification: when a combobox or picker projects a group (and its options) by slot into its shadow `swc-listbox`, the parent's `ariaActiveDescendantElement` reference to an option *inside* the group must still resolve in real assistive technology. This is tracked on the combobox side; see [`swc-combobox`'s Shadow DOM section](../combobox/accessibility-migration-analysis.md#shadow-dom-and-cross-root-aria-issues). What `swc-option-group` must guarantee: `role="group"` and the group name live on the referenceable host, and the option children are not re-rendered as shadow copies (which would strip their `value`/`lang`/state and break the combobox's references). Expected axe-core note: the same `aria-required-children` false positive the combobox and listbox trigger applies through the group when options are slotted or cross-root; document it as a written exclusion on the composed story per the [forms strategy axe policy](../../05_strategies/forms-strategy-rfc.md#34-axe-core-policy), not a silent disable. ### Accessibility tree expectations diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/option/accessibility-migration-analysis.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/option/accessibility-migration-analysis.md index d0a6853add4..1f45cbb1b8f 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/option/accessibility-migration-analysis.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/option/accessibility-migration-analysis.md @@ -52,7 +52,7 @@ The reason `swc-option` exists is **role ownership**. 1st-gen `sp-combobox` buil - A **selectable option in a listbox**: `role="option"`, a visible label, an optional description, and optional decorative icon/avatar content. Its selected state is exposed through `aria-selected`; its disabled state through `aria-disabled`. - It carries a **`value`** property that identifies the option to the combobox for selection and form submission. Every option in a combobox must have a **unique `value`**, and sibling options must have **distinct labels** (see [ARIA roles, states, and properties](#aria-roles-states-and-properties)). -- It may be a direct child of a [`swc-combobox`](../combobox/accessibility-migration-analysis.md) or a child of a [`swc-option-group`](../option-group/accessibility-migration-analysis.md); the group adds a labeled category around a set of options. +- Its parent may be a [`swc-listbox`](../listbox/accessibility-migration-analysis.md) (standalone), a [`swc-combobox`](../combobox/accessibility-migration-analysis.md), or a picker — and, within any of those, it may be nested in a [`swc-option-group`](../option-group/accessibility-migration-analysis.md) that adds a labeled category. In a combobox or picker, the option is authored in that parent's light DOM and projected by slot into the `swc-listbox` the parent renders in its shadow DOM; the option itself stays a real light-DOM element. - **The role lives on the `swc-option` host**, set via `ElementInternals` (`internals.role = 'option'`). This is deliberate and is what makes the component worth creating: because the option's own element carries the role, a combobox (or listbox) in a different shadow root can reference it with `aria-activedescendant` via the `ariaActiveDescendantElement` element-reference property and land on a node that is already an `option`. - Its accessible name comes from its slotted content (label text), with an optional `textValue`-style string for typeahead when the visible content is not plain text (icon-only or richly formatted rows), mirroring [React Spectrum's `textValue` on `ComboBoxItem`](https://react-spectrum.adobe.com/ComboBox). @@ -71,7 +71,8 @@ The reason `swc-option` exists is **role ownership**. 1st-gen `sp-combobox` buil ### Related - [`swc-combobox`](../combobox/accessibility-migration-analysis.md) — the parent that references `swc-option` elements as active descendants. -- [`swc-option-group`](../option-group/accessibility-migration-analysis.md) — a labeled `role="group"` container that groups a set of `swc-option`s inside the combobox. +- [`swc-listbox`](../listbox/accessibility-migration-analysis.md) — the `role="listbox"` container an option belongs to (directly when standalone, or by slot projection inside a combobox/picker). +- [`swc-option-group`](../option-group/accessibility-migration-analysis.md) — a labeled `role="group"` container that groups a set of `swc-option`s inside a listbox. - [`swc-menu-item`](../menu-item/accessibility-migration-analysis.md) — the `menuitem` counterpart; same visual family, different role and pattern. - The [`LiveSelectionController`](../../../../2nd-gen/packages/core/controllers/live-selection-controller/live-selection-controller.mdx) and [`FocusgroupNavigationController`](../../../../2nd-gen/packages/core/controllers/focusgroup-navigation-controller/focusgroup-navigation-controller.mdx) — driven by the *parent* listbox/combobox over a set of `swc-option` children, not by the option itself. @@ -132,7 +133,9 @@ Component tag may change until API freeze. `swc-option` is new in 2nd-gen; there `swc-option`'s own label and description render in its shadow root, and its accessible name computes from that content same-root, so the option has no internal cross-root problem. The cross-root relationship is **between the option and its parent**, and it is resolved on the parent's side: the combobox (or listbox) references the option via the `ariaActiveDescendantElement` and `ariaControlsElements` element-reference properties, which resolve across shadow boundaries — see [`swc-combobox`'s Shadow DOM section](../combobox/accessibility-migration-analysis.md#shadow-dom-and-cross-root-aria-issues). -What `swc-option` must guarantee for that to work: it carries `role="option"` **on the referenceable host** (not on an inner shadow node the parent cannot reach), and its `lang`, name, and `aria-selected`/`aria-disabled` state are all on that same host node. Because the option is the author's real element (slotted into the combobox, or placed in a listbox), there is no duplicate shadow-DOM copy to keep in sync — the defect class 1st-gen's re-render created ([SWC-592](https://jira.corp.adobe.com/browse/SWC-592), [SWC-2359](https://jira.corp.adobe.com/browse/SWC-2359)) does not exist here. +What `swc-option` must guarantee for that to work: it carries `role="option"` **on the referenceable host** (not on an inner shadow node the parent cannot reach), and its `lang`, name, and `aria-selected`/`aria-disabled` state are all on that same host node. Because the option is the author's real element (slotted into the combobox/picker and projected into their shadow `swc-listbox`, or placed directly in a standalone listbox), there is no duplicate shadow-DOM copy to keep in sync — the defect class 1st-gen's re-render created ([SWC-592](https://jira.corp.adobe.com/browse/SWC-592), [SWC-2359](https://jira.corp.adobe.com/browse/SWC-2359)) does not exist here. + +**Open verification (embedded case):** when the option is projected by slot into a combobox's or picker's shadow `swc-listbox` — and especially when it is nested one further level inside a `swc-option-group` — it must be confirmed that the parent's `ariaActiveDescendantElement` reference actually resolves to this option across the slot projection in real assistive technology, not just in spec. This is tracked on the parent side; see [`swc-combobox`'s Shadow DOM section](../combobox/accessibility-migration-analysis.md#shadow-dom-and-cross-root-aria-issues). `swc-option`'s obligation is only to keep the role and state on the referenceable host so the reference has a valid target. Expected axe-core note: a `listbox` whose `option` children are slotted or cross-root can trip `aria-required-children`, because axe does not traverse the slot or element reference to find them. That is the **parent's** exclusion to document (per the [forms strategy axe policy](../../05_strategies/forms-strategy-rfc.md#34-axe-core-policy)); `swc-option` itself, scanned in isolation, simply exposes `role="option"` with a name and state. diff --git a/CONTRIBUTOR-DOCS/03_project-planning/README.md b/CONTRIBUTOR-DOCS/03_project-planning/README.md index 02f7b69b0a0..502eb9db421 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/README.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/README.md @@ -54,6 +54,7 @@ - Infield Button - Infield Progress Circle - Link + - Listbox - Menu - Menu Group - Menu Item From 18a630abda7a6b742972b252e7bd6a4a777b9481 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Thu, 13 Aug 2026 15:52:56 -0400 Subject: [PATCH 4/4] docs(combobox); added pending guidance --- .../combobox/accessibility-migration-analysis.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/combobox/accessibility-migration-analysis.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/combobox/accessibility-migration-analysis.md index aebf218c34f..caba0948c51 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/combobox/accessibility-migration-analysis.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/combobox/accessibility-migration-analysis.md @@ -156,7 +156,7 @@ Component tag may change until API freeze. Shared label/help/error/form-associat | **Selection enforcement** | Use the **`LiveSelectionController`** in `'single'` mode: `swc-option`s own their own `selected` state and dispatch a change event, and the controller deselects the others when one becomes selected. This is the correct controller for option children that flip their own state, rather than a host-authoritative `SelectionController`. The field's value updates from the selected option's text/value on selection. | | **Option traversal** | Use the **`FocusgroupNavigationController`** to compute which option is next/previous under the arrow keys, but **do not let it move DOM focus** — see [Keyboard and focus](#keyboard-and-focus). The combobox uses the controller's `setActiveItem()` / active-change event to track the active option and reflect it through `ariaActiveDescendantElement`; focus stays in the input for typing. Set `skipDisabled: true` so disabled options are skipped during arrow traversal, matching 1st-gen's `do…while` that steps over `disabled` options. | | **Trigger button** | The chevron/picker button that toggles the popup is a pointer affordance only. It is **not** a separate tab stop (`tabindex="-1"`) and its state duplicates the input's, so it must not introduce a second, competing set of `aria-expanded`/`aria-controls` announcements the way 1st-gen's `sp-picker-button` does (it currently carries its own `aria-controls`, `aria-expanded`, `aria-label`, and `aria-labelledby`). Keep it out of the accessibility tree as an interactive control, or expose it only as a decorative toggle; the input is the combobox. | -| **Pending / loading** | The pending (loading) state must be announced without flooding. Do not reuse 1st-gen's approach wholesale: [SWC-1255](https://jira.corp.adobe.com/browse/SWC-1255) flagged its progress circle as semantically incorrect. Expose "loading" as a bounded status tied to the field (a labelled busy state), not an assertive live region, and never `aria-live="assertive"`. The pending spinner itself is decorative (`aria-hidden`); the pending *label* carries the text. | +| **Pending / loading** | Adopt the **shared pending model**, not a combobox-specific one: the [`PendingController`](../../../../2nd-gen/packages/core/controllers/pending-controller/pending-controller.mdx) and [`PendingMixin`](../../../../2nd-gen/packages/core/mixins/pending-mixin.ts) that [`swc-button`](../button/accessibility-migration-analysis.md) and `swc-action-button` already consume, and that [`swc-text-field`](../text-field/accessibility-migration-analysis.md) references as the same structural pattern (`renderPendingSpinner`). This gives every busy control one consistent behavior: (1) the host **stays focusable** while pending — the mixin never sets native `disabled`; it sets `aria-disabled="true"` and suppresses activation; (2) the busy state is exposed through the control's **own accessible name**, derived as `", busy"` (or an explicit `pending-label`), **not** through a live region — there is no `aria-live` and never `aria-live="assertive"`; (3) the spinner is **decorative** and adds no tree node; and (4) there is a **delay** before the busy visual appears so quick loads do not flash. Dev-warn if `pending` and `disabled` are set together (the mixin already does): use `pending` to stay focusable-but-busy, `disabled` to leave the tab order. While pending, the combobox must not open the listbox. This is the structural fix for 1st-gen [SWC-1255](https://jira.corp.adobe.com/browse/SWC-1255) (its `sp-progress-circle` was semantically incorrect) and 1st-gen's ad-hoc visually-hidden `pending-label` span. Align the spinner's visual treatment and motion with Adobe's design **Loading animation discovery** (see [References](#references)) and the project's reduced-motion handling. | | **Placeholder, `aria-invalid`, required, validation icon, `autocomplete`/`inputmode`** | Inherited unchanged from [`swc-text-field`](../text-field/accessibility-migration-analysis.md#aria-roles-states-and-properties): placeholder is never the accessible name (drop the placeholder→`aria-label` fallback and dev-warn instead); `aria-invalid` only when actually invalid; native `required` rather than `aria-required`; the validation icon stays `aria-hidden="true"` and is never the sole signal of invalid state (addresses [SWC-1127](https://jira.corp.adobe.com/browse/SWC-1127)); `autocomplete` keeps its input-purpose value and `inputmode` is added. | | **Long option text / popup width** | Consumers report long option values forcing the popup very wide with no way to customize it. The width/customization API (compare [React Spectrum's `menuWidth`](https://react-spectrum.adobe.com/ComboBox)) belongs in the rendering-and-styling doc, but two a11y constraints apply here: option text that truncates must keep its full text available to AT (the option's accessible name is the full label, never the visually clipped string — same principle as the text field's truncated value), and the popup must still satisfy [reflow (1.4.10)](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html) and text-spacing without clipping content or forcing two-dimensional scrolling. | | **Result count (optional, measured)** | If the design calls for announcing how many suggestions match, associate a concise, debounced status ("3 results") tied to the combobox — never an assertive region, and never per-keystroke without debouncing. Treat this as opt-in, not default, to avoid over-announcing under [WCAG 4.1.3](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html). | @@ -188,7 +188,7 @@ Because these are element references, the active option can be the **author's ow - **Collapsed, labeled, empty:** role `combobox` on the input; name from `accessible-labelledby`/`accessible-label`/slotted label (one source); `aria-expanded="false"`; value empty; no `aria-activedescendant`. Description from same-root `aria-describedby` when help text is slotted. - **Expanded, arrowing through options:** input still has role `combobox` and DOM focus; `aria-expanded="true"`; `aria-controls` resolves to the `listbox`; `aria-activedescendant` resolves to the active `option`. The listbox exposes role `listbox` with its own accessible name; each option exposes role `option`, its own text (and its own `lang` when set), and `aria-selected="true"` only on the option matching the field value — *not* on the merely-active option. - **Value selected:** the input's value reflects the chosen option's text; the matching option is `aria-selected="true"`; the popup collapses (`aria-expanded="false"`) and `aria-activedescendant` is cleared. Exactly one option is selected (`LiveSelectionController`, single mode). -- **Pending / loading:** a bounded, labelled busy state associated with the field; the spinner adds no separate interactive node (it is `aria-hidden`); no assertive live region. +- **Pending / loading:** the input stays focusable with `aria-disabled="true"` and a `", busy"` accessible name (via the shared `PendingController`), so AT reads the busy state from the control's own name rather than a live region; the spinner adds no separate node (it is decorative); the listbox does not open while pending. - **Invalid:** `aria-invalid="true"`; error text visible and reachable via `aria-describedby`/`aria-errormessage`; the alert icon stays `aria-hidden="true"` and is not the only signal of the state. - **Disabled vs. readonly:** `disabled` removes the field from the tab order and the popup cannot open; `readonly` keeps the field focusable and its value selectable but non-editable and non-expandable. These must be programmatically distinct outcomes, not just CSS — and the disabled state must still expose the field's accessible name (the specific 1st-gen gap in [SWC-1195](https://jira.corp.adobe.com/browse/SWC-1195)). @@ -242,7 +242,7 @@ DOM focus stays on the input for the entire interaction; this is an **active-des - [ ] `LiveSelectionController` enforces single selection; `FocusgroupNavigationController` drives arrow traversal with `skipDisabled: true` and **never** moves DOM focus off the input. - [ ] DOM focus stays on the input throughout; one Tab stop with `delegatesFocus: true`; trigger button is `tabindex="-1"`. - [ ] Keyboard model matches the APG list-autocomplete combobox; Home/End/arrow-left/right move the caret and clear the active option; Escape yields to IME composition before clearing/closing. -- [ ] Pending/loading is a bounded, labelled busy state (not an assertive live region); the spinner is `aria-hidden`; addresses [SWC-1255](https://jira.corp.adobe.com/browse/SWC-1255). +- [ ] Pending/loading uses the shared `PendingController`/`PendingMixin` (as `swc-button`/`swc-action-button` do, and `swc-text-field` references): focusable-while-busy with `aria-disabled` and a `", busy"` accessible name, decorative spinner, delayed visual, no live region, dev-warn on `pending`+`disabled`, listbox stays closed; spinner motion aligns with the design Loading animation discovery; addresses [SWC-1255](https://jira.corp.adobe.com/browse/SWC-1255). - [ ] Disabled vs. readonly are programmatically distinct; the disabled field still exposes its accessible name (fixes [SWC-1195](https://jira.corp.adobe.com/browse/SWC-1195)). - [ ] Disabled/invalid state is not conveyed by color alone (fixes [SWC-1127](https://jira.corp.adobe.com/browse/SWC-1127)). - [ ] Shared label/help/error/form guidance is inherited from `swc-text-field` via `LabellingController` + `FieldAssociationController`, not reimplemented. @@ -259,6 +259,8 @@ DOM focus stays on the input for the entire interaction; this is an **active-des - [WAI-ARIA](https://www.w3.org/TR/wai-aria-1.2/), [WCAG 2.2](https://www.w3.org/TR/WCAG22/), [APG: read me first](https://www.w3.org/WAI/ARIA/apg/practices/read-me-first/) - [APG: combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/), the [editable combobox with list autocomplete example](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list/), and the [grouped listbox example](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-grouped/) - [`FocusgroupNavigationController` (this repo)](../../../../2nd-gen/packages/core/controllers/focusgroup-navigation-controller/focusgroup-navigation-controller.mdx) and [`LiveSelectionController` (this repo)](../../../../2nd-gen/packages/core/controllers/live-selection-controller/live-selection-controller.mdx) — option traversal and single-selection enforcement. +- [`PendingController` (this repo)](../../../../2nd-gen/packages/core/controllers/pending-controller/pending-controller.mdx) and [`PendingMixin` (this repo)](../../../../2nd-gen/packages/core/mixins/pending-mixin.ts) — the shared pending/busy model `swc-button` and `swc-action-button` use and `swc-text-field` references; combobox's pending state follows it. +- [Loading animation discovery (Figma)](https://www.figma.com/design/42VzvpW262EAUbYsadO4e8/Loading-animation-discovery) — Adobe design guidance for loading/busy visuals and motion the combobox's pending spinner should align with. - [React Spectrum: ComboBox](https://react-spectrum.adobe.com/ComboBox) — S2 API reference (selection vs. input value, `menuTrigger`, loading state, sections). - 1st-gen: [`sp-combobox`](../../../../1st-gen/packages/combobox/README.md), [`sp-textfield`](../../../../1st-gen/packages/textfield/README.md) - Jira: [SWC-2452](https://jira.corp.adobe.com/browse/SWC-2452) (gen2 combobox epic), [SWC-2453](https://jira.corp.adobe.com/browse/SWC-2453) (this a11y analysis), [SWC-2455](https://jira.corp.adobe.com/browse/SWC-2455) (file structure, API, TypeScript, accessibility), [SWC-1888](https://jira.corp.adobe.com/browse/SWC-1888) (RFC: form field strategy for 2nd-gen migration)