diff --git a/1st-gen/tools/reactive-controllers/README.md b/1st-gen/tools/reactive-controllers/README.md index 98d8609685..ff7924942b 100644 --- a/1st-gen/tools/reactive-controllers/README.md +++ b/1st-gen/tools/reactive-controllers/README.md @@ -1,14 +1,270 @@ -## Description +## Overview -[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](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 +### Usage -- [ColorController](../color-controller) -- [ElementResolutionController](../element-resolution) -- FocusGroupController -- LanguageResolutionController -- [MatchMediaController](../match-media) -- [RovingTabindexController](../roving-tab-index) -- [PendingStateController](../pending-state) -- SystemContextResolutionController +```bash +yarn add @spectrum-web-components/reactive-controllers +``` + +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 + } +} +``` + +### Available controllers + +#### ColorController + +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) + +--- + +#### DependencyManagerController + +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) + +--- + +#### ElementResolutionController + +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) + +--- + +#### 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](../roving-tab-index) 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 + +[Learn more →](../language-resolution) + +--- + +#### MatchMediaController + +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) + +--- + +#### PendingStateController + +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) + +--- + +#### RovingTabindexController + +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) + +--- + +#### SystemContextResolutionController + +Resolves and tracks system-level context like color scheme and scale preferences from Spectrum theme providers. + +**Use cases:** + +- Theme integration +- Design system variant detection (Spectrum Classic, Express, Spectrum 2) +- System-specific asset loading +- Adaptive UI rendering + +**Key features:** + +- Automatic theme context resolution +- Reactive system variant updates +- Event-based communication with `` +- Automatic cleanup on disconnect + +**Note:** Private Beta API - subject to changes. + +[Learn more →](../system-context-resolution) diff --git a/1st-gen/tools/reactive-controllers/color-controller.md b/1st-gen/tools/reactive-controllers/color-controller.md index 093acb508e..b13ec9e031 100644 --- a/1st-gen/tools/reactive-controllers/color-controller.md +++ b/1st-gen/tools/reactive-controllers/color-controller.md @@ -1,15 +1,14 @@ -## Description +## Overview -### 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 preserve the format of the original color input when returning values ### Properties @@ -22,45 +21,55 @@ The `ColorController` class is a comprehensive utility for managing and validati - **`validateColorString(color: string): ColorValidationResult`**: Validates a color string and returns the validation result, including the color space, coordinates, alpha value, and validity. + **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 + - **`getColor(format: string | ColorSpace): ColorObject`**: Converts the current color to the specified format. Throws an error if the format is not valid. + **Returns:** `ColorObject` - The color object in the specified format + - **`getHslString(): string`**: Returns the current color in HSL string format. + **Returns:** string - HSL representation of the current color + - **`savePreviousColor(): void`**: Saves the current color as the previous color. - **`restorePreviousColor(): void`**: Restores the previous color. -## 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) -``` +```bash yarn add @spectrum-web-components/reactive-controllers ``` Import the `ColorController` via: -``` -import {ColorController,} from '@spectrum-web-components/reactive-controllers/src/ColorController.js'; +```typescript +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 +78,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 +359,50 @@ 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 + +### Related components + +The `ColorController` is used by these Spectrum Web Components: + +- [``](../../components/color-area/) - Two-dimensional color picker +- [``](../../components/color-field/) - Text input for color values +- [``](../../components/color-slider/) - Slider for selecting color channel values +- [``](../../components/color-wheel/) - Circular hue selector +- [``](../../components/swatch/) - Color preview display diff --git a/1st-gen/tools/reactive-controllers/dependency-manager.md b/1st-gen/tools/reactive-controllers/dependency-manager.md index 2151a0fd9c..0a1f3046c0 100644 --- a/1st-gen/tools/reactive-controllers/dependency-manager.md +++ b/1st-gen/tools/reactive-controllers/dependency-manager.md @@ -1,58 +1,69 @@ -## Description +## Overview -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 + +- **Lazy loading support**: Delays functionality until required custom elements are registered +- **Multiple dependency tracking**: Manages 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 ### 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) -``` +```bash yarn add @spectrum-web-components/reactive-controllers ``` Import the `DependencyManagerController` via: -``` -import { DependencyManagerController } from '@spectrum-web-components/reactive-controllers/DependencyManager.js'; +```typescript +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 +74,234 @@ 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); +``` + +#### 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. + +### 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. diff --git a/1st-gen/tools/reactive-controllers/element-resolution.md b/1st-gen/tools/reactive-controllers/element-resolution.md index 133eb358c4..4c6b9bf4dd 100644 --- a/1st-gen/tools/reactive-controllers/element-resolution.md +++ b/1st-gen/tools/reactive-controllers/element-resolution.md @@ -1,43 +1,65 @@ -## Description +## Overview -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. + +### 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 ### 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) -``` +```bash 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'; +```typescript +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 @@ -54,29 +76,284 @@ 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 across shadow DOM boundaries: + +```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 + // 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; + + 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() || '' + ); + } + } + } + + generateId() { + return `label-${Math.random().toString(36).substr(2, 9)}`; + } + + render() { + return html` + + `; + } +} + +customElements.define('custom-input', CustomInput); +``` + +Usage: + +```html-no-demo +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. 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'; +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

+ + + +
+ `; + } +} + +customElements.define('modal-manager', ModalManager); +``` + +Usage: + +```html-no-demo + + + + + + +

Modal content...

+ +
+``` + +### 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. + +### 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. + +### 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) diff --git a/1st-gen/tools/reactive-controllers/language-resolution.md b/1st-gen/tools/reactive-controllers/language-resolution.md new file mode 100644 index 0000000000..41679e7f7e --- /dev/null +++ b/1st-gen/tools/reactive-controllers/language-resolution.md @@ -0,0 +1,218 @@ +## 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) + +```bash +yarn add @spectrum-web-components/reactive-controllers +``` + +Import the `LanguageResolutionController` via: + +```typescript +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); +``` + +Usage: + +```html-no-demo + +
+ + +
+``` + +#### 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-no-demo + +
+
+
+ +
+
+
+ + +
+
+ + +
+
+ + + + + + + + + + +``` + +### 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) diff --git a/1st-gen/tools/reactive-controllers/match-media.md b/1st-gen/tools/reactive-controllers/match-media.md index 3225aed2b3..b8f1ffe0b7 100644 --- a/1st-gen/tools/reactive-controllers/match-media.md +++ b/1st-gen/tools/reactive-controllers/match-media.md @@ -1,33 +1,40 @@ -## Description +## Overview -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 + +- **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 ### 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) -``` +```bash yarn add @spectrum-web-components/reactive-controllers ``` Import the `MatchMediaController` via: -``` -import { MatchMediaController } from '@spectrum-web-components/reactive-controllers/MatchMedia.js'; +```typescript +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 +43,216 @@ 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); ``` + +### 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) + +### Resources + +- [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/1st-gen/tools/reactive-controllers/pending-state.md b/1st-gen/tools/reactive-controllers/pending-state.md index 547219167d..058f7d2643 100644 --- a/1st-gen/tools/reactive-controllers/pending-state.md +++ b/1st-gen/tools/reactive-controllers/pending-state.md @@ -1,67 +1,322 @@ -## Description +## Overview -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 -## Usage +- **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 `` 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**: This controller may be deprecated in future versions as it's not widely adopted beyond ``. + +### 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) -``` +```bash yarn add @spectrum-web-components/reactive-controllers ``` Import the `PendingStateController` via: +```typescript +import { + PendingStateController, + HostWithPendingState, +} from '@spectrum-web-components/reactive-controllers/src/PendingState.js'; +``` + +### Examples + +#### Basic usage + +A simple button component that displays a loading state with an accessible progress indicator. + +```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'; + +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 button is disabled. */ + @property({ type: Boolean, reflect: true }) + public disabled = false; + + /** Label to announce when the button is pending. */ + @property({ type: String, attribute: 'pending-label' }) + public pendingLabel = 'Loading'; + + public pendingStateController: PendingStateController; + + constructor() { + super(); + this.pendingStateController = new PendingStateController(this); + } + + render(): TemplateResult { + return html` + + `; + } +} + +customElements.define('async-button', AsyncButton); ``` -import { PendingStateController } from '@spectrum-web-components/reactive-controllers/src/PendingState.js'; + +Usage: + +```html-no-demo + + Save + ``` -## Example +#### Async operation handling -```js -import { LitElement } from 'lit'; -import { PendingStateController, HostWithPendingState } from '@spectrum-web-components/reactive-controllers/src/PendingState.js'; +Handle asynchronous operations with proper pending state management and success/error event dispatching. -class Host extends LitElement { +```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'; - /** Whether the items are currently loading. */ +class SaveButton extends LitElement implements HostWithPendingState { @property({ type: Boolean, reflect: true }) public pending = false; - /** Whether the host is disabled. */ - @property({type: boolean}) + @property({ type: Boolean, reflect: true }) public disabled = false; - /** Defines a string value that labels the while it is in pending state. */ @property({ type: String, attribute: 'pending-label' }) - public pendingLabel = 'Pending'; + public pendingLabel = 'Saving'; + public pendingStateController: PendingStateController; - /** - * Initializes the `PendingStateController` for the component. - * The `PendingStateController` manages the pending state of the Component. - */ + 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); } - render(){ + + 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` - - ${when( - this.pending, - () => { - return this.pendingStateController.renderPendingState(); - } - )} - ` + + `; + } +} + +customElements.define('save-button', SaveButton); +``` + +#### Multiple pending states + +Dynamically update the pending label based on different actions being performed. + +```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. + +#### 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. + +### 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) + +### Related components + +The `PendingStateController` is used by: + +- [``](../../components/button/) - Primary use case for pending state + +### Resources + +- [``](../../components/progress-circle/) - The visual indicator component +- [Buttons with loading states](https://spectrum.adobe.com/page/button/#Pending) - UX for pending states diff --git a/1st-gen/tools/reactive-controllers/roving-tab-index.md b/1st-gen/tools/reactive-controllers/roving-tab-index.md index 3a06b9dd11..8c4d1de724 100644 --- a/1st-gen/tools/reactive-controllers/roving-tab-index.md +++ b/1st-gen/tools/reactive-controllers/roving-tab-index.md @@ -1,39 +1,45 @@ -## Description +## Overview -[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. + +### 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 ### 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) -``` +```bash yarn add @spectrum-web-components/reactive-controllers ``` Import the `RovingTabindexController` via: -``` -import { RovingTabindexController } from '@spectrum-web-components/reactive-controllers/RovingTabindex.js'; +```typescript +import { RovingTabindexController } from '@spectrum-web-components/reactive-controllers/src/RovingTabindex.js'; ``` -## Example +### Examples -A `Container` element that manages a collection of `` elements that are slotted into it from outside might look like the following: +#### Basic usage -```js +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/RovingTabindex.js'; import type { Button } from '@spectrum-web-components/button'; class Container extends LitElement { - rovingTabindexController = - new RovingTabindexController() < - Button > - (this, - { - elements: () => [...this.querySelectorAll('sp-button')], - }); + rovingTabindexController = new RovingTabindexController() + +
+``` + + +For listboxes + + +```html-no-demo +
+
Option 1
+
Option 2
+
+``` + +
+For Radiogroups + + +```html-no-demo +
+ + +
+``` + +
+For menus + + +```html-no-demo +
+
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) + + + + +#### 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 +
+ `; + } + + 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