From 47184665f4c7819aa94527a809a793703d3fbcf9 Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Mon, 13 Oct 2025 15:50:22 +0530 Subject: [PATCH 01/17] docs: updated documentation of reactive-controllers --- tools/reactive-controllers/README.md | 399 ++++++++++- .../reactive-controllers/color-controller.md | 387 ++++++++-- .../dependency-manager.md | 519 +++++++++++++- .../element-resolution.md | 425 ++++++++++- tools/reactive-controllers/match-media.md | 416 ++++++++++- tools/reactive-controllers/pending-state.md | 555 +++++++++++++- .../reactive-controllers/roving-tab-index.md | 674 ++++++++++++++++-- 7 files changed, 3204 insertions(+), 171 deletions(-) diff --git a/tools/reactive-controllers/README.md b/tools/reactive-controllers/README.md index 98d8609685d..90f45f51dfa 100644 --- a/tools/reactive-controllers/README.md +++ b/tools/reactive-controllers/README.md @@ -1,14 +1,389 @@ ## Description -[Reactive controllers](https://lit.dev/docs/composition/controllers/) are a tool for code reuse and composition within [Lit](https://lit.dev), a core dependency of Spectrum Web Components. Reactive controllers can be reused across components to reduce both code complexity and size, and to deliver a consistent user experience. These reactive controllers are used by the Spectrum Web Components library and are published to NPM for you to leverage in your projects as well. - -### Reactive controllers - -- [ColorController](../color-controller) -- [ElementResolutionController](../element-resolution) -- FocusGroupController -- LanguageResolutionController -- [MatchMediaController](../match-media) -- [RovingTabindexController](../roving-tab-index) -- [PendingStateController](../pending-state) -- SystemContextResolutionController +[Reactive controllers](https://lit.dev/docs/composition/controllers/) are a powerful tool for code reuse and composition within [Lit](https://lit.dev), a core dependency of Spectrum Web Components. They enable you to extract common behaviors into reusable packages that can be shared across multiple components, reducing code complexity and size while delivering a consistent user experience. + +Reactive controllers hook into a component's lifecycle and can: + +- Maintain state +- Respond to lifecycle events +- Request updates when state changes +- Access the host element's properties and methods + +The Spectrum Web Components library publishes several reactive controllers to NPM that you can leverage in your own projects. These controllers handle common patterns like keyboard navigation, media queries, element resolution, lazy loading, color management, and pending states. + +## Available controllers + +### [ColorController](./color-controller.md) + +Manages and validates color values in various color spaces (RGB, HSL, HSV, Hex). Provides conversion between formats and state management for color-related interactions. + +**Use cases:** + +- Color pickers and selectors +- Color input validation +- Color format conversion +- Theme customization UIs + +**Key features:** + +- Multiple color format support (hex, RGB, HSL, HSV) +- Color validation +- Format preservation +- Undo/redo support + +[Learn more →](./color-controller.md) + +--- + +### [DependencyManagerController](./dependency-manager.md) + +Manages the availability of custom element dependencies, enabling lazy loading patterns and progressive enhancement strategies. + +**Use cases:** + +- Code splitting and lazy loading +- Progressive enhancement +- Route-based component loading +- Conditional feature loading + +**Key features:** + +- Tracks custom element registration +- Reactive loading state +- Multiple dependency management +- Works with dynamic imports + +[Learn more →](./dependency-manager.md) + +--- + +### [ElementResolutionController](./element-resolution.md) + +Maintains an active reference to another element in the DOM tree, automatically tracking changes and updating when the DOM mutates. + +**Use cases:** + +- Accessible label associations +- Focus trap management +- Form validation connections +- Dynamic element relationships + +**Key features:** + +- Automatic DOM observation +- ID selector optimization +- Shadow DOM support +- Reactive updates + +[Learn more →](./element-resolution.md) + +--- + +### FocusGroupController + +Base controller for managing keyboard focus within groups of elements. Extended by `RovingTabindexController` with tabindex management capabilities. + +**Use cases:** + +- Custom composite widgets +- Keyboard navigation patterns +- Focus management + +**Key features:** + +- Arrow key navigation +- Configurable direction modes +- Focus entry points +- Element enter actions + +**Note:** This controller is typically not used directly. Use `RovingTabindexController` instead for most use cases. + +--- + +### LanguageResolutionController + +Resolves and tracks the language/locale context of the host element, responding to changes in the `lang` attribute up the DOM tree. + +**Use cases:** + +- Internationalization (i18n) +- Localized content +- RTL/LTR text direction +- Locale-specific formatting + +**Key features:** + +- Automatic language detection +- Locale change tracking +- Supports Shadow DOM +- Bubbles up DOM tree + +--- + +### [MatchMediaController](./match-media.md) + +Binds CSS media queries to reactive elements, automatically updating when queries match or unmatch. + +**Use cases:** + +- Responsive design +- Dark mode detection +- Mobile/desktop layouts +- Print styles +- Accessibility preferences (prefers-reduced-motion, etc.) + +**Key features:** + +- Multiple media query support +- Reactive updates +- Predefined queries (DARK_MODE, IS_MOBILE) +- Event-driven + +[Learn more →](./match-media.md) + +--- + +### [PendingStateController](./pending-state.md) + +Manages pending/loading states for interactive elements, providing visual feedback and accessible state communication. + +**Use cases:** + +- Async button actions +- Form submission states +- Loading indicators +- Progress feedback + +**Key features:** + +- Automatic ARIA label management +- Progress circle rendering +- Label caching and restoration +- Disabled state awareness + +**Note:** Currently used primarily by Button component. May be deprecated in future versions. + +[Learn more →](./pending-state.md) + +--- + +### [RovingTabindexController](./roving-tab-index.md) + +Implements the W3C ARIA roving tabindex pattern for keyboard navigation in composite widgets, managing `tabindex` attributes and arrow key navigation. + +**Use cases:** + +- Toolbars +- Tab lists +- Menus +- Radio groups +- Listboxes +- Grids + +**Key features:** + +- Arrow key navigation (with Home/End support) +- Automatic tabindex management +- Flexible direction modes (horizontal, vertical, both, grid) +- Skips disabled elements +- WCAG compliant + +[Learn more →](./roving-tab-index.md) + +--- + +### SystemContextResolutionController + +Resolves and tracks system-level context like color scheme and scale preferences from Spectrum theme providers. + +**Use cases:** + +- Theme integration +- Scale-aware components +- System preference detection +- Spectrum theme consumption + +**Key features:** + +- Automatic theme context resolution +- Color scheme tracking +- Scale preference tracking +- Works with Spectrum theme providers + +## Installation + +All controllers are published as part of the `@spectrum-web-components/reactive-controllers` package: + +```bash +yarn add @spectrum-web-components/reactive-controllers +``` + +Or with npm: + +```bash +npm install @spectrum-web-components/reactive-controllers +``` + +## Basic usage + +Reactive controllers are instantiated in your component and automatically hook into the component's lifecycle: + +```typescript +import { LitElement, html } from 'lit'; +import { MatchMediaController } from '@spectrum-web-components/reactive-controllers/src/MatchMedia.js'; + +class MyComponent extends LitElement { + // Create controller instance + darkMode = new MatchMediaController(this, '(prefers-color-scheme: dark)'); + + render() { + // Use controller state in render + return html` +
Content
+ `; + } +} +``` + +## Controller lifecycle + +Reactive controllers implement the `ReactiveController` interface with the following optional lifecycle methods: + +- **`hostConnected()`**: Called when the host element is connected to the DOM +- **`hostDisconnected()`**: Called when the host element is disconnected from the DOM +- **`hostUpdate()`**: Called before the host's `update()` method +- **`hostUpdated()`**: Called after the host's `update()` method + +Controllers can also call `host.requestUpdate()` to trigger an update cycle on the host element. + +## Creating your own controllers + +You can create custom reactive controllers by implementing the `ReactiveController` interface: + +```typescript +import { ReactiveController, ReactiveElement } from 'lit'; + +export class MyController implements ReactiveController { + private host: ReactiveElement; + + constructor(host: ReactiveElement) { + this.host = host; + // Register this controller with the host + this.host.addController(this); + } + + hostConnected() { + // Called when host is connected to DOM + } + + hostDisconnected() { + // Called when host is disconnected from DOM + } +} +``` + +## Best practices + +### Composition over inheritance + +Use reactive controllers to share behavior across components instead of extending base classes: + +```typescript +// Good: Composition with controllers +class ButtonA extends LitElement { + pending = new PendingStateController(this); +} + +class ButtonB extends LitElement { + pending = new PendingStateController(this); +} + +// Avoid: Inheritance +class BaseButton extends LitElement { + // shared pending logic +} +class ButtonA extends BaseButton {} +class ButtonB extends BaseButton {} +``` + +### Keep controllers focused + +Each controller should have a single responsibility: + +```typescript +// Good: Focused controllers +darkMode = new MatchMediaController(this, '(prefers-color-scheme: dark)'); +isMobile = new MatchMediaController(this, '(max-width: 768px)'); + +// Avoid: One controller doing too much +``` + +### Clean up resources + +Always clean up in `hostDisconnected()`: + +```typescript +hostConnected() { + window.addEventListener('resize', this.handleResize); +} + +hostDisconnected() { + window.removeEventListener('resize', this.handleResize); +} +``` + +### Request updates appropriately + +Only call `host.requestUpdate()` when state that affects rendering changes: + +```typescript +set value(newValue: string) { + if (newValue === this._value) return; + this._value = newValue; + this.host.requestUpdate(); // Only when value actually changes +} +``` + +## Accessibility considerations + +Many of the reactive controllers in this package support or enable accessibility features: + +- **RovingTabindexController**: Implements WCAG keyboard navigation patterns +- **PendingStateController**: Manages ARIA labels for loading states +- **ElementResolutionController**: Helps connect accessible labels and descriptions +- **MatchMediaController**: Supports accessibility preference queries (prefers-reduced-motion, etc.) + +Always consider accessibility when using these controllers. See each controller's documentation for specific guidance. + +## Browser support + +Reactive controllers work in all browsers that support Lit (all modern browsers). No polyfills are required for the controllers themselves, though individual controllers may use browser APIs that require polyfills in older browsers: + +- `MatchMediaController` requires `window.matchMedia()` +- `ElementResolutionController` requires `MutationObserver` +- `DependencyManagerController` requires `customElements.whenDefined()` + +## Resources + +### Documentation + +- [Lit Reactive Controllers](https://lit.dev/docs/composition/controllers/) - Official Lit documentation +- [Spectrum Web Components](https://opensource.adobe.com/spectrum-web-components/) - Component library documentation + +### Specifications + +- [Web Components](https://www.webcomponents.org/) - Web Components specifications +- [ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/) - Accessibility patterns + +### Community + +- [GitHub Repository](https://github.com/adobe/spectrum-web-components) - Source code and issues +- [Lit Discord](https://lit.dev/discord/) - Community support + +## Contributing + +Contributions are welcome! Please see the [contributing guidelines](../../CONTRIBUTING.md) for more information. + +## License + +Apache License 2.0. See [LICENSE](../../LICENSE) file for details. diff --git a/tools/reactive-controllers/color-controller.md b/tools/reactive-controllers/color-controller.md index 093acb508eb..1d3a4c01aea 100644 --- a/tools/reactive-controllers/color-controller.md +++ b/tools/reactive-controllers/color-controller.md @@ -1,38 +1,103 @@ ## Description -### ColorController - -The `ColorController` class is a comprehensive utility for managing and validating color values in various color spaces, including RGB, HSL, HSV, and Hex. It provides a robust set of methods to set, get, and validate colors, as well as convert between different color formats. This class is designed to be used within web components or other reactive elements to handle color-related interactions efficiently. +The `ColorController` is a comprehensive [reactive controller](https://lit.dev/docs/composition/controllers/) for managing and validating color values in various color spaces, including RGB, HSL, HSV, and Hex. It provides robust methods to set, get, and validate colors, as well as convert between different color formats. This controller is designed to be used within web components or other reactive elements to handle color-related interactions efficiently. ### Features -- **Color Management**: The `ColorController` allows you to manage color values in multiple formats, including RGB, HSL, HSV, and Hex. -- **Validation**: It provides methods to validate color strings and ensure they conform to the expected formats. -- **Conversion**: The class can convert colors between different color spaces, making it versatile for various applications. -- **State Management**: It maintains the current color state and allows saving and restoring previous color values. +- **Color management**: Manage color values in multiple formats, including RGB, HSL, HSV, and Hex +- **Validation**: Validate color strings and ensure they conform to expected formats +- **Conversion**: Convert colors between different color spaces for versatile applications +- **State management**: Maintain current color state and save/restore previous color values +- **Format preservation**: Automatically preserves the format of the original color input when returning values + +## API + +### Constructor + +```typescript +new ColorController(host: ReactiveElement, options?: { manageAs?: string }) +``` + +**Parameters:** + +- `host` (ReactiveElement): The host element that uses this controller +- `options.manageAs` (string, optional): Specifies the color space to manage the color as (e.g., 'hsv', 'hsl', 'srgb') ### Properties -- **`color`**: Gets or sets the current color value. The color can be provided in various formats, including strings, objects, or instances of the `Color` class. -- **`colorValue`**: Gets the color value in various formats based on the original color input. -- **`hue`**: Gets or sets the hue value of the current color. +#### `color` + +- **Type**: `Color` +- **Description**: Gets or sets the current color value. The color can be provided in various formats, including strings, objects, or instances of the `Color` class from [Color.js](https://colorjs.io/). +- **Settable**: Yes + +#### `colorValue` + +- **Type**: `ColorTypes` +- **Description**: Gets the color value in the same format as the original color input. This preserves the format you initially set (e.g., if you set an HSL string, you'll get an HSL string back). +- **Settable**: No + +#### `colorOrigin` + +- **Type**: `ColorTypes` +- **Description**: Gets or sets the original color value provided by the user, before any transformations. +- **Settable**: Yes + +#### `hue` + +- **Type**: `number` +- **Description**: Gets or sets the hue value of the current color in HSL format (0-360 degrees). +- **Settable**: Yes ### Methods -- **`validateColorString(color: string): ColorValidationResult`**: - Validates a color string and returns the validation result, including the color space, coordinates, alpha value, and validity. +#### `validateColorString(color: string): ColorValidationResult` + +Validates a color string and returns the validation result. + +**Parameters:** + +- `color` (string): The color string to validate + +**Returns:** `ColorValidationResult` object with: + +- `spaceId` (string | null): The color space identifier ('srgb', 'hsl', or 'hsv') +- `coords` (number[]): Array of numeric values representing the color coordinates +- `alpha` (number): The alpha value of the color (0 to 1) +- `isValid` (boolean): Whether the color string is valid + +**Supported formats:** + +- RGB: `rgb(r, g, b)`, `rgba(r, g, b, a)`, `rgb r g b`, `rgba r g b a` +- HSL: `hsl(h, s, l)`, `hsla(h, s, l, a)`, `hsl h s l`, `hsla h s l a` +- HSV: `hsv(h, s, v)`, `hsva(h, s, v, a)`, `hsv h s v`, `hsva h s v a` +- HEX: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa` + +#### `getColor(format: string | ColorSpace): ColorObject` + +Converts the current color to the specified format. + +**Parameters:** + +- `format` (string | ColorSpace): The desired color format ('srgb', 'hsva', 'hsv', 'hsl', 'hsla') + +**Returns:** `ColorObject` - The color object in the specified format + +**Throws:** Error if the format is not valid + +#### `getHslString(): string` + +Returns the current color in HSL string format. -- **`getColor(format: string | ColorSpace): ColorObject`**: - Converts the current color to the specified format. Throws an error if the format is not valid. +**Returns:** string - HSL representation of the current color -- **`getHslString(): string`**: - Returns the current color in HSL string format. +#### `savePreviousColor(): void` -- **`savePreviousColor(): void`**: - Saves the current color as the previous color. +Saves the current color as the previous color. Useful for implementing undo functionality or color comparison features. -- **`restorePreviousColor(): void`**: - Restores the previous color. +#### `restorePreviousColor(): void` + +Restores the previously saved color. ## Usage @@ -49,18 +114,18 @@ Import the `ColorController` via: import {ColorController,} from '@spectrum-web-components/reactive-controllers/src/ColorController.js'; ``` -## Example +## Examples -```js -import { LitElement } from 'lit'; -import {ColorController} from '@spectrum-web-components/reactive-controllers/src/ColorController.js'; +### Basic usage -class Host extends LitElement { +```typescript +import { LitElement, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { ColorController } from '@spectrum-web-components/reactive-controllers/src/ColorController.js'; +class ColorPickerElement extends LitElement { /** * Gets the current color value from the color controller. - * - * @returns {ColorTypes} The current color value. */ @property({ type: String }) public get color(): ColorTypes { @@ -69,65 +134,227 @@ class Host extends LitElement { /** * Sets the color for the color controller. - * - * @param {ColorTypes} color - The color to be set. */ public set color(color: ColorTypes) { this.colorController.color = color; } + // Initialize the controller to manage colors in HSV color space private colorController = new ColorController(this, { manageAs: 'hsv' }); - + render() { + return html` +
+ Current color: ${this.color} +
+ `; + } } +customElements.define('color-picker-element', ColorPickerElement); ``` -The color Controller could also be initialised in the constructor as shown below +### Constructor initialization -```js -import { LitElement } from 'lit'; -import {ColorController} from '@spectrum-web-components/reactive-controllers/src/ColorController.js'; +The color controller can also be initialized in the constructor: -class Host extends LitElement { +```typescript +import { LitElement } from 'lit'; +import { ColorController } from '@spectrum-web-components/reactive-controllers/src/ColorController.js'; - /** - * Gets the current color value from the color controller. - * - * @returns {ColorTypes} The current color value. - */ +class ColorPickerElement extends LitElement { @property({ type: String }) public get color(): ColorTypes { return this.colorController.colorValue; } - /** - * Sets the color for the color controller. - * - * @param {ColorTypes} color - The color to be set. - */ public set color(color: ColorTypes) { this.colorController.color = color; } - private colorController: ColorController; ; + private colorController: ColorController; constructor() { super(); this.colorController = new ColorController(this, { manageAs: 'hsv' }); } +} +``` + +### Color validation +Validate color strings before using them: + +```typescript +import { ColorController } from '@spectrum-web-components/reactive-controllers/src/ColorController.js'; + +class ColorInputElement extends LitElement { + private colorController = new ColorController(this); + + handleColorInput(event: InputEvent) { + const input = event.target as HTMLInputElement; + const validation = this.colorController.validateColorString( + input.value + ); + + if (validation.isValid) { + this.colorController.color = input.value; + // Announce successful color change for screen readers + this.setAttribute('aria-live', 'polite'); + this.setAttribute('aria-label', `Color changed to ${input.value}`); + } else { + // Provide error feedback + input.setAttribute('aria-invalid', 'true'); + input.setAttribute('aria-describedby', 'color-error'); + } + } + + render() { + return html` + + + + Enter a color in hex, RGB, HSL, or HSV format + + + `; + } } ``` -## Supported Color Formats +### Usage with color components + +Example of using `ColorController` within a color picker that works with other Spectrum Web Components: + +```typescript +import { LitElement, html } from 'lit'; +import { ColorController } from '@spectrum-web-components/reactive-controllers/src/ColorController.js'; +import '@spectrum-web-components/field-label/sp-field-label.js'; +import '@spectrum-web-components/help-text/sp-help-text.js'; +import '@spectrum-web-components/color-area/sp-color-area.js'; +import '@spectrum-web-components/color-slider/sp-color-slider.js'; + +class CompleteColorPicker extends LitElement { + private colorController = new ColorController(this, { manageAs: 'hsv' }); + + @property({ type: String }) + public get color(): ColorTypes { + return this.colorController.colorValue; + } + + public set color(color: ColorTypes) { + const oldColor = this.color; + this.colorController.color = color; + this.requestUpdate('color', oldColor); + } + + handleColorChange(event: Event) { + const target = event.target as any; + this.color = target.color; + } + + render() { + return html` +
+ + Color picker + + + Choose a color from the picker or enter a value manually + + + + + +
+ `; + } +} +``` + +### Saving and restoring colors + +Implement undo functionality using `savePreviousColor` and `restorePreviousColor`: + +```typescript +import { LitElement, html } from 'lit'; +import { ColorController } from '@spectrum-web-components/reactive-controllers/src/ColorController.js'; +import '@spectrum-web-components/button/sp-button.js'; + +class ColorPickerWithUndo extends LitElement { + private colorController = new ColorController(this, { manageAs: 'hsv' }); + + @property({ type: String }) + public get color(): ColorTypes { + return this.colorController.colorValue; + } + + public set color(color: ColorTypes) { + // Save the current color before changing + this.colorController.savePreviousColor(); + this.colorController.color = color; + } + + handleUndo() { + this.colorController.restorePreviousColor(); + this.requestUpdate(); + // Announce undo action for screen readers + this.dispatchEvent( + new CustomEvent('color-restored', { + detail: { color: this.color }, + bubbles: true, + composed: true, + }) + ); + } + + render() { + return html` +
+ + (this.color = (e.target as HTMLInputElement).value)} + aria-label="Color picker" + /> + + Undo + +
+ `; + } +} +``` + +## Supported color formats The `ColorController` supports a wide range of color formats for input and output: Format - Example Values + Example values Description @@ -188,4 +415,66 @@ The `ColorController` supports a wide range of color formats for input and outpu -``` + +## Accessibility + +When implementing color pickers or other color-related UI with the `ColorController`, consider these accessibility best practices: + +### Color perception + +- **Never rely on color alone** to convey information. Always provide alternative text descriptions or patterns. +- **Provide text alternatives** for color values (e.g., "red", "dark blue", "#FF0000") that are announced by screen readers. +- Use **ARIA labels** (`aria-label` or `aria-labelledby`) to describe the purpose of color controls. + +### Screen reader support + +- Announce color changes with `aria-live` regions when colors update dynamically. +- Provide meaningful labels for all interactive color controls. +- Include instructions in `aria-describedby` for how to use color inputs. + +### Keyboard accessibility + +When building color pickers with this controller: + +- Ensure all color selection methods are keyboard accessible. +- Provide visible focus indicators for all interactive elements. +- Consider implementing keyboard shortcuts for common actions (e.g., arrow keys for fine-tuning). + +### Error handling + +- Use `aria-invalid` and `aria-describedby` to communicate validation errors. +- Provide clear error messages when color values are invalid. + +### Color contrast + +When using colors selected via this controller for text or UI elements, ensure they meet [WCAG 2.1 Level AA contrast requirements](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html): + +- **Normal text**: 4.5:1 contrast ratio +- **Large text** (18pt+ or 14pt+ bold): 3:1 contrast ratio +- **UI components and graphics**: 3:1 contrast ratio + +### References + +- [Web Content Accessibility Guidelines (WCAG) 2.1](https://www.w3.org/WAI/WCAG21/Understanding/) +- [Adobe Accessibility Guidelines](https://www.adobe.com/accessibility/products/spectrum.html) +- [MDN: Accessibility](https://developer.mozilla.org/en-US/docs/Web/Accessibility) + +## Events + +The `ColorController` doesn't dispatch custom events directly. Instead, it calls `requestUpdate()` on the host element when the color changes, triggering the host's reactive update cycle. The host element is responsible for dispatching any custom events as needed. + +## Related components + +The `ColorController` is used by these Spectrum Web Components: + +- [``](../../packages/color-area/) - Two-dimensional color picker +- [``](../../packages/color-field/) - Text input for color values +- [``](../../packages/color-slider/) - Slider for selecting color channel values +- [``](../../packages/color-wheel/) - Circular hue selector +- [``](../../packages/swatch/) - Color preview display + +## Resources + +- [Lit Reactive Controllers](https://lit.dev/docs/composition/controllers/) - Learn more about reactive controllers +- [Color.js](https://colorjs.io/) - The underlying color manipulation library +- [CSS Color Module Level 4](https://www.w3.org/TR/css-color-4/) - Specification for CSS color formats diff --git a/tools/reactive-controllers/dependency-manager.md b/tools/reactive-controllers/dependency-manager.md index 2151a0fd9cc..d4ed9871a19 100644 --- a/tools/reactive-controllers/dependency-manager.md +++ b/tools/reactive-controllers/dependency-manager.md @@ -1,10 +1,71 @@ ## Description -In cases where you choose to lazily register custom element definitions across the lifecycle of your application, delaying certain functionality until that registration is complete can be beneficial. To normalize management of this process, a `DependencyManagerController` can be added to your custom element. +The `DependencyManagerController` is a [reactive controller](https://lit.dev/docs/composition/controllers/) designed to manage the availability of custom element dependencies in your host element. It helps gate rendering and functional behavior before and after the presence of required custom elements, which is especially useful when lazily loading custom element definitions across the lifecycle of your application. -Use the `add()` method to inform the manager which custom element tag names you need to be defined before doing some action. When the elements you have provided to the manager are available, the controller will request an update to your host element and surface a `loaded` boolean to clarify the current load state of the managed dependencies. +### Features -### Usage +- **Lazy loading support**: Delay functionality until required custom elements are registered +- **Multiple dependency tracking**: Manage any number of custom element dependencies +- **Reactive loading state**: Automatically updates the host when all dependencies are loaded +- **Async registration handling**: Works seamlessly with dynamic imports and lazy loading + +## API + +### Constructor + +```typescript +new DependencyManagerController(host: ReactiveElement) +``` + +**Parameters:** + +- `host` (ReactiveElement): The host element that uses this controller + +### Properties + +#### `loaded` + +- **Type**: `boolean` +- **Description**: Whether all of the provided dependencies have been registered. This will be `false` when no dependencies have been listed for management. Changes to this value trigger `requestUpdate()` on the host element. +- **Settable**: No (read-only, managed by the controller) + +### Methods + +#### `add(dependency: string, alreadyLoaded?: boolean): void` + +Submit a custom element tag name to be managed as a dependency. + +**Parameters:** + +- `dependency` (string): The custom element tag name to manage (e.g., `'sp-button'`) +- `alreadyLoaded` (boolean, optional): Force the managed custom element to be listed as loaded + +**Behavior:** + +- If the element is not yet defined, the method uses `customElements.whenDefined()` to wait for registration +- When the element becomes defined, the `loaded` property updates automatically +- The host element is notified via `requestUpdate()` when the loaded state changes + +### Symbols + +#### `dependencyManagerLoadedSymbol` + +- **Type**: `Symbol` +- **Description**: Exported symbol used as the property key when calling `requestUpdate()` on the host element. This allows the host to track when the loaded state has changed. + +**Usage:** + +```typescript +import { dependencyManagerLoadedSymbol } from '@spectrum-web-components/reactive-controllers/src/DependencyManger.js'; + +protected override willUpdate(changes: PropertyValues): void { + if (changes.has(dependencyManagerLoadedSymbol)) { + // React to dependency loading state changes + } +} +``` + +## Usage [![See it on NPM!](https://img.shields.io/npm/v/@spectrum-web-components/reactive-controllers?style=for-the-badge)](https://www.npmjs.com/package/@spectrum-web-components/reactive-controllers) [![How big is this package in your project?](https://img.shields.io/bundlephobia/minzip/@spectrum-web-components/reactive-controllers?style=for-the-badge)](https://bundlephobia.com/result?p=@spectrum-web-components/reactive-controllers) @@ -16,43 +77,49 @@ yarn add @spectrum-web-components/reactive-controllers Import the `DependencyManagerController` via: ``` -import { DependencyManagerController } from '@spectrum-web-components/reactive-controllers/DependencyManager.js'; +import { DependencyManagerController } from '@spectrum-web-components/reactive-controllers/src/DependencyManger.js'; ``` -## Example +## Examples -A `Host` element that renders a different message depending on the `loaded` state of the `` dependency in the following custom element definition: +### Basic usage with lazy loading -```js +A `Host` element that renders different content depending on the `loaded` state of a heavy dependency: + +```typescript import { html, LitElement } from 'lit'; -import { DependencyManagerController } from '@spectrum-web-components/reactive-controllers/DependencyManager.js'; +import { DependencyManagerController } from '@spectrum-web-components/reactive-controllers/src/DependencyManger.js'; import '@spectrum-web-components/button/sp-button.js'; -class Host extends LitElement { +class LazyHost extends LitElement { dependencyManager = new DependencyManagerController(this); state = 'initial'; forwardState() { this.state = 'heavy'; + this.requestUpdate(); } render() { const isInitialState = this.state === 'initial'; + if (isInitialState || !this.dependencyManager.loaded) { if (!isInitialState) { // When not in the initial state, this element depends on this.dependencyManager.add('some-heavy-element'); // Lazily load that element - import('path/to/register/some-heavy-element.js'); + import('./some-heavy-element.js'); } + return html` - Go to next state + ${!isInitialState ? 'Loading...' : 'Go to next state'} `; } else { @@ -63,4 +130,434 @@ class Host extends LitElement { } } } + +customElements.define('lazy-host', LazyHost); +``` + +### Multiple dependencies + +Manage multiple custom element dependencies: + +```typescript +import { html, LitElement } from 'lit'; +import { DependencyManagerController } from '@spectrum-web-components/reactive-controllers/src/DependencyManger.js'; + +class MultiDependencyHost extends LitElement { + dependencyManager = new DependencyManagerController(this); + + connectedCallback() { + super.connectedCallback(); + + // Add multiple dependencies + this.dependencyManager.add('sp-button'); + this.dependencyManager.add('sp-dialog'); + this.dependencyManager.add('sp-progress-circle'); + + // Lazy load all dependencies + import('@spectrum-web-components/button/sp-button.js'); + import('@spectrum-web-components/dialog/sp-dialog.js'); + import( + '@spectrum-web-components/progress-circle/sp-progress-circle.js' + ); + } + + render() { + if (!this.dependencyManager.loaded) { + return html` +
+ Loading components... +
+ `; + } + + return html` + Open Dialog + +

Dialog Title

+

All dependencies loaded successfully!

+
+ `; + } +} + +customElements.define('multi-dependency-host', MultiDependencyHost); +``` + +### Conditional feature loading + +Load features based on user interaction or conditions: + +```typescript +import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { DependencyManagerController } from '@spectrum-web-components/reactive-controllers/src/DependencyManger.js'; +import '@spectrum-web-components/button/sp-button.js'; + +class FeatureLoader extends LitElement { + dependencyManager = new DependencyManagerController(this); + + @property({ type: String }) + activeFeature = ''; + + loadFeature(featureName: string) { + this.activeFeature = featureName; + + switch (featureName) { + case 'chart': + this.dependencyManager.add('chart-component'); + import('./features/chart-component.js'); + break; + case 'table': + this.dependencyManager.add('table-component'); + import('./features/table-component.js'); + break; + case 'form': + this.dependencyManager.add('form-component'); + import('./features/form-component.js'); + break; + } + + this.requestUpdate(); + } + + renderFeature() { + if (!this.dependencyManager.loaded) { + return html` +
+ Loading ${this.activeFeature}... +
+ `; + } + + switch (this.activeFeature) { + case 'chart': + return html` + + `; + case 'table': + return html` + + `; + case 'form': + return html` + + `; + default: + return html``; + } + } + + render() { + return html` + + +
+ ${this.renderFeature()} +
+ `; + } +} + +customElements.define('feature-loader', FeatureLoader); +``` + +### Route-based lazy loading + +Load components based on routing: + +```typescript +import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { DependencyManagerController } from '@spectrum-web-components/reactive-controllers/src/DependencyManger.js'; + +class AppRouter extends LitElement { + dependencyManager = new DependencyManagerController(this); + + @property({ type: String }) + currentRoute = 'home'; + + private routeComponents = new Map([ + ['home', 'home-page'], + ['dashboard', 'dashboard-page'], + ['settings', 'settings-page'], + ]); + + updated(changedProperties: Map) { + if (changedProperties.has('currentRoute')) { + const component = this.routeComponents.get(this.currentRoute); + + if (component) { + this.dependencyManager.add(component); + import(`./pages/${component}.js`).catch((error) => { + console.error(`Failed to load ${component}:`, error); + }); + } + } + } + + renderRoute() { + if (!this.dependencyManager.loaded) { + return html` +
+ Loading page... +
+ `; + } + + const component = this.routeComponents.get(this.currentRoute); + return html` + ${component ? html`<${component}>` : html``} + `; + } + + render() { + return html` +
${this.renderRoute()}
+ `; + } +} + +customElements.define('app-router', AppRouter); +``` + +### Progressive enhancement + +Enhance basic functionality with advanced components: + +```typescript +import { html, LitElement, css } from 'lit'; +import { DependencyManagerController } from '@spectrum-web-components/reactive-controllers/src/DependencyManger.js'; + +class ProgressiveForm extends LitElement { + dependencyManager = new DependencyManagerController(this); + + static styles = css` + .enhanced { + opacity: 1; + transition: opacity 0.3s; + } + + .loading { + opacity: 0.6; + } + `; + + connectedCallback() { + super.connectedCallback(); + + // Start loading enhanced components + this.dependencyManager.add('sp-textfield'); + this.dependencyManager.add('sp-picker'); + this.dependencyManager.add('sp-checkbox'); + + Promise.all([ + import('@spectrum-web-components/textfield/sp-textfield.js'), + import('@spectrum-web-components/picker/sp-picker.js'), + import('@spectrum-web-components/checkbox/sp-checkbox.js'), + ]); + } + + renderBasicForm() { + return html` +
+ + + + + + + +
+ `; + } + + renderEnhancedForm() { + return html` +
+ + + + + Subscribe to newsletter + + Submit +
+ `; + } + + render() { + const isEnhanced = this.dependencyManager.loaded; + const formClass = isEnhanced ? 'enhanced' : 'loading'; + + return html` +
+ ${isEnhanced + ? this.renderEnhancedForm() + : this.renderBasicForm()} +
+ `; + } +} + +customElements.define('progressive-form', ProgressiveForm); ``` + +### Tracking load state changes + +Use the `dependencyManagerLoadedSymbol` to react to loading state changes: + +```typescript +import { html, LitElement, PropertyValues } from 'lit'; +import { + DependencyManagerController, + dependencyManagerLoadedSymbol, +} from '@spectrum-web-components/reactive-controllers/src/DependencyManger.js'; + +class TrackingHost extends LitElement { + dependencyManager = new DependencyManagerController(this); + + connectedCallback() { + super.connectedCallback(); + this.dependencyManager.add('heavy-component'); + import('./heavy-component.js'); + } + + protected override willUpdate(changes: PropertyValues): void { + if (changes.has(dependencyManagerLoadedSymbol)) { + const wasLoaded = changes.get(dependencyManagerLoadedSymbol); + + if (!wasLoaded && this.dependencyManager.loaded) { + // Dependencies just finished loading + console.log('All dependencies loaded!'); + + // Announce to screen readers + const announcement = document.createElement('div'); + announcement.setAttribute('role', 'status'); + announcement.setAttribute('aria-live', 'polite'); + announcement.textContent = 'Components loaded successfully'; + this.shadowRoot?.appendChild(announcement); + + setTimeout(() => announcement.remove(), 1000); + } + } + } + + render() { + return html` +
+ ${this.dependencyManager.loaded + ? html` + + ` + : html` +

Loading...

+ `} +
+ `; + } +} + +customElements.define('tracking-host', TrackingHost); +``` + +## Accessibility + +When using `DependencyManagerController` to manage lazy-loaded components, consider these accessibility best practices: + +### Loading states + +- **Provide clear feedback**: Always inform users when content is loading using `role="status"` and `aria-live="polite"`. +- **Use aria-busy**: Set `aria-busy="true"` on containers while dependencies are loading. +- **Loading indicators**: Include visible loading indicators (spinners, progress bars) for better user experience. + +### Screen reader announcements + +- Announce when loading begins: Use `aria-live="polite"` regions to notify screen reader users. +- Announce when loading completes: Inform users when content has finished loading. +- Avoid announcement spam: Debounce or throttle announcements if multiple components load in quick succession. + +### Keyboard accessibility + +- Ensure keyboard focus is managed correctly when lazy-loaded components appear. +- Don't trap focus in loading states. +- Return focus to a logical location after content loads. + +### Progressive enhancement + +- Provide basic functionality before enhanced components load. +- Don't block critical features on lazy-loaded dependencies. +- Ensure the experience degrades gracefully if components fail to load. + +### Error handling + +```typescript +// Example error handling with accessibility +connectedCallback() { + super.connectedCallback(); + this.dependencyManager.add('sp-button'); + + import('@spectrum-web-components/button/sp-button.js') + .catch((error) => { + console.error('Failed to load component:', error); + + // Announce error to screen readers + const errorElement = document.createElement('div'); + errorElement.setAttribute('role', 'alert'); + errorElement.textContent = 'Failed to load component. Please refresh the page.'; + this.shadowRoot?.appendChild(errorElement); + }); +} +``` + +### References + +- [WCAG 2.1 - Status Messages](https://www.w3.org/WAI/WCAG21/Understanding/status-messages.html) +- [ARIA: status role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/status_role) +- [ARIA: aria-busy attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-busy) +- [Adobe Accessibility Guidelines](https://www.adobe.com/accessibility/products/spectrum.html) + +## Events + +The `DependencyManagerController` doesn't dispatch custom events directly. Instead, it calls `requestUpdate(dependencyManagerLoadedSymbol, previousLoadedState)` on the host element when the loaded state changes, triggering the host's reactive update cycle. + +## Performance considerations + +- **Code splitting**: Use the dependency manager with dynamic imports to split code and reduce initial bundle size. +- **Lazy loading strategy**: Load components just-in-time based on user interaction or route changes. +- **Preloading**: Consider preloading critical dependencies during idle time using `requestIdleCallback()`. +- **Caching**: Browsers will cache imported modules, so subsequent loads are fast. + +## Related patterns + +- [Lazy loading web components](https://web.dev/patterns/components/lazy-loading/) +- [Code splitting](https://web.dev/reduce-javascript-payloads-with-code-splitting/) +- [Progressive enhancement](https://developer.mozilla.org/en-US/docs/Glossary/Progressive_Enhancement) + +## Resources + +- [Lit Reactive Controllers](https://lit.dev/docs/composition/controllers/) - Learn more about reactive controllers +- [MDN: customElements.whenDefined()](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/whenDefined) +- [MDN: Dynamic imports](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import) +- [Web Components Best Practices](https://web.dev/custom-elements-best-practices/) diff --git a/tools/reactive-controllers/element-resolution.md b/tools/reactive-controllers/element-resolution.md index 133eb358c43..384f7c5fa9c 100644 --- a/tools/reactive-controllers/element-resolution.md +++ b/tools/reactive-controllers/element-resolution.md @@ -1,8 +1,87 @@ ## Description -An `ElementResolutionController` keeps an active reference to another element in the same DOM tree. Supply the controller with a selector to query and it will manage observing the DOM tree to ensure that the reference it holds is always the first matched element or `null`. +The `ElementResolutionController` is a [reactive controller](https://lit.dev/docs/composition/controllers/) that maintains an active reference to another element in the same DOM tree. It automatically observes the DOM tree for changes and ensures that the reference it holds is always up-to-date with the first matched element or `null` if no match is found. -### Usage +### Features + +- **Automatic element tracking**: Maintains a live reference to elements matching a CSS selector +- **DOM observation**: Uses `MutationObserver` to track changes in the DOM tree +- **Efficient ID resolution**: Optimized path for ID-based selectors +- **Reactive updates**: Automatically triggers host updates when the resolved element changes +- **Scope awareness**: Works within Shadow DOM and regular DOM contexts + +## API + +### Constructor + +```typescript +new ElementResolutionController( + host: ReactiveElement, + options?: { selector: string } +) +``` + +**Parameters:** + +- `host` (ReactiveElement): The host element that uses this controller +- `options.selector` (string, optional): The CSS selector to query for (can be set later via the `selector` property) + +### Properties + +#### `element` + +- **Type**: `HTMLElement | null` +- **Description**: The currently resolved element matching the selector, or `null` if no match is found. Updates automatically when the DOM changes. +- **Settable**: No (managed by the controller) + +#### `selector` + +- **Type**: `string` +- **Description**: The CSS selector used to find the element. When changed, the controller automatically updates the resolved element. +- **Settable**: Yes + +#### `selectorIsId` + +- **Type**: `boolean` +- **Description**: Whether the selector is an ID selector (starts with `#`). Used internally for optimization. +- **Settable**: No (read-only) + +#### `selectorAsId` + +- **Type**: `string` +- **Description**: The ID value (without the `#` prefix) when using an ID selector. +- **Settable**: No (read-only) + +### Methods + +#### `hostConnected(): void` + +Called when the host element is connected to the DOM. Starts observing DOM changes and resolves the element. + +#### `hostDisconnected(): void` + +Called when the host element is disconnected from the DOM. Stops observing DOM changes and releases the element reference. + +### Symbols + +#### `elementResolverUpdatedSymbol` + +- **Type**: `Symbol` +- **Description**: Exported symbol used as the property key when calling `requestUpdate()` on the host element. This allows the host to track when the resolved element has changed. + +**Usage:** + +```typescript +import { elementResolverUpdatedSymbol } from '@spectrum-web-components/reactive-controllers/src/ElementResolution.js'; + +protected override willUpdate(changes: PropertyValues): void { + if (changes.has(elementResolverUpdatedSymbol)) { + // React to element resolution changes + } +} +``` + +## Usage [![See it on NPM!](https://img.shields.io/npm/v/@spectrum-web-components/reactive-controllers?style=for-the-badge)](https://www.npmjs.com/package/@spectrum-web-components/reactive-controllers) [![How big is this package in your project?](https://img.shields.io/bundlephobia/minzip/@spectrum-web-components/reactive-controllers?style=for-the-badge)](https://bundlephobia.com/result?p=@spectrum-web-components/reactive-controllers) @@ -14,39 +93,52 @@ yarn add @spectrum-web-components/reactive-controllers Import the `ElementResolutionController` and/or `elementResolverUpdatedSymbol` via: ``` -import { ElementResolutionController, elementResolverUpdatedSymbol } from '@spectrum-web-components/reactive-controllers/ElementResolution.js'; +import { ElementResolutionController, elementResolverUpdatedSymbol } from '@spectrum-web-components/reactive-controllers/src/ElementResolution.js'; ``` -## Example +## Examples -An `ElementResolutionController` can be applied to a host element like the following. +### Basic usage -```js +An `ElementResolutionController` can be applied to a host element like the following: + +```typescript import { html, LitElement } from 'lit'; -import { ElementResolutionController } from '@spectrum-web-components/reactive-controllers/ElementResolution.js'; +import { ElementResolutionController } from '@spectrum-web-components/reactive-controllers/src/ElementResolution.js'; class RootEl extends LitElement { resolvedElement = new ElementResolutionController(this); - costructor() { + constructor() { super(); this.resolvedElement.selector = '.other-element'; } + + render() { + return html` +

+ Resolved element: + ${this.resolvedElement.element ? 'Found' : 'Not found'} +

+ `; + } } customElements.define('root-el', RootEl); ``` -In this example, the selector `'.other-element'` is supplied to the resolver, which mean in the following example, `this.resolvedElement.element` will maintain a reference to the sibling `
` element: +In this example, the selector `'.other-element'` is supplied to the resolver, which means in the following example, `this.resolvedElement.element` will maintain a reference to the sibling `
` element: -```html-no-demo +```html
``` +### Multiple matching elements + The resolved reference will always be the first element matching the selector applied, so in the following example the element with content "First!" will be the reference: -```html-no-demo +```html
First!
Last.
@@ -54,29 +146,328 @@ The resolved reference will always be the first element matching the selector ap A [`MutationObserver`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) is leveraged to track mutations to the DOM tree in which the host element resides in order to update the element reference on any changes to the content therein that could change the resolved element. -## Updates +### Constructor-based selector + +You can provide the selector in the constructor options: + +```typescript +import { LitElement } from 'lit'; +import { ElementResolutionController } from '@spectrum-web-components/reactive-controllers/src/ElementResolution.js'; + +class FormController extends LitElement { + resolvedElement = new ElementResolutionController(this, { + selector: '#submit-button', + }); + + handleSubmit() { + if (this.resolvedElement.element) { + this.resolvedElement.element.click(); + } + } +} + +customElements.define('form-controller', FormController); +``` + +### Tracking resolution updates Changes to the resolved element reference are reported to the host element via a call to the `requestUpdate()` method. This will be provided the `elementResolverUpdatedSymbol` as the changed key. If your element leverages this value against the changes map, it can react directly to changes in the resolved element: -```ts -import { html, LitElement } from 'lit'; +```typescript +import { html, LitElement, PropertyValues } from 'lit'; import { ElementResolutionController, elementResolverUpdatedSymbol, -} from '@spectrum-web-components/reactive-controllers/ElementResolution.js'; +} from '@spectrum-web-components/reactive-controllers/src/ElementResolution.js'; class RootEl extends LitElement { resolvedElement = new ElementResolutionController(this); - costructor() { + constructor() { super(); this.resolvedElement.selector = '.other-element'; } protected override willUpdate(changes: PropertyValues): void { if (changes.has(elementResolverUpdatedSymbol)) { - // work to be done only when the element reference has been updated + // Work to be done only when the element reference has been updated + console.log( + 'Resolved element changed:', + this.resolvedElement.element + ); + } + } + + render() { + return html` +

+ Element status: + ${this.resolvedElement.element ? 'Found' : 'Not found'} +

+ `; + } +} + +customElements.define('root-el', RootEl); +``` + +### Accessible label resolution + +Use `ElementResolutionController` to resolve accessible labeling elements: + +```typescript +import { html, LitElement } from 'lit'; +import { ElementResolutionController } from '@spectrum-web-components/reactive-controllers/src/ElementResolution.js'; + +class CustomInput extends LitElement { + labelElement = new ElementResolutionController(this, { + selector: '.input-label', + }); + + firstUpdated() { + // Connect input to label for accessibility + if (this.labelElement.element) { + const labelId = this.labelElement.element.id || this.generateId(); + this.labelElement.element.id = labelId; + + const input = this.shadowRoot?.querySelector('input'); + if (input) { + input.setAttribute('aria-labelledby', labelId); + } } } + + generateId() { + return `label-${Math.random().toString(36).substr(2, 9)}`; + } + + render() { + return html` + + `; + } } + +customElements.define('custom-input', CustomInput); +``` + +Usage: + +```html +Enter your name + ``` + +### Dynamic selector changes + +The selector can be changed dynamically, and the controller will automatically update: + +```typescript +import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { ElementResolutionController } from '@spectrum-web-components/reactive-controllers/src/ElementResolution.js'; + +class DynamicResolver extends LitElement { + resolvedElement = new ElementResolutionController(this); + + @property({ type: String }) + targetSelector = '.default-target'; + + updated(changedProperties: Map) { + if (changedProperties.has('targetSelector')) { + this.resolvedElement.selector = this.targetSelector; + } + } + + render() { + const status = this.resolvedElement.element + ? `Found: ${this.resolvedElement.element.tagName}` + : 'Not found'; + + return html` +
+ Current target (${this.targetSelector}): ${status} +
+ `; + } +} + +customElements.define('dynamic-resolver', DynamicResolver); +``` + +### Modal and overlay management + +Use element resolution to manage focus trap elements in modals: + +```typescript +import { html, LitElement } from 'lit'; +import { ElementResolutionController } from '@spectrum-web-components/reactive-controllers/src/ElementResolution.js'; + +class ModalManager extends LitElement { + firstFocusableElement = new ElementResolutionController(this, { + selector: '[data-first-focus]', + }); + + lastFocusableElement = new ElementResolutionController(this, { + selector: '[data-last-focus]', + }); + + connectedCallback() { + super.connectedCallback(); + this.addEventListener('keydown', this.handleKeydown); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener('keydown', this.handleKeydown); + } + + handleKeydown(event: KeyboardEvent) { + if (event.key === 'Tab') { + const activeElement = document.activeElement; + + if (event.shiftKey) { + // Tabbing backward + if (activeElement === this.firstFocusableElement.element) { + event.preventDefault(); + this.lastFocusableElement.element?.focus(); + } + } else { + // Tabbing forward + if (activeElement === this.lastFocusableElement.element) { + event.preventDefault(); + this.firstFocusableElement.element?.focus(); + } + } + } + } + + render() { + return html` +
+

Modal Dialog

+

Content goes here

+
+ `; + } +} + +customElements.define('modal-manager', ModalManager); +``` + +### Form validation integration + +Resolve and connect to error message elements: + +```typescript +import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { ElementResolutionController } from '@spectrum-web-components/reactive-controllers/src/ElementResolution.js'; + +class ValidatedInput extends LitElement { + errorElement = new ElementResolutionController(this, { + selector: '.error-message', + }); + + @property({ type: Boolean }) + invalid = false; + + updated() { + const input = this.shadowRoot?.querySelector('input'); + + if (input && this.errorElement.element) { + input.setAttribute('aria-invalid', String(this.invalid)); + + if (this.invalid) { + input.setAttribute( + 'aria-describedby', + this.errorElement.element.id + ); + } else { + input.removeAttribute('aria-describedby'); + } + } + } + + render() { + return html` + + `; + } +} + +customElements.define('validated-input', ValidatedInput); +``` + +Usage: + +```html + + + This field is required + +``` + +## Accessibility + +When using `ElementResolutionController` for accessibility-related functionality, consider these best practices: + +### Label associations + +- When resolving label elements, always use proper ARIA attributes (`aria-labelledby`, `aria-describedby`) to create programmatic relationships. +- Ensure labels have unique IDs that can be referenced. +- Generate IDs programmatically if they don't exist. + +### Error messages + +- Error message elements should have `role="alert"` for screen reader announcements. +- Use `aria-describedby` to associate error messages with form controls. +- Ensure error messages are visible and programmatically associated when validation fails. + +### Focus management + +- When resolving focusable elements, ensure they meet keyboard accessibility requirements. +- Maintain logical tab order when using resolved elements for focus trapping. +- Provide clear focus indicators for all resolved interactive elements. + +### Dynamic content + +- Use `aria-live` regions when resolved elements change dynamically and users need to be notified. +- Consider using `aria-live="polite"` for non-critical updates. +- Use `aria-live="assertive"` sparingly for critical information. + +### Element visibility + +- Verify that resolved elements are visible and accessible to assistive technologies. +- Check that resolved elements aren't hidden with `display: none` or `visibility: hidden` unless intentional. +- Use appropriate ARIA attributes (`aria-hidden`) when hiding decorative resolved elements. + +### References + +- [WCAG 2.1 - Name, Role, Value](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html) +- [ARIA: aria-labelledby attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby) +- [ARIA: aria-describedby attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-describedby) +- [Adobe Accessibility Guidelines](https://www.adobe.com/accessibility/products/spectrum.html) + +## Events + +The `ElementResolutionController` doesn't dispatch custom events directly. Instead, it calls `requestUpdate(elementResolverUpdatedSymbol, previousElement)` on the host element when the resolved element changes, triggering the host's reactive update cycle with the symbol as the change key. + +## Performance considerations + +- **ID selectors are optimized**: The controller uses `getElementById()` for ID-based selectors (starting with `#`), which is faster than `querySelector()`. +- **MutationObserver scope**: The observer watches the entire root node (Shadow DOM or document) for changes. For large DOMs, this could have performance implications. +- **Automatic cleanup**: The controller automatically disconnects the MutationObserver when the host is disconnected from the DOM. + +## Related patterns + +- [ARIA relationships](https://www.w3.org/TR/wai-aria-1.2/#attrs_relationships) +- [Focus management](https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets) +- [Shadow DOM and accessibility](https://web.dev/shadowdom-v1/#accessibility) + +## Resources + +- [Lit Reactive Controllers](https://lit.dev/docs/composition/controllers/) - Learn more about reactive controllers +- [MDN: MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) +- [MDN: Element.querySelector()](https://developer.mozilla.org/en-US/docs/Web/API/Element/querySelector) +- [MDN: Document.getElementById()](https://developer.mozilla.org/en-US/docs/Web/API/Document/getElementById) diff --git a/tools/reactive-controllers/match-media.md b/tools/reactive-controllers/match-media.md index 3225aed2b35..a1f2fa533de 100644 --- a/tools/reactive-controllers/match-media.md +++ b/tools/reactive-controllers/match-media.md @@ -1,10 +1,54 @@ ## Description -The [match media](https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia) API allows for a developer to query the state of a supplied CSS media query from the JS scope while surfacing an event based API to listen for changes to whether that query is currently matched or not. `MatchMediaController` binds the supplied CSS media query to the supplied Reactive Element and calls for an update in the host element when the query goes between matching and not. This allow for the `matches` property on the reactive controller to be leveraged in your render lifecycle. +The `MatchMediaController` is a [reactive controller](https://lit.dev/docs/composition/controllers/) that binds a CSS media query to a reactive element, automatically updating when the query matches or stops matching. It leverages the [match media API](https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia) to query the state of CSS media queries from JavaScript while providing an event-based API to listen for changes. -A `MatchMediaController` can be bound to any of the growing number of [CSS media queries](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Using_media_queries) and any number of `MatchMediaControllers` can be bound to a host element. With this in mind the `MatchMediaController` can support a wide array of complex layouts. +### Features -### Usage +- **Reactive media query monitoring**: Automatically updates the host element when media query state changes +- **Event-driven**: Listens for changes and triggers host updates +- **Multiple instances**: Support multiple controllers on a single host for complex responsive layouts +- **Performance optimized**: Uses native browser APIs for efficient media query observation + +## API + +### Constructor + +```typescript +new MatchMediaController(host: ReactiveElement, query: string) +``` + +**Parameters:** + +- `host` (ReactiveElement): The host element that uses this controller +- `query` (string): The CSS media query to monitor (e.g., `'(min-width: 768px)'`) + +### Properties + +#### `matches` + +- **Type**: `boolean` +- **Description**: Whether the media query currently matches. This property updates automatically when the media query state changes. +- **Settable**: No (read-only) + +#### `key` + +- **Type**: `Symbol` +- **Description**: A unique symbol used to identify this controller when requesting updates from the host element. +- **Settable**: No (read-only) + +### Methods + +The `MatchMediaController` implements the `ReactiveController` interface with the following lifecycle methods: + +#### `hostConnected(): void` + +Called when the host element is connected to the DOM. Starts listening for media query changes. + +#### `hostDisconnected(): void` + +Called when the host element is disconnected from the DOM. Stops listening for media query changes. + +## Usage [![See it on NPM!](https://img.shields.io/npm/v/@spectrum-web-components/reactive-controllers?style=for-the-badge)](https://www.npmjs.com/package/@spectrum-web-components/reactive-controllers) [![How big is this package in your project?](https://img.shields.io/bundlephobia/minzip/@spectrum-web-components/reactive-controllers?style=for-the-badge)](https://bundlephobia.com/result?p=@spectrum-web-components/reactive-controllers) @@ -16,18 +60,20 @@ yarn add @spectrum-web-components/reactive-controllers Import the `MatchMediaController` via: ``` -import { MatchMediaController } from '@spectrum-web-components/reactive-controllers/MatchMedia.js'; +import { MatchMediaController } from '@spectrum-web-components/reactive-controllers/src/MatchMedia.js'; ``` -## Example +## Examples -A `Host` element that renders a different message depending on the "orientation" of the window in which is it delivered: +### Basic usage -```js +A `Host` element that renders different content based on window orientation: + +```typescript import { html, LitElement } from 'lit'; -import { MatchMediaController } from '@spectrum-web-components/reactive-controllers/MatchMedia.js'; +import { MatchMediaController } from '@spectrum-web-components/reactive-controllers/src/MatchMedia.js'; -class Host extends LitElement { +class ResponsiveElement extends LitElement { orientationLandscape = new MatchMediaController( this, '(orientation: landscape)' @@ -36,12 +82,360 @@ class Host extends LitElement { render() { if (this.orientationLandscape.matches) { return html` - The orientation is landscape. +

The orientation is landscape.

`; } return html` - The orientation is portrait. +

The orientation is portrait.

`; } } + +customElements.define('responsive-element', ResponsiveElement); ``` + +### Multiple media queries + +Use multiple `MatchMediaController` instances to create complex responsive layouts: + +```typescript +import { html, LitElement, css } from 'lit'; +import { MatchMediaController } from '@spectrum-web-components/reactive-controllers/src/MatchMedia.js'; + +class ResponsiveLayout extends LitElement { + isMobile = new MatchMediaController(this, '(max-width: 768px)'); + isTablet = new MatchMediaController( + this, + '(min-width: 769px) and (max-width: 1024px)' + ); + isDesktop = new MatchMediaController(this, '(min-width: 1025px)'); + + static styles = css` + :host { + display: block; + padding: var(--spacing, 16px); + } + + .mobile { + font-size: 14px; + } + .tablet { + font-size: 16px; + } + .desktop { + font-size: 18px; + } + `; + + render() { + const deviceClass = this.isMobile.matches + ? 'mobile' + : this.isTablet.matches + ? 'tablet' + : 'desktop'; + + return html` +
+

Current viewport: ${deviceClass}

+

Content adapts to your screen size.

+
+ `; + } +} + +customElements.define('responsive-layout', ResponsiveLayout); +``` + +### Dark mode detection + +Detect and respond to user's color scheme preference: + +```typescript +import { html, LitElement, css } from 'lit'; +import { + MatchMediaController, + DARK_MODE, +} from '@spectrum-web-components/reactive-controllers/src/MatchMedia.js'; + +class ThemeAwareComponent extends LitElement { + darkMode = new MatchMediaController(this, DARK_MODE); + + static styles = css` + :host { + display: block; + padding: 20px; + transition: + background-color 0.3s, + color 0.3s; + } + + .light-theme { + background-color: #ffffff; + color: #000000; + } + + .dark-theme { + background-color: #1a1a1a; + color: #ffffff; + } + `; + + render() { + const theme = this.darkMode.matches ? 'dark-theme' : 'light-theme'; + const themeLabel = this.darkMode.matches ? 'dark mode' : 'light mode'; + + return html` +
+

Current theme: ${themeLabel}

+

+ This component automatically adapts to your system theme + preference. +

+
+ `; + } +} + +customElements.define('theme-aware-component', ThemeAwareComponent); +``` + +### Mobile detection + +Detect mobile devices with touch input: + +```typescript +import { html, LitElement } from 'lit'; +import { + MatchMediaController, + IS_MOBILE, +} from '@spectrum-web-components/reactive-controllers/src/MatchMedia.js'; +import '@spectrum-web-components/button/sp-button.js'; + +class TouchOptimizedElement extends LitElement { + isMobile = new MatchMediaController(this, IS_MOBILE); + + render() { + const buttonSize = this.isMobile.matches ? 'xl' : 'm'; + const instructions = this.isMobile.matches + ? 'Tap to continue' + : 'Click to continue'; + + return html` +
+ + ${instructions} + +
+ `; + } +} + +customElements.define('touch-optimized-element', TouchOptimizedElement); +``` + +### Responsive navigation + +Create a navigation component that changes layout based on screen size: + +```typescript +import { html, LitElement, css } from 'lit'; +import { MatchMediaController } from '@spectrum-web-components/reactive-controllers/src/MatchMedia.js'; +import '@spectrum-web-components/action-menu/sp-action-menu.js'; +import '@spectrum-web-components/menu/sp-menu.js'; +import '@spectrum-web-components/menu/sp-menu-item.js'; +import '@spectrum-web-components/top-nav/sp-top-nav.js'; +import '@spectrum-web-components/top-nav/sp-top-nav-item.js'; + +class ResponsiveNav extends LitElement { + isNarrow = new MatchMediaController(this, '(max-width: 960px)'); + + static styles = css` + :host { + display: block; + } + + nav { + display: flex; + gap: 8px; + padding: 16px; + } + `; + + renderMobileNav() { + return html` + + `; + } + + renderDesktopNav() { + return html` + + Home + Products + About + Contact + + `; + } + + render() { + return this.isNarrow.matches + ? this.renderMobileNav() + : this.renderDesktopNav(); + } +} + +customElements.define('responsive-nav', ResponsiveNav); +``` + +### Print media query + +Detect when the page is being printed: + +```typescript +import { html, LitElement, css } from 'lit'; +import { MatchMediaController } from '@spectrum-web-components/reactive-controllers/src/MatchMedia.js'; + +class PrintAwareDocument extends LitElement { + isPrinting = new MatchMediaController(this, 'print'); + + static styles = css` + .print-only { + display: none; + } + + :host([printing]) .print-only { + display: block; + } + + :host([printing]) .no-print { + display: none; + } + `; + + updated() { + // Reflect printing state to host for CSS styling + this.toggleAttribute('printing', this.isPrinting.matches); + } + + render() { + return html` +
+
+

This content won't appear when printed.

+
+ +
+ `; + } +} + +customElements.define('print-aware-document', PrintAwareDocument); +``` + +## Predefined media queries + +The `MatchMediaController` exports commonly used media queries as constants: + +### `DARK_MODE` + +```typescript +const DARK_MODE = '(prefers-color-scheme: dark)'; +``` + +Matches when the user has requested a dark color scheme. + +### `IS_MOBILE` + +```typescript +const IS_MOBILE = '(max-width: 743px) and (hover: none) and (pointer: coarse)'; +``` + +Matches mobile devices with touch input (no hover support and coarse pointer). + +## Accessibility + +When using `MatchMediaController` to create responsive designs, consider these accessibility best practices: + +### Content parity + +- Ensure that content available on one screen size is also available on others, even if the presentation differs. +- Don't hide critical information or functionality based solely on screen size. + +### Touch targets + +- On mobile devices (detected via media queries), ensure interactive elements meet the minimum touch target size of 44x44 pixels as per [WCAG 2.5.5 Target Size](https://www.w3.org/WAI/WCAG21/Understanding/target-size.html). +- Increase spacing between interactive elements on touch devices. + +### Responsive text + +- Ensure text remains readable at all breakpoints. +- Allow text to reflow naturally without horizontal scrolling (required by [WCAG 1.4.10 Reflow](https://www.w3.org/WAI/WCAG21/Understanding/reflow.html)). + +### Keyboard navigation + +- Responsive layouts must maintain logical keyboard navigation order. +- Ensure focus indicators remain visible and clear at all breakpoints. + +### ARIA labels + +- Update ARIA labels when content significantly changes based on media queries. +- Use `aria-label` to describe the current layout state when it affects user interaction. + +### Screen reader announcements + +- Consider using `aria-live` regions to announce significant layout changes. +- Avoid disorienting users with unexpected content shifts. + +### Color scheme preferences + +When using `DARK_MODE` or other color scheme media queries: + +- Respect user preferences for reduced motion ([`prefers-reduced-motion`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion)). +- Maintain sufficient contrast ratios in both light and dark modes. +- Test with high contrast modes. + +### References + +- [WCAG 2.1 - Reflow](https://www.w3.org/WAI/WCAG21/Understanding/reflow.html) +- [WCAG 2.1 - Target Size](https://www.w3.org/WAI/WCAG21/Understanding/target-size.html) +- [MDN: Using media queries for accessibility](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Using_media_queries#targeting_media_features) +- [Adobe Accessibility Guidelines](https://www.adobe.com/accessibility/products/spectrum.html) + +## Events + +The `MatchMediaController` doesn't dispatch custom events directly. It calls `requestUpdate()` on the host element when the media query match state changes, triggering the host's reactive update cycle. The `key` property (a Symbol) is passed to `requestUpdate()` to allow the host to track what changed. + +## Browser support + +The `MatchMediaController` relies on the [`window.matchMedia()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia) API, which is supported in all modern browsers. For older browsers, consider using a polyfill. + +## Related patterns + +- [Responsive design patterns](https://web.dev/patterns/layout/) +- [CSS media queries](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Using_media_queries) +- [Container queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Container_Queries) - For component-level responsive design + +## Resources + +- [Lit Reactive Controllers](https://lit.dev/docs/composition/controllers/) - Learn more about reactive controllers +- [MDN: Window.matchMedia()](https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia) +- [MDN: Using media queries](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Using_media_queries) +- [CSS Media Queries Level 5](https://www.w3.org/TR/mediaqueries-5/) - Specification diff --git a/tools/reactive-controllers/pending-state.md b/tools/reactive-controllers/pending-state.md index 547219167d3..14c4b5cd2c9 100644 --- a/tools/reactive-controllers/pending-state.md +++ b/tools/reactive-controllers/pending-state.md @@ -1,10 +1,87 @@ ## Description -The `PendingStateController` is a class that helps manage the pending state of a reactive element. It provides a standardized way to indicate when an element is in a pending state, such as during an asynchronous operation. -When the components is in a pending state it supplies the pending state UI `sp-progress-circle` which gets rendered in the component. -It also updates the value of ARIA label of the host element to its pending-label based on the pending state. +The `PendingStateController` is a [reactive controller](https://lit.dev/docs/composition/controllers/) that helps manage the pending state of a reactive element. It provides a standardized way to indicate when an element is in a pending state (such as during an asynchronous operation) by rendering a progress indicator and managing ARIA labels for accessibility. -The `HostWithPendingState` interface defines the properties that a host element must implement to work with the `PendingStateController`. +### Features + +- **Visual feedback**: Renders an `` element during pending states +- **Accessible state management**: Automatically updates ARIA labels to reflect pending status +- **Label caching**: Preserves and restores the original `aria-label` when transitioning states +- **Disabled state awareness**: Respects the disabled state of the host element + +### Current limitations + +**Note**: This controller is currently used primarily by the Button component, where the host element is the interactive element that needs pending state. This pattern does not work optimally for components where the interactive element requiring pending state is in the shadow DOM (e.g., Combobox and Picker). + +> **Deprecation consideration**: See issue [SWC-1119, SWC-1255, SWC-459] - This controller may be deprecated in future versions as it's not widely adopted beyond Button. Consider alternative patterns for new implementations. + +## API + +### Constructor + +```typescript +new PendingStateController(host: T) +``` + +**Parameters:** + +- `host` (T extends HostWithPendingState): The host element that uses this controller. Must implement the `HostWithPendingState` interface. + +### Host element requirements + +Your host element must implement the `HostWithPendingState` interface: + +```typescript +interface HostWithPendingState extends LitElement { + pendingLabel?: string; // Label to announce during pending state + pending: boolean; // Whether the element is pending + disabled: boolean; // Whether the element is disabled + pendingStateController: PendingStateController; +} +``` + +### Properties + +#### `host` + +- **Type**: `T extends HostWithPendingState` +- **Description**: The host element that this controller is attached to. +- **Settable**: No (set in constructor) + +#### `cachedAriaLabel` + +- **Type**: `string | null` +- **Description**: Cached value of the original `aria-label` attribute, used to restore it when exiting pending state. +- **Settable**: Yes (public property, managed by the controller) + +### Methods + +#### `renderPendingState(): TemplateResult` + +Renders the pending state UI (progress circle). + +**Returns:** A Lit `TemplateResult` containing either an `` (when pending) or an empty template. + +**Example:** + +```typescript +render() { + return html` + + `; +} +``` + +#### `hostConnected(): void` + +Called when the host element is connected to the DOM. Caches the initial `aria-label` and updates it based on pending state. + +#### `hostUpdated(): void` + +Called after the host element has updated. Updates the `aria-label` based on the current pending state. ## Usage @@ -18,50 +95,472 @@ yarn add @spectrum-web-components/reactive-controllers Import the `PendingStateController` via: ``` -import { PendingStateController } from '@spectrum-web-components/reactive-controllers/src/PendingState.js'; +import { PendingStateController, HostWithPendingState } from '@spectrum-web-components/reactive-controllers/src/PendingState.js'; ``` -## Example +## Examples -```js -import { LitElement } from 'lit'; -import { PendingStateController, HostWithPendingState } from '@spectrum-web-components/reactive-controllers/src/PendingState.js'; +### Basic usage -class Host extends LitElement { +```typescript +import { html, LitElement, TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; +import { + PendingStateController, + HostWithPendingState, +} from '@spectrum-web-components/reactive-controllers/src/PendingState.js'; +import { when } from 'lit/directives/when.js'; - /** Whether the items are currently loading. */ +class AsyncButton extends LitElement implements HostWithPendingState { + /** Whether the button is currently in a pending state. */ @property({ type: Boolean, reflect: true }) public pending = false; - /** Whether the host is disabled. */ - @property({type: boolean}) + /** Whether the button is disabled. */ + @property({ type: Boolean, reflect: true }) public disabled = false; - /** Defines a string value that labels the while it is in pending state. */ + /** Label to announce when the button is pending. */ @property({ type: String, attribute: 'pending-label' }) - public pendingLabel = 'Pending'; + public pendingLabel = 'Loading'; + public pendingStateController: PendingStateController; - /** - * Initializes the `PendingStateController` for the component. - * The `PendingStateController` manages the pending state of the Component. - */ constructor() { super(); this.pendingStateController = new PendingStateController(this); } - render(){ + + render(): TemplateResult { return html` - - ${when( - this.pending, - () => { - return this.pendingStateController.renderPendingState(); - } - )} - ` + + `; + } +} + +customElements.define('async-button', AsyncButton); +``` + +Usage: + +```html +Save +``` + +### Async operation handling + +```typescript +import { html, LitElement, css } from 'lit'; +import { property } from 'lit/decorators.js'; +import { + PendingStateController, + HostWithPendingState, +} from '@spectrum-web-components/reactive-controllers/src/PendingState.js'; +import { when } from 'lit/directives/when.js'; + +class SaveButton extends LitElement implements HostWithPendingState { + @property({ type: Boolean, reflect: true }) + public pending = false; + + @property({ type: Boolean, reflect: true }) + public disabled = false; + + @property({ type: String, attribute: 'pending-label' }) + public pendingLabel = 'Saving'; + + public pendingStateController: PendingStateController; + + static styles = css` + :host { + display: inline-block; + } + + button { + position: relative; + padding: 8px 16px; + } + + sp-progress-circle { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + } + `; + + constructor() { + super(); + this.pendingStateController = new PendingStateController(this); + } + + async handleClick() { + this.pending = true; + + try { + // Simulate async operation + await this.saveData(); + + // Announce success to screen readers + this.dispatchEvent( + new CustomEvent('save-success', { + detail: { message: 'Data saved successfully' }, + bubbles: true, + composed: true, + }) + ); + } catch (error) { + // Announce error to screen readers + this.dispatchEvent( + new CustomEvent('save-error', { + detail: { message: 'Failed to save data' }, + bubbles: true, + composed: true, + }) + ); + } finally { + this.pending = false; + } + } + + async saveData(): Promise { + return new Promise((resolve) => setTimeout(resolve, 2000)); } + render() { + return html` + + `; + } } +customElements.define('save-button', SaveButton); ``` + +### Form submission with pending state + +```typescript +import { html, LitElement, css } from 'lit'; +import { property } from 'lit/decorators.js'; +import { + PendingStateController, + HostWithPendingState, +} from '@spectrum-web-components/reactive-controllers/src/PendingState.js'; +import { when } from 'lit/directives/when.js'; +import '@spectrum-web-components/textfield/sp-textfield.js'; + +class SubmitButton extends LitElement implements HostWithPendingState { + @property({ type: Boolean, reflect: true }) + public pending = false; + + @property({ type: Boolean, reflect: true }) + public disabled = false; + + @property({ type: String, attribute: 'pending-label' }) + public pendingLabel = 'Submitting'; + + public pendingStateController: PendingStateController; + + static styles = css` + :host { + display: inline-block; + } + + button { + padding: 10px 20px; + min-width: 120px; + position: relative; + } + `; + + constructor() { + super(); + this.pendingStateController = new PendingStateController(this); + } + + async handleSubmit(event: Event) { + event.preventDefault(); + + if (this.pending) return; + + this.pending = true; + + try { + const form = this.closest('form'); + if (form) { + const formData = new FormData(form); + await this.submitForm(formData); + + // Announce success + this.announceToScreenReader('Form submitted successfully'); + } + } catch (error) { + // Announce error + this.announceToScreenReader('Form submission failed', 'assertive'); + } finally { + this.pending = false; + } + } + + announceToScreenReader( + message: string, + priority: 'polite' | 'assertive' = 'polite' + ) { + const announcement = document.createElement('div'); + announcement.setAttribute('role', 'status'); + announcement.setAttribute('aria-live', priority); + announcement.textContent = message; + announcement.style.position = 'absolute'; + announcement.style.left = '-10000px'; + announcement.style.width = '1px'; + announcement.style.height = '1px'; + announcement.style.overflow = 'hidden'; + + document.body.appendChild(announcement); + setTimeout(() => announcement.remove(), 1000); + } + + async submitForm(formData: FormData): Promise { + // Simulate API call + return new Promise((resolve) => setTimeout(resolve, 2000)); + } + + render() { + return html` + + `; + } +} + +customElements.define('submit-button', SubmitButton); +``` + +### Multiple pending states + +```typescript +import { html, LitElement, css } from 'lit'; +import { property } from 'lit/decorators.js'; +import { + PendingStateController, + HostWithPendingState, +} from '@spectrum-web-components/reactive-controllers/src/PendingState.js'; +import { when } from 'lit/directives/when.js'; + +class ActionButton extends LitElement implements HostWithPendingState { + @property({ type: Boolean, reflect: true }) + public pending = false; + + @property({ type: Boolean, reflect: true }) + public disabled = false; + + @property({ type: String, attribute: 'pending-label' }) + public pendingLabel = 'Processing'; + + @property({ type: String }) + public action = ''; + + public pendingStateController: PendingStateController; + + constructor() { + super(); + this.pendingStateController = new PendingStateController(this); + } + + async performAction(actionType: string) { + this.action = actionType; + this.pending = true; + + // Update pending label based on action + switch (actionType) { + case 'save': + this.pendingLabel = 'Saving'; + break; + case 'delete': + this.pendingLabel = 'Deleting'; + break; + case 'upload': + this.pendingLabel = 'Uploading'; + break; + } + + try { + await this.executeAction(actionType); + } finally { + this.pending = false; + this.action = ''; + } + } + + async executeAction(action: string): Promise { + return new Promise((resolve) => setTimeout(resolve, 2000)); + } + + render() { + return html` + + `; + } +} + +customElements.define('action-button', ActionButton); +``` + +## Accessibility + +The `PendingStateController` includes several accessibility features, but additional considerations should be taken when implementing it: + +### ARIA label management + +- **Automatic label updates**: The controller automatically updates the `aria-label` when entering/exiting pending state. +- **Label preservation**: The original `aria-label` is cached and restored when the pending state ends. +- **Custom pending labels**: Use the `pendingLabel` property to provide context-specific messages (e.g., "Saving...", "Uploading..."). + +### Screen reader announcements + +The pending state changes are communicated to screen readers through: + +- **aria-label changes**: The `aria-label` attribute is updated to reflect the pending state. +- **Progress indicator**: The `` has `role="presentation"` to avoid redundant announcements. + +**Best practices:** + +```typescript +render() { + return html` + + `; +} +``` + +### Keyboard accessibility + +- **Disable during pending**: The element should be disabled (`disabled` attribute) or not interactive during pending states to prevent multiple submissions. +- **Focus management**: Ensure focus remains on the element or moves appropriately after async operations complete. + +### Visual indicators + +- **Progress circle**: The rendered `` provides visual feedback. +- **Text changes**: Consider changing button text during pending states (e.g., "Save" → "Saving..."). +- **Disabled state**: Apply visual styling to indicate the element is not interactive. + +### Error handling and recovery + +```typescript +async handleAction() { + this.pending = true; + + try { + await this.performAsync(); + // Success announcement + this.announceToScreenReader('Action completed successfully', 'polite'); + } catch (error) { + // Error announcement + this.announceToScreenReader( + 'Action failed. Please try again.', + 'assertive' + ); + } finally { + this.pending = false; + } +} + +announceToScreenReader(message: string, priority: 'polite' | 'assertive' = 'polite') { + const liveRegion = document.createElement('div'); + liveRegion.setAttribute('role', priority === 'assertive' ? 'alert' : 'status'); + liveRegion.setAttribute('aria-live', priority); + liveRegion.setAttribute('aria-atomic', 'true'); + liveRegion.textContent = message; + + // Visually hide the live region + Object.assign(liveRegion.style, { + position: 'absolute', + left: '-10000px', + width: '1px', + height: '1px', + overflow: 'hidden' + }); + + document.body.appendChild(liveRegion); + setTimeout(() => liveRegion.remove(), 1000); +} +``` + +### Known issues + +> **Note**: [SWC-1119, SWC-1255, SWC-459] - Accessibility warnings and the a11y DOM tree should be confirmed for pending state in Button, Combobox, and Picker components. + +### References + +- [WCAG 2.1 - Status Messages](https://www.w3.org/WAI/WCAG21/Understanding/status-messages.html) +- [ARIA: aria-busy attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-busy) +- [ARIA: aria-label attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-label) +- [ARIA: status role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/status_role) +- [Adobe Accessibility Guidelines](https://www.adobe.com/accessibility/products/spectrum.html) + +## Events + +The `PendingStateController` doesn't dispatch custom events directly. Host elements should dispatch their own events to communicate state changes: + +```typescript +this.dispatchEvent( + new CustomEvent('pending-change', { + detail: { pending: this.pending }, + bubbles: true, + composed: true, + }) +); +``` + +## Related components + +The `PendingStateController` is used by: + +- [``](../../packages/button/) - Primary use case for pending state + +## Resources + +- [Lit Reactive Controllers](https://lit.dev/docs/composition/controllers/) - Learn more about reactive controllers +- [``](../../packages/progress-circle/) - The visual indicator component +- [Buttons with loading states](https://www.nngroup.com/articles/indicators-validating-user-input/) - UX best practices diff --git a/tools/reactive-controllers/roving-tab-index.md b/tools/reactive-controllers/roving-tab-index.md index 3a06b9dd11f..22ef168203b 100644 --- a/tools/reactive-controllers/roving-tab-index.md +++ b/tools/reactive-controllers/roving-tab-index.md @@ -1,8 +1,133 @@ ## Description -[Roving tabindex](https://www.w3.org/TR/wai-aria-practices-1.2/#kbd_roving_tabindex) is a pattern whereby multiple focusable elements are represented by a single `tabindex=0` element, while the individual elements maintain `tabindex=-1` and are made accessible via arrow keys after the entry element if focused. This allows keyboard users to quickly tab through a page without having to stop on every element in a large collection. Attaching a `RovingTabindexController` to your custom element will manage the supplied `elements` via this pattern. +The `RovingTabindexController` is a [reactive controller](https://lit.dev/docs/composition/controllers/) that implements the [roving tabindex pattern](https://www.w3.org/TR/wai-aria-practices-1.2/#kbd_roving_tabindex), a key accessibility technique for managing keyboard navigation in composite widgets. This pattern allows multiple focusable elements to be represented by a single `tabindex=0` element in the tab order, while making all elements accessible via arrow keys. This enables keyboard users to quickly tab through a page without stopping on every item in a large collection. -### Usage +### Features + +- **Keyboard navigation**: Manages arrow key navigation (Left, Right, Up, Down, Home, End) through collections +- **Flexible direction modes**: Supports horizontal, vertical, both, and grid navigation patterns +- **Focus management**: Automatically manages `tabindex` attributes on elements +- **Customizable behavior**: Configure which element receives initial focus and how elements respond to keyboard input +- **Accessibility compliant**: Implements WCAG accessibility patterns for keyboard navigation + +## API + +The `RovingTabindexController` extends `FocusGroupController` and inherits all of its functionality while adding tabindex management capabilities. + +### Constructor + +```typescript +new RovingTabindexController( + host: ReactiveElement, + config: RovingTabindexConfig +) +``` + +**Parameters:** + +- `host` (ReactiveElement): The host element that uses this controller +- `config` (RovingTabindexConfig): Configuration object with the following options: + +### Configuration options + +#### `elements` (required) + +- **Type**: `() => T[]` +- **Description**: Function that returns an array of elements to be managed by the controller + +#### `direction` + +- **Type**: `'horizontal' | 'vertical' | 'both' | 'grid' | (() => DirectionTypes)` +- **Default**: `'both'` +- **Description**: Defines which arrow keys are active: + - `'horizontal'`: Only `ArrowLeft` and `ArrowRight` + - `'vertical'`: Only `ArrowUp` and `ArrowDown` + - `'both'`: All four arrow keys + - `'grid'`: All four arrow keys with 2D grid navigation + +#### `elementEnterAction` + +- **Type**: `(el: T) => void` +- **Default**: No-op +- **Description**: Callback executed when an element receives focus, before the focus actually moves + +#### `focusInIndex` + +- **Type**: `(elements: T[]) => number` +- **Default**: `() => 0` +- **Description**: Determines which element receives `tabindex=0` when focus enters the container + +#### `isFocusableElement` + +- **Type**: `(el: T) => boolean` +- **Default**: `() => true` +- **Description**: Predicate to determine if an element can receive focus (useful for skipping disabled elements) + +#### `hostDelegatesFocus` + +- **Type**: `boolean` +- **Default**: `false` +- **Description**: Whether the host element uses `delegatesFocus` in its shadow root + +#### `listenerScope` + +- **Type**: `HTMLElement | (() => HTMLElement)` +- **Default**: Host element +- **Description**: Element to attach keyboard event listeners to + +### Properties + +#### `currentIndex` + +- **Type**: `number` +- **Description**: Index of the currently focused element +- **Settable**: Yes + +#### `direction` + +- **Type**: `'horizontal' | 'vertical' | 'both' | 'grid'` +- **Description**: Current navigation direction mode +- **Settable**: Via configuration + +#### `elements` + +- **Type**: `T[]` +- **Description**: Array of managed elements +- **Settable**: No (computed from `elements` config function) + +#### `focusInElement` + +- **Type**: `T` +- **Description**: The element that should receive focus when entering the container +- **Settable**: No (computed from `focusInIndex`) + +### Methods + +#### `focus(options?: FocusOptions): void` + +Focuses the current element in the managed collection. + +#### `manageTabindexes(): void` + +Updates `tabindex` attributes on all managed elements based on the current focus state. + +#### `clearElementCache(offset?: number): void` + +Clears the cached elements array and optionally sets an offset for virtualized lists. + +#### `manage(): void` + +Starts managing the elements (enables keyboard navigation). + +#### `unmanage(): void` + +Stops managing the elements (disables keyboard navigation and resets tabindexes). + +#### `reset(): void` + +Resets focus to the initial element defined by `focusInIndex`. + +## Usage [![See it on NPM!](https://img.shields.io/npm/v/@spectrum-web-components/reactive-controllers?style=for-the-badge)](https://www.npmjs.com/package/@spectrum-web-components/reactive-controllers) [![How big is this package in your project?](https://img.shields.io/bundlephobia/minzip/@spectrum-web-components/reactive-controllers?style=for-the-badge)](https://bundlephobia.com/result?p=@spectrum-web-components/reactive-controllers) @@ -14,79 +139,542 @@ yarn add @spectrum-web-components/reactive-controllers Import the `RovingTabindexController` via: ``` -import { RovingTabindexController } from '@spectrum-web-components/reactive-controllers/RovingTabindex.js'; +import { RovingTabindexController } from '@spectrum-web-components/reactive-controllers/src/RovingTabindex.js'; ``` -## Example +## Examples + +### Basic usage -A `Container` element that manages a collection of `` elements that are slotted into it from outside might look like the following: +A `Container` element that manages a collection of `` elements: -```js +```typescript import { html, LitElement } from 'lit'; -import { RovingTabindexController } from '@spectrum-web-components/reactive-controllers/RovingTabindex.js'; +import { RovingTabindexController } from '@spectrum-web-components/reactive-controllers/src/RovingTabindex.js'; import type { Button } from '@spectrum-web-components/button'; +import '@spectrum-web-components/button/sp-button.js'; -class Container extends LitElement { - rovingTabindexController = - new RovingTabindexController() < - Button > - (this, - { - elements: () => [...this.querySelectorAll('sp-button')], - }); +class ButtonGroup extends LitElement { + rovingTabindexController = new RovingTabindexController + +
+``` + +#### For listboxes: + +```html +
+
Option 1
+
Option 2
+
+``` + +#### For radiogroups: + +```html +
+ + +
+``` + +#### For menus: + +```html +
+
New
+
Open
+
+``` + +### Keyboard support + +The `RovingTabindexController` provides the following keyboard interactions: + +| Key | Direction Mode | Action | +| ------------------- | ---------------------- | --------------------------------------------------- | +| **Tab** | All | Moves focus into or out of the composite widget | +| **→ (Right Arrow)** | horizontal, both, grid | Moves focus to the next element | +| **← (Left Arrow)** | horizontal, both, grid | Moves focus to the previous element | +| **↓ (Down Arrow)** | vertical, both, grid | Moves focus to the next element (or down in grid) | +| **↑ (Up Arrow)** | vertical, both, grid | Moves focus to the previous element (or up in grid) | +| **Home** | All | Moves focus to the first element | +| **End** | All | Moves focus to the last element | + +### Focus indicators + +Always provide clear visual focus indicators: + +```css +.managed-element:focus { + outline: 2px solid var(--spectrum-global-color-blue-400); + outline-offset: 2px; +} + +/* Or for high contrast */ +@media (prefers-contrast: high) { + .managed-element:focus { + outline: 3px solid currentColor; } } ``` -The above usage is very close to what can be seen in the [`` element](../components/radio). +### Disabled elements + +Use the `isFocusableElement` option to skip disabled elements: + +```typescript +rovingTabindexController = new RovingTabindexController - - `; - } - - renderEnhancedForm() { - return html` -
- - - - - Subscribe to newsletter - - Submit -
- `; - } - - render() { - const isEnhanced = this.dependencyManager.loaded; - const formClass = isEnhanced ? 'enhanced' : 'loading'; - - return html` -
- ${isEnhanced - ? this.renderEnhancedForm() - : this.renderBasicForm()} -
- `; - } -} - -customElements.define('progressive-form', ProgressiveForm); -``` - -### Tracking load state changes +#### Tracking load state changes Use the `dependencyManagerLoadedSymbol` to react to loading state changes: @@ -482,35 +338,29 @@ class TrackingHost extends LitElement { customElements.define('tracking-host', TrackingHost); ``` -## Accessibility +### Accessibility When using `DependencyManagerController` to manage lazy-loaded components, consider these accessibility best practices: -### Loading states +#### Loading states - **Provide clear feedback**: Always inform users when content is loading using `role="status"` and `aria-live="polite"`. - **Use aria-busy**: Set `aria-busy="true"` on containers while dependencies are loading. - **Loading indicators**: Include visible loading indicators (spinners, progress bars) for better user experience. -### Screen reader announcements +#### Screen reader announcements - Announce when loading begins: Use `aria-live="polite"` regions to notify screen reader users. - Announce when loading completes: Inform users when content has finished loading. - Avoid announcement spam: Debounce or throttle announcements if multiple components load in quick succession. -### Keyboard accessibility +#### Keyboard accessibility - Ensure keyboard focus is managed correctly when lazy-loaded components appear. - Don't trap focus in loading states. - Return focus to a logical location after content loads. -### Progressive enhancement - -- Provide basic functionality before enhanced components load. -- Don't block critical features on lazy-loaded dependencies. -- Ensure the experience degrades gracefully if components fail to load. - -### Error handling +#### Error handling ```typescript // Example error handling with accessibility @@ -531,33 +381,9 @@ connectedCallback() { } ``` -### References - -- [WCAG 2.1 - Status Messages](https://www.w3.org/WAI/WCAG21/Understanding/status-messages.html) -- [ARIA: status role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/status_role) -- [ARIA: aria-busy attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-busy) -- [Adobe Accessibility Guidelines](https://www.adobe.com/accessibility/products/spectrum.html) - -## Events - -The `DependencyManagerController` doesn't dispatch custom events directly. Instead, it calls `requestUpdate(dependencyManagerLoadedSymbol, previousLoadedState)` on the host element when the loaded state changes, triggering the host's reactive update cycle. - -## Performance considerations +### Performance considerations - **Code splitting**: Use the dependency manager with dynamic imports to split code and reduce initial bundle size. - **Lazy loading strategy**: Load components just-in-time based on user interaction or route changes. - **Preloading**: Consider preloading critical dependencies during idle time using `requestIdleCallback()`. - **Caching**: Browsers will cache imported modules, so subsequent loads are fast. - -## Related patterns - -- [Lazy loading web components](https://web.dev/patterns/components/lazy-loading/) -- [Code splitting](https://web.dev/reduce-javascript-payloads-with-code-splitting/) -- [Progressive enhancement](https://developer.mozilla.org/en-US/docs/Glossary/Progressive_Enhancement) - -## Resources - -- [Lit Reactive Controllers](https://lit.dev/docs/composition/controllers/) - Learn more about reactive controllers -- [MDN: customElements.whenDefined()](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/whenDefined) -- [MDN: Dynamic imports](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import) -- [Web Components Best Practices](https://web.dev/custom-elements-best-practices/) From aa88fc49321425cfe4c05a9acf7ff20fb91b8e40 Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Thu, 16 Oct 2025 15:13:10 +0530 Subject: [PATCH 05/17] docs: updated element resolution controller readme to align with doc standard --- .../element-resolution.md | 130 ++++-------------- 1 file changed, 26 insertions(+), 104 deletions(-) diff --git a/tools/reactive-controllers/element-resolution.md b/tools/reactive-controllers/element-resolution.md index 384f7c5fa9c..517afa9073c 100644 --- a/tools/reactive-controllers/element-resolution.md +++ b/tools/reactive-controllers/element-resolution.md @@ -1,4 +1,4 @@ -## Description +## Overview The `ElementResolutionController` is a [reactive controller](https://lit.dev/docs/composition/controllers/) that maintains an active reference to another element in the same DOM tree. It automatically observes the DOM tree for changes and ensures that the reference it holds is always up-to-date with the first matched element or `null` if no match is found. @@ -10,66 +10,7 @@ The `ElementResolutionController` is a [reactive controller](https://lit.dev/doc - **Reactive updates**: Automatically triggers host updates when the resolved element changes - **Scope awareness**: Works within Shadow DOM and regular DOM contexts -## API - -### Constructor - -```typescript -new ElementResolutionController( - host: ReactiveElement, - options?: { selector: string } -) -``` - -**Parameters:** - -- `host` (ReactiveElement): The host element that uses this controller -- `options.selector` (string, optional): The CSS selector to query for (can be set later via the `selector` property) - -### Properties - -#### `element` - -- **Type**: `HTMLElement | null` -- **Description**: The currently resolved element matching the selector, or `null` if no match is found. Updates automatically when the DOM changes. -- **Settable**: No (managed by the controller) - -#### `selector` - -- **Type**: `string` -- **Description**: The CSS selector used to find the element. When changed, the controller automatically updates the resolved element. -- **Settable**: Yes - -#### `selectorIsId` - -- **Type**: `boolean` -- **Description**: Whether the selector is an ID selector (starts with `#`). Used internally for optimization. -- **Settable**: No (read-only) - -#### `selectorAsId` - -- **Type**: `string` -- **Description**: The ID value (without the `#` prefix) when using an ID selector. -- **Settable**: No (read-only) - -### Methods - -#### `hostConnected(): void` - -Called when the host element is connected to the DOM. Starts observing DOM changes and resolves the element. - -#### `hostDisconnected(): void` - -Called when the host element is disconnected from the DOM. Stops observing DOM changes and releases the element reference. - -### Symbols - -#### `elementResolverUpdatedSymbol` - -- **Type**: `Symbol` -- **Description**: Exported symbol used as the property key when calling `requestUpdate()` on the host element. This allows the host to track when the resolved element has changed. - -**Usage:** + -## Usage +### Usage [![See it on NPM!](https://img.shields.io/npm/v/@spectrum-web-components/reactive-controllers?style=for-the-badge)](https://www.npmjs.com/package/@spectrum-web-components/reactive-controllers) [![How big is this package in your project?](https://img.shields.io/bundlephobia/minzip/@spectrum-web-components/reactive-controllers?style=for-the-badge)](https://bundlephobia.com/result?p=@spectrum-web-components/reactive-controllers) @@ -96,9 +37,9 @@ Import the `ElementResolutionController` and/or `elementResolverUpdatedSymbol` v import { ElementResolutionController, elementResolverUpdatedSymbol } from '@spectrum-web-components/reactive-controllers/src/ElementResolution.js'; ``` -## Examples +### Examples -### Basic usage +#### Basic usage An `ElementResolutionController` can be applied to a host element like the following: @@ -134,8 +75,6 @@ In this example, the selector `'.other-element'` is supplied to the resolver, wh
``` -### Multiple matching elements - The resolved reference will always be the first element matching the selector applied, so in the following example the element with content "First!" will be the reference: ```html @@ -146,7 +85,7 @@ The resolved reference will always be the first element matching the selector ap A [`MutationObserver`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) is leveraged to track mutations to the DOM tree in which the host element resides in order to update the element reference on any changes to the content therein that could change the resolved element. -### Constructor-based selector +#### Constructor-based selector You can provide the selector in the constructor options: @@ -169,7 +108,7 @@ class FormController extends LitElement { customElements.define('form-controller', FormController); ``` -### Tracking resolution updates +#### Tracking resolution updates Changes to the resolved element reference are reported to the host element via a call to the `requestUpdate()` method. This will be provided the `elementResolverUpdatedSymbol` as the changed key. If your element leverages this value against the changes map, it can react directly to changes in the resolved element: @@ -211,7 +150,7 @@ class RootEl extends LitElement { customElements.define('root-el', RootEl); ``` -### Accessible label resolution +#### Accessible label resolution Use `ElementResolutionController` to resolve accessible labeling elements: @@ -251,14 +190,14 @@ class CustomInput extends LitElement { customElements.define('custom-input', CustomInput); ``` -Usage: +**Usage:** ```html Enter your name ``` -### Dynamic selector changes +#### Dynamic selector changes The selector can be changed dynamically, and the controller will automatically update: @@ -295,7 +234,7 @@ class DynamicResolver extends LitElement { customElements.define('dynamic-resolver', DynamicResolver); ``` -### Modal and overlay management +#### Modal and overlay management Use element resolution to manage focus trap elements in modals: @@ -355,7 +294,7 @@ class ModalManager extends LitElement { customElements.define('modal-manager', ModalManager); ``` -### Form validation integration +#### Form validation integration Resolve and connect to error message elements: @@ -399,7 +338,7 @@ class ValidatedInput extends LitElement { customElements.define('validated-input', ValidatedInput); ``` -Usage: +**Usage:** ```html @@ -408,66 +347,49 @@ Usage: ``` -## Accessibility +### Accessibility When using `ElementResolutionController` for accessibility-related functionality, consider these best practices: -### Label associations +#### Label associations - When resolving label elements, always use proper ARIA attributes (`aria-labelledby`, `aria-describedby`) to create programmatic relationships. - Ensure labels have unique IDs that can be referenced. - Generate IDs programmatically if they don't exist. -### Error messages +#### Error messages - Error message elements should have `role="alert"` for screen reader announcements. - Use `aria-describedby` to associate error messages with form controls. - Ensure error messages are visible and programmatically associated when validation fails. -### Focus management +#### Focus management - When resolving focusable elements, ensure they meet keyboard accessibility requirements. - Maintain logical tab order when using resolved elements for focus trapping. - Provide clear focus indicators for all resolved interactive elements. -### Dynamic content +#### Dynamic content - Use `aria-live` regions when resolved elements change dynamically and users need to be notified. - Consider using `aria-live="polite"` for non-critical updates. - Use `aria-live="assertive"` sparingly for critical information. -### Element visibility +#### Element visibility - Verify that resolved elements are visible and accessible to assistive technologies. - Check that resolved elements aren't hidden with `display: none` or `visibility: hidden` unless intentional. - Use appropriate ARIA attributes (`aria-hidden`) when hiding decorative resolved elements. -### References - -- [WCAG 2.1 - Name, Role, Value](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html) -- [ARIA: aria-labelledby attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby) -- [ARIA: aria-describedby attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-describedby) -- [Adobe Accessibility Guidelines](https://www.adobe.com/accessibility/products/spectrum.html) - -## Events - -The `ElementResolutionController` doesn't dispatch custom events directly. Instead, it calls `requestUpdate(elementResolverUpdatedSymbol, previousElement)` on the host element when the resolved element changes, triggering the host's reactive update cycle with the symbol as the change key. - -## Performance considerations +### Performance considerations - **ID selectors are optimized**: The controller uses `getElementById()` for ID-based selectors (starting with `#`), which is faster than `querySelector()`. - **MutationObserver scope**: The observer watches the entire root node (Shadow DOM or document) for changes. For large DOMs, this could have performance implications. - **Automatic cleanup**: The controller automatically disconnects the MutationObserver when the host is disconnected from the DOM. -## Related patterns - -- [ARIA relationships](https://www.w3.org/TR/wai-aria-1.2/#attrs_relationships) -- [Focus management](https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets) -- [Shadow DOM and accessibility](https://web.dev/shadowdom-v1/#accessibility) - -## Resources +### References -- [Lit Reactive Controllers](https://lit.dev/docs/composition/controllers/) - Learn more about reactive controllers -- [MDN: MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) -- [MDN: Element.querySelector()](https://developer.mozilla.org/en-US/docs/Web/API/Element/querySelector) -- [MDN: Document.getElementById()](https://developer.mozilla.org/en-US/docs/Web/API/Document/getElementById) +- [WCAG 2.1 - Name, Role, Value](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html) +- [ARIA: aria-labelledby attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby) +- [ARIA: aria-describedby attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-describedby) +- [Adobe Accessibility Guidelines](https://www.adobe.com/accessibility/products/spectrum.html) From cc621ec5dcf37fedf122e3d10d20ff39ecae75d2 Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Thu, 16 Oct 2025 15:22:42 +0530 Subject: [PATCH 06/17] docs: updated match media controller readme to align with doc standard --- tools/reactive-controllers/match-media.md | 180 +++------------------- 1 file changed, 18 insertions(+), 162 deletions(-) diff --git a/tools/reactive-controllers/match-media.md b/tools/reactive-controllers/match-media.md index a1f2fa533de..cb247f01d96 100644 --- a/tools/reactive-controllers/match-media.md +++ b/tools/reactive-controllers/match-media.md @@ -1,4 +1,4 @@ -## Description +## Overview The `MatchMediaController` is a [reactive controller](https://lit.dev/docs/composition/controllers/) that binds a CSS media query to a reactive element, automatically updating when the query matches or stops matching. It leverages the [match media API](https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia) to query the state of CSS media queries from JavaScript while providing an event-based API to listen for changes. @@ -9,7 +9,7 @@ The `MatchMediaController` is a [reactive controller](https://lit.dev/docs/compo - **Multiple instances**: Support multiple controllers on a single host for complex responsive layouts - **Performance optimized**: Uses native browser APIs for efficient media query observation -## API + -## Usage +### Usage [![See it on NPM!](https://img.shields.io/npm/v/@spectrum-web-components/reactive-controllers?style=for-the-badge)](https://www.npmjs.com/package/@spectrum-web-components/reactive-controllers) [![How big is this package in your project?](https://img.shields.io/bundlephobia/minzip/@spectrum-web-components/reactive-controllers?style=for-the-badge)](https://bundlephobia.com/result?p=@spectrum-web-components/reactive-controllers) @@ -63,9 +63,9 @@ Import the `MatchMediaController` via: import { MatchMediaController } from '@spectrum-web-components/reactive-controllers/src/MatchMedia.js'; ``` -## Examples +### Examples -### Basic usage +#### Basic usage A `Host` element that renders different content based on window orientation: @@ -94,7 +94,7 @@ class ResponsiveElement extends LitElement { customElements.define('responsive-element', ResponsiveElement); ``` -### Multiple media queries +#### Multiple media queries Use multiple `MatchMediaController` instances to create complex responsive layouts: @@ -150,7 +150,7 @@ class ResponsiveLayout extends LitElement { customElements.define('responsive-layout', ResponsiveLayout); ``` -### Dark mode detection +#### Dark mode detection Detect and respond to user's color scheme preference: @@ -207,7 +207,7 @@ class ThemeAwareComponent extends LitElement { customElements.define('theme-aware-component', ThemeAwareComponent); ``` -### Mobile detection +#### Mobile detection Detect mobile devices with touch input: @@ -241,170 +241,41 @@ class TouchOptimizedElement extends LitElement { customElements.define('touch-optimized-element', TouchOptimizedElement); ``` -### Responsive navigation - -Create a navigation component that changes layout based on screen size: - -```typescript -import { html, LitElement, css } from 'lit'; -import { MatchMediaController } from '@spectrum-web-components/reactive-controllers/src/MatchMedia.js'; -import '@spectrum-web-components/action-menu/sp-action-menu.js'; -import '@spectrum-web-components/menu/sp-menu.js'; -import '@spectrum-web-components/menu/sp-menu-item.js'; -import '@spectrum-web-components/top-nav/sp-top-nav.js'; -import '@spectrum-web-components/top-nav/sp-top-nav-item.js'; - -class ResponsiveNav extends LitElement { - isNarrow = new MatchMediaController(this, '(max-width: 960px)'); - - static styles = css` - :host { - display: block; - } - - nav { - display: flex; - gap: 8px; - padding: 16px; - } - `; - - renderMobileNav() { - return html` - - `; - } - - renderDesktopNav() { - return html` - - Home - Products - About - Contact - - `; - } - - render() { - return this.isNarrow.matches - ? this.renderMobileNav() - : this.renderDesktopNav(); - } -} - -customElements.define('responsive-nav', ResponsiveNav); -``` - -### Print media query - -Detect when the page is being printed: - -```typescript -import { html, LitElement, css } from 'lit'; -import { MatchMediaController } from '@spectrum-web-components/reactive-controllers/src/MatchMedia.js'; - -class PrintAwareDocument extends LitElement { - isPrinting = new MatchMediaController(this, 'print'); - - static styles = css` - .print-only { - display: none; - } - - :host([printing]) .print-only { - display: block; - } - - :host([printing]) .no-print { - display: none; - } - `; - - updated() { - // Reflect printing state to host for CSS styling - this.toggleAttribute('printing', this.isPrinting.matches); - } - - render() { - return html` -
-
-

This content won't appear when printed.

-
- -
- `; - } -} - -customElements.define('print-aware-document', PrintAwareDocument); -``` - -## Predefined media queries - -The `MatchMediaController` exports commonly used media queries as constants: - -### `DARK_MODE` - -```typescript -const DARK_MODE = '(prefers-color-scheme: dark)'; -``` - -Matches when the user has requested a dark color scheme. - -### `IS_MOBILE` - -```typescript -const IS_MOBILE = '(max-width: 743px) and (hover: none) and (pointer: coarse)'; -``` - -Matches mobile devices with touch input (no hover support and coarse pointer). - -## Accessibility +### Accessibility When using `MatchMediaController` to create responsive designs, consider these accessibility best practices: -### Content parity +#### Content parity - Ensure that content available on one screen size is also available on others, even if the presentation differs. - Don't hide critical information or functionality based solely on screen size. -### Touch targets +#### Touch targets - On mobile devices (detected via media queries), ensure interactive elements meet the minimum touch target size of 44x44 pixels as per [WCAG 2.5.5 Target Size](https://www.w3.org/WAI/WCAG21/Understanding/target-size.html). - Increase spacing between interactive elements on touch devices. -### Responsive text +#### Responsive text - Ensure text remains readable at all breakpoints. - Allow text to reflow naturally without horizontal scrolling (required by [WCAG 1.4.10 Reflow](https://www.w3.org/WAI/WCAG21/Understanding/reflow.html)). -### Keyboard navigation +#### Keyboard navigation - Responsive layouts must maintain logical keyboard navigation order. - Ensure focus indicators remain visible and clear at all breakpoints. -### ARIA labels +#### ARIA labels - Update ARIA labels when content significantly changes based on media queries. - Use `aria-label` to describe the current layout state when it affects user interaction. -### Screen reader announcements +#### Screen reader announcements - Consider using `aria-live` regions to announce significant layout changes. - Avoid disorienting users with unexpected content shifts. -### Color scheme preferences +#### Color scheme preferences When using `DARK_MODE` or other color scheme media queries: @@ -419,23 +290,8 @@ When using `DARK_MODE` or other color scheme media queries: - [MDN: Using media queries for accessibility](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Using_media_queries#targeting_media_features) - [Adobe Accessibility Guidelines](https://www.adobe.com/accessibility/products/spectrum.html) -## Events - -The `MatchMediaController` doesn't dispatch custom events directly. It calls `requestUpdate()` on the host element when the media query match state changes, triggering the host's reactive update cycle. The `key` property (a Symbol) is passed to `requestUpdate()` to allow the host to track what changed. - -## Browser support - -The `MatchMediaController` relies on the [`window.matchMedia()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia) API, which is supported in all modern browsers. For older browsers, consider using a polyfill. - -## Related patterns - -- [Responsive design patterns](https://web.dev/patterns/layout/) -- [CSS media queries](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Using_media_queries) -- [Container queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Container_Queries) - For component-level responsive design - -## Resources +### Resources -- [Lit Reactive Controllers](https://lit.dev/docs/composition/controllers/) - Learn more about reactive controllers - [MDN: Window.matchMedia()](https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia) - [MDN: Using media queries](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Using_media_queries) - [CSS Media Queries Level 5](https://www.w3.org/TR/mediaqueries-5/) - Specification From 2584cab626d7ec552be2f3dde0ed2de1f674d16a Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Thu, 16 Oct 2025 15:38:55 +0530 Subject: [PATCH 07/17] docs: updated pending state controller readme to align with doc standard --- .../dependency-manager.md | 21 -- tools/reactive-controllers/pending-state.md | 297 +----------------- 2 files changed, 17 insertions(+), 301 deletions(-) diff --git a/tools/reactive-controllers/dependency-manager.md b/tools/reactive-controllers/dependency-manager.md index 2a88cb88836..84cd04ec414 100644 --- a/tools/reactive-controllers/dependency-manager.md +++ b/tools/reactive-controllers/dependency-manager.md @@ -360,27 +360,6 @@ When using `DependencyManagerController` to manage lazy-loaded components, consi - Don't trap focus in loading states. - Return focus to a logical location after content loads. -#### Error handling - -```typescript -// Example error handling with accessibility -connectedCallback() { - super.connectedCallback(); - this.dependencyManager.add('sp-button'); - - import('@spectrum-web-components/button/sp-button.js') - .catch((error) => { - console.error('Failed to load component:', error); - - // Announce error to screen readers - const errorElement = document.createElement('div'); - errorElement.setAttribute('role', 'alert'); - errorElement.textContent = 'Failed to load component. Please refresh the page.'; - this.shadowRoot?.appendChild(errorElement); - }); -} -``` - ### Performance considerations - **Code splitting**: Use the dependency manager with dynamic imports to split code and reduce initial bundle size. diff --git a/tools/reactive-controllers/pending-state.md b/tools/reactive-controllers/pending-state.md index 14c4b5cd2c9..2c150bea6dc 100644 --- a/tools/reactive-controllers/pending-state.md +++ b/tools/reactive-controllers/pending-state.md @@ -1,4 +1,4 @@ -## Description +## Overview The `PendingStateController` is a [reactive controller](https://lit.dev/docs/composition/controllers/) that helps manage the pending state of a reactive element. It provides a standardized way to indicate when an element is in a pending state (such as during an asynchronous operation) by rendering a progress indicator and managing ARIA labels for accessibility. @@ -11,79 +11,11 @@ The `PendingStateController` is a [reactive controller](https://lit.dev/docs/com ### Current limitations -**Note**: This controller is currently used primarily by the Button component, where the host element is the interactive element that needs pending state. This pattern does not work optimally for components where the interactive element requiring pending state is in the shadow DOM (e.g., Combobox and Picker). +**Note**: This controller is currently used primarily by the `` component, where the host element is the interactive element that needs pending state. This pattern does not work optimally for components where the interactive element requiring pending state is in the shadow DOM (e.g., Combobox and Picker). -> **Deprecation consideration**: See issue [SWC-1119, SWC-1255, SWC-459] - This controller may be deprecated in future versions as it's not widely adopted beyond Button. Consider alternative patterns for new implementations. +**Deprecation consideration**: This controller may be deprecated in future versions as it's not widely adopted beyond ``. -## API - -### Constructor - -```typescript -new PendingStateController(host: T) -``` - -**Parameters:** - -- `host` (T extends HostWithPendingState): The host element that uses this controller. Must implement the `HostWithPendingState` interface. - -### Host element requirements - -Your host element must implement the `HostWithPendingState` interface: - -```typescript -interface HostWithPendingState extends LitElement { - pendingLabel?: string; // Label to announce during pending state - pending: boolean; // Whether the element is pending - disabled: boolean; // Whether the element is disabled - pendingStateController: PendingStateController; -} -``` - -### Properties - -#### `host` - -- **Type**: `T extends HostWithPendingState` -- **Description**: The host element that this controller is attached to. -- **Settable**: No (set in constructor) - -#### `cachedAriaLabel` - -- **Type**: `string | null` -- **Description**: Cached value of the original `aria-label` attribute, used to restore it when exiting pending state. -- **Settable**: Yes (public property, managed by the controller) - -### Methods - -#### `renderPendingState(): TemplateResult` - -Renders the pending state UI (progress circle). - -**Returns:** A Lit `TemplateResult` containing either an `` (when pending) or an empty template. - -**Example:** - -```typescript -render() { - return html` - - `; -} -``` - -#### `hostConnected(): void` - -Called when the host element is connected to the DOM. Caches the initial `aria-label` and updates it based on pending state. - -#### `hostUpdated(): void` - -Called after the host element has updated. Updates the `aria-label` based on the current pending state. - -## Usage +### Usage [![See it on NPM!](https://img.shields.io/npm/v/@spectrum-web-components/reactive-controllers?style=for-the-badge)](https://www.npmjs.com/package/@spectrum-web-components/reactive-controllers) [![How big is this package in your project?](https://img.shields.io/bundlephobia/minzip/@spectrum-web-components/reactive-controllers?style=for-the-badge)](https://bundlephobia.com/result?p=@spectrum-web-components/reactive-controllers) @@ -98,9 +30,9 @@ Import the `PendingStateController` via: import { PendingStateController, HostWithPendingState } from '@spectrum-web-components/reactive-controllers/src/PendingState.js'; ``` -## Examples +### Examples -### Basic usage +#### Basic usage ```typescript import { html, LitElement, TemplateResult } from 'lit'; @@ -149,14 +81,6 @@ class AsyncButton extends LitElement implements HostWithPendingState { customElements.define('async-button', AsyncButton); ``` -Usage: - -```html -Save -``` - -### Async operation handling - ```typescript import { html, LitElement, css } from 'lit'; import { property } from 'lit/decorators.js'; @@ -253,115 +177,7 @@ class SaveButton extends LitElement implements HostWithPendingState { customElements.define('save-button', SaveButton); ``` -### Form submission with pending state - -```typescript -import { html, LitElement, css } from 'lit'; -import { property } from 'lit/decorators.js'; -import { - PendingStateController, - HostWithPendingState, -} from '@spectrum-web-components/reactive-controllers/src/PendingState.js'; -import { when } from 'lit/directives/when.js'; -import '@spectrum-web-components/textfield/sp-textfield.js'; - -class SubmitButton extends LitElement implements HostWithPendingState { - @property({ type: Boolean, reflect: true }) - public pending = false; - - @property({ type: Boolean, reflect: true }) - public disabled = false; - - @property({ type: String, attribute: 'pending-label' }) - public pendingLabel = 'Submitting'; - - public pendingStateController: PendingStateController; - - static styles = css` - :host { - display: inline-block; - } - - button { - padding: 10px 20px; - min-width: 120px; - position: relative; - } - `; - - constructor() { - super(); - this.pendingStateController = new PendingStateController(this); - } - - async handleSubmit(event: Event) { - event.preventDefault(); - - if (this.pending) return; - - this.pending = true; - - try { - const form = this.closest('form'); - if (form) { - const formData = new FormData(form); - await this.submitForm(formData); - - // Announce success - this.announceToScreenReader('Form submitted successfully'); - } - } catch (error) { - // Announce error - this.announceToScreenReader('Form submission failed', 'assertive'); - } finally { - this.pending = false; - } - } - - announceToScreenReader( - message: string, - priority: 'polite' | 'assertive' = 'polite' - ) { - const announcement = document.createElement('div'); - announcement.setAttribute('role', 'status'); - announcement.setAttribute('aria-live', priority); - announcement.textContent = message; - announcement.style.position = 'absolute'; - announcement.style.left = '-10000px'; - announcement.style.width = '1px'; - announcement.style.height = '1px'; - announcement.style.overflow = 'hidden'; - - document.body.appendChild(announcement); - setTimeout(() => announcement.remove(), 1000); - } - - async submitForm(formData: FormData): Promise { - // Simulate API call - return new Promise((resolve) => setTimeout(resolve, 2000)); - } - - render() { - return html` - - `; - } -} - -customElements.define('submit-button', SubmitButton); -``` - -### Multiple pending states +#### Multiple pending states ```typescript import { html, LitElement, css } from 'lit'; @@ -440,127 +256,48 @@ class ActionButton extends LitElement implements HostWithPendingState { customElements.define('action-button', ActionButton); ``` -## Accessibility +### Accessibility The `PendingStateController` includes several accessibility features, but additional considerations should be taken when implementing it: -### ARIA label management +#### ARIA label management - **Automatic label updates**: The controller automatically updates the `aria-label` when entering/exiting pending state. - **Label preservation**: The original `aria-label` is cached and restored when the pending state ends. - **Custom pending labels**: Use the `pendingLabel` property to provide context-specific messages (e.g., "Saving...", "Uploading..."). -### Screen reader announcements +#### Screen reader announcements The pending state changes are communicated to screen readers through: - **aria-label changes**: The `aria-label` attribute is updated to reflect the pending state. - **Progress indicator**: The `` has `role="presentation"` to avoid redundant announcements. -**Best practices:** - -```typescript -render() { - return html` - - `; -} -``` - -### Keyboard accessibility +#### Keyboard accessibility - **Disable during pending**: The element should be disabled (`disabled` attribute) or not interactive during pending states to prevent multiple submissions. - **Focus management**: Ensure focus remains on the element or moves appropriately after async operations complete. -### Visual indicators +#### Visual indicators - **Progress circle**: The rendered `` provides visual feedback. - **Text changes**: Consider changing button text during pending states (e.g., "Save" → "Saving..."). - **Disabled state**: Apply visual styling to indicate the element is not interactive. -### Error handling and recovery - -```typescript -async handleAction() { - this.pending = true; - - try { - await this.performAsync(); - // Success announcement - this.announceToScreenReader('Action completed successfully', 'polite'); - } catch (error) { - // Error announcement - this.announceToScreenReader( - 'Action failed. Please try again.', - 'assertive' - ); - } finally { - this.pending = false; - } -} - -announceToScreenReader(message: string, priority: 'polite' | 'assertive' = 'polite') { - const liveRegion = document.createElement('div'); - liveRegion.setAttribute('role', priority === 'assertive' ? 'alert' : 'status'); - liveRegion.setAttribute('aria-live', priority); - liveRegion.setAttribute('aria-atomic', 'true'); - liveRegion.textContent = message; - - // Visually hide the live region - Object.assign(liveRegion.style, { - position: 'absolute', - left: '-10000px', - width: '1px', - height: '1px', - overflow: 'hidden' - }); - - document.body.appendChild(liveRegion); - setTimeout(() => liveRegion.remove(), 1000); -} -``` - -### Known issues - -> **Note**: [SWC-1119, SWC-1255, SWC-459] - Accessibility warnings and the a11y DOM tree should be confirmed for pending state in Button, Combobox, and Picker components. - ### References - [WCAG 2.1 - Status Messages](https://www.w3.org/WAI/WCAG21/Understanding/status-messages.html) - [ARIA: aria-busy attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-busy) - [ARIA: aria-label attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-label) - [ARIA: status role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/status_role) -- [Adobe Accessibility Guidelines](https://www.adobe.com/accessibility/products/spectrum.html) - -## Events - -The `PendingStateController` doesn't dispatch custom events directly. Host elements should dispatch their own events to communicate state changes: - -```typescript -this.dispatchEvent( - new CustomEvent('pending-change', { - detail: { pending: this.pending }, - bubbles: true, - composed: true, - }) -); -``` -## Related components +### Related components The `PendingStateController` is used by: -- [``](../../packages/button/) - Primary use case for pending state +- [``](../../components/button/) - Primary use case for pending state -## Resources +### Resources -- [Lit Reactive Controllers](https://lit.dev/docs/composition/controllers/) - Learn more about reactive controllers -- [``](../../packages/progress-circle/) - The visual indicator component -- [Buttons with loading states](https://www.nngroup.com/articles/indicators-validating-user-input/) - UX best practices +- [``](../../components/progress-circle/) - The visual indicator component +- [Buttons with loading states](https://spectrum.adobe.com/page/button/#Pending) - UX for pending states From a0eb897491aa9a8d25c2ef566c2afe3bc0fc731a Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Fri, 17 Oct 2025 16:21:53 +0530 Subject: [PATCH 08/17] docs: updated pending roving tab index controller readme to align with doc standard --- .../reactive-controllers/roving-tab-index.md | 384 +++++------------- 1 file changed, 96 insertions(+), 288 deletions(-) diff --git a/tools/reactive-controllers/roving-tab-index.md b/tools/reactive-controllers/roving-tab-index.md index 22ef168203b..ee2f0d0563d 100644 --- a/tools/reactive-controllers/roving-tab-index.md +++ b/tools/reactive-controllers/roving-tab-index.md @@ -1,4 +1,4 @@ -## Description +## Overview The `RovingTabindexController` is a [reactive controller](https://lit.dev/docs/composition/controllers/) that implements the [roving tabindex pattern](https://www.w3.org/TR/wai-aria-practices-1.2/#kbd_roving_tabindex), a key accessibility technique for managing keyboard navigation in composite widgets. This pattern allows multiple focusable elements to be represented by a single `tabindex=0` element in the tab order, while making all elements accessible via arrow keys. This enables keyboard users to quickly tab through a page without stopping on every item in a large collection. @@ -10,124 +10,7 @@ The `RovingTabindexController` is a [reactive controller](https://lit.dev/docs/c - **Customizable behavior**: Configure which element receives initial focus and how elements respond to keyboard input - **Accessibility compliant**: Implements WCAG accessibility patterns for keyboard navigation -## API - -The `RovingTabindexController` extends `FocusGroupController` and inherits all of its functionality while adding tabindex management capabilities. - -### Constructor - -```typescript -new RovingTabindexController( - host: ReactiveElement, - config: RovingTabindexConfig -) -``` - -**Parameters:** - -- `host` (ReactiveElement): The host element that uses this controller -- `config` (RovingTabindexConfig): Configuration object with the following options: - -### Configuration options - -#### `elements` (required) - -- **Type**: `() => T[]` -- **Description**: Function that returns an array of elements to be managed by the controller - -#### `direction` - -- **Type**: `'horizontal' | 'vertical' | 'both' | 'grid' | (() => DirectionTypes)` -- **Default**: `'both'` -- **Description**: Defines which arrow keys are active: - - `'horizontal'`: Only `ArrowLeft` and `ArrowRight` - - `'vertical'`: Only `ArrowUp` and `ArrowDown` - - `'both'`: All four arrow keys - - `'grid'`: All four arrow keys with 2D grid navigation - -#### `elementEnterAction` - -- **Type**: `(el: T) => void` -- **Default**: No-op -- **Description**: Callback executed when an element receives focus, before the focus actually moves - -#### `focusInIndex` - -- **Type**: `(elements: T[]) => number` -- **Default**: `() => 0` -- **Description**: Determines which element receives `tabindex=0` when focus enters the container - -#### `isFocusableElement` - -- **Type**: `(el: T) => boolean` -- **Default**: `() => true` -- **Description**: Predicate to determine if an element can receive focus (useful for skipping disabled elements) - -#### `hostDelegatesFocus` - -- **Type**: `boolean` -- **Default**: `false` -- **Description**: Whether the host element uses `delegatesFocus` in its shadow root - -#### `listenerScope` - -- **Type**: `HTMLElement | (() => HTMLElement)` -- **Default**: Host element -- **Description**: Element to attach keyboard event listeners to - -### Properties - -#### `currentIndex` - -- **Type**: `number` -- **Description**: Index of the currently focused element -- **Settable**: Yes - -#### `direction` - -- **Type**: `'horizontal' | 'vertical' | 'both' | 'grid'` -- **Description**: Current navigation direction mode -- **Settable**: Via configuration - -#### `elements` - -- **Type**: `T[]` -- **Description**: Array of managed elements -- **Settable**: No (computed from `elements` config function) - -#### `focusInElement` - -- **Type**: `T` -- **Description**: The element that should receive focus when entering the container -- **Settable**: No (computed from `focusInIndex`) - -### Methods - -#### `focus(options?: FocusOptions): void` - -Focuses the current element in the managed collection. - -#### `manageTabindexes(): void` - -Updates `tabindex` attributes on all managed elements based on the current focus state. - -#### `clearElementCache(offset?: number): void` - -Clears the cached elements array and optionally sets an offset for virtualized lists. - -#### `manage(): void` - -Starts managing the elements (enables keyboard navigation). - -#### `unmanage(): void` - -Stops managing the elements (disables keyboard navigation and resets tabindexes). - -#### `reset(): void` - -Resets focus to the initial element defined by `focusInIndex`. - -## Usage +### Usage [![See it on NPM!](https://img.shields.io/npm/v/@spectrum-web-components/reactive-controllers?style=for-the-badge)](https://www.npmjs.com/package/@spectrum-web-components/reactive-controllers) [![How big is this package in your project?](https://img.shields.io/bundlephobia/minzip/@spectrum-web-components/reactive-controllers?style=for-the-badge)](https://bundlephobia.com/result?p=@spectrum-web-components/reactive-controllers) @@ -142,46 +25,44 @@ Import the `RovingTabindexController` via: import { RovingTabindexController } from '@spectrum-web-components/reactive-controllers/src/RovingTabindex.js'; ``` -## Examples +### Examples -### Basic usage +#### Basic usage -A `Container` element that manages a collection of `` elements: +A Container element that manages a collection of `` elements that are slotted into it from outside might look like the following: ```typescript import { html, LitElement } from 'lit'; -import { RovingTabindexController } from '@spectrum-web-components/reactive-controllers/src/RovingTabindex.js'; +import { RovingTabindexController } from '@spectrum-web-components/reactive-controllers/RovingTabindex.js'; import type { Button } from '@spectrum-web-components/button'; -import '@spectrum-web-components/button/sp-button.js'; -class ButtonGroup extends LitElement { - rovingTabindexController = new RovingTabindexController
``` -#### For listboxes: + +For listboxes + -```html +```typescript
Option 1
Option 2
``` -#### For radiogroups: +
+For Radiogroups + -```html +```typescript
``` -#### For menus: +
+For menus + -```html +```typescript
New
Open
``` -### Keyboard support +
+ -The `RovingTabindexController` provides the following keyboard interactions: +#### Keyboard support -| Key | Direction Mode | Action | -| ------------------- | ---------------------- | --------------------------------------------------- | -| **Tab** | All | Moves focus into or out of the composite widget | -| **→ (Right Arrow)** | horizontal, both, grid | Moves focus to the next element | -| **← (Left Arrow)** | horizontal, both, grid | Moves focus to the previous element | -| **↓ (Down Arrow)** | vertical, both, grid | Moves focus to the next element (or down in grid) | -| **↑ (Up Arrow)** | vertical, both, grid | Moves focus to the previous element (or up in grid) | -| **Home** | All | Moves focus to the first element | -| **End** | All | Moves focus to the last element | - -### Focus indicators - -Always provide clear visual focus indicators: - -```css -.managed-element:focus { - outline: 2px solid var(--spectrum-global-color-blue-400); - outline-offset: 2px; -} - -/* Or for high contrast */ -@media (prefers-contrast: high) { - .managed-element:focus { - outline: 3px solid currentColor; - } -} -``` +The `RovingTabindexController` provides the following keyboard interactions: -### Disabled elements + + + Key + Direction Mode + Action + + + + Tab + All + Moves focus into or out of the composite widget + + + → (Right Arrow) + horizontal, both, grid + Moves focus to the next element + + + ← (Left Arrow) + horizontal, both, grid + Moves focus to the previous element + + + ↓ (Down Arrow) + vertical, both, grid + Moves focus to the next element (or down in grid) + + + ↑ (Up Arrow) + vertical, both, grid + Moves focus to the previous element (or up in grid) + + + + + +#### Disabled elements Use the `isFocusableElement` option to skip disabled elements: @@ -615,7 +441,7 @@ Ensure disabled elements have appropriate ARIA attributes: Disabled ``` -### Screen reader announcements +#### Screen reader announcements When selection changes, announce it to screen readers: @@ -650,31 +476,13 @@ The roving tabindex pattern helps meet several WCAG success criteria: - [ARIA Authoring Practices Guide - Roving tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex) - [ARIA Authoring Practices Guide - Composite Widgets](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_general_within) - [WCAG 2.1 - Keyboard Accessible](https://www.w3.org/WAI/WCAG21/Understanding/keyboard-accessible) -- [Adobe Accessibility Guidelines](https://www.adobe.com/accessibility/products/spectrum.html) - -## Events -The `RovingTabindexController` doesn't dispatch custom events directly. Host elements should dispatch their own events to communicate state changes (see examples above). - -## Related components +### Related components The `RovingTabindexController` is used by these Spectrum Web Components: -- [``](../../packages/tabs/) - Tab navigation -- [``](../../packages/radio/) - Radio button groups -- [``](../../packages/action-group/) - Action button groups -- [``](../../packages/menu/) - Menu navigation -- [``](../../packages/table/) - Table keyboard navigation - -## Performance - -- **Efficient tabindex updates**: Only updates tabindex when necessary -- **Element caching**: Caches the element list to avoid repeated DOM queries -- **Request animation frame**: Uses `requestAnimationFrame` for smooth tabindex updates - -## Resources - -- [Lit Reactive Controllers](https://lit.dev/docs/composition/controllers/) - Learn more about reactive controllers -- [W3C ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/) - Comprehensive guide to ARIA patterns -- [Managing focus with roving tabindex](https://web.dev/control-focus-with-tabindex/) - Web.dev article -- [Building accessible composite widgets](https://www.24a11y.com/2019/building-composite-widgets/) +- [``](../../components/tabs/) - Tab navigation +- [``](../../components/radio/) - Radio button groups +- [``](../../components/action-group/) - Action button groups +- [``](../../components/menu/) - Menu navigation +- [``](../../components/table/) - Table keyboard navigation From b1e1e41dcb1b55ee69ea2d30c2d53806c4bcaf97 Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Mon, 20 Oct 2025 10:02:36 +0530 Subject: [PATCH 09/17] docs: updated the element resolution example with form field logic --- .../element-resolution.md | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/tools/reactive-controllers/element-resolution.md b/tools/reactive-controllers/element-resolution.md index 517afa9073c..3be4c991345 100644 --- a/tools/reactive-controllers/element-resolution.md +++ b/tools/reactive-controllers/element-resolution.md @@ -152,7 +152,7 @@ customElements.define('root-el', RootEl); #### Accessible label resolution -Use `ElementResolutionController` to resolve accessible labeling elements: +Use `ElementResolutionController` to resolve accessible labeling elements across shadow DOM boundaries: ```typescript import { html, LitElement } from 'lit'; @@ -165,13 +165,24 @@ class CustomInput extends LitElement { firstUpdated() { // Connect input to label for accessibility - if (this.labelElement.element) { - const labelId = this.labelElement.element.id || this.generateId(); - this.labelElement.element.id = labelId; + // This handles cross-root ARIA relationships + const target = this.labelElement.element; + const input = this.shadowRoot?.querySelector('input'); + + if (input && target) { + const targetParent = target.getRootNode() as HTMLElement; - const input = this.shadowRoot?.querySelector('input'); - if (input) { + if (targetParent === (this.getRootNode() as HTMLElement)) { + // Same root: use aria-labelledby with ID reference + const labelId = target.id || this.generateId(); + target.id = labelId; input.setAttribute('aria-labelledby', labelId); + } else { + // Different root: use aria-label with text content + input.setAttribute( + 'aria-label', + target.textContent?.trim() || '' + ); } } } @@ -182,7 +193,7 @@ class CustomInput extends LitElement { render() { return html` - + `; } } From f31137d55124becfcea991695c1751dc4321ddac Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Mon, 20 Oct 2025 10:05:21 +0530 Subject: [PATCH 10/17] docs: updated element resolution modal overlay example to show the usage pattern --- .../element-resolution.md | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tools/reactive-controllers/element-resolution.md b/tools/reactive-controllers/element-resolution.md index 3be4c991345..25e269d8e1f 100644 --- a/tools/reactive-controllers/element-resolution.md +++ b/tools/reactive-controllers/element-resolution.md @@ -247,7 +247,7 @@ customElements.define('dynamic-resolver', DynamicResolver); #### Modal and overlay management -Use element resolution to manage focus trap elements in modals: +Use element resolution to manage focus trap elements in modals. The controller can find elements across shadow DOM boundaries, making it useful for overlays where content might be slotted or projected: ```typescript import { html, LitElement } from 'lit'; @@ -296,7 +296,9 @@ class ModalManager extends LitElement { return html`

Modal Dialog

-

Content goes here

+ + +
`; } @@ -305,6 +307,22 @@ class ModalManager extends LitElement { customElements.define('modal-manager', ModalManager); ``` +**Usage:** + +```html + + + + + + +

Modal content...

+ +
+``` + +The `ElementResolutionController` automatically finds the marked elements whether they're in the shadow DOM or slotted from the light DOM, which is essential for managing focus traps in reusable overlay components. + #### Form validation integration Resolve and connect to error message elements: From 63ab40f80b9bffca754ead10a6736269350ccf43 Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Mon, 20 Oct 2025 16:51:55 +0530 Subject: [PATCH 11/17] docs: added new language resolution controller readme --- tools/reactive-controllers/README.md | 4 +- .../language-resolution.md | 208 ++++++++++++++++++ 2 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 tools/reactive-controllers/language-resolution.md diff --git a/tools/reactive-controllers/README.md b/tools/reactive-controllers/README.md index 93adc4c76c8..a89e36d8fce 100644 --- a/tools/reactive-controllers/README.md +++ b/tools/reactive-controllers/README.md @@ -149,7 +149,7 @@ Base controller for managing keyboard focus within groups of elements. Extended - Focus entry points - Element enter actions -**Note:** This controller is typically not used directly. Use `RovingTabindexController` instead for most use cases. +**Note:** This controller is typically not used directly. Use [RovingTabindexController](../roving-tab-index) instead for most use cases. --- @@ -171,6 +171,8 @@ Resolves and tracks the language/locale context of the host element, responding - Supports Shadow DOM - Bubbles up DOM tree +[Learn more →](../language-resolution) + --- #### MatchMediaController diff --git a/tools/reactive-controllers/language-resolution.md b/tools/reactive-controllers/language-resolution.md new file mode 100644 index 00000000000..6e8404e4231 --- /dev/null +++ b/tools/reactive-controllers/language-resolution.md @@ -0,0 +1,208 @@ +## Overview + +The `LanguageResolutionController` is a Lit reactive controller that automatically resolves and tracks the language/locale context of a web component. It detects language changes up the DOM tree, including across shadow DOM boundaries, making it essential for internationalized applications. + +### Usage + +[![See it on NPM!](https://img.shields.io/npm/v/@spectrum-web-components/reactive-controllers?style=for-the-badge)](https://www.npmjs.com/package/@spectrum-web-components/reactive-controllers) +[![How big is this package in your project?](https://img.shields.io/bundlephobia/minzip/@spectrum-web-components/reactive-controllers?style=for-the-badge)](https://bundlephobia.com/result?p=@spectrum-web-components/reactive-controllers) + +``` +yarn add @spectrum-web-components/reactive-controllers +``` + +Import the `LanguageResolutionController` via: + +``` +import { LanguageResolutionController } from '@spectrum-web-components/reactive-controllers/src/LanguageResolution.js'; +``` + +### Key features + +- **Automatic language detection**: Resolves `lang` attribute from DOM tree +- **Locale change tracking**: Triggers updates when language context changes +- **Shadow DOM support**: Works across shadow boundaries via event bubbling +- **Fallback handling**: Uses `navigator.language` or defaults to `en-US` +- **Validation**: Ensures locale is supported by `Intl` APIs + +### When to use + +Use `LanguageResolutionController` when your component needs to: + +- Format numbers based on locale +- Format dates and times according to locale conventions +- Display localized content or messages +- Determine text direction (RTL/LTR) +- Apply locale-specific formatting rules + +### Examples + +#### Automatic language detection + +The controller automatically detects the language from the DOM tree without requiring manual configuration: + +```typescript +import { LitElement, html } from 'lit'; +import { LanguageResolutionController } from '@spectrum-web-components/reactive-controllers/src/LanguageResolution.js'; + +class LocalizedGreeting extends LitElement { + private languageResolver = new LanguageResolutionController(this); + + render() { + const greetings = { + en: 'Hello', + es: 'Hola', + fr: 'Bonjour', + de: 'Guten Tag', + ja: 'こんにちは', + }; + + // Get base language code (e.g., 'en' from 'en-US') + const lang = this.languageResolver.language.split('-')[0]; + const greeting = greetings[lang] || greetings['en']; + + return html` +

+ ${greeting}, World! + (${this.languageResolver.language}) +

+ `; + } +} + +customElements.define('localized-greeting', LocalizedGreeting); +``` + +#### Locale change tracking + +The controller automatically re-renders components when the language context changes: + +```typescript +import { LitElement, html } from 'lit'; +import { + LanguageResolutionController, + languageResolverUpdatedSymbol, +} from '@spectrum-web-components/reactive-controllers/src/LanguageResolution.js'; + +class LanguageTracker extends LitElement { + private languageResolver = new LanguageResolutionController(this); + private updateCount = 0; + + protected updated(changedProperties: Map): void { + super.updated(changedProperties); + + // Detect when language has changed + if (changedProperties.has(languageResolverUpdatedSymbol)) { + this.updateCount++; + console.log('Language changed to:', this.languageResolver.language); + } + } + + render() { + return html` +
+

+ Current language: + ${this.languageResolver.language} +

+

Change count: ${this.updateCount}

+
+ `; + } +} + +customElements.define('language-tracker', LanguageTracker); +``` + +#### Supports shadow DOM + +The controller works across shadow DOM boundaries using event bubbling: + +```typescript +import { LitElement, html } from 'lit'; +import { LanguageResolutionController } from '@spectrum-web-components/reactive-controllers/src/LanguageResolution.js'; + +// Component with shadow DOM +class LocalizedCard extends LitElement { + private languageResolver = new LanguageResolutionController(this); + + render() { + const lang = this.languageResolver.language; + + return html` +
+

Language: ${lang}

+ +
+ `; + } +} + +customElements.define('localized-card', LocalizedCard); +``` + +#### Bubbles up DOM tree + +The controller searches up through parent elements to find language context: + +```html + +
+
+
+ +
+
+
+ + +
+
+ + +
+
+ + + + + + + + + + +``` + +### How it works + +The controller follows this resolution process: + +1. **On connection**: Dispatches `sp-language-context` event that bubbles up the DOM +2. **Theme provider response**: `` or other context providers respond with their `lang` value +3. **Callback registration**: Provider calls the callback with language and unsubscribe function +4. **Validation**: Language is validated using `Intl.DateTimeFormat.supportedLocalesOf()` +5. **Fallback**: If validation fails, falls back to `document.documentElement.lang`, `navigator.language`, or `en-US` +6. **Updates**: When language changes, triggers a component update via `requestUpdate()` + +### Related components + +Components in Spectrum Web Components that use `LanguageResolutionController`: + +- [``](../../components/number-field/) - Number input with locale formatting +- [``](../../components/slider/) - Slider with localized values +- [``](../../components/meter/) - Meter with formatted values +- [``](../../components/progress-bar/) - Progress with formatted percentage +- [``](../../components/color-wheel/) - Color picker with locale support +- [``](../../components/color-slider/) - Color slider with formatted values +- [``](../../components/color-area/) - Color area with locale support + +### Resources + +- [Intl.NumberFormat - MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat) +- [Intl.DateTimeFormat - MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) +- [Language tags (BCP 47)](https://www.w3.org/International/articles/language-tags/) +- [WCAG - Language of Page](https://www.w3.org/WAI/WCAG21/Understanding/language-of-page.html) From b3b345925f200188077d700fce2a32f0134119dd Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Mon, 20 Oct 2025 17:02:13 +0530 Subject: [PATCH 12/17] docs: removed commented code in main readme --- tools/reactive-controllers/README.md | 62 ---------------------------- 1 file changed, 62 deletions(-) diff --git a/tools/reactive-controllers/README.md b/tools/reactive-controllers/README.md index a89e36d8fce..e5c1fa842ab 100644 --- a/tools/reactive-controllers/README.md +++ b/tools/reactive-controllers/README.md @@ -264,65 +264,3 @@ Resolves and tracks system-level context like color scheme and scale preferences - Color scheme tracking - Scale preference tracking - Works with Spectrum theme providers - - From 22a5a1c97a02f1dc48164341b80b1ba2f3d91714 Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Mon, 20 Oct 2025 17:03:44 +0530 Subject: [PATCH 13/17] docs: update docs for the disabled items in robing tab index controller --- .../reactive-controllers/roving-tab-index.md | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/tools/reactive-controllers/roving-tab-index.md b/tools/reactive-controllers/roving-tab-index.md index ee2f0d0563d..d9f5dcf5a7f 100644 --- a/tools/reactive-controllers/roving-tab-index.md +++ b/tools/reactive-controllers/roving-tab-index.md @@ -396,27 +396,27 @@ The `RovingTabindexController` provides the following keyboard interactions: - Tab + Tab All Moves focus into or out of the composite widget - → (Right Arrow) + (Right Arrow) horizontal, both, grid Moves focus to the next element - ← (Left Arrow) + (Left Arrow) horizontal, both, grid Moves focus to the previous element - ↓ (Down Arrow) + (Down Arrow) vertical, both, grid Moves focus to the next element (or down in grid) - ↑ (Up Arrow) + (Up Arrow) vertical, both, grid Moves focus to the previous element (or up in grid) @@ -426,19 +426,37 @@ The `RovingTabindexController` provides the following keyboard interactions: #### Disabled elements -Use the `isFocusableElement` option to skip disabled elements: +**Important:** According to [WAI-ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#focusabilityofdisabledcontrols), disabled items should remain focusable in these composite widgets: + +- Options in a Listbox +- Menu items in a Menu or menu bar +- Tab elements in a set of Tabs +- Tree items in a Tree View + +For these widgets, use `aria-disabled="true"` instead of the `disabled` attribute so items can still receive focus and be read in screen readers' forms/interactive mode: + +```typescript +// For menu items, tabs, listbox options - DO NOT skip disabled items +rovingTabindexController = new RovingTabindexController(this, { + elements: () => [...this.querySelectorAll('sp-menu-item')], + // Disabled items remain focusable for accessibility + isFocusableElement: (item) => true, +}); +``` + +For other controls like buttons or form inputs where disabled items should be skipped: ```typescript +// For buttons/forms - skip disabled items rovingTabindexController = new RovingTabindexController @@ -352,7 +352,7 @@ When using the `RovingTabindexController`, ensure you apply appropriate ARIA rol For listboxes -```typescript +```html-no-demo
Option 1
Option 2
@@ -363,7 +363,7 @@ When using the `RovingTabindexController`, ensure you apply appropriate ARIA rol For Radiogroups -```typescript +```html-no-demo
@@ -374,7 +374,7 @@ When using the `RovingTabindexController`, ensure you apply appropriate ARIA rol For menus -```typescript +```html-no-demo
New
Open
@@ -420,7 +420,6 @@ The `RovingTabindexController` provides the following keyboard interactions: vertical, both, grid Moves focus to the previous element (or up in grid) - @@ -454,7 +453,7 @@ rovingTabindexController = new RovingTabindexController +
+ `; + } + + renderSpectrum2Content() { + return html` +
+

Modern Spectrum 2 design

+ +
+ `; + } + + renderSpectrumContent() { + return html` +
+

Classic Spectrum design

+ +
+ `; + } + + render() { + switch (this.systemResolver.system) { + case 'express': + return this.renderExpressContent(); + case 'spectrum-two': + return this.renderSpectrum2Content(); + case 'spectrum': + default: + return this.renderSpectrumContent(); + } + } +} + +customElements.define('system-specific-content', SystemSpecificContent); +``` + +#### Loading system-specific assets + +Load different icon sets or images based on the system variant for consistent visual design. + +```typescript +import { LitElement, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { SystemResolutionController } from '@spectrum-web-components/reactive-controllers/src/SystemContextResolution.js'; +import type { SystemVariant } from '@spectrum-web-components/theme'; + +class IconLoader extends LitElement { + private systemResolver = new SystemResolutionController(this); + + @property({ type: String }) + iconName = ''; + + private getIconPath(system: SystemVariant): string { + return `/assets/icons/${system}/${this.iconName}.svg`; + } + + render() { + const iconSrc = this.getIconPath(this.systemResolver.system); + + return html` + ${this.iconName} + `; + } +} + +customElements.define('icon-loader', IconLoader); +``` + +Usage: + +```html-no-demo + + + +``` + +#### Nested theme contexts + +Components automatically resolve to their nearest parent ``, allowing different system variants in nested contexts. + +```html-no-demo + + + + + + + + + + + + + + +``` + +### Accessibility + +When using `SystemResolutionController` to adapt UI based on design systems, consider these accessibility best practices: + +#### Screen reader announcements + +When the system context changes dynamically, consider announcing it: + +```typescript +private announceSystemChange(): void { + const announcement = document.createElement('div'); + announcement.setAttribute('role', 'status'); + announcement.setAttribute('aria-live', 'polite'); + announcement.textContent = `Design system updated to ${this.systemResolver.system}`; + + // Add to DOM temporarily + document.body.appendChild(announcement); + setTimeout(() => announcement.remove(), 1000); +} +``` + +#### ARIA attributes + +- Maintain proper ARIA attributes regardless of system variant. +- Ensure labels and descriptions remain accurate after system changes. +- Don't rely on visual styling alone to convey information. + +#### Keyboard navigation + +- Keyboard navigation patterns should remain consistent across system variants. +- Focus indicators must be visible in all system themes. +- Tab order should not change based on system variant. + +#### Color contrast + +Different system variants may have different color palettes: + +- Verify that all system variants meet [WCAG 2.1 Level AA contrast requirements](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html). +- Test with system-specific color tokens. +- Consider high contrast modes for each system variant. + +### How it works + +The controller uses an event-based protocol to communicate with `` elements: + +1. When the host connects, it dispatches an `sp-system-context` event that bubbles up the DOM +2. The nearest `` element catches this event and calls the provided callback with the current system variant +3. The theme also provides an `unsubscribe` function for cleanup +4. When the system attribute changes on the theme, it notifies all subscribed components +5. When the host disconnects, the controller automatically unsubscribes + +### Related components + +The `SystemResolutionController` works with: + +- [``](../../tools/theme/) - Provides the system context +- All Spectrum Web Components that need to adapt to different design systems + +### Best practices + +#### Do: + +- Use the controller to adapt visual presentation to the current system +- Maintain consistent functionality across all system variants +- Test your component with all three system variants +- Clean up properly (the controller handles this automatically) + +#### Don't: + +- Don't completely change component behavior based on system variant +- Don't remove accessibility features in certain systems +- Don't assume a default system - always check `systemResolver.system` +- Don't query the system context manually - use the controller + +### Resources + +- [Lit Reactive Controllers](https://lit.dev/docs/composition/controllers/) - Learn more about reactive controllers +- [Spectrum Design System](https://spectrum.adobe.com/) - Official Spectrum documentation +- [Spectrum Theme Component](../../tools/theme/) - Theme provider documentation +- [Spectrum 2 Design System](https://s2.spectrum.adobe.com/) - What's new in Spectrum 2 From 6b98e9e00583543aeaee4df3645ab8b436c0983a Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Fri, 24 Oct 2025 21:33:45 +0530 Subject: [PATCH 17/17] chore: main readme formatting fix --- tools/reactive-controllers/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/reactive-controllers/README.md b/tools/reactive-controllers/README.md index a8acb25f94b..ff7924942b0 100644 --- a/tools/reactive-controllers/README.md +++ b/tools/reactive-controllers/README.md @@ -247,7 +247,7 @@ Implements the W3C ARIA roving tabindex pattern for keyboard navigation in compo --- -### [SystemContextResolutionController](./system-context-resolution.md) +#### SystemContextResolutionController Resolves and tracks system-level context like color scheme and scale preferences from Spectrum theme providers.