diff --git a/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/02_step-by-step/01_washing-machine-workflow.md b/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/02_step-by-step/01_washing-machine-workflow.md index f31334d30aa..2333e367bcb 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/02_step-by-step/01_washing-machine-workflow.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/02_step-by-step/01_washing-machine-workflow.md @@ -31,6 +31,7 @@ - [What to do](#what-to-do) - [Property migration scenarios](#property-migration-scenarios) - [API patterns (statics and warnings)](#api-patterns-statics-and-warnings) + - [Form participation (form fields only)](#form-participation-form-fields-only) - [What to check](#what-to-check) - [Common problems and solutions](#common-problems-and-solutions) - [Quality gate](#quality-gate) @@ -356,6 +357,12 @@ Notes on the pattern: - The URL links to the component docs section that describes the new API. - `level: 'deprecation'` sorts the warning under the deprecation channel and lets consumers silence the whole class via `window.__swc.ignoreWarningLevels.deprecation = true`. +### Form participation (form fields only) + +Applies when the component is a **form field** (text field, checkbox, radio, picker, combobox). Skip this for non-form components. + +Wire the field per the approved [forms strategy](../../../05_strategies/forms-strategy-rfc.md): form participation through the **ElementInternals / form-associated custom element (FACE)** API (not a nested light-DOM ``) and the form lifecycle callbacks. Name the public API from its [naming table](../../../05_strategies/forms-strategy-rfc.md#4-naming-table) so property, slot, and event names match; do not invent per-component names, and align text-like fields and pickers to the same table. + ### What to check - [ ] All relevant 1st-gen props have a 2nd-gen home (base or SWC). @@ -363,6 +370,8 @@ Notes on the pattern: - [ ] Internal helpers are marked `@internal`. - [ ] Static `readonly` arrays match types; used for validation, Storybook, and tests where applicable. - [ ] Invalid prop combinations emit `window.__swc.warn()` when debug is on (where the component has combination rules). +- [ ] **Form fields:** the field is form-associated (`static formAssociated = true`); value flows through `ElementInternals` (`setFormValue()`), not a hidden ``; and property, slot, and event names match the forms strategy naming table. (Validity reporting via `setValidity()` is pending — do not block on it.) + ### Common problems and solutions @@ -399,6 +408,7 @@ If you are renaming or removing a public prop or attribute, confirm with the tea 6. **Native vs custom controls:** Native form control (e.g. Checkbox) → `delegatesFocus: true`. Custom control (e.g. Radio) → `role` and `aria-*` on host, manage focus/keyboard. See Checkbox and Radio as references. 7. **Focus delegation on internal control:** When a component wraps a native form control inside its shadow DOM, set `delegatesFocus: true` via `static override shadowRootOptions = { ...ParentClass.shadowRootOptions, delegatesFocus: true }` so that focus lands on the internal control, not the host. This belongs in the base class if all subclasses share the same host-wraps-native-control structure. **Do not** override `createRenderRoot()` to set this option — doing so bypasses Lit’s `adoptStyles()`, silently preventing all component CSS from being injected into the shadow DOM. See [Rendering patterns: Shadow root customization](../../../../02_style-guide/02_typescript/09_rendering-patterns.md#shadow-root-customization). 8. **Accessible name forwarding:** Attributes like `aria-label` on the host do not automatically apply to the internal control — either bind them explicitly in the render template (e.g. `aria-label=${this.getAttribute(‘aria-label’)}`) or derive the accessible name in a protected helper and forward it. See `ButtonBase.getResolvedAccessibleName()` as a reference. Note that implementing these patterns may require adding methods or modifying the render template, so Phase 4 often touches component class files, not only Storybook or docs. +9. **Form field label, help text, and errors (form fields only):** Wire the accessible name, help text, and error text per the approved [forms strategy](../../../05_strategies/forms-strategy-rfc.md#33-idref-strategy-label-help-text-and-errors), which covers the labelling surface, `aria-describedby` / `aria-errormessage`, and the cross-root IDREF pattern. See also [semantic HTML and ARIA](../../../../../2nd-gen/packages/swc/.storybook/guides/accessibility-guides/semantic_html_aria.mdx). ### What to check @@ -416,6 +426,7 @@ If you are renaming or removing a public prop or attribute, confirm with the tea | Unclear which pattern applies | Start from the component’s primary role (e.g. "combobox" → Combobox pattern). Consider splitting into more than one component (e.g. "sp-menu" into menu and listbox components). | | Focus trap in overlays | Use a shared focus-trap utility if the repo provides one; follow APG for modal/dialog. | | Custom controls | Ensure they have roles, names, and keyboard support; avoid div/span without semantics. | +| Help text or error not announced | The describing text lives in a different shadow root; a bare `aria-describedby` IDREF does not cross the boundary. Use the cross-root pattern from the [forms strategy](../../../05_strategies/forms-strategy-rfc.md#33-idref-strategy-label-help-text-and-errors). |
**Stop and ask:** Custom events vs native events @@ -431,6 +442,7 @@ Prefer native events when they give the right semantics (e.g. `click`). Add cust - [ ] Keyboard and ARIA implemented - [ ] a11y tests added - [ ] Screen reader testing performed +- [ ] **Form fields:** axe-core passes in CI. Any known false positive (e.g. a role exposed through `ElementInternals` that axe cannot yet see) is handled per the forms strategy [axe policy](../../../05_strategies/forms-strategy-rfc.md#34-axe-core-policy): a story-level exclusion with a written rationale and an upstream tracking link, not a silent disable. --- @@ -656,6 +668,7 @@ Use Badge as the reference implementation: ## Style guides and resources +- **Forms strategy:** [2nd-gen forms strategy](../../../05_strategies/forms-strategy-rfc.md) — ElementInternals/FACE decision, label/help/error pattern, IDREF and cross-root rules, and axe policy for form fields. - **Workspace:** [spectrum-css](https://github.com/adobe/spectrum-css) cloned **next to** this repo—see [Workspace setup](#workspace-setup). - **TypeScript:** Team conventions; for 2nd-gen API patterns (static `readonly`, `window.__swc.warn`), see Phase 3 [API patterns](#api-patterns-statics-and-warnings) and 2nd-gen Badge (`core` + `swc`). - **CSS:** [2nd-gen CSS style guide (CONTRIBUTOR-DOCS)](../../../../02_style-guide/01_css/README.md) — component CSS, custom properties, Spectrum→SWC migration, anti-patterns, property order diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md index 346f4b8574e..72baa9ebbb9 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md @@ -183,3 +183,5 @@ This section provides a **component-centric view** of individual components and Each component has its own folder (kebab-case). Inside you'll find analysis and planning docs for that component—for example, **rendering-and-styling-migration-analysis.md** for Spectrum 2 CSS-to-SWC migration. The same structure is intended for future peer docs (e.g. a11y analysis, comparative API analysis). For a workstream-centric view of the same work, see [Workstreams](../02_workstreams/README.md). For how the two views fit together, see the [Project planning overview](../README.md). + +When migrating a **form field** (text field, checkbox, radio, picker, combobox, and similar), start from the canonical [forms strategy](../05_strategies/forms-strategy-rfc.md): it defines the ElementInternals/FACE decision, the label/help/error pattern, IDREF and cross-root rules, and the axe policy that Phase 3 and Phase 4 of the [washing machine workflow](../02_workstreams/02_2nd-gen-component-migration/02_step-by-step/01_washing-machine-workflow.md) depend on. diff --git a/CONTRIBUTOR-DOCS/03_project-planning/05_strategies/forms-strategy-rfc.md b/CONTRIBUTOR-DOCS/03_project-planning/05_strategies/forms-strategy-rfc.md new file mode 100644 index 00000000000..1deba65d328 --- /dev/null +++ b/CONTRIBUTOR-DOCS/03_project-planning/05_strategies/forms-strategy-rfc.md @@ -0,0 +1,186 @@ + + +[CONTRIBUTOR-DOCS](../../README.md) / [Project planning](../README.md) / Strategies / Forms Strategy: 2nd-Gen Proposal + + + +# Forms Strategy: 2nd-Gen Proposal + + + +
+In this doc + +- [Summary](#summary) +- [Value Impact](#value-impact) + - [Accessibility](#accessibility) + - [Consumer Experience](#consumer-experience) + - [Author Maintenance](#author-maintenance) +- [1. Why Change?](#1-why-change) +- [2. Scope](#2-scope) +- [3. Recommendations](#3-recommendations) + - [3.1 Form participation: ElementInternals / FACE](#31-form-participation-elementinternals--face) + - [3.2 Where ARIA roles live](#32-where-aria-roles-live) + - [3.3 IDREF strategy: label, help text, and errors](#33-idref-strategy-label-help-text-and-errors) + - [3.4 axe-core policy](#34-axe-core-policy) +- [4. Naming table](#4-naming-table) +- [5. Migration path](#5-migration-path) +- [6. Open questions](#6-open-questions) +- [Appendix A: PoC findings](#appendix-a-poc-findings) + +
+ + + +## Summary + +This proposal records the team's recommended direction for **2nd-gen form fields** (text field, checkbox, radio, picker, combobox) before scaling migration. It synthesizes the proof-of-concept findings for text field and combobox as form-associated custom elements, plus the cross-root ARIA `referenceTarget` shim research. The core decisions are: form fields participate in forms through the **ElementInternals / form-associated custom element (FACE)** API; ARIA roles default to the **shadow DOM**, with an explicit host-role exception for button-like and radio-like controls (see [§3.2](#32-where-aria-roles-live)); label, help text, and error text associate through **IDREF relationships** that use a cross-root-safe pattern; and **axe-core** exclusions are documented, not silent. + + +> **Scope:** Form-field API and accessibility direction only. This proposal does **not** implement the shared controllers or migrate a production component; those are follow-up work. + +> ⚠️ **Important:** The direction and names below are drawn from the [form-strategy proof-of-concept](https://github.com/nikkimk/web-component-form-strategy-demos). Controllers marked *pending research* have not been accepted yet; their names and APIs may change when the corresponding research spikes conclude. + +--- + +## Value Impact + +### Accessibility + +1. **Native form validation and focus.** Form-associated custom elements expose validity, validation messages, and focus behavior to the browser and to assistive technology the same way native controls do, rather than reconstructing them in JavaScript over a hidden input. +2. **Consistent label, help, and error exposure.** A single label/help/error pattern across all fields means screen readers announce the accessible name and descriptions consistently, instead of per-component variation. +3. **Cross-root descriptions that actually associate.** A documented cross-root ARIA pattern ensures `aria-describedby` and `aria-errormessage` relationships resolve across the shadow boundary instead of silently pointing at nothing. + +### Consumer Experience + +1. **Fields work inside a native `
`.** Values submit, reset, and restore through the standard form lifecycle without wrapper glue in consumer code. +2. **Predictable validation surface.** Consumers read and set validity through one consistent property/method surface across every field. + +### Author Maintenance + +1. **One form-participation pattern to learn.** Authors follow the same FACE wiring for every field rather than inventing per-component value/validity plumbing. +2. **A shared naming table.** Property, slot, and event names come from one table, so text-like fields and pickers stay aligned and migration reviews are faster. + +--- + +## 1. Why Change? + +1st-gen form controls vary in how they participate in forms, where roles live, and how label/help/error text is associated. Some rely on a nested light-DOM ``; some manage validity by hand; cross-root ARIA relationships are inconsistent. Scaling 2nd-gen migration without a single agreed direction would multiply that inconsistency across every field. This proposal fixes the direction once so contributors do not re-litigate it per component. + +--- + +## 2. Scope + +**In scope:** the recommended direction for form participation, role placement, IDREF/label/help/error association, and axe policy, plus a naming table contributors follow during migration. + +**Out of scope:** implementing a shared form-field mixin, migrating a specific production component, and full validation UI. Those are tracked as follow-up work. + +--- + +## 3. Recommendations + +### 3.1 Form participation: ElementInternals / FACE + +2nd-gen form fields are **form-associated custom elements**: set `static formAssociated = true`, attach internals with `this.attachInternals()`, and mirror value through `setFormValue()`. Do not nest a hidden light-DOM `` to participate in forms. A `setValidity()` pass-through on `FieldAssociationController` is proposed but not yet implemented (*pending research*); see [§6](#6-open-questions) before hand-rolling validity per component. + +- **Decision:** yes, adopt ElementInternals/FACE for form fields. The value is submitted via `internals.setFormValue(value)` on change, and the `formDisabledCallback(disabled)` lifecycle hook receives cascades from an ancestor `
` or an owning form. +- **Shared controller:** a **`FieldAssociationController`** wraps `ElementInternals` to handle value submission, the disabled cascade, and form reset once, so text field, checkbox, and combobox do not each reimplement it. +- **Browser / AT notes:** Chromium and Safari expose `ElementInternals` ARIA more consistently than Firefox; verify exposure manually in Firefox (see [§3.4](#34-axe-core-policy)). + +### 3.2 Where ARIA roles live + +For value-bearing fields, the role element **defaults to the shadow DOM**, not the host. This is a deliberate design decision: keeping the role element inside the shadow root enables CSS encapsulation and lets slotted label and description content associate through same-root `aria-labelledby` / `aria-describedby` ID references, while external (light-DOM) label sources use cross-root element-reference properties (see [§3.3](#33-idref-strategy-label-help-text-and-errors)). + +- **Exception (button-like and radio-like controls):** these put the role on the **host** instead, via `ElementInternals` (`internals.role = 'button'` / `'radio'`). This is safe specifically because neither role needs to expose a *live value* to assistive technology the way a textbox or combobox trigger does; a radio's or button's full state is carried by `aria-checked` or the role itself, so there is no value-mirroring problem to solve on the host. It also collapses the focusable element, the role element, and (for radio) the roving-tabindex participant the group's focus controller drives into a single node, instead of splitting them across host and shadow root. Do not extend this exception to controls that carry a live value (textbox, combobox); see [Why not put the value-bearing role on the host?](#why-not-put-the-value-bearing-role-on-the-host) for why that case does not work the same way. Even with the role on the host, name these controls through `accessible-label` / `accessible-labelledby`, not raw `aria-label` (see [§3.3](#33-idref-strategy-label-help-text-and-errors)). +- **Consequence:** because a value-bearing field's host has no role, an axe-core scan of the host alone reports a false positive; this is expected and handled by the axe policy in [§3.4](#34-axe-core-policy). + +#### Why not put the value-bearing role on the host? + +A textbox or combobox exposes a **live value** to assistive technology, and that value must live on the element that carries the role. If the role were placed on the host via `ElementInternals` while the editable control stayed in the shadow DOM, the value and the role would sit on different nodes, and the accessible value would not track the control. Keeping the role on the inner shadow control keeps the role and its value on the same node. Button-like and radio-like controls do not hit this problem because they expose no live value (see the exception above), which is why the host-role shortcut is safe for them but not for value-bearing fields. + +### 3.3 IDREF strategy: label, help text, and errors + +Hosts do **not** expose the raw `aria-label` / `aria-labelledby` attributes. Because of cross-root ARIA issues, and so consumers get one consistent API across every field instead of setting `aria-label` on the components that happen to support it (and then reaching for it on the ones that do not), fields expose the established **`accessible-label`** attribute for a string name and a proposed **`accessible-labelledby`** attribute for an ID reference. This holds even for host-role controls (button-like, radio-like); see [§3.2](#32-where-aria-roles-live). + +A **`LabellingController`** (in flight as part of the text field epic; *pending research*) owns the wiring so fields do not hand-roll it: it watches the shadow DOM slots, shows or hides the internal label element based on slot content presence, and keeps the ARIA relationships in sync as content changes. Help text and error text associate through `aria-describedby` and `aria-errormessage`. + +Two complementary sources feed the accessible name and description: + +- **Slotted content** (`slot="label"` / `slot="description"`) projects into the shadow DOM and wires through **same-root** `aria-labelledby` / `aria-describedby` pointing at the shadow-internal elements. This is a plain IDREF because both ends live in the same root. +- **Light-DOM siblings** wire through the `accessible-labelledby` attribute, which resolves element IDs and, via the `LabellingController`, sets the **cross-root element-reference property** `ariaLabelledByElements`, rather than a raw IDREF that cannot cross the shadow boundary. + +When both sources exist, the shadow-internal label appears first in the merged element-reference list. Error text associates the same way through `aria-errormessage`. + +- **Reference:** [semantic HTML and ARIA guide](../../../2nd-gen/packages/swc/.storybook/guides/accessibility-guides/semantic_html_aria.mdx). + +### 3.4 axe-core policy + +Browsers currently lack a standardized path for axe-core to read ARIA relationships set via `ElementInternals` (for example `ariaLabelledByElements`, `ariaDescribedByElements`), so form-field stories produce known false positives and blind spots. + +**Known false positives** (valid patterns axe flags as violations): + +- **`label`**: "Form element does not have a label" on the host, because axe inspects the roleless host without following the shadow root to the inner control's cross-root label wiring. +- **`aria-required-children`**: fires on a combobox-style component with slotted options because axe does not traverse the light-DOM slot to find the `role="option"` children. +- **`duplicate-id-aria`**: fires on shadow DOM IDs, which cannot actually conflict across instances because they are shadow-scoped. + +**Known blind spots** (real issues axe misses): a misconfigured `labelledby` that silently yields a missing label, and stale element references after a target is removed. Screen reader testing is authoritative for these; axe is supplementary. + +**Policy:** + +- Add a **story-level or test-level exclusion with a written rationale**, not a silent global disable. +- Include a `// reason:` comment linking the relevant upstream Deque / axe-core issue, and remove the exclusion once that issue ships a fix (review on a quarterly cadence). +- Verify exposure with manual AT testing, particularly in **Firefox**, which handles `ElementInternals` ARIA less consistently than Chromium and Safari. +- Align with the [Storybook test-runner axe usage](https://github.com/adobe/spectrum-web-components/blob/main/2nd-gen/packages/swc/.storybook/test-runner.ts). A dedicated axe-core and `ElementInternals` guide in the [accessibility guides](../../../2nd-gen/packages/swc/.storybook/guides/accessibility-guides/) is the detailed reference; link it here once published. + +--- + +## 4. Naming table + +The canonical surface for form fields. Contributors align Phase 3 (API) and Phase 4 (accessibility) work to this table. Names are drawn from the [form-strategy proof-of-concept](https://github.com/nikkimk/web-component-form-strategy-demos); treat *pending research* rows as provisional until their spikes conclude. + +| Concern | Name / approach | Notes | +|---------|-----------------|-------| +| Form participation | `static formAssociated = true` + `attachInternals()`, wrapped by `FieldAssociationController` | Value submitted via `internals.setFormValue(value)`. | +| Label surface (visible) | Default slot when the label is the component's only or primary content; named `slot="label"` when it is supplementary to other primary content | Primary-vs-supplementary rule; pending the slot-API research decision. | +| Accessible name (no visible label) | `accessible-label` attribute | Established convention; do **not** expose raw `aria-label` on the host. | +| Help / description surface | `slot="description"`, wired by `LabellingController` | Associates via `aria-describedby`. | +| Error text surface | Error text wired by `LabellingController` | Associates via `aria-errormessage`. | +| Disabled cascade | `formDisabledCallback(disabled)` | Receives cascade from ancestor `
` or owning form. | +| Reset | `formResetCallback()` | Restores the field to its default value on form reset. | +| Cross-root name from light DOM | `accessible-labelledby` attribute → `ariaLabelledByElements` via `LabellingController` *(pending research)* | Element references, not raw IDREFs; do **not** expose raw `aria-labelledby`. | + +| Component class | Role placement | Internals (FACE) | IDREF approach | axe note | +|-----------------|----------------|------------------|----------------|----------| +| Text-like (text field) | inner control in shadow DOM | `FieldAssociationController` | `LabellingController` (slotted same-root + light-DOM element refs) | `label` false positive on host | +| Button-like (clear / submit) | **host**, via `ElementInternals` (see [§3.2 exception](#32-where-aria-roles-live)) | `ButtonAssociationController` *(pending research)* | n/a | verify role and activation exposure manually | +| Grouped selection (radio group) | **host**, via `ElementInternals` (see [§3.2 exception](#32-where-aria-roles-live)): `role="radiogroup"` on the group, `role="radio"` on each item | per-item `FieldAssociationController`, coordinated by `RadioGroupController` *(pending research)* | per-item labelling | verify roving focus and group semantics manually | + +--- + +## 5. Migration path + +Contributors migrating a form field follow the washing machine workflow with these additions: + +- **Phase 3 (API):** wire form participation and name the API from the [naming table](#4-naming-table). See [Washing machine workflow, Phase 3](../02_workstreams/02_2nd-gen-component-migration/02_step-by-step/01_washing-machine-workflow.md#phase-3-api-migration). +- **Phase 4 (accessibility):** wire label, help text, and errors per [§3.3](#33-idref-strategy-label-help-text-and-errors), and satisfy the axe policy in [§3.4](#34-axe-core-policy). See [Washing machine workflow, Phase 4](../02_workstreams/02_2nd-gen-component-migration/02_step-by-step/01_washing-machine-workflow.md#phase-4-accessibility). + +--- + +## 6. Open questions + +These are active research spikes; their outcomes finalize the *pending research* rows in the [naming table](#4-naming-table): + +- **Button activation:** whether a dedicated `ButtonAssociationController` is needed for button-like fields (clear button, a future submit button), or whether a native inner `