From 0b5b3d041c1166e7ef254d6cd2e8f9f870b286d7 Mon Sep 17 00:00:00 2001 From: Casey Eickhoff Date: Wed, 1 Apr 2026 15:42:58 -0600 Subject: [PATCH 01/21] chore: carry over all 2nd-gen code used in 1st-gen to remove dependency --- 1st-gen/packages/alert-banner/package.json | 1 - .../alert-banner/src/AlertBanner.base.ts | 137 ++++++++++++++ .../packages/alert-banner/src/AlertBanner.ts | 6 +- .../alert-banner/src/AlertBanner.types.ts | 27 +++ 1st-gen/packages/asset/package.json | 3 +- 1st-gen/packages/asset/src/Asset.base.ts | 69 +++++++ 1st-gen/packages/asset/src/Asset.ts | 2 +- 1st-gen/packages/asset/src/Asset.types.ts | 18 ++ 1st-gen/packages/badge/package.json | 1 - 1st-gen/packages/badge/src/Badge.base.ts | 149 +++++++++++++++ 1st-gen/packages/badge/src/Badge.ts | 8 +- 1st-gen/packages/badge/src/Badge.types.ts | 114 +++++++++++ 1st-gen/packages/divider/package.json | 3 +- 1st-gen/packages/divider/src/Divider.base.ts | 74 ++++++++ 1st-gen/packages/divider/src/Divider.ts | 2 +- 1st-gen/packages/divider/src/Divider.types.ts | 27 +++ 1st-gen/packages/progress-circle/package.json | 2 +- .../src/ProgressCircle.base.ts | 178 ++++++++++++++++++ .../progress-circle/src/ProgressCircle.ts | 8 +- .../src/ProgressCircle.types.ts | 32 ++++ 1st-gen/packages/status-light/package.json | 3 +- .../status-light/src/StatusLight.base.ts | 95 ++++++++++ .../packages/status-light/src/StatusLight.ts | 8 +- .../status-light/src/StatusLight.types.ts | 88 +++++++++ 1st-gen/tools/base/package.json | 1 - 1st-gen/tools/base/src/Base.ts | 157 ++++++++++++++- 1st-gen/tools/base/src/define-element.ts | 20 +- 1st-gen/tools/base/src/sizedMixin.ts | 91 +++++++-- .../tools/reactive-controllers/package.json | 1 - .../src/LanguageResolution.ts | 145 +++++++++++++- 1st-gen/tools/shared/package.json | 1 - .../tools/shared/src/get-label-from-slot.ts | 24 ++- .../tools/shared/src/observe-slot-presence.ts | 96 +++++++++- 1st-gen/tools/shared/src/observe-slot-text.ts | 114 ++++++++++- 1st-gen/tools/theme/package.json | 1 - yarn.lock | 11 +- 36 files changed, 1643 insertions(+), 74 deletions(-) create mode 100644 1st-gen/packages/alert-banner/src/AlertBanner.base.ts create mode 100644 1st-gen/packages/alert-banner/src/AlertBanner.types.ts create mode 100644 1st-gen/packages/asset/src/Asset.base.ts create mode 100644 1st-gen/packages/asset/src/Asset.types.ts create mode 100644 1st-gen/packages/badge/src/Badge.base.ts create mode 100644 1st-gen/packages/badge/src/Badge.types.ts create mode 100644 1st-gen/packages/divider/src/Divider.base.ts create mode 100644 1st-gen/packages/divider/src/Divider.types.ts create mode 100644 1st-gen/packages/progress-circle/src/ProgressCircle.base.ts create mode 100644 1st-gen/packages/progress-circle/src/ProgressCircle.types.ts create mode 100644 1st-gen/packages/status-light/src/StatusLight.base.ts create mode 100644 1st-gen/packages/status-light/src/StatusLight.types.ts diff --git a/1st-gen/packages/alert-banner/package.json b/1st-gen/packages/alert-banner/package.json index 8b0a1802074..6764678488e 100644 --- a/1st-gen/packages/alert-banner/package.json +++ b/1st-gen/packages/alert-banner/package.json @@ -56,7 +56,6 @@ "dependencies": { "@spectrum-web-components/base": "1.11.2", "@spectrum-web-components/button": "1.11.2", - "@spectrum-web-components/core": "0.0.4", "@spectrum-web-components/icons-workflow": "1.11.2" }, "keywords": [ diff --git a/1st-gen/packages/alert-banner/src/AlertBanner.base.ts b/1st-gen/packages/alert-banner/src/AlertBanner.base.ts new file mode 100644 index 00000000000..c6b38b983f8 --- /dev/null +++ b/1st-gen/packages/alert-banner/src/AlertBanner.base.ts @@ -0,0 +1,137 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { + CSSResultArray, + PropertyValues, + SpectrumElement, + TemplateResult, +} from '@spectrum-web-components/base'; +import { property } from '@spectrum-web-components/base/src/decorators.js'; + +import { + ALERT_BANNER_VALID_VARIANTS, + type AlertBannerVariant, +} from './AlertBanner.types.js'; + +/** + * An alert banner shows pressing and high-signal messages, such as system alerts. + * It is meant to be noticed and prompt users to take action. + * + * @slot - The main content of the alert banner. + * @slot action - An optional action button for the alert banner. + * + * @fires close - Dispatched when the alert banner is dismissed. Cancelable. + */ +export abstract class AlertBannerBase extends SpectrumElement { + public static override get styles(): CSSResultArray { + return []; + } + + // ────────────────── + // SHARED API + // ────────────────── + + /** + * Controls the display of the alert banner. + */ + @property({ type: Boolean, reflect: true }) + public open = false; + + /** + * Whether to include an icon-only close button to dismiss the alert banner. + */ + @property({ type: Boolean, reflect: true }) + public dismissible = false; + + /** + * The variant applies specific styling when set to `negative` or `info`; + * `variant` attribute is removed when it's passed an invalid variant. + */ + @property({ type: String }) + public set variant(variant: AlertBannerVariant) { + if (variant === this.variant) { + return; + } + const oldValue = this.variant; + + if (this.isValidVariant(variant)) { + this.setAttribute('variant', variant); + this._variant = variant; + } else { + this.removeAttribute('variant'); + this._variant = ''; + + if (window.__swc?.DEBUG) { + window.__swc.warn( + this, + `<${this.localName}> element expects the "variant" attribute to be one of the following:`, + 'https://opensource.adobe.com/spectrum-web-components/components/alert-banner/#variants', + { + issues: [...ALERT_BANNER_VALID_VARIANTS], + } + ); + } + } + this.requestUpdate('variant', oldValue); + } + + public get variant(): AlertBannerVariant { + return this._variant; + } + + private _variant: AlertBannerVariant = ''; + + // ────────────────────── + // IMPLEMENTATION + // ────────────────────── + + protected isValidVariant(variant: string): boolean { + return (ALERT_BANNER_VALID_VARIANTS as readonly string[]).includes(variant); + } + + protected abstract renderIcon(variant: string): TemplateResult; + + protected shouldClose(): void { + const applyDefault = this.dispatchEvent( + new CustomEvent('close', { + composed: true, + bubbles: true, + cancelable: true, + }) + ); + if (applyDefault) { + this.close(); + } + } + + public close(): void { + this.open = false; + } + + protected handleKeydown(event: KeyboardEvent): void { + if (event.code === 'Escape' && this.dismissible) { + this.shouldClose(); + } + } + + protected override updated(changes: PropertyValues): void { + super.updated(changes); + + if (changes.has('open')) { + if (this.open) { + this.addEventListener('keydown', this.handleKeydown); + } else { + this.removeEventListener('keydown', this.handleKeydown); + } + } + } +} diff --git a/1st-gen/packages/alert-banner/src/AlertBanner.ts b/1st-gen/packages/alert-banner/src/AlertBanner.ts index c6e1b2acfba..3c659c56af1 100644 --- a/1st-gen/packages/alert-banner/src/AlertBanner.ts +++ b/1st-gen/packages/alert-banner/src/AlertBanner.ts @@ -14,16 +14,14 @@ import { html, TemplateResult, } from '@spectrum-web-components/base'; -import { - AlertBannerBase, - AlertBannerVariants, -} from '@spectrum-web-components/core/components/alert-banner'; import '@spectrum-web-components/button/sp-close-button.js'; import '@spectrum-web-components/icons-workflow/icons/sp-icon-alert.js'; import '@spectrum-web-components/icons-workflow/icons/sp-icon-info.js'; import styles from './alert-banner.css.js'; +import { AlertBannerBase } from './AlertBanner.base.js'; +import type { AlertBannerVariants } from './AlertBanner.types.js'; export type { AlertBannerVariants }; diff --git a/1st-gen/packages/alert-banner/src/AlertBanner.types.ts b/1st-gen/packages/alert-banner/src/AlertBanner.types.ts new file mode 100644 index 00000000000..43c94a553e9 --- /dev/null +++ b/1st-gen/packages/alert-banner/src/AlertBanner.types.ts @@ -0,0 +1,27 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export const ALERT_BANNER_VALID_VARIANTS = [ + 'neutral', + 'info', + 'negative', +] as const satisfies readonly string[]; + +export type AlertBannerVariant = + | (typeof ALERT_BANNER_VALID_VARIANTS)[number] + | ''; + +/** + * @deprecated Use `AlertBannerVariant` instead. + * Kept as `string` for backward compatibility with 1st-gen. + */ +export type AlertBannerVariants = string; diff --git a/1st-gen/packages/asset/package.json b/1st-gen/packages/asset/package.json index cfb6dff3f0d..c84a5c53131 100644 --- a/1st-gen/packages/asset/package.json +++ b/1st-gen/packages/asset/package.json @@ -58,8 +58,7 @@ ], "types": "./src/index.d.ts", "dependencies": { - "@spectrum-web-components/base": "1.11.2", - "@spectrum-web-components/core": "0.0.4" + "@spectrum-web-components/base": "1.11.2" }, "keywords": [ "design-system", diff --git a/1st-gen/packages/asset/src/Asset.base.ts b/1st-gen/packages/asset/src/Asset.base.ts new file mode 100644 index 00000000000..0a58dd3eef5 --- /dev/null +++ b/1st-gen/packages/asset/src/Asset.base.ts @@ -0,0 +1,69 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { PropertyValues, SpectrumElement } from '@spectrum-web-components/base'; +import { property } from '@spectrum-web-components/base/src/decorators.js'; + +import { ASSET_VARIANTS, type AssetVariant } from './Asset.types.js'; + +export abstract class AssetBase extends SpectrumElement { + // ───────────────────────── + // API TO OVERRIDE + // ───────────────────────── + + /** + * @internal + * + * A readonly array of all valid variants for the asset. + */ + static readonly VARIANTS: readonly AssetVariant[] = ASSET_VARIANTS; + + // ───────────────── + // SHARED API + // ───────────────── + + /** + * The variant of the asset. When not provided, slot content is rendered (e.g., an image). + */ + @property({ type: String, reflect: true }) + public variant: AssetVariant | undefined; + + /** + * Accessible label for the asset's file or folder variant. + */ + @property() + public label = ''; + + // ────────────────────── + // IMPLEMENTATION + // ────────────────────── + + protected override updated(changes: PropertyValues): void { + super.updated(changes); + if (window.__swc?.DEBUG) { + const constructor = this.constructor as typeof AssetBase; + if ( + typeof this.variant !== 'undefined' && + !constructor.VARIANTS.includes(this.variant) + ) { + window.__swc.warn( + this, + `<${this.localName}> element expects the "variant" attribute to be one of the following:`, + 'https://opensource.adobe.com/spectrum-web-components/components/asset/', + { + issues: [...constructor.VARIANTS], + } + ); + } + } + } +} diff --git a/1st-gen/packages/asset/src/Asset.ts b/1st-gen/packages/asset/src/Asset.ts index bcba71eeb00..d10172a4891 100644 --- a/1st-gen/packages/asset/src/Asset.ts +++ b/1st-gen/packages/asset/src/Asset.ts @@ -15,8 +15,8 @@ import { html, TemplateResult, } from '@spectrum-web-components/base'; -import { AssetBase } from '@spectrum-web-components/core/components/asset'; +import { AssetBase } from './Asset.base.js'; import styles from './asset.css.js'; const file = (label: string): TemplateResult => html` diff --git a/1st-gen/packages/asset/src/Asset.types.ts b/1st-gen/packages/asset/src/Asset.types.ts new file mode 100644 index 00000000000..e67d9b25f2f --- /dev/null +++ b/1st-gen/packages/asset/src/Asset.types.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export const ASSET_VARIANTS = [ + 'file', + 'folder', +] as const satisfies readonly string[]; + +export type AssetVariant = (typeof ASSET_VARIANTS)[number]; diff --git a/1st-gen/packages/badge/package.json b/1st-gen/packages/badge/package.json index 62ef282d955..d89cf59a305 100644 --- a/1st-gen/packages/badge/package.json +++ b/1st-gen/packages/badge/package.json @@ -55,7 +55,6 @@ "types": "./src/index.d.ts", "dependencies": { "@spectrum-web-components/base": "1.11.2", - "@spectrum-web-components/core": "0.0.4", "@spectrum-web-components/shared": "1.11.2" }, "keywords": [ diff --git a/1st-gen/packages/badge/src/Badge.base.ts b/1st-gen/packages/badge/src/Badge.base.ts new file mode 100644 index 00000000000..40f30547404 --- /dev/null +++ b/1st-gen/packages/badge/src/Badge.base.ts @@ -0,0 +1,149 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { PropertyValues, SpectrumElement } from '@spectrum-web-components/base'; +import { property } from '@spectrum-web-components/base/src/decorators.js'; +import { SizedMixin } from '@spectrum-web-components/base/src/sizedMixin.js'; +import { ObserveSlotPresence } from '@spectrum-web-components/shared/src/observe-slot-presence.js'; +import { ObserveSlotText } from '@spectrum-web-components/shared/src/observe-slot-text.js'; + +import { + BADGE_VARIANTS_SEMANTIC, + type BadgeVariant, + FIXED_VALUES, + type FixedValues, +} from './Badge.types.js'; + +/** + * A badge component that displays short, descriptive information about an element. + * Badges are typically used to indicate status, categories, or provide supplementary information. + * + * @attribute {ElementSize} size - The size of the badge. + * + * @slot - Text label of the badge. + * @slot icon - Optional icon that appears to the left of the label + */ +export abstract class BadgeBase extends SizedMixin( + ObserveSlotText(ObserveSlotPresence(SpectrumElement, '[slot="icon"]'), ''), + { + noDefaultSize: true, + } +) { + // ───────────────────────── + // API TO OVERRIDE + // ───────────────────────── + + /** + * @internal + * + * A readonly array of the valid color variants for the badge. + */ + static readonly VARIANTS_COLOR: readonly string[]; + + /** + * @internal + * + * A readonly array of all valid variants for the badge. + */ + static readonly VARIANTS: readonly string[]; + + /** + * @internal + * + * The variant of the badge. + */ + @property({ type: String, reflect: true }) + public variant: BadgeVariant = 'informative'; + + // ────────────────── + // SHARED API + // ────────────────── + + /** + * @internal + */ + static readonly FIXED_VALUES: readonly string[] = FIXED_VALUES; + + /** + * @internal + */ + static readonly VARIANTS_SEMANTIC: readonly string[] = + BADGE_VARIANTS_SEMANTIC; + + /** + * The fixed position of the badge. + */ + @property({ reflect: true }) + public get fixed(): FixedValues | undefined { + return this._fixed; + } + + public set fixed(fixed: FixedValues | undefined) { + if (fixed === this.fixed) { + return; + } + const oldValue = this.fixed; + this._fixed = fixed; + if (fixed) { + this.setAttribute('fixed', fixed); + } else { + this.removeAttribute('fixed'); + } + this.requestUpdate('fixed', oldValue); + } + + private _fixed?: FixedValues; + + // ────────────────────── + // IMPLEMENTATION + // ────────────────────── + + /** + * Used for rendering gap when the badge has an icon. + * + * @internal + */ + protected get hasIcon(): boolean { + return this.slotContentIsPresent; + } + + protected override update(changedProperties: PropertyValues): void { + super.update(changedProperties); + if (window.__swc?.DEBUG) { + const constructor = this.constructor as typeof BadgeBase; + if (!constructor.VARIANTS.includes(this.variant)) { + window.__swc.warn( + this, + `<${this.localName}> element expect the "variant" attribute to be one of the following:`, + 'https://opensource.adobe.com/spectrum-web-components/components/badge/#variants', + { + issues: [...constructor.VARIANTS], + } + ); + } + // Check outline property if it exists (S2 only) + if ( + 'outline' in this && + (this as { outline: boolean }).outline === true && + !constructor.VARIANTS_SEMANTIC.includes(this.variant) + ) { + window.__swc.warn( + this, + `<${this.localName}> element only supports the outline styling if the variant is a semantic color variant.`, + 'https://opensource.adobe.com/spectrum-web-components/components/badge/#variants', + { + issues: [...constructor.VARIANTS_SEMANTIC], + } + ); + } + } + } +} diff --git a/1st-gen/packages/badge/src/Badge.ts b/1st-gen/packages/badge/src/Badge.ts index 63b644e89e8..acb7116fdb3 100644 --- a/1st-gen/packages/badge/src/Badge.ts +++ b/1st-gen/packages/badge/src/Badge.ts @@ -17,16 +17,16 @@ import { TemplateResult, } from '@spectrum-web-components/base'; import { property } from '@spectrum-web-components/base/src/decorators.js'; + +import { BadgeBase } from './Badge.base.js'; +import styles from './badge.css.js'; import { BADGE_VARIANTS_COLOR_S1, BADGE_VARIANTS_S1, - BadgeBase, type BadgeVariantS1, FIXED_VALUES as FIXED_VALUES_BASE, type FixedValues as FixedValuesBase, -} from '@spectrum-web-components/core/components/badge'; - -import styles from './badge.css.js'; +} from './Badge.types.js'; /** * @deprecated The `BADGE_VARIANTS` export is deprecated and will be removed diff --git a/1st-gen/packages/badge/src/Badge.types.ts b/1st-gen/packages/badge/src/Badge.types.ts new file mode 100644 index 00000000000..68b7d95a9b1 --- /dev/null +++ b/1st-gen/packages/badge/src/Badge.types.ts @@ -0,0 +1,114 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { ElementSize } from '@spectrum-web-components/base/src/sizedMixin.js'; + +// ────────────────── +// SHARED +// ────────────────── + +export const BADGE_VALID_SIZES = [ + 's', + 'm', + 'l', + 'xl', +] as const satisfies readonly ElementSize[]; + +export const BADGE_VARIANTS_SEMANTIC = [ + 'accent', + 'informative', + 'neutral', + 'positive', + 'notice', + 'negative', +] as const; + +export const BADGE_VARIANTS_COLOR = [ + 'fuchsia', + 'indigo', + 'magenta', + 'purple', + 'seafoam', + 'yellow', + 'gray', + 'red', + 'orange', + 'chartreuse', + 'celery', + 'green', + 'cyan', + 'blue', + 'pink', + 'turquoise', + 'brown', + 'cinnamon', + 'silver', +] as const; + +export const FIXED_VALUES = [ + 'block-start', + 'block-end', + 'inline-start', + 'inline-end', +] as const; + +// ────────────────────────────────────────── +// S1-ONLY (remove with 1st-gen) +// ────────────────────────────────────────── + +export const BADGE_VARIANTS_COLOR_S1 = [ + 'fuchsia', + 'indigo', + 'magenta', + 'purple', + 'seafoam', + 'yellow', + 'gray', + 'red', + 'orange', + 'chartreuse', + 'celery', + 'green', + 'cyan', + 'blue', +] as const satisfies readonly BadgeColorVariant[]; + +export const BADGE_VARIANTS_S1 = [ + ...BADGE_VARIANTS_SEMANTIC, + ...BADGE_VARIANTS_COLOR_S1, +] as const; + +// ────────────────── +// CANONICAL +// ────────────────── + +export const BADGE_VARIANTS = [ + ...BADGE_VARIANTS_SEMANTIC, + ...BADGE_VARIANTS_COLOR, +] as const; + +// ────────────────── +// TYPES +// ────────────────── + +// Shared +export type FixedValues = (typeof FIXED_VALUES)[number]; +export type BadgeSize = (typeof BADGE_VALID_SIZES)[number]; +export type BadgeSemanticVariant = (typeof BADGE_VARIANTS_SEMANTIC)[number]; + +// S1-only (remove with 1st-gen) +export type BadgeColorVariantS1 = (typeof BADGE_VARIANTS_COLOR_S1)[number]; // remove with 1st-gen +export type BadgeVariantS1 = (typeof BADGE_VARIANTS_S1)[number]; // remove with 1st-gen + +// Canonical +export type BadgeColorVariant = (typeof BADGE_VARIANTS_COLOR)[number]; +export type BadgeVariant = (typeof BADGE_VARIANTS)[number]; diff --git a/1st-gen/packages/divider/package.json b/1st-gen/packages/divider/package.json index 6685a7f1b4a..c26afd46c81 100644 --- a/1st-gen/packages/divider/package.json +++ b/1st-gen/packages/divider/package.json @@ -55,8 +55,7 @@ ], "types": "./src/index.d.ts", "dependencies": { - "@spectrum-web-components/base": "1.11.2", - "@spectrum-web-components/core": "0.0.4" + "@spectrum-web-components/base": "1.11.2" }, "keywords": [ "design-system", diff --git a/1st-gen/packages/divider/src/Divider.base.ts b/1st-gen/packages/divider/src/Divider.base.ts new file mode 100644 index 00000000000..5ab334db5ea --- /dev/null +++ b/1st-gen/packages/divider/src/Divider.base.ts @@ -0,0 +1,74 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { PropertyValues, SpectrumElement } from '@spectrum-web-components/base'; +import { property } from '@spectrum-web-components/base/src/decorators.js'; +import { SizedMixin } from '@spectrum-web-components/base/src/sizedMixin.js'; + +import { + DIVIDER_STATIC_COLORS, + DIVIDER_VALID_SIZES, + type DividerStaticColor, +} from './Divider.types.js'; + +/** + * A divider separates and distinguishes sections of content or groups of menu items. + * + * @attribute {ElementSize} size - The size of the divider. + */ +export abstract class DividerBase extends SizedMixin(SpectrumElement, { + validSizes: DIVIDER_VALID_SIZES, + noDefaultSize: true, +}) { + // ────────────────── + // SHARED API + // ────────────────── + + /** + * @internal + * + * A readonly array of the valid static color variants for the divider. + */ + static readonly STATIC_COLORS: readonly string[] = DIVIDER_STATIC_COLORS; + + /** + * Whether the divider is vertical. If false, the divider is horizontal. The default is false. + */ + @property({ type: Boolean, reflect: true }) + public vertical = false; + + /** + * The static color variant to use for the divider. + */ + @property({ reflect: true, attribute: 'static-color' }) + public staticColor?: DividerStaticColor; + + // ────────────────────── + // IMPLEMENTATION + // ────────────────────── + + protected override firstUpdated(changed: PropertyValues): void { + super.firstUpdated(changed); + this.setAttribute('role', 'separator'); + } + + protected override updated(changed: PropertyValues): void { + super.updated(changed); + if (changed.has('vertical')) { + if (this.vertical) { + this.setAttribute('aria-orientation', 'vertical'); + } else { + this.removeAttribute('aria-orientation'); + } + } + } +} diff --git a/1st-gen/packages/divider/src/Divider.ts b/1st-gen/packages/divider/src/Divider.ts index 55ec8c98661..350e3445b1e 100644 --- a/1st-gen/packages/divider/src/Divider.ts +++ b/1st-gen/packages/divider/src/Divider.ts @@ -15,8 +15,8 @@ import { html, TemplateResult, } from '@spectrum-web-components/base'; -import { DividerBase } from '@spectrum-web-components/core/components/divider'; +import { DividerBase } from './Divider.base.js'; import styles from './divider.css.js'; /** diff --git a/1st-gen/packages/divider/src/Divider.types.ts b/1st-gen/packages/divider/src/Divider.types.ts new file mode 100644 index 00000000000..49f47df2dbb --- /dev/null +++ b/1st-gen/packages/divider/src/Divider.types.ts @@ -0,0 +1,27 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { ElementSize } from '@spectrum-web-components/base/src/sizedMixin.js'; + +export const DIVIDER_VALID_SIZES = [ + 's', + 'm', + 'l', +] as const satisfies readonly ElementSize[]; +export const DIVIDER_STATIC_COLORS = [ + 'white', + 'black', +] as const satisfies readonly string[]; + +export type DividerStaticColor = (typeof DIVIDER_STATIC_COLORS)[number]; + +export type DividerSize = (typeof DIVIDER_VALID_SIZES)[number]; diff --git a/1st-gen/packages/progress-circle/package.json b/1st-gen/packages/progress-circle/package.json index 46459c44979..f4e6b25ef7a 100644 --- a/1st-gen/packages/progress-circle/package.json +++ b/1st-gen/packages/progress-circle/package.json @@ -55,7 +55,7 @@ "types": "./src/index.d.ts", "dependencies": { "@spectrum-web-components/base": "1.11.2", - "@spectrum-web-components/core": "0.0.4", + "@spectrum-web-components/reactive-controllers": "1.11.2", "@spectrum-web-components/shared": "1.11.2" }, "keywords": [ diff --git a/1st-gen/packages/progress-circle/src/ProgressCircle.base.ts b/1st-gen/packages/progress-circle/src/ProgressCircle.base.ts new file mode 100644 index 00000000000..903c150f135 --- /dev/null +++ b/1st-gen/packages/progress-circle/src/ProgressCircle.base.ts @@ -0,0 +1,178 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { PropertyValues, SpectrumElement } from '@spectrum-web-components/base'; +import { + property, + query, +} from '@spectrum-web-components/base/src/decorators.js'; +import { SizedMixin } from '@spectrum-web-components/base/src/sizedMixin.js'; +import { + LanguageResolutionController, + languageResolverUpdatedSymbol, +} from '@spectrum-web-components/reactive-controllers/src/LanguageResolution.js'; +import { getLabelFromSlot } from '@spectrum-web-components/shared/src/get-label-from-slot.js'; + +import { + PROGRESS_CIRCLE_VALID_SIZES, + ProgressCircleStaticColor, +} from './ProgressCircle.types.js'; + +/** + * A progress circle component that visually represents the completion progress of a task. + * Can be used in both determinate (with specific progress value) and indeterminate (loading) states. + * + * @attribute {ElementSize} size - The size of the progress circle. + * + * @slot - Accessible label for the progress circle. + */ +export abstract class ProgressCircleBase extends SizedMixin(SpectrumElement, { + validSizes: PROGRESS_CIRCLE_VALID_SIZES, +}) { + // ───────────────────────── + // API TO OVERRIDE + // ───────────────────────── + + /** + * @internal + * + * A readonly array of the valid static colors for the progress circle. + */ + static readonly STATIC_COLORS: readonly string[]; + + /** + * @internal + * + * Static color variant for use on different backgrounds. + */ + @property({ type: String, reflect: true, attribute: 'static-color' }) + public staticColor?: ProgressCircleStaticColor; + + // ────────────────── + // SHARED API + // ────────────────── + + /** + * Whether the progress circle shows indeterminate progress (loading state). + */ + @property({ type: Boolean, reflect: true }) + public indeterminate = false; + + /** + * Accessible label for the progress circle. + */ + @property({ type: String }) + public label = ''; + + /** + * Progress value from 0 to 100. + */ + @property({ type: Number }) + public progress = 0; + + private languageResolver = new LanguageResolutionController(this); + + // ────────────────────── + // IMPLEMENTATION + // ────────────────────── + + /** + * @internal + */ + @query('slot') + private slotEl!: HTMLSlotElement; + + protected makeRotation(rotation: number): string | undefined { + return this.indeterminate + ? undefined + : `transform: rotate(${rotation}deg);`; + } + + protected handleSlotchange(): void { + const labelFromSlot = getLabelFromSlot(this.label, this.slotEl); + if (labelFromSlot) { + this.label = labelFromSlot; + } + } + + protected override firstUpdated(changes: PropertyValues): void { + super.firstUpdated(changes); + if (!this.hasAttribute('role')) { + this.setAttribute('role', 'progressbar'); + } + } + + private formatProgress(): string { + return new Intl.NumberFormat(this.languageResolver.language, { + style: 'percent', + unitDisplay: 'narrow', + }).format(this.progress / 100); + } + + protected override updated(changes: PropertyValues): void { + super.updated(changes); + if (changes.has('indeterminate')) { + if (this.indeterminate) { + this.removeAttribute('aria-valuemin'); + this.removeAttribute('aria-valuemax'); + this.removeAttribute('aria-valuenow'); + this.removeAttribute('aria-valuetext'); + } else { + this.setAttribute('aria-valuemin', '0'); + this.setAttribute('aria-valuemax', '100'); + this.setAttribute('aria-valuenow', '' + this.progress); + this.setAttribute('aria-valuetext', this.formatProgress()); + } + } + if (!this.indeterminate && changes.has('progress')) { + this.setAttribute('aria-valuenow', '' + this.progress); + this.setAttribute('aria-valuetext', this.formatProgress()); + } + if (!this.indeterminate && changes.has(languageResolverUpdatedSymbol)) { + this.setAttribute('aria-valuetext', this.formatProgress()); + } + if (changes.has('label')) { + if (this.label.length) { + this.setAttribute('aria-label', this.label); + } else if (changes.get('label') === this.getAttribute('aria-label')) { + this.removeAttribute('aria-label'); + } + } + + const hasAccessibleName = (): boolean => { + return Boolean( + this.label || + this.getAttribute('aria-label') || + this.getAttribute('aria-labelledby') || + this.slotEl.assignedNodes().length + ); + }; + + if (window.__swc?.DEBUG) { + if (!hasAccessibleName() && this.getAttribute('role') === 'progressbar') { + window.__swc?.warn( + this, + ' elements need one of the following to be accessible:', + 'https://opensource.adobe.com/spectrum-web-components/components/progress-circle/#accessibility', + { + type: 'accessibility', + issues: [ + 'value supplied to the "label" attribute, which will be displayed visually as part of the element, or', + 'text content supplied directly to the element, or', + 'value supplied to the "aria-label" attribute, which will only be provided to screen readers, or', + 'an element ID reference supplied to the "aria-labelledby" attribute, which will be provided by screen readers and will need to be managed manually by the parent application.', + ], + } + ); + } + } + } +} diff --git a/1st-gen/packages/progress-circle/src/ProgressCircle.ts b/1st-gen/packages/progress-circle/src/ProgressCircle.ts index ae86fee77ab..f5bcff8d766 100644 --- a/1st-gen/packages/progress-circle/src/ProgressCircle.ts +++ b/1st-gen/packages/progress-circle/src/ProgressCircle.ts @@ -17,13 +17,13 @@ import { } from '@spectrum-web-components/base'; import { property } from '@spectrum-web-components/base/src/decorators.js'; import { ifDefined } from '@spectrum-web-components/base/src/directives.js'; + +import progressCircleStyles from './progress-circle.css.js'; +import { ProgressCircleBase } from './ProgressCircle.base.js'; import { PROGRESS_CIRCLE_STATIC_COLORS_S1, - ProgressCircleBase, type ProgressCircleStaticColorS1, -} from '@spectrum-web-components/core/components/progress-circle'; - -import progressCircleStyles from './progress-circle.css.js'; +} from './ProgressCircle.types.js'; /** * @element sp-progress-circle diff --git a/1st-gen/packages/progress-circle/src/ProgressCircle.types.ts b/1st-gen/packages/progress-circle/src/ProgressCircle.types.ts new file mode 100644 index 00000000000..fa730b187c8 --- /dev/null +++ b/1st-gen/packages/progress-circle/src/ProgressCircle.types.ts @@ -0,0 +1,32 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { ElementSize } from '@spectrum-web-components/base/src/sizedMixin.js'; + +export const PROGRESS_CIRCLE_VALID_SIZES = [ + 's', + 'm', + 'l', +] as const satisfies ElementSize[]; +export const PROGRESS_CIRCLE_STATIC_COLORS_S1 = ['white'] as const; +export const PROGRESS_CIRCLE_STATIC_COLORS_S2 = [ + ...PROGRESS_CIRCLE_STATIC_COLORS_S1, + 'black', +] as const; + +export type ProgressCircleStaticColorS1 = + (typeof PROGRESS_CIRCLE_STATIC_COLORS_S1)[number]; +export type ProgressCircleStaticColorS2 = + (typeof PROGRESS_CIRCLE_STATIC_COLORS_S2)[number]; +export type ProgressCircleStaticColor = + | ProgressCircleStaticColorS1 + | ProgressCircleStaticColorS2; diff --git a/1st-gen/packages/status-light/package.json b/1st-gen/packages/status-light/package.json index 9f7c8b570ca..f3a76146e22 100644 --- a/1st-gen/packages/status-light/package.json +++ b/1st-gen/packages/status-light/package.json @@ -54,8 +54,7 @@ ], "types": "./src/index.d.ts", "dependencies": { - "@spectrum-web-components/base": "1.11.2", - "@spectrum-web-components/core": "0.0.4" + "@spectrum-web-components/base": "1.11.2" }, "keywords": [ "design-system", diff --git a/1st-gen/packages/status-light/src/StatusLight.base.ts b/1st-gen/packages/status-light/src/StatusLight.base.ts new file mode 100644 index 00000000000..c3dfaced0db --- /dev/null +++ b/1st-gen/packages/status-light/src/StatusLight.base.ts @@ -0,0 +1,95 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { PropertyValues, SpectrumElement } from '@spectrum-web-components/base'; +import { property } from '@spectrum-web-components/base/src/decorators.js'; +import { SizedMixin } from '@spectrum-web-components/base/src/sizedMixin.js'; + +import { + STATUSLIGHT_VALID_SIZES, + type StatusLightVariant, +} from './StatusLight.types.js'; + +/** + * A status light is a great way to convey semantic meaning and the condition of an entity, such as statuses and categories. + * + * @slot - The text label of the status light. + * @attribute {ElementSize} size - The size of the status light. + */ +export abstract class StatusLightBase extends SizedMixin(SpectrumElement, { + validSizes: STATUSLIGHT_VALID_SIZES, + noDefaultSize: true, +}) { + // ───────────────────────── + // API TO OVERRIDE + // ───────────────────────── + + /** + * @internal + * + * A readonly array of the valid color variants for the status light. + */ + static readonly VARIANTS_COLOR: readonly string[]; + + /** + * @internal + * + * A readonly array of the valid semantic variants for the status light. + */ + static readonly VARIANTS_SEMANTIC: readonly string[]; + + /** + * @internal + * + * A readonly array of all valid variants for the status light. + */ + static readonly VARIANTS: readonly string[]; + + /** + * @internal + * + * The variant of the status light. + */ + @property({ type: String, reflect: true }) + public variant: StatusLightVariant = 'info'; + + // ────────────────────── + // IMPLEMENTATION + // ────────────────────── + + protected override updated(changes: PropertyValues): void { + super.updated(changes); + if (window.__swc?.DEBUG) { + const constructor = this.constructor as typeof StatusLightBase; + if (!constructor.VARIANTS.includes(this.variant)) { + window.__swc.warn( + this, + `<${this.localName}> element expects the "variant" attribute to be one of the following:`, + 'https://opensource.adobe.com/spectrum-web-components/components/status-light/#variants', + { + issues: [...constructor.VARIANTS], + } + ); + } + // Check disabled property if it exists (S1 only) + if (this.hasAttribute('disabled') && !('disabled' in this)) { + window.__swc.warn( + this, + `<${this.localName}> element does not support the disabled state.`, + 'https://opensource.adobe.com/spectrum-web-components/components/status-light/#states', + { + issues: ['disabled is not a supported property in Spectrum 2'], + } + ); + } + } + } +} diff --git a/1st-gen/packages/status-light/src/StatusLight.ts b/1st-gen/packages/status-light/src/StatusLight.ts index 9a8b995a2bc..a11d4942ada 100644 --- a/1st-gen/packages/status-light/src/StatusLight.ts +++ b/1st-gen/packages/status-light/src/StatusLight.ts @@ -17,15 +17,15 @@ import { TemplateResult, } from '@spectrum-web-components/base'; import { property } from '@spectrum-web-components/base/src/decorators.js'; + +import statusLightStyles from './status-light.css.js'; +import { StatusLightBase } from './StatusLight.base.js'; import { STATUSLIGHT_VARIANTS_COLOR_S1, STATUSLIGHT_VARIANTS_S1, STATUSLIGHT_VARIANTS_SEMANTIC_S1, - StatusLightBase, type StatusLightVariantS1, -} from '@spectrum-web-components/core/components/status-light'; - -import statusLightStyles from './status-light.css.js'; +} from './StatusLight.types.js'; /** * @deprecated The `STATUSLIGHT_VARIANTS` export is deprecated and will be removed diff --git a/1st-gen/packages/status-light/src/StatusLight.types.ts b/1st-gen/packages/status-light/src/StatusLight.types.ts new file mode 100644 index 00000000000..7ab4c4e0062 --- /dev/null +++ b/1st-gen/packages/status-light/src/StatusLight.types.ts @@ -0,0 +1,88 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { ElementSize } from '@spectrum-web-components/base/src/sizedMixin.js'; + +export const STATUSLIGHT_VALID_SIZES = [ + 's', + 'm', + 'l', + 'xl', +] as const satisfies readonly ElementSize[]; + +export const STATUSLIGHT_VARIANTS_SEMANTIC = [ + 'neutral', + 'info', + 'positive', + 'negative', + 'notice', +] as const; + +export const STATUSLIGHT_VARIANTS_SEMANTIC_S1 = [ + ...STATUSLIGHT_VARIANTS_SEMANTIC, + 'accent', +] as const; + +export const STATUSLIGHT_VARIANTS_SEMANTIC_S2 = [ + ...STATUSLIGHT_VARIANTS_SEMANTIC, +] as const; + +export const STATUSLIGHT_VARIANTS_COLOR_S1 = [ + 'fuchsia', + 'indigo', + 'magenta', + 'purple', + 'seafoam', + 'yellow', + 'chartreuse', + 'celery', + 'cyan', +] as const; + +export const STATUSLIGHT_VARIANTS_COLOR_S2 = [ + ...STATUSLIGHT_VARIANTS_COLOR_S1, + 'pink', + 'turquoise', + 'brown', + 'cinnamon', + 'silver', +] as const; + +export const STATUSLIGHT_VARIANTS_S1 = [ + ...STATUSLIGHT_VARIANTS_SEMANTIC_S1, + ...STATUSLIGHT_VARIANTS_COLOR_S1, +] as const; + +export const STATUSLIGHT_VARIANTS_S2 = [ + ...STATUSLIGHT_VARIANTS_SEMANTIC_S2, + ...STATUSLIGHT_VARIANTS_COLOR_S2, +] as const; + +export type StatusLightSemanticVariantS1 = + (typeof STATUSLIGHT_VARIANTS_SEMANTIC_S1)[number]; +export type StatusLightSemanticVariantS2 = + (typeof STATUSLIGHT_VARIANTS_SEMANTIC_S2)[number]; +export type StatusLightSemanticVariant = + | StatusLightSemanticVariantS1 + | StatusLightSemanticVariantS2; + +export type StatusLightColorVariantS1 = + (typeof STATUSLIGHT_VARIANTS_COLOR_S1)[number]; +export type StatusLightColorVariantS2 = + (typeof STATUSLIGHT_VARIANTS_COLOR_S2)[number]; +export type StatusLightColorVariant = + | StatusLightColorVariantS1 + | StatusLightColorVariantS2; + +export type StatusLightVariantS1 = (typeof STATUSLIGHT_VARIANTS_S1)[number]; +export type StatusLightVariantS2 = (typeof STATUSLIGHT_VARIANTS_S2)[number]; +export type StatusLightVariant = StatusLightVariantS1 | StatusLightVariantS2; diff --git a/1st-gen/tools/base/package.json b/1st-gen/tools/base/package.json index d3d6c55b17b..c113638181c 100644 --- a/1st-gen/tools/base/package.json +++ b/1st-gen/tools/base/package.json @@ -108,7 +108,6 @@ ], "types": "./src/index.d.ts", "dependencies": { - "@spectrum-web-components/core": "0.0.4", "lit": "^2.5.0 || ^3.1.3" }, "keywords": [ diff --git a/1st-gen/tools/base/src/Base.ts b/1st-gen/tools/base/src/Base.ts index a856fdafaa2..96c9da22fb8 100644 --- a/1st-gen/tools/base/src/Base.ts +++ b/1st-gen/tools/base/src/Base.ts @@ -10,27 +10,166 @@ * governing permissions and limitations under the License. */ -import { SpectrumElement as CoreSpectrumElement } from '@spectrum-web-components/core/element/spectrum-element.js'; +import { LitElement, ReactiveElement } from 'lit'; import { coreVersion, version } from './version.js'; -export { - type SpectrumInterface, - SpectrumMixin, -} from '@spectrum-web-components/core/element/spectrum-element.js'; +type Constructor> = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new (...args: any[]): T; + prototype: T; +}; + +export interface SpectrumInterface { + shadowRoot: ShadowRoot; + hasVisibleFocusInTree(): boolean; +} + +export function SpectrumMixin>( + constructor: T +): T & Constructor { + class SpectrumMixinElement extends constructor { + /** + * @internal + */ + public override shadowRoot!: ShadowRoot; + public hasVisibleFocusInTree(): boolean { + const getAncestors = (root: Document = document): HTMLElement[] => { + let currentNode = root.activeElement as HTMLElement; + while ( + currentNode?.shadowRoot && + currentNode.shadowRoot.activeElement + ) { + currentNode = currentNode.shadowRoot.activeElement as HTMLElement; + } + const ancestors: HTMLElement[] = currentNode ? [currentNode] : []; + while (currentNode) { + const ancestor = + currentNode.assignedSlot || + currentNode.parentElement || + (currentNode.getRootNode() as ShadowRoot)?.host; + if (ancestor) { + ancestors.push(ancestor as HTMLElement); + } + currentNode = ancestor as HTMLElement; + } + return ancestors; + }; + const activeElement = getAncestors(this.getRootNode() as Document)[0]; + if (!activeElement) { + return false; + } + // Browsers without support for the `:focus-visible` + // selector will throw on the following test (Safari, older things). + // Some won't throw, but will be focusing item rather than the menu and + // will rely on the polyfill to know whether focus is "visible" or not. + return ( + activeElement.matches(':focus-visible') || + activeElement.matches('.focus-visible') + ); + } + } + return SpectrumMixinElement; +} /** * Base class for 1st-gen Spectrum Web Components. - * Extends the core SpectrumElement with 1st-gen specific version information. */ -export class SpectrumElement extends CoreSpectrumElement { +export class SpectrumElement extends SpectrumMixin(LitElement) { /** * The version of the 1st-gen Spectrum Web Components library. */ - static override VERSION = version; + static VERSION = version; /** * The version of the core base package. */ - static override CORE_VERSION = coreVersion; + static CORE_VERSION = coreVersion; + + public override get dir(): CSSStyleDeclaration['direction'] { + return getComputedStyle(this).direction ?? 'ltr'; + } +} + +if (process.env.NODE_ENV === 'development') { + const ignoreWarningTypes = { + default: false, + accessibility: false, + api: false, + }; + const ignoreWarningLevels = { + default: false, + low: false, + medium: false, + high: false, + deprecation: false, + }; + window.__swc = { + ...window.__swc, + DEBUG: true, + ignoreWarningLocalNames: { + ...(window.__swc?.ignoreWarningLocalNames || {}), + }, + ignoreWarningTypes: { + ...ignoreWarningTypes, + ...(window.__swc?.ignoreWarningTypes || {}), + }, + ignoreWarningLevels: { + ...ignoreWarningLevels, + ...(window.__swc?.ignoreWarningLevels || {}), + }, + issuedWarnings: new Set(), + warn: ( + element, + message, + url, + { type = 'api', level = 'default', issues } = {} + ): void => { + const { localName = 'base' } = element || {}; + const id = `${localName}:${type}:${level}` as BrandedSWCWarningID; + if (!window.__swc.verbose && window.__swc.issuedWarnings.has(id)) { + return; + } + if (window.__swc.ignoreWarningLocalNames[localName]) { + return; + } + if (window.__swc.ignoreWarningTypes[type]) { + return; + } + if (window.__swc.ignoreWarningLevels[level]) { + return; + } + window.__swc.issuedWarnings.add(id); + let listedIssues = ''; + if (issues && issues.length) { + issues.unshift(''); + listedIssues = issues.join('\n - ') + '\n'; + } + const intro = level === 'deprecation' ? 'DEPRECATION NOTICE: ' : ''; + const inspectElement = element + ? '\nInspect this issue in the follow element:' + : ''; + const displayURL = (element ? '\n\n' : '\n') + url + '\n'; + const messages: unknown[] = []; + messages.push(intro + message + '\n' + listedIssues + inspectElement); + if (element) { + messages.push(element); + } + messages.push(displayURL, { + data: { + localName, + type, + level, + }, + }); + console.warn(...messages); + }, + }; + + window.__swc.warn( + undefined, + 'Spectrum Web Components is in dev mode. Not recommended for production!', + 'https://opensource.adobe.com/spectrum-web-components/dev-mode/', + { type: 'default' } + ); } diff --git a/1st-gen/tools/base/src/define-element.ts b/1st-gen/tools/base/src/define-element.ts index 79652b73a8f..76d498ccea4 100644 --- a/1st-gen/tools/base/src/define-element.ts +++ b/1st-gen/tools/base/src/define-element.ts @@ -10,4 +10,22 @@ * governing permissions and limitations under the License. */ -export { defineElement } from '@spectrum-web-components/core/element/define-element.js'; +interface CustomElementConstructor { + new (...params: unknown[]): HTMLElement; +} + +export function defineElement( + name: string, + constructor: CustomElementConstructor +): void { + if (window.__swc && window.__swc.DEBUG) { + if (customElements.get(name)) { + window.__swc.warn( + undefined, + `Attempted to redefine <${name}>. This usually indicates that multiple versions of the same web component were loaded onto a single page.`, + 'https://opensource.adobe.com/spectrum-web-components/registry-conflicts' + ); + } + } + customElements.define(name, constructor); +} diff --git a/1st-gen/tools/base/src/sizedMixin.ts b/1st-gen/tools/base/src/sizedMixin.ts index 067739dc6fe..331d8fce874 100644 --- a/1st-gen/tools/base/src/sizedMixin.ts +++ b/1st-gen/tools/base/src/sizedMixin.ts @@ -9,18 +9,85 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -export { - DEFAULT_ELEMENT_SIZES, - ELEMENT_SIZES, - SizedMixin, -} from '@spectrum-web-components/core/mixins/sized-mixin.js'; -export type { - DefaultElementSize, - ElementSize, - SizedElementInterface, -} from '@spectrum-web-components/core/mixins/sized-mixin.js'; - -import type { ElementSize } from '@spectrum-web-components/core/mixins/sized-mixin.js'; +import { PropertyValues, ReactiveElement } from 'lit'; +import { property } from 'lit/decorators.js'; + +export type Constructor> = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new (...args: any[]): T; + prototype: T; +}; + +export const ELEMENT_SIZES = ['xxs', 'xs', 's', 'm', 'l', 'xl', 'xxl'] as const; +export type ElementSize = (typeof ELEMENT_SIZES)[number]; + +export const DEFAULT_ELEMENT_SIZES = [ + 's', + 'm', + 'l', + 'xl', +] as const satisfies readonly ElementSize[]; +export type DefaultElementSize = (typeof DEFAULT_ELEMENT_SIZES)[number]; + +export interface SizedElementInterface { + size: ElementSize; +} + +export interface SizedElementConstructor { + readonly VALID_SIZES: readonly ElementSize[]; +} + +export function SizedMixin>( + constructor: T, + { + validSizes = [...DEFAULT_ELEMENT_SIZES], + noDefaultSize, + defaultSize = 'm', + }: { + validSizes?: readonly ElementSize[]; + noDefaultSize?: boolean; + defaultSize?: ElementSize; + } = {} +): T & Constructor & SizedElementConstructor { + class SizedElement extends constructor { + /** + * @internal + */ + static readonly VALID_SIZES: readonly ElementSize[] = validSizes; + + @property({ type: String }) + public get size(): ElementSize { + return this._size || defaultSize; + } + + public set size(value: ElementSize) { + const fallbackSize = noDefaultSize ? null : defaultSize; + const size = (value ? value.toLocaleLowerCase() : value) as ElementSize; + const validSize = ( + validSizes.includes(size) ? size : fallbackSize + ) as ElementSize; + if (validSize) { + this.setAttribute('size', validSize); + } + if (this._size === validSize) { + return; + } + const oldSize = this._size; + this._size = validSize; + this.requestUpdate('size', oldSize); + } + + private _size: ElementSize | null = defaultSize; + + protected override update(changes: PropertyValues): void { + if (!this.hasAttribute('size') && !noDefaultSize) { + this.setAttribute('size', this.size); + } + super.update(changes); + } + } + return SizedElement; +} /** * @deprecated Use `ELEMENT_SIZES` instead. This record will be removed in a future release. diff --git a/1st-gen/tools/reactive-controllers/package.json b/1st-gen/tools/reactive-controllers/package.json index 5feb3dfc5b1..b4356af40bc 100644 --- a/1st-gen/tools/reactive-controllers/package.json +++ b/1st-gen/tools/reactive-controllers/package.json @@ -78,7 +78,6 @@ ], "types": "./src/index.d.ts", "dependencies": { - "@spectrum-web-components/core": "0.0.4", "@spectrum-web-components/progress-circle": "1.11.2", "colorjs.io": "0.5.2", "lit": "^2.5.0 || ^3.1.3" diff --git a/1st-gen/tools/reactive-controllers/src/LanguageResolution.ts b/1st-gen/tools/reactive-controllers/src/LanguageResolution.ts index 26a0d116771..7f1166d8c42 100644 --- a/1st-gen/tools/reactive-controllers/src/LanguageResolution.ts +++ b/1st-gen/tools/reactive-controllers/src/LanguageResolution.ts @@ -10,11 +10,144 @@ * governing permissions and limitations under the License. */ +import type { ReactiveController, ReactiveElement } from 'lit'; + +// TODO: Update this when theme is migrated to 2nd-gen +type ProvideLang = { + callback: (lang: string, unsubscribe: () => void) => void; +}; + +/** + * Symbol used to track language resolver updates in reactive element lifecycle. + * When the language context changes, components use this symbol to trigger updates + * to locale-dependent content (e.g., formatted dates, numbers, currency). + */ +export const languageResolverUpdatedSymbol = Symbol( + 'language resolver updated' +); + +// ──────────────────────────────────────────── +// Shared observer (singleton) +// ──────────────────────────────────────────── + +type LangChangeListener = () => void; + +const listeners = new Set(); +let sharedObserver: MutationObserver | undefined; + +function addLangListener(listener: LangChangeListener): () => void { + listeners.add(listener); + + if (!sharedObserver) { + sharedObserver = new MutationObserver(() => { + for (const cb of listeners) { + cb(); + } + }); + sharedObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ['lang'], + }); + } + + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + sharedObserver?.disconnect(); + sharedObserver = undefined; + } + }; +} + /** - * Re-export LanguageResolutionController from core so 1st-gen and 2nd-gen share - * the same implementation and do not drift. + * A reactive controller that manages language/locale resolution for components. */ -export { - LanguageResolutionController, - languageResolverUpdatedSymbol, -} from '@spectrum-web-components/core/controllers/language-resolution.js'; +export class LanguageResolutionController implements ReactiveController { + private host: ReactiveElement; + + /** + * The currently resolved language/locale code (e.g., 'en-US', 'fr-FR'). + * Defaults to document language, browser language, or 'en-US'. + */ + language = this.getDocumentLanguage(); + + /** Unsubscribe from the sp-language-context provider (if any). */ + private unsubscribe?: () => void; + + /** Unsubscribe from the shared observer. */ + private removeLangListener?: () => void; + + constructor(host: ReactiveElement) { + this.host = host; + this.host.addController(this); + } + + /** + * Reads language from document and validates. Used for initial value and + * when syncing from `` changes. + */ + private getDocumentLanguage(): string { + const raw = document.documentElement.lang || navigator.language || 'en-US'; + try { + Intl.DateTimeFormat.supportedLocalesOf([raw]); + return raw; + } catch { + return 'en-US'; + } + } + + public hostConnected(): void { + this.resolveLanguage(); + this.removeLangListener = addLangListener(this.handleLangChange.bind(this)); + } + + public hostDisconnected(): void { + this.unsubscribe?.(); + this.unsubscribe = undefined; + this.removeLangListener?.(); + this.removeLangListener = undefined; + } + + /** + * Called by the shared observer when `` changes. + * Skipped when a provider (e.g. sp-theme) is the source of truth. + */ + private handleLangChange(): void { + if (this.unsubscribe) { + return; + } + const next = this.getDocumentLanguage(); + if (next === this.language) { + return; + } + const previous = this.language; + this.language = next; + this.host.requestUpdate(languageResolverUpdatedSymbol, previous); + } + + /** + * Resolves the language: syncs from document, then queries for a provider + * (e.g. sp-theme) via 'sp-language-context'. If a provider calls the + * callback, it becomes the source of truth until disconnected. + */ + private resolveLanguage(): void { + this.language = this.getDocumentLanguage(); + const queryThemeEvent = new CustomEvent( + 'sp-language-context', + { + bubbles: true, + composed: true, + detail: { + callback: (lang: string, unsubscribe: () => void) => { + const previous = this.language; + this.language = lang; + this.unsubscribe = unsubscribe; + this.host.requestUpdate(languageResolverUpdatedSymbol, previous); + }, + }, + cancelable: true, + } + ); + this.host.dispatchEvent(queryThemeEvent); + } +} diff --git a/1st-gen/tools/shared/package.json b/1st-gen/tools/shared/package.json index efaca0f4898..2e9299f30bd 100644 --- a/1st-gen/tools/shared/package.json +++ b/1st-gen/tools/shared/package.json @@ -97,7 +97,6 @@ "dependencies": { "@lit-labs/observers": "2.0.2", "@spectrum-web-components/base": "1.11.2", - "@spectrum-web-components/core": "0.0.4", "focus-visible": "5.2.1" }, "keywords": [ diff --git a/1st-gen/tools/shared/src/get-label-from-slot.ts b/1st-gen/tools/shared/src/get-label-from-slot.ts index 4ff73a81e9a..66e1aafec7d 100644 --- a/1st-gen/tools/shared/src/get-label-from-slot.ts +++ b/1st-gen/tools/shared/src/get-label-from-slot.ts @@ -9,4 +9,26 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -export { getLabelFromSlot } from '@spectrum-web-components/core/utils/get-label-from-slot.js'; + +export const getLabelFromSlot = ( + label: string, + slotEl: HTMLSlotElement +): string | null => { + if (label) { + return null; + } + const textContent = slotEl + .assignedNodes() + .reduce((accumulator: string, node: Node) => { + if (node.textContent) { + return accumulator + node.textContent; + } else { + return accumulator; + } + }, ''); + if (textContent) { + return textContent.trim(); + } else { + return null; + } +}; diff --git a/1st-gen/tools/shared/src/observe-slot-presence.ts b/1st-gen/tools/shared/src/observe-slot-presence.ts index a58eca0bdaa..7c27b18d2b5 100644 --- a/1st-gen/tools/shared/src/observe-slot-presence.ts +++ b/1st-gen/tools/shared/src/observe-slot-presence.ts @@ -9,5 +9,97 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -export { ObserveSlotPresence } from '@spectrum-web-components/core/mixins/observe-slot-presence.js'; -export type { SlotPresenceObservingInterface } from '@spectrum-web-components/core/mixins/observe-slot-presence.js'; +import { ReactiveElement } from 'lit'; +import { MutationController } from '@lit-labs/observers/mutation-controller.js'; + +const slotContentIsPresent = Symbol('slotContentIsPresent'); + +type Constructor> = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new (...args: any[]): T; + prototype: T; +}; + +export interface SlotPresenceObservingInterface { + slotContentIsPresent: boolean; + getSlotContentPresence(selector: string): boolean; + managePresenceObservedSlot(): void; +} + +export function ObserveSlotPresence>( + constructor: T, + lightDomSelector: string | string[] +): T & Constructor { + const lightDomSelectors = Array.isArray(lightDomSelector) + ? lightDomSelector + : [lightDomSelector]; + class SlotPresenceObservingElement + extends constructor + implements SlotPresenceObservingInterface + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(...args: any[]) { + super(...args); + + new MutationController(this, { + config: { + childList: true, + subtree: true, + }, + callback: () => { + this.managePresenceObservedSlot(); + }, + }); + + this.managePresenceObservedSlot(); + } + + /** + * @internal + */ + public get slotContentIsPresent(): boolean { + if (lightDomSelectors.length === 1) { + return this[slotContentIsPresent].get(lightDomSelectors[0]) || false; + } else { + throw new Error( + 'Multiple selectors provided to `ObserveSlotPresence` use `getSlotContentPresence(selector: string)` instead.' + ); + } + } + private [slotContentIsPresent]: Map = new Map(); + + /** + * @internal + */ + public getSlotContentPresence(selector: string): boolean { + if (this[slotContentIsPresent].has(selector)) { + return this[slotContentIsPresent].get(selector) || false; + } + throw new Error( + `The provided selector \`${selector}\` is not being observed.` + ); + } + + /** + * @internal + */ + public managePresenceObservedSlot = (): void => { + let changes = false; + lightDomSelectors.forEach((selector) => { + const nextValue = !!this.querySelector(`:scope > ${selector}`); + const previousValue = this[slotContentIsPresent].get(selector) || false; + changes = changes || previousValue !== nextValue; + this[slotContentIsPresent].set( + selector, + !!this.querySelector(`:scope > ${selector}`) + ); + }); + if (changes) { + this.updateComplete.then(() => { + this.requestUpdate(); + }); + } + }; + } + return SlotPresenceObservingElement; +} diff --git a/1st-gen/tools/shared/src/observe-slot-text.ts b/1st-gen/tools/shared/src/observe-slot-text.ts index d5d06afae5a..b235322def1 100644 --- a/1st-gen/tools/shared/src/observe-slot-text.ts +++ b/1st-gen/tools/shared/src/observe-slot-text.ts @@ -9,5 +9,115 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -export { ObserveSlotText } from '@spectrum-web-components/core/mixins/observe-slot-text.js'; -export type { SlotTextObservingInterface } from '@spectrum-web-components/core/mixins/observe-slot-text.js'; +import { PropertyValues, ReactiveElement } from 'lit'; +import { property, queryAssignedNodes } from 'lit/decorators.js'; +import { MutationController } from '@lit-labs/observers/mutation-controller.js'; + +const assignedNodesList = Symbol('assignedNodes'); + +type Constructor> = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new (...args: any[]): T; + prototype: T; +}; + +export interface SlotTextObservingInterface { + slotHasContent: boolean; + manageTextObservedSlot(): void; +} + +export function ObserveSlotText>( + constructor: T, + slotName?: string, + excludedSelectors: string[] = [] +): T & Constructor { + const notExcluded = (el: HTMLElement) => (selector: string) => { + return el.matches(selector); + }; + + class SlotTextObservingElement + extends constructor + implements SlotTextObservingInterface + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(...args: any[]) { + super(...args); + + new MutationController(this, { + config: { + characterData: true, + subtree: true, + }, + callback: (mutationsList: Array) => { + for (const mutation of mutationsList) { + if (mutation.type === 'characterData') { + this.manageTextObservedSlot(); + return; + } + } + }, + }); + } + + /** + * @internal + */ + @property({ type: Boolean, attribute: false }) + public slotHasContent = false; + + @queryAssignedNodes({ + slot: slotName, + flatten: true, + }) + private [assignedNodesList]!: NodeListOf; + + /** + * @internal + */ + public manageTextObservedSlot(): void { + if (!this[assignedNodesList]) { + return; + } + const assignedNodes = [...this[assignedNodesList]].filter( + (currentNode) => { + const node = currentNode as HTMLElement; + if (node.tagName) { + return !excludedSelectors.some(notExcluded(node)); + } + return node.textContent ? node.textContent.trim() : false; + } + ); + this.slotHasContent = assignedNodes.length > 0; + } + + protected override update(changedProperties: PropertyValues): void { + if (!this.hasUpdated) { + const { childNodes } = this; + const textNodes = [...childNodes].filter((currentNode) => { + const node = currentNode as HTMLElement; + if (node.tagName) { + const excluded = excludedSelectors.some(notExcluded(node)); + return !excluded + ? // This pass happens at element upgrade and before slot rendering. + // Confirm it would exist in a targeted slot if there was one supplied. + slotName + ? node.getAttribute('slot') === slotName + : !node.hasAttribute('slot') + : false; + } + return node.textContent ? node.textContent.trim() : false; + }); + this.slotHasContent = textNodes.length > 0; + } + super.update(changedProperties); + } + + protected override firstUpdated(changedProperties: PropertyValues): void { + super.firstUpdated(changedProperties); + this.updateComplete.then(() => { + this.manageTextObservedSlot(); + }); + } + } + return SlotTextObservingElement; +} diff --git a/1st-gen/tools/theme/package.json b/1st-gen/tools/theme/package.json index 01de1ab036a..a80aff86818 100755 --- a/1st-gen/tools/theme/package.json +++ b/1st-gen/tools/theme/package.json @@ -298,7 +298,6 @@ "types": "./src/index.d.ts", "dependencies": { "@spectrum-web-components/base": "1.11.2", - "@spectrum-web-components/core": "0.0.4", "@spectrum-web-components/styles": "1.11.2" }, "keywords": [ diff --git a/yarn.lock b/yarn.lock index 676ea1d876b..70426cb34a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6111,7 +6111,6 @@ __metadata: dependencies: "@spectrum-web-components/base": "npm:1.11.2" "@spectrum-web-components/button": "npm:1.11.2" - "@spectrum-web-components/core": "npm:0.0.4" "@spectrum-web-components/icons-workflow": "npm:1.11.2" languageName: unknown linkType: soft @@ -6135,7 +6134,6 @@ __metadata: resolution: "@spectrum-web-components/asset@workspace:1st-gen/packages/asset" dependencies: "@spectrum-web-components/base": "npm:1.11.2" - "@spectrum-web-components/core": "npm:0.0.4" languageName: unknown linkType: soft @@ -6153,7 +6151,6 @@ __metadata: resolution: "@spectrum-web-components/badge@workspace:1st-gen/packages/badge" dependencies: "@spectrum-web-components/base": "npm:1.11.2" - "@spectrum-web-components/core": "npm:0.0.4" "@spectrum-web-components/shared": "npm:1.11.2" languageName: unknown linkType: soft @@ -6162,7 +6159,6 @@ __metadata: version: 0.0.0-use.local resolution: "@spectrum-web-components/base@workspace:1st-gen/tools/base" dependencies: - "@spectrum-web-components/core": "npm:0.0.4" lit: "npm:^2.5.0 || ^3.1.3" languageName: unknown linkType: soft @@ -6517,7 +6513,6 @@ __metadata: resolution: "@spectrum-web-components/divider@workspace:1st-gen/packages/divider" dependencies: "@spectrum-web-components/base": "npm:1.11.2" - "@spectrum-web-components/core": "npm:0.0.4" languageName: unknown linkType: soft @@ -6796,7 +6791,7 @@ __metadata: resolution: "@spectrum-web-components/progress-circle@workspace:1st-gen/packages/progress-circle" dependencies: "@spectrum-web-components/base": "npm:1.11.2" - "@spectrum-web-components/core": "npm:0.0.4" + "@spectrum-web-components/reactive-controllers": "npm:1.11.2" "@spectrum-web-components/shared": "npm:1.11.2" languageName: unknown linkType: soft @@ -6817,7 +6812,6 @@ __metadata: version: 0.0.0-use.local resolution: "@spectrum-web-components/reactive-controllers@workspace:1st-gen/tools/reactive-controllers" dependencies: - "@spectrum-web-components/core": "npm:0.0.4" "@spectrum-web-components/progress-circle": "npm:1.11.2" colorjs.io: "npm:0.5.2" lit: "npm:^2.5.0 || ^3.1.3" @@ -6842,7 +6836,6 @@ __metadata: dependencies: "@lit-labs/observers": "npm:2.0.2" "@spectrum-web-components/base": "npm:1.11.2" - "@spectrum-web-components/core": "npm:0.0.4" focus-visible: "npm:5.2.1" languageName: unknown linkType: soft @@ -6886,7 +6879,6 @@ __metadata: resolution: "@spectrum-web-components/status-light@workspace:1st-gen/packages/status-light" dependencies: "@spectrum-web-components/base": "npm:1.11.2" - "@spectrum-web-components/core": "npm:0.0.4" languageName: unknown linkType: soft @@ -7005,7 +6997,6 @@ __metadata: resolution: "@spectrum-web-components/theme@workspace:1st-gen/tools/theme" dependencies: "@spectrum-web-components/base": "npm:1.11.2" - "@spectrum-web-components/core": "npm:0.0.4" "@spectrum-web-components/styles": "npm:1.11.2" languageName: unknown linkType: soft From 37d2f248be26e968d3ed340b5ad5c2555833fb0b Mon Sep 17 00:00:00 2001 From: Casey Eickhoff Date: Wed, 1 Apr 2026 17:24:51 -0600 Subject: [PATCH 02/21] feat(core): add focus utilities and clean up hasVisibleFocusInTree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of focus management migration (FOCUS-MANAGEMENT-PROPOSAL.md §5). - Add get-active-element.ts: shadow DOM-aware activeElement traversal - Add focusable-selectors.ts: standard HTML focusable/tabbable selectors (removes 1st-gen [focusable] attribute workaround) - Refactor hasVisibleFocusInTree() to use getActiveElement(), removing dead ancestor-chain code and legacy .focus-visible polyfill fallback - Export new utilities from utils/index.ts --- .../packages/core/element/spectrum-element.ts | 36 ++--------- .../core/utils/focusable-selectors.ts | 61 +++++++++++++++++++ .../packages/core/utils/get-active-element.ts | 40 ++++++++++++ 2nd-gen/packages/core/utils/index.ts | 2 + 4 files changed, 107 insertions(+), 32 deletions(-) create mode 100644 2nd-gen/packages/core/utils/focusable-selectors.ts create mode 100644 2nd-gen/packages/core/utils/get-active-element.ts diff --git a/2nd-gen/packages/core/element/spectrum-element.ts b/2nd-gen/packages/core/element/spectrum-element.ts index 5403b644c51..a0d87b27137 100644 --- a/2nd-gen/packages/core/element/spectrum-element.ts +++ b/2nd-gen/packages/core/element/spectrum-element.ts @@ -12,6 +12,7 @@ import { LitElement, ReactiveElement } from 'lit'; +import { getActiveElement } from '../utils/get-active-element.js'; import { coreVersion, version } from './version.js'; type Constructor> = { @@ -34,39 +35,10 @@ export function SpectrumMixin>( */ public override shadowRoot!: ShadowRoot; public hasVisibleFocusInTree(): boolean { - const getAncestors = (root: Document = document): HTMLElement[] => { - let currentNode = root.activeElement as HTMLElement; - while ( - currentNode?.shadowRoot && - currentNode.shadowRoot.activeElement - ) { - currentNode = currentNode.shadowRoot.activeElement as HTMLElement; - } - const ancestors: HTMLElement[] = currentNode ? [currentNode] : []; - while (currentNode) { - const ancestor = - currentNode.assignedSlot || - currentNode.parentElement || - (currentNode.getRootNode() as ShadowRoot)?.host; - if (ancestor) { - ancestors.push(ancestor as HTMLElement); - } - currentNode = ancestor as HTMLElement; - } - return ancestors; - }; - const activeElement = getAncestors(this.getRootNode() as Document)[0]; - if (!activeElement) { - return false; - } - // Browsers without support for the `:focus-visible` - // selector will throw on the following test (Safari, older things). - // Some won't throw, but will be focusing item rather than the menu and - // will rely on the polyfill to know whether focus is "visible" or not. - return ( - activeElement.matches(':focus-visible') || - activeElement.matches('.focus-visible') + const active = getActiveElement( + this.getRootNode() as Document | ShadowRoot ); + return active?.matches(':focus-visible') ?? false; } } return SpectrumMixinElement; diff --git a/2nd-gen/packages/core/utils/focusable-selectors.ts b/2nd-gen/packages/core/utils/focusable-selectors.ts new file mode 100644 index 00000000000..08e6f43d3f7 --- /dev/null +++ b/2nd-gen/packages/core/utils/focusable-selectors.ts @@ -0,0 +1,61 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * CSS selector strings for matching focusable and tabbable DOM elements + * per the HTML specification. + * + * These selectors use standard HTML focusability rules only — the 1st-gen + * custom `[focusable]` attribute selector is intentionally not included, + * as native `delegatesFocus` replaces that workaround. + */ + +/** + * Matches elements that can receive focus programmatically (via `.focus()`). + * + * Includes elements with `tabindex="-1"` which are focusable via script + * but not reachable via the Tab key. + * + * @example + * ```typescript + * const firstFocusable = shadowRoot.querySelector(focusableSelector); + * ``` + */ +export const focusableSelector = [ + 'input:not([inert]):not([disabled])', + 'select:not([inert]):not([disabled])', + 'textarea:not([inert]):not([disabled])', + 'a[href]:not([inert])', + 'button:not([inert]):not([disabled])', + '[tabindex]:not([inert])', + 'audio[controls]:not([inert])', + 'video[controls]:not([inert])', + '[contenteditable]:not([contenteditable="false"]):not([inert])', + 'details>summary:first-of-type:not([inert])', + 'details:not([inert])', +].join(','); + +/** + * Matches elements reachable via the Tab key. + * + * This is a subset of {@link focusableSelector} that excludes elements + * with `tabindex="-1"` (which are focusable via script but not tabbable). + * + * @example + * ```typescript + * const tabbableElements = [...container.querySelectorAll(tabbableSelector)]; + * ``` + */ +export const tabbableSelector = focusableSelector + .split(',') + .map((s) => s + ':not([tabindex="-1"])') + .join(','); diff --git a/2nd-gen/packages/core/utils/get-active-element.ts b/2nd-gen/packages/core/utils/get-active-element.ts new file mode 100644 index 00000000000..088f67b9f1f --- /dev/null +++ b/2nd-gen/packages/core/utils/get-active-element.ts @@ -0,0 +1,40 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * Returns the deepest focused element by traversing shadow DOM boundaries. + * + * `document.activeElement` stops at shadow hosts — this utility follows + * `shadowRoot.activeElement` chains to find the leaf-level focused element. + * + * @param root - The document or shadow root to start traversal from. + * Defaults to `document`. + * @returns The deepest focused element, or `null` if nothing is focused. + * + * @example + * ```typescript + * // Get the truly focused element across all shadow boundaries + * const active = getActiveElement(); + * + * // Start traversal from a specific root + * const active = getActiveElement(el.getRootNode() as Document); + * ``` + */ +export function getActiveElement( + root: Document | ShadowRoot = document +): HTMLElement | null { + let current = root.activeElement as HTMLElement | null; + while (current?.shadowRoot?.activeElement) { + current = current.shadowRoot.activeElement as HTMLElement; + } + return current; +} diff --git a/2nd-gen/packages/core/utils/index.ts b/2nd-gen/packages/core/utils/index.ts index 090776620c3..26867bea526 100644 --- a/2nd-gen/packages/core/utils/index.ts +++ b/2nd-gen/packages/core/utils/index.ts @@ -11,4 +11,6 @@ */ export { capitalize } from './capitalize.js'; +export { getActiveElement } from './get-active-element.js'; +export { focusableSelector, tabbableSelector } from './focusable-selectors.js'; export { getLabelFromSlot } from './get-label-from-slot.js'; From 5166a4a51d4409b401c6b053d42b05be5fa7150c Mon Sep 17 00:00:00 2001 From: Casey Eickhoff Date: Wed, 1 Apr 2026 17:32:51 -0600 Subject: [PATCH 03/21] feat(core): add RovingTabindexController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of focus management migration (FOCUS-MANAGEMENT-PROPOSAL.md §5). Consolidates 1st-gen FocusGroupController + RovingTabindexController into a single, self-contained reactive controller. Key changes from 1st-gen: - Flat single-file architecture (no base class inheritance) - MutationObserver uses attributeFilter for disabled/aria-disabled only - Listener scope defaults to host.renderRoot for shadow DOM encapsulation - Removes redundant click handler (focusin covers click-to-focus) - Removes virtualization offset (deferred until needed) - Simplified tabindex management (direct tabIndex set, no requestUpdate) --- .vscode/settings.json | 2 + 2nd-gen/packages/core/controllers/index.ts | 4 + .../core/controllers/roving-tabindex.ts | 561 ++++++++++++++++++ 3 files changed, 567 insertions(+) create mode 100644 2nd-gen/packages/core/controllers/roving-tabindex.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index a50353789e3..b89efb845eb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -70,6 +70,8 @@ "seafoam", "sidenav", "tabindex", + "tabindexes", + "unmanage", "unsuffixed", "valuenow", "valuetext" diff --git a/2nd-gen/packages/core/controllers/index.ts b/2nd-gen/packages/core/controllers/index.ts index a022b4457d6..2d60923ad31 100644 --- a/2nd-gen/packages/core/controllers/index.ts +++ b/2nd-gen/packages/core/controllers/index.ts @@ -14,3 +14,7 @@ export { LanguageResolutionController, languageResolverUpdatedSymbol, } from './language-resolution.js'; +export { + RovingTabindexController, + type RovingTabindexConfig, +} from './roving-tabindex.js'; diff --git a/2nd-gen/packages/core/controllers/roving-tabindex.ts b/2nd-gen/packages/core/controllers/roving-tabindex.ts new file mode 100644 index 00000000000..6dcb42f2825 --- /dev/null +++ b/2nd-gen/packages/core/controllers/roving-tabindex.ts @@ -0,0 +1,561 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { ReactiveController, ReactiveElement } from 'lit'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type DirectionTypes = 'horizontal' | 'vertical' | 'both' | 'grid'; + +export interface RovingTabindexConfig { + /** Function returning the current list of focusable elements. */ + elements: () => T[]; + + /** Navigation direction. Default: 'both'. */ + direction?: DirectionTypes | (() => DirectionTypes); + + /** + * Which element index to focus when entering the group. + * Accepts a static number or a function. Default: first element (0). + */ + focusInIndex?: number | ((elements: T[]) => number); + + /** Filter to determine if an element is currently focusable. */ + isFocusableElement?: (el: T) => boolean; + + /** Called before focusing an element (e.g., to auto-select in radio groups). */ + elementEnterAction?: (el: T) => void; + + /** + * When true, arrow key events will stop propagation after being handled. + * This prevents parent elements from also reacting to arrow keys. + * + * @default false + */ + stopKeyEventPropagation?: boolean; + + /** + * Scope element for event listeners. Accepts a static element or a function. + * Default: host.renderRoot (shadow root) for better shadow DOM encapsulation. + */ + listenerScope?: HTMLElement | (() => HTMLElement); + + /** Whether host uses delegatesFocus. @default false */ + hostDelegatesFocus?: boolean; + + /** + * Number of items per row in grid mode. + * Required when direction is 'grid' — no arbitrary default. + */ + directionLength?: number; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Normalizes a config value that may be a static value or a function. + * Always returns a function for consistent internal access. + */ +function normalize( + value: TFn | TStatic | undefined, + staticType: string, + fallback: TFn +): TFn { + if (typeof value === 'function') { + return value as TFn; + } + if (typeof value === staticType) { + return (() => value) as unknown as TFn; + } + return fallback; +} + +// --------------------------------------------------------------------------- +// Controller +// --------------------------------------------------------------------------- + +/** + * A reactive controller implementing the WAI-ARIA roving tabindex pattern. + * + * Manages keyboard navigation (arrow keys, Home/End) and tabindex attributes + * across a group of elements so that only the current element has + * `tabindex="0"` and all others have `tabindex="-1"`. The group appears as + * a single tab stop. + * + * Consolidates the 1st-gen `FocusGroupController` + `RovingTabindexController` + * into a single, self-contained file. + * + * @see https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex + */ +export class RovingTabindexController< + T extends HTMLElement, +> implements ReactiveController { + // ---- State --------------------------------------------------------------- + + private host: ReactiveElement; + private _elements: () => T[]; + private _direction: () => DirectionTypes; + private _focusInIndex: (elements: T[]) => number; + private _listenerScope: () => HTMLElement; + + isFocusableElement: (el: T) => boolean; + elementEnterAction: (el: T) => void; + stopKeyEventPropagation: boolean; + hostDelegatesFocus: boolean; + directionLength: number; + + private cachedElements?: T[]; + private _currentIndex = -1; + private prevIndex = -1; + private _focused = false; + private managed = true; + private recentlyConnected = false; + private manageIndexesAnimationFrame = 0; + + private mutationObserver: MutationObserver; + + // ---- Constructor --------------------------------------------------------- + + constructor(host: ReactiveElement, config: RovingTabindexConfig) { + this.host = host; + this.host.addController(this); + + this._elements = config.elements; + + this._direction = normalize<() => DirectionTypes, DirectionTypes>( + config.direction, + 'string', + () => 'both' + ); + + this._focusInIndex = normalize<(els: T[]) => number, number>( + config.focusInIndex, + 'number', + () => 0 + ); + + this._listenerScope = normalize<() => HTMLElement, HTMLElement>( + config.listenerScope, + 'object', + () => (this.host.renderRoot as unknown as HTMLElement) ?? this.host + ); + + this.isFocusableElement = config.isFocusableElement ?? (() => true); + this.elementEnterAction = config.elementEnterAction ?? (() => {}); + this.stopKeyEventPropagation = config.stopKeyEventPropagation ?? false; + this.hostDelegatesFocus = config.hostDelegatesFocus ?? false; + this.directionLength = config.directionLength ?? 1; + + this.mutationObserver = new MutationObserver(() => { + this.handleItemMutation(); + }); + } + + // ---- Public getters ------------------------------------------------------ + + get direction(): DirectionTypes { + return this._direction(); + } + + get elements(): T[] { + if (!this.cachedElements) { + this.cachedElements = this._elements(); + } + return this.cachedElements; + } + + get currentIndex(): number { + if (this._currentIndex === -1) { + this._currentIndex = this.focusInIndex; + } + return this._currentIndex; + } + + set currentIndex(index: number) { + this._currentIndex = index; + } + + get focusInIndex(): number { + return this._focusInIndex(this.elements); + } + + get focusInElement(): T { + return this.elements[this.focusInIndex]; + } + + private get focused(): boolean { + return this._focused; + } + + private set focused(value: boolean) { + if (value === this._focused) { + return; + } + this._focused = value; + this.manageTabindexes(); + } + + // ---- Public methods ------------------------------------------------------ + + /** + * Focus the current element in the group, advancing circularly if needed. + */ + focus(options?: FocusOptions): void { + const elements = this.elements; + if (!elements.length) { + return; + } + + let focusElement = elements[this.currentIndex]; + if (!focusElement || !this.isFocusableElement(focusElement)) { + this.setCurrentIndexCircularly(1); + focusElement = elements[this.currentIndex]; + } + if (focusElement && this.isFocusableElement(focusElement)) { + if ( + !this.hostDelegatesFocus || + elements[this.prevIndex] !== focusElement + ) { + elements[this.prevIndex]?.setAttribute('tabindex', '-1'); + } + focusElement.tabIndex = 0; + focusElement.focus(options); + if (this.hostDelegatesFocus && !this.focused) { + this.hostContainsFocus(); + } + } + } + + /** + * Focus a specific item in the group. + */ + focusOnItem(item?: T, options?: FocusOptions): void { + const elements = this.elements || []; + const newIndex = + !item || !this.isFocusableElement(item) ? -1 : elements.indexOf(item); + if (newIndex > -1) { + this.currentIndex = newIndex; + elements[this.prevIndex]?.setAttribute('tabindex', '-1'); + } + this.focus(options); + } + + /** + * Reset focus tracking to the initial element. + */ + reset(): void { + const elements = this.elements; + if (!elements.length) { + return; + } + + this.setCurrentIndexCircularly(this.focusInIndex - this.currentIndex); + let focusElement = elements[this.currentIndex]; + if (this.currentIndex < 0) { + return; + } + + if (!focusElement || !this.isFocusableElement(focusElement)) { + this.setCurrentIndexCircularly(1); + focusElement = elements[this.currentIndex]; + } + if (focusElement && this.isFocusableElement(focusElement)) { + elements[this.prevIndex]?.setAttribute('tabindex', '-1'); + focusElement.setAttribute('tabindex', '0'); + } + } + + /** + * Clear the element cache. Call when the set of managed elements changes + * dynamically (e.g., items added/removed via slots). + */ + clearElementCache(): void { + cancelAnimationFrame(this.manageIndexesAnimationFrame); + this.mutationObserver.disconnect(); + delete this.cachedElements; + + requestAnimationFrame(() => { + this.elements.forEach((element) => { + this.mutationObserver.observe(element, { + attributes: true, + attributeFilter: ['disabled', 'aria-disabled'], + }); + }); + }); + + if (!this.managed) { + return; + } + this.manageIndexesAnimationFrame = requestAnimationFrame(() => + this.manageTabindexes() + ); + } + + /** + * Replace the element supplier and re-initialize. + */ + update(config: Pick, 'elements'>): void { + this.unmanage(); + this._elements = config.elements; + this.clearElementCache(); + this.manage(); + } + + /** + * Start managing tabindexes and listening for events. + */ + manage(): void { + this.managed = true; + this.manageTabindexes(); + this.addEventListeners(); + } + + /** + * Stop managing — restore all elements to `tabindex="0"`. + */ + unmanage(): void { + this.managed = false; + this.elements.forEach((el) => { + el.tabIndex = 0; + }); + this.removeEventListeners(); + } + + // ---- Circular index calculation ------------------------------------------ + + setCurrentIndexCircularly(diff: number): void { + const { length } = this.elements; + let steps = length; + this.prevIndex = this.currentIndex; + let nextIndex = (length + this.currentIndex + diff) % length; + while ( + steps && + this.elements[nextIndex] && + !this.isFocusableElement(this.elements[nextIndex]) + ) { + nextIndex = (length + nextIndex + diff) % length; + steps -= 1; + } + this.currentIndex = nextIndex; + } + + // ---- Tabindex management ------------------------------------------------- + + private manageTabindexes(): void { + if (this.focused && !this.hostDelegatesFocus) { + // While focused, only the current element keeps tabindex="0" + // (set by focus()), all others get -1. + this.elements.forEach((el) => { + if (el !== this.elements[this.currentIndex]) { + el.tabIndex = -1; + } + }); + } else { + // When not focused (or host delegates focus), the focusInElement + // gets tabindex="0" so Tab lands there; all others get -1. + this.elements.forEach((el) => { + el.tabIndex = el === this.focusInElement ? 0 : -1; + }); + } + } + + // ---- Event handling ------------------------------------------------------ + + private isEventWithinListenerScope(event: Event): boolean { + const scope = this._listenerScope(); + if (scope === this.host) { + return true; + } + return event.composedPath().includes(scope); + } + + private isRelatedTargetAnElementOrChild(event: FocusEvent): boolean { + const relatedTarget = event.relatedTarget as null | Element; + const isElement = this.elements.includes(relatedTarget as T); + const isChild = this.elements.some((el) => el.contains(relatedTarget)); + return !(isElement || isChild); + } + + private handleFocusin = (event: FocusEvent): void => { + if (!this.isEventWithinListenerScope(event)) { + return; + } + + const path = event.composedPath() as T[]; + let targetIndex = -1; + path.find((el) => { + targetIndex = this.elements.indexOf(el); + return targetIndex !== -1; + }); + this.prevIndex = this.currentIndex; + this.currentIndex = targetIndex > -1 ? targetIndex : this.currentIndex; + + if (this.isRelatedTargetAnElementOrChild(event)) { + this.hostContainsFocus(); + } + }; + + private handleFocusout = (event: FocusEvent): void => { + if (this.isRelatedTargetAnElementOrChild(event)) { + this.hostNoLongerContainsFocus(); + } + }; + + private handleKeydown = (event: KeyboardEvent): void => { + if (!this.acceptsEventKey(event.key) || event.defaultPrevented) { + return; + } + + let diff = 0; + this.prevIndex = this.currentIndex; + switch (event.key) { + case 'ArrowRight': + diff += 1; + break; + case 'ArrowDown': + diff += this.direction === 'grid' ? this.directionLength : 1; + break; + case 'ArrowLeft': + diff -= 1; + break; + case 'ArrowUp': + diff -= this.direction === 'grid' ? this.directionLength : 1; + break; + case 'End': + this.currentIndex = 0; + diff -= 1; + break; + case 'Home': + this.currentIndex = this.elements.length - 1; + diff += 1; + break; + } + + event.preventDefault(); + if (this.stopKeyEventPropagation) { + event.stopPropagation(); + } + + // Grid mode: clamp rather than wrap + if (this.direction === 'grid' && this.currentIndex + diff < 0) { + this.currentIndex = 0; + } else if ( + this.direction === 'grid' && + this.currentIndex + diff > this.elements.length - 1 + ) { + this.currentIndex = this.elements.length - 1; + } else { + this.setCurrentIndexCircularly(diff); + } + + // Enter action before focus so callbacks can read "after" state + this.elementEnterAction(this.elements[this.currentIndex]); + this.focus(); + }; + + private acceptsEventKey(key: string): boolean { + if (key === 'End' || key === 'Home') { + return true; + } + switch (this.direction) { + case 'horizontal': + return key === 'ArrowLeft' || key === 'ArrowRight'; + case 'vertical': + return key === 'ArrowUp' || key === 'ArrowDown'; + case 'both': + case 'grid': + return key.startsWith('Arrow'); + } + } + + // ---- Focus containment tracking ------------------------------------------ + + private hostContainsFocus(): void { + this.host.addEventListener('focusout', this.handleFocusout); + this.host.addEventListener('keydown', this.handleKeydown); + this.focused = true; + } + + private hostNoLongerContainsFocus(): void { + this.host.addEventListener('focusin', this.handleFocusin); + this.host.removeEventListener('focusout', this.handleFocusout); + this.host.removeEventListener('keydown', this.handleKeydown); + this.focused = false; + } + + // ---- Mutation handling --------------------------------------------------- + + private handleItemMutation(): void { + if ( + this._currentIndex === -1 || + this.elements.length <= this._elements().length + ) { + return; + } + const focusedElement = this.elements[this.currentIndex]; + this.clearElementCache(); + if (this.elements.includes(focusedElement)) { + return; + } + + const moveToNext = this.currentIndex !== this.elements.length; + const diff = moveToNext ? 1 : -1; + if (moveToNext) { + this.setCurrentIndexCircularly(-1); + } + this.setCurrentIndexCircularly(diff); + this.focus(); + } + + // ---- Listener lifecycle -------------------------------------------------- + + private addEventListeners(): void { + this.host.addEventListener('focusin', this.handleFocusin); + } + + private removeEventListeners(): void { + this.host.removeEventListener('focusin', this.handleFocusin); + this.host.removeEventListener('focusout', this.handleFocusout); + this.host.removeEventListener('keydown', this.handleKeydown); + } + + // ---- Reactive controller lifecycle --------------------------------------- + + hostConnected(): void { + this.recentlyConnected = true; + this.addEventListeners(); + } + + hostDisconnected(): void { + this.mutationObserver.disconnect(); + this.removeEventListeners(); + } + + hostUpdated(): void { + if (this.recentlyConnected) { + this.recentlyConnected = false; + this.elements.forEach((element) => { + this.mutationObserver.observe(element, { + attributes: true, + attributeFilter: ['disabled', 'aria-disabled'], + }); + }); + } + if (!this.host.hasUpdated) { + this.manageTabindexes(); + } + } +} From 4242706ec1dc23b2864b011d7c95566e8350bf57 Mon Sep 17 00:00:00 2001 From: Casey Eickhoff Date: Wed, 1 Apr 2026 17:37:50 -0600 Subject: [PATCH 04/21] feat(core): add DisabledMixin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of focus management migration (FOCUS-MANAGEMENT-PROPOSAL.md §5). Extracts disabled state handling from 1st-gen Focusable base class into a standalone, composable mixin. Uses aria-disabled on the host for screen reader discoverability rather than the native disabled attribute. Manages tabindex removal/restoration and blurs active element on disable. --- .../packages/core/mixins/disabled-mixin.ts | 85 +++++++++++++++++++ 2nd-gen/packages/core/mixins/index.ts | 1 + 2 files changed, 86 insertions(+) create mode 100644 2nd-gen/packages/core/mixins/disabled-mixin.ts diff --git a/2nd-gen/packages/core/mixins/disabled-mixin.ts b/2nd-gen/packages/core/mixins/disabled-mixin.ts new file mode 100644 index 00000000000..82710a61c50 --- /dev/null +++ b/2nd-gen/packages/core/mixins/disabled-mixin.ts @@ -0,0 +1,85 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { PropertyValues, ReactiveElement } from 'lit'; +import { property } from 'lit/decorators.js'; + +type Constructor> = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new (...args: any[]): T; + prototype: T; +}; + +export interface DisabledInterface { + disabled: boolean; +} + +/** + * A mixin that adds a reactive `disabled` property with associated + * accessibility behavior. + * + * Sets `aria-disabled` on the host (not the native `disabled` attribute) + * so the element remains discoverable by assistive technology. Components + * wrapping native form controls (e.g., ``, ``; + * } + * } + * ``` + */ +export function DisabledMixin>( + constructor: T +): T & Constructor { + class DisabledElement extends constructor { + @property({ type: Boolean, reflect: true }) + disabled = false; + + /** + * Uses `update()` (not `updated()`) so side effects apply BEFORE render. + * Using `updated()` would leave a single frame where the component + * renders as enabled but is behaviorally disabled, allowing the element + * to be briefly focusable/clickable. + */ + protected override update(changedProperties: PropertyValues): void { + if (changedProperties.has('disabled')) { + if (this.disabled) { + // Host gets aria-disabled for screen reader discoverability. + // Components wrapping native form controls should ALSO set + // disabled on the inner element in render(). + this.setAttribute('aria-disabled', 'true'); + if (this.hasAttribute('tabindex')) { + this.dataset.prevTabindex = this.getAttribute('tabindex')!; + this.setAttribute('tabindex', '-1'); + } + if (this.matches(':focus-within')) { + (this.shadowRoot?.activeElement as HTMLElement)?.blur(); + } + } else { + this.removeAttribute('aria-disabled'); + if (this.dataset.prevTabindex !== undefined) { + this.setAttribute('tabindex', this.dataset.prevTabindex); + delete this.dataset.prevTabindex; + } + } + } + super.update(changedProperties); + } + } + return DisabledElement as unknown as T & Constructor; +} diff --git a/2nd-gen/packages/core/mixins/index.ts b/2nd-gen/packages/core/mixins/index.ts index f8343051236..321f1138dfb 100644 --- a/2nd-gen/packages/core/mixins/index.ts +++ b/2nd-gen/packages/core/mixins/index.ts @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +export { DisabledMixin, type DisabledInterface } from './disabled-mixin.js'; export { ObserveSlotPresence, type SlotPresenceObservingInterface, From bbdc92e067d38e6b5486831e5332a282a6ce1ce3 Mon Sep 17 00:00:00 2001 From: Casey Eickhoff Date: Wed, 1 Apr 2026 17:57:24 -0600 Subject: [PATCH 05/21] chore: add contributor guide for focus management based on proposal --- 2nd-gen/packages/swc/.storybook/preview.ts | 1 + .../13_focus-management.md | 635 ++++++++++++++++++ 2 files changed, 636 insertions(+) create mode 100644 CONTRIBUTOR-DOCS/01_contributor-guides/13_focus-management.md diff --git a/2nd-gen/packages/swc/.storybook/preview.ts b/2nd-gen/packages/swc/.storybook/preview.ts index ea4fa60de96..13decc838d1 100644 --- a/2nd-gen/packages/swc/.storybook/preview.ts +++ b/2nd-gen/packages/swc/.storybook/preview.ts @@ -255,6 +255,7 @@ const preview = { 'Using stackblitz', '2nd-gen testing', 'Tools vs packages', + 'Focus management', ], 'Style guide', [ diff --git a/CONTRIBUTOR-DOCS/01_contributor-guides/13_focus-management.md b/CONTRIBUTOR-DOCS/01_contributor-guides/13_focus-management.md new file mode 100644 index 00000000000..a9130484d72 --- /dev/null +++ b/CONTRIBUTOR-DOCS/01_contributor-guides/13_focus-management.md @@ -0,0 +1,635 @@ + + +[CONTRIBUTOR-DOCS](../README.md) / [Contributor guides](README.md) / Focus management + + + +# Focus management + + + +
+In this doc + +- [Overview](#overview) +- [The three primitives](#the-three-primitives) +- [Choosing a focus strategy](#choosing-a-focus-strategy) +- [delegatesFocus: true](#delegatesfocus-true) + - [When to use](#when-to-use) + - [How to use](#how-to-use) + - [Gotchas](#gotchas) + - [CSS and focus styling](#css-and-focus-styling) + - [Common mistakes](#common-mistakes-delegatesfocus) +- [DisabledMixin](#disabledmixin) + - [When to use](#when-to-use-1) + - [How to use](#how-to-use-1) + - [Why aria-disabled](#why-aria-disabled) + - [Common mistakes](#common-mistakes-disabledmixin) +- [RovingTabindexController](#rovingtabindexcontroller) + - [When to use](#when-to-use-2) + - [How to use](#how-to-use-2) + - [Configuration options](#configuration-options) + - [Common mistakes](#common-mistakes-rovingtabindexcontroller) +- [Focus utilities](#focus-utilities) + - [getActiveElement()](#getactiveelement) + - [focusableSelector and tabbableSelector](#focusableselector-and-tabbableselector) + - [hasVisibleFocusInTree()](#hasvisiblefocusintree) +- [Migration from 1st-gen](#migration-from-1st-gen) + - [Replacing Focusable base class](#replacing-focusable-base-class) + - [Replacing focusElement getter](#replacing-focuselement-getter) + - [Replacing FocusGroupController](#replacing-focusgroupcontroller) +- [Testing focus behavior](#testing-focus-behavior) +- [Resources](#resources) + +
+ + + +## Overview + +2nd-gen Spectrum Web Components use three composable, opt-in primitives for focus management instead of the 1st-gen `Focusable` base class inheritance chain. Each component picks only what it needs: + +``` +SpectrumElement (base, no focus logic) + ├── + DisabledMixin (opt-in disabled state) + ├── + delegatesFocus: true (native browser focus delegation) + └── + RovingTabindexController (opt-in roving tabindex + arrow keys) +``` + +This guide explains when and how to use each primitive, with correct examples and common mistakes to avoid. + +> **Scope:** This guide covers core focus management for standard components. Overlay, dialog, and dropdown focus concerns (focus trapping, focus restoration, overlay stacking) are **out of scope** and will be documented when those components are migrated. + +For the full technical rationale, see the [Focus Management Proposal](../../2nd-gen/packages/core/FOCUS-MANAGEMENT-PROPOSAL.md). + +--- + +## The three primitives + +| Primitive | What it does | Import | +|-----------|-------------|--------| +| `delegatesFocus: true` | Browser-native focus delegation from host to first focusable child | Built-in (shadow root option) | +| `DisabledMixin` | Reactive `disabled` property with `aria-disabled`, tabindex, blur | `@spectrum-web-components/core/mixins` | +| `RovingTabindexController` | Arrow key navigation + tabindex management for composite widgets | `@spectrum-web-components/core/controllers` | + +--- + +## Choosing a focus strategy + +Use this decision tree for every component: + +1. **Does the component manage focus across child elements?** (e.g., tabs, radio group, menu) + - **Yes** → Use `RovingTabindexController` + +2. **Does the host element itself receive focus?** (e.g., button, menu item) + - **Yes** → Use `DisabledMixin` only. No delegation needed. + +3. **Should focus go to a single inner element?** (e.g., textfield → ``, link → ``) + - **Yes** → Use `delegatesFocus: true`. Ensure the focus target is the **first focusable element** in the shadow DOM template. + +Most components also need `DisabledMixin` regardless of which focus strategy they use. + +--- + +## delegatesFocus: true + +### When to use + +Use `delegatesFocus` when there is **exactly one place focus should ever go** inside the shadow root. This covers components like textfields, checkboxes, links, color inputs, and accordion items. + +**Do not use** when: +- The component has multiple internal focus targets (e.g., combobox, multi-handle slider) +- The component manages its own focus routing (e.g., roving tabindex groups) +- The host element itself should be the focus target + +### How to use + +```typescript +import { DisabledMixin } from '@spectrum-web-components/core/mixins'; +import { SpectrumElement } from '@spectrum-web-components/core/element'; +import { html, css } from 'lit'; + +class SpCheckbox extends DisabledMixin(SpectrumElement) { + static override shadowRootOptions = { + ...SpectrumElement.shadowRootOptions, + delegatesFocus: true, + }; + + static override styles = css` + /* Suppress host focus outline — the inner element owns the focus ring */ + :host { + outline: none; + } + /* Container-level styling when focused */ + :host(:focus-within) { + border-color: var(--spectrum-focus-indicator-color); + } + /* Keyboard focus ring on the actual interactive element */ + input:focus-visible { + outline: 2px solid var(--spectrum-focus-indicator-color); + } + `; + + override render() { + // The MUST be the first focusable element in the template + return html` + + + `; + } + + // Re-dispatch focus/blur as composed events so they cross the shadow boundary + private _handleFocus(event: FocusEvent): void { + this.dispatchEvent(new FocusEvent('focus', { + bubbles: true, + composed: true, + relatedTarget: event.relatedTarget, + })); + } + + private _handleBlur(event: FocusEvent): void { + this.dispatchEvent(new FocusEvent('blur', { + bubbles: true, + composed: true, + relatedTarget: event.relatedTarget, + })); + } +} +``` + +### Gotchas + +1. **Do not set `tabindex` on the host.** Adding `tabindex="0"` creates **two tab stops** — the host gets focus first, then the inner element. This breaks keyboard navigation entirely. + +2. **Focus/blur events do not bubble out of the shadow root.** `delegatesFocus` handles focus *routing*, not event *bubbling*. If consumers need `focus`/`blur` events, you must re-dispatch them as composed events (see example above). + +3. **The focus target must be first.** The browser always delegates to the **first focusable element** in the shadow DOM. If your template puts decorative elements or other controls before the primary interactive element, focus will land in the wrong place. Restructure the template if needed. + +4. **`:focus` on the host is a pseudo-class match, not actual focus.** The host matches `:focus` for CSS styling when an inner element is focused, but `document.activeElement` still points to the host. Use `shadowRoot.activeElement` or `getActiveElement()` to find the real focused element. + +### CSS and focus styling + +| Selector | Where to use | Purpose | +|----------|-------------|---------| +| `:host(:focus-within)` | Host styles | Container-level styling when any descendant is focused | +| `:host(:focus)` | Host styles | Matches when inner element is focused (via delegation) | +| `input:focus-visible` | Shadow styles | Keyboard focus ring on the actual interactive element | +| `:host { outline: none; }` | Host styles | Suppress the default host focus outline to avoid double rings | + +### Common mistakes (delegatesFocus) + +```typescript +// BAD: tabindex on the host creates a double tab stop +class SpTextfield extends DisabledMixin(SpectrumElement) { + static override shadowRootOptions = { + ...SpectrumElement.shadowRootOptions, + delegatesFocus: true, + }; + connectedCallback() { + super.connectedCallback(); + this.tabIndex = 0; // WRONG — creates two tab stops + } +} +``` + +```typescript +// BAD: focus target is NOT the first focusable element +class SpTextfield extends DisabledMixin(SpectrumElement) { + static override shadowRootOptions = { + ...SpectrumElement.shadowRootOptions, + delegatesFocus: true, + }; + override render() { + return html` + + + `; + } +} +``` + +```typescript +// BAD: using delegatesFocus when the host should receive focus directly +class SpButton extends DisabledMixin(SpectrumElement) { + static override shadowRootOptions = { + ...SpectrumElement.shadowRootOptions, + delegatesFocus: true, // WRONG — the host IS the interactive element + }; +} + +// GOOD: host receives focus directly, no delegation needed +class SpButton extends DisabledMixin(SpectrumElement) { + // No delegatesFocus — the host is the focus target +} +``` + +--- + +## DisabledMixin + +### When to use + +Any interactive component that can be disabled — buttons, inputs, links, menu items, sliders, etc. Most components that use `delegatesFocus` or `RovingTabindexController` will also use `DisabledMixin`. + +### How to use + +```typescript +import { DisabledMixin } from '@spectrum-web-components/core/mixins'; +import { SpectrumElement } from '@spectrum-web-components/core/element'; +import { html } from 'lit'; + +class SpButton extends DisabledMixin(SpectrumElement) { + override render() { + return html` + + + `; + } +} +``` + +**What it does automatically:** +- Adds `disabled` as a reflected boolean property +- Sets `aria-disabled="true"` on the host when disabled +- Sets `tabindex="-1"` when disabled (preserves and restores the previous value) +- Blurs the element if it has focus when disabled +- Applies side effects in `update()` (before render) to prevent a 1-frame focusable gap + +### Why aria-disabled + +The mixin uses `aria-disabled` on the host instead of the native `disabled` attribute. This is intentional: + +| | `disabled` | `aria-disabled` | +|--|-----------|-----------------| +| Focusable? | No — removed from tab order | Yes — remains keyboard-accessible | +| Click events? | Blocked by browser | Still fire (guard in handler) | +| Screen readers | Disabled + undiscoverable | Disabled but still discoverable | + +Custom elements are not native form controls — the browser's `disabled` attribute has no built-in effect on a custom element host. Using `aria-disabled` keeps the element discoverable by assistive technology, which is generally better UX. + +**Components wrapping native form controls** (textfield, checkbox, etc.) should **also** set `disabled` on the inner element in `render()` to get correct platform behavior. + +For the full rationale, see [On disabled and aria-disabled attributes](https://kittygiraudel.com/2024/03/29/on-disabled-and-aria-disabled-attributes/). + +### Common mistakes (DisabledMixin) + +```typescript +// BAD: not setting disabled on the inner native element +class SpTextfield extends DisabledMixin(SpectrumElement) { + static override shadowRootOptions = { + ...SpectrumElement.shadowRootOptions, + delegatesFocus: true, + }; + override render() { + // The inner is still interactive even when host is "disabled" + return html``; // WRONG — missing ?disabled=${this.disabled} + } +} + +// GOOD: propagate disabled to the inner element +class SpTextfield extends DisabledMixin(SpectrumElement) { + static override shadowRootOptions = { + ...SpectrumElement.shadowRootOptions, + delegatesFocus: true, + }; + override render() { + return html``; + } +} +``` + +```typescript +// BAD: using updated() instead of update() for disabled side effects +class SpButton extends DisabledMixin(SpectrumElement) { + override updated(changed: PropertyValues) { + super.updated(changed); + // WRONG — this runs AFTER render, leaving a 1-frame gap where + // the element is visually enabled but behaviorally disabled + if (changed.has('disabled') && this.disabled) { + this.style.pointerEvents = 'none'; + } + } +} +``` + +```typescript +// BAD: not guarding click handlers when disabled +class SpButton extends DisabledMixin(SpectrumElement) { + private handleClick() { + // WRONG — aria-disabled doesn't block click events! + this.dispatchEvent(new Event('action')); + } + + // GOOD — guard against clicks when disabled + private handleClick() { + if (this.disabled) return; + this.dispatchEvent(new Event('action')); + } +} +``` + +--- + +## RovingTabindexController + +### When to use + +Composite widgets that should appear as a **single tab stop** with arrow key navigation between children. This follows WAI-ARIA patterns for: +- Tablists (``) +- Toolbars / action groups (``) +- Radio groups (``) +- Menus (``) +- Listboxes, grids, tree views + +### How to use + +```typescript +import { RovingTabindexController } from '@spectrum-web-components/core/controllers'; +import { SpectrumElement } from '@spectrum-web-components/core/element'; + +class SpTabs extends SpectrumElement { + private rovingTabindex = new RovingTabindexController(this, { + elements: () => [...this.querySelectorAll('sp-tab')] as Tab[], + direction: 'horizontal', + isFocusableElement: (tab) => !tab.disabled, + // Auto-select tab on arrow key navigation + elementEnterAction: (tab) => { + if (this.auto) { + this.selectTab(tab); + } + }, + // Return to selected tab (or first enabled tab) when re-entering + focusInIndex: (tabs) => { + const selectedIndex = tabs.findIndex((t) => t.selected); + return selectedIndex >= 0 ? selectedIndex : 0; + }, + }); + + // Expose the focus-in element for external focus management + get focusElement(): Tab { + return this.rovingTabindex.focusInElement; + } +} +``` + +**How it works at runtime:** + +``` +Tab into group → focus lands on element with tabindex="0": + [ A: 0 ] [ B: -1 ] [ C: -1 ] [ D: -1 ] + +Arrow Right → tabindex swaps, focus moves to B: + [ A: -1 ] [ B: 0 ] [ C: -1 ] [ D: -1 ] + +Tab out, then Tab back in → returns to B (remembered): + [ A: -1 ] [ B: 0 ] [ C: -1 ] [ D: -1 ] +``` + +### Configuration options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `elements` | `() => T[]` | **(required)** | Returns the current list of focusable elements | +| `direction` | `DirectionTypes \| () => DirectionTypes` | `'both'` | `'horizontal'`, `'vertical'`, `'both'`, or `'grid'` | +| `focusInIndex` | `number \| (elements: T[]) => number` | `0` | Which element to focus when entering the group | +| `isFocusableElement` | `(el: T) => boolean` | `() => true` | Filter non-focusable/disabled elements | +| `elementEnterAction` | `(el: T) => void` | no-op | Callback before focusing an element (e.g., auto-select) | +| `stopKeyEventPropagation` | `boolean` | `false` | Stop arrow key events from propagating | +| `listenerScope` | `HTMLElement \| () => HTMLElement` | `host.renderRoot` | Scope element for event listeners | +| `hostDelegatesFocus` | `boolean` | `false` | Set `true` if host also uses `delegatesFocus` | +| `directionLength` | `number` | `1` | Items per row in grid mode (required for `'grid'` direction) | + +### Common mistakes (RovingTabindexController) + +```typescript +// BAD: elements() returning a static array that goes stale +class SpTabs extends SpectrumElement { + private tabs = [...this.querySelectorAll('sp-tab')]; // Captured once at construction + + private rovingTabindex = new RovingTabindexController(this, { + elements: () => this.tabs, // WRONG — won't reflect DOM changes + }); +} + +// GOOD: elements() queries live DOM every time +class SpTabs extends SpectrumElement { + private rovingTabindex = new RovingTabindexController(this, { + elements: () => [...this.querySelectorAll('sp-tab')] as Tab[], + }); +} +``` + +```typescript +// BAD: forgetting isFocusableElement — arrow keys land on disabled items +class SpMenu extends SpectrumElement { + private rovingTabindex = new RovingTabindexController(this, { + elements: () => [...this.querySelectorAll('sp-menu-item')] as MenuItem[], + direction: 'vertical', + // WRONG — disabled items will receive focus via arrow keys + }); +} + +// GOOD: skip disabled elements +class SpMenu extends SpectrumElement { + private rovingTabindex = new RovingTabindexController(this, { + elements: () => [...this.querySelectorAll('sp-menu-item')] as MenuItem[], + direction: 'vertical', + isFocusableElement: (item) => !item.disabled, + }); +} +``` + +```typescript +// BAD: using delegatesFocus with RovingTabindexController without telling it +class SpActionGroup extends SpectrumElement { + static override shadowRootOptions = { + ...SpectrumElement.shadowRootOptions, + delegatesFocus: true, + }; + private rovingTabindex = new RovingTabindexController(this, { + elements: () => [...this.querySelectorAll('sp-action-button')] as ActionButton[], + // WRONG — controller doesn't know about delegatesFocus, tabindex conflicts + }); +} + +// GOOD: tell the controller about delegatesFocus +class SpActionGroup extends SpectrumElement { + static override shadowRootOptions = { + ...SpectrumElement.shadowRootOptions, + delegatesFocus: true, + }; + private rovingTabindex = new RovingTabindexController(this, { + elements: () => [...this.querySelectorAll('sp-action-button')] as ActionButton[], + hostDelegatesFocus: true, // Coordinates tabindex management with delegation + }); +} +``` + +```typescript +// BAD: not calling clearElementCache() when items change dynamically +class SpMenu extends SpectrumElement { + addItem(item: MenuItem) { + this.appendChild(item); + // WRONG — controller still has the old cached element list + } +} + +// GOOD: invalidate the cache after DOM mutations +class SpMenu extends SpectrumElement { + addItem(item: MenuItem) { + this.appendChild(item); + this.rovingTabindex.clearElementCache(); + } +} +``` + +--- + +## Focus utilities + +### getActiveElement() + +Returns the deepest focused element by traversing shadow DOM boundaries. `document.activeElement` stops at shadow hosts — this follows the chain. + +```typescript +import { getActiveElement } from '@spectrum-web-components/core/utils'; + +// Get the truly focused element across all shadow boundaries +const active = getActiveElement(); + +// Start from a specific root +const active = getActiveElement(this.getRootNode() as Document); +``` + +### focusableSelector and tabbableSelector + +CSS selector strings matching focusable and tabbable elements per the HTML spec. + +```typescript +import { focusableSelector, tabbableSelector } from '@spectrum-web-components/core/utils'; + +// Find the first focusable element in a container +const first = container.querySelector(focusableSelector); + +// Find all tabbable elements (excludes tabindex="-1") +const tabbable = [...container.querySelectorAll(tabbableSelector)]; +``` + +These use standard HTML focusability rules only. The 1st-gen `[focusable]` attribute selector is not included — native `delegatesFocus` replaces that workaround. + +### hasVisibleFocusInTree() + +Available on all `SpectrumElement` subclasses. Returns `true` if the deepest focused element in the current tree matches `:focus-visible` — i.e., the browser would show a focus ring. + +```typescript +class SpButton extends SpectrumElement { + private handleFocus() { + if (this.hasVisibleFocusInTree()) { + // Keyboard focus — show custom focus indicator + } + } +} +``` + +--- + +## Migration from 1st-gen + +### Replacing Focusable base class + +```typescript +// 1st-gen +import { Focusable } from '@spectrum-web-components/shared'; + +class SpTextfield extends Focusable { + get focusElement() { + return this.shadowRoot.querySelector('input'); + } +} + +// 2nd-gen +import { DisabledMixin } from '@spectrum-web-components/core/mixins'; +import { SpectrumElement } from '@spectrum-web-components/core/element'; + +class SpTextfield extends DisabledMixin(SpectrumElement) { + static override shadowRootOptions = { + ...SpectrumElement.shadowRootOptions, + delegatesFocus: true, + }; + // No focusElement getter needed — browser handles it +} +``` + +### Replacing focusElement getter + +The `focusElement` getter is no longer needed. `delegatesFocus: true` automatically delegates to the first focusable child. Make sure the focus target is first in the template: + +```typescript +// 1st-gen: explicit focusElement +get focusElement() { + return this.shadowRoot.querySelector('#inner-input'); +} + +// 2nd-gen: template order handles it +override render() { + return html` + + + `; +} +``` + +### Replacing FocusGroupController + +`FocusGroupController` no longer exists as a separate class. Its logic is consolidated into `RovingTabindexController`. If you were using `FocusGroupController` directly (only Accordion did this in 1st-gen), switch to `RovingTabindexController`: + +```typescript +// 1st-gen +import { FocusGroupController } from '@spectrum-web-components/reactive-controllers'; + +// 2nd-gen +import { RovingTabindexController } from '@spectrum-web-components/core/controllers'; +// Same API — just a different import and class name +``` + +--- + +## Testing focus behavior + +When submitting a PR that affects focus management, you must verify: + +**Keyboard testing:** +- [ ] Tab key moves focus into and out of the component correctly (single tab stop for roving groups) +- [ ] Arrow keys navigate within composite widgets in the expected direction +- [ ] Home/End jump to first/last item +- [ ] Disabled items are skipped during arrow key navigation +- [ ] Focus returns to the last-focused item when re-entering a roving group +- [ ] Focus ring is visible on keyboard focus (`:focus-visible`) +- [ ] No focus ring on mouse click + +**Screen reader testing:** +- [ ] Component role and name are announced correctly +- [ ] Disabled state is announced (`aria-disabled`) +- [ ] Focus delegation announces the inner element, not the host + +**Automated testing:** +- Write interaction tests using Storybook play functions to verify Tab, Arrow, Home/End behavior +- Include accessibility tests that validate ARIA snapshots + +--- + +## Resources + +- [Focus Management Proposal](../../2nd-gen/packages/core/FOCUS-MANAGEMENT-PROPOSAL.md) — Full technical rationale +- [WAI-ARIA Roving Tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex) — The pattern `RovingTabindexController` implements +- [Shadow DOM delegatesFocus](https://frontendmasters.com/blog/shadow-dom-focus-delegation-getting-delegatesfocus-right/) — Implementation deep-dive +- [On disabled and aria-disabled](https://kittygiraudel.com/2024/03/29/on-disabled-and-aria-disabled-attributes/) — Why `DisabledMixin` uses `aria-disabled` +- [Accessibility Testing Guide](09_accessibility-testing.md) — Automated and manual a11y testing From 33ca99209dce7620d5b5a8a27c416e992e1a7453 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Thu, 2 Apr 2026 20:08:42 -0400 Subject: [PATCH 06/21] feat(controllers): focusgroup navigation controller proof of concept --- .../focus-group-navigation-controller.md | 119 +++ .../focus-group-navigation-controller.ts | 709 ++++++++++++++++++ 2nd-gen/packages/core/controllers/index.ts | 11 + ...cus-group-navigation-controller.stories.ts | 415 ++++++++++ 2nd-gen/packages/core/package.json | 7 + .../swc/.storybook/controllers/overview.mdx | 17 + 2nd-gen/packages/swc/.storybook/main.ts | 10 + .../swc/.storybook/storybook-env.d.ts | 5 + .../14_controller-composition.md | 22 +- 9 files changed, 1313 insertions(+), 2 deletions(-) create mode 100644 2nd-gen/packages/core/controllers/focus-group-navigation-controller.md create mode 100644 2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts create mode 100644 2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts create mode 100644 2nd-gen/packages/swc/.storybook/controllers/overview.mdx diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md new file mode 100644 index 00000000000..21a3e2c5268 --- /dev/null +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md @@ -0,0 +1,119 @@ +# Focus group navigation controller + +`FocusgroupNavigationController` implements the [roving `tabindex` pattern](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#managingfocuswithincomponentsusingarovingtabindex) from the ARIA Authoring Practices Guide (APG) and directional keyboard behavior aligned with the Open UI [`focusgroup` explainer](https://open-ui.org/components/scoped-focusgroup.explainer/). Use it inside Lit-based custom elements (or any `ReactiveElement`) until native `focusgroup` is widely available. + +## What it does + +- Collapses the tab sequence to **one** tab stop for the composite by setting `tabindex="0"` on the active item and `tabindex="-1"` on the others it manages. +- Moves focus with **Arrow** keys according to `direction`: horizontal (inline axis), vertical (block axis), or **grid** (rows and columns from layout). +- Supports **Home** / **End** to jump to the first or last item (for `grid`, order is visual row-major). +- Optional **wrap** (end wraps to start) and **memory** (Tab returns to the last focused item), similar to `wrap` and `nomemory` concepts in the `focusgroup` proposal. + +## Import + +```typescript +import { + FocusgroupNavigationController, + focusgroupNavigationActiveChange, +} from '@spectrum-web-components/core/controllers/focus-group-navigation-controller.js'; +``` + +## Basic usage + +1. Construct the controller in your element’s `constructor`, passing `getItems` and `direction`. +2. Ensure `getItems` returns live `HTMLElement` references (for example from `this.renderRoot` or slotted content). +3. After the first render, if items live in shadow DOM, call **`refresh()`** from `firstUpdated` (or after slotting) so roving tabindex can run once nodes exist. +4. Provide appropriate **roles** and **labels** on the host and items (the controller does not set ARIA roles). + +### Example (horizontal toolbar) + +```typescript +import { LitElement, html, css } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { FocusgroupNavigationController } from '@spectrum-web-components/core/controllers/focus-group-navigation-controller.js'; + +@customElement('my-format-toolbar') +export class MyFormatToolbar extends LitElement { + static styles = css` + :host { + display: flex; + gap: 4px; + } + `; + + private readonly navigation = new FocusgroupNavigationController(this, { + direction: 'horizontal', + wrap: true, + getItems: () => + Array.from(this.renderRoot.querySelectorAll('button')), + }); + + protected override firstUpdated(): void { + super.firstUpdated(); + this.navigation.refresh(); + } + + protected override render() { + return html` + + + + `; + } +} +``` + +### Example (vertical list, skip disabled) + +```typescript +this.navigation = new FocusgroupNavigationController(this, { + direction: 'vertical', + wrap: true, + skipDisabled: true, + getItems: () => + Array.from( + this.renderRoot.querySelectorAll('[role="menuitem"]') + ), +}); +``` + +### Example (grid) + +Use `direction: 'grid'` when items are laid out in rows (for example CSS Grid). The controller groups items into rows using bounding rectangles, then maps Arrow keys to cell movement. **Home** / **End** use visual row-major order. + +## API + +| Member | Description | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `setOptions(partial)` | Merge new options and reapply roving tabindex. | +| `refresh()` | Re-query items and sync tabindex (call after DOM changes). | +| `focusItem(element, focusOptions?)` | Programmatically focus an item and update roving tabindex. Returns `false` if the element is not in the current item list. | +| `getActiveItem()` | Returns the item with `tabindex="0"`, if any. | + +### Events + +On the host, the controller dispatches **`swc-focusgroup-navigation-active-change`** (`focusgroupNavigationActiveChange`) with `detail: { activeElement }` when the active item changes. + +### Options + +| Option | Type | Default | Description | +| -------------------- | -------------------------------------- | ---------- | ----------------------------------------------- | +| `getItems` | `() => HTMLElement[]` | (required) | Current navigable items. | +| `direction` | `'horizontal' \| 'vertical' \| 'grid'` | (required) | Arrow-key mode. | +| `wrap` | `boolean` | `false` | Wrap at ends. | +| `memory` | `boolean` | `true` | Remember last focused for re-entry via Tab. | +| `skipDisabled` | `boolean` | `false` | Skip `disabled` / `aria-disabled="true"` items. | +| `onActiveItemChange` | `(el) => void` | — | Callback when active item changes. | + +## RTL and writing modes + +For `horizontal`, **ArrowLeft** / **ArrowRight** follow the host’s resolved `dir` (`rtl` swaps forward/back). Vertical grid movement uses row geometry; column movement respects `dir` for left/right. + +## Relationship to native `focusgroup` + +Native `focusgroup` would supply guaranteed tab stops, memory, and arrow behavior in the browser. This controller provides a **JavaScript** implementation for custom elements: you keep explicit ARIA roles and selection logic, and use the controller for tabindex and arrow-key focus movement. + +## See also + +- [Keyboard navigation inside components (APG)](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#keyboardnavigationinsidecomponents) +- [Focusgroup explainer (Open UI)](https://open-ui.org/components/scoped-focusgroup.explainer/) diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts new file mode 100644 index 00000000000..16d1ee4a8ca --- /dev/null +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts @@ -0,0 +1,709 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { ReactiveController, ReactiveElement } from 'lit'; + +// ───────────────────────── +// TYPES +// ───────────────────────── + +/** + * Spatial mode for arrow-key movement. Aligns with logical axes (inline/block) and a + * 2D layout mode derived from element geometry. + * + * - **horizontal**: Arrow keys on the inline axis move focus (respects `dir`). + * - **vertical**: Arrow keys on the block axis move focus. + * - **grid**: Arrow keys move in rows and columns using bounding-rect layout. + */ +export type FocusgroupDirection = 'horizontal' | 'vertical' | 'grid'; + +/** + * Options for {@link FocusgroupNavigationController}. + */ +export type FocusgroupNavigationOptions = { + /** + * Returns the current set of items that participate in roving tabindex and + * directional navigation. Callers typically close over the host (for example + * querying slotted or shadow DOM children). + */ + getItems: () => HTMLElement[]; + + /** + * Determines which arrow keys move focus and how grid navigation is computed. + */ + direction: FocusgroupDirection; + + /** + * When true, arrow keys wrap from the last item to the first (and reverse). + * Defaults to false. + */ + wrap?: boolean; + + /** + * When true, restoring focus into the composite (for example with Tab) targets + * the item that was last focused, if it is still a member of the group. + * Similar to the default memory behavior described for `focusgroup` in Open UI. + * Defaults to true. + */ + memory?: boolean; + + /** + * When true, items that are disabled for interaction are skipped for arrow + * navigation and are not chosen as the roving tab stop. When false, disabled + * items remain in sequence (useful for patterns such as menus where disabled + * items may still be focusable per APG guidance). + * Defaults to false. + */ + skipDisabled?: boolean; + + /** + * Invoked after the active item changes and `tabindex` values are synchronized. + * The argument is the new active element, or null when the group has no eligible items. + */ + onActiveItemChange?: (active: HTMLElement | null) => void; +}; + +// ───────────────────────── +// CONSTANTS +// ───────────────────────── + +/** + * Default boolean flags merged with the constructor `options` object. + * + * @internal + */ +const DEFAULT_OPTIONS = { + wrap: false, + memory: true, + skipDisabled: false, +} as const; + +/** + * Tolerance in CSS pixels for grouping items into the same grid row when using + * {@link FocusgroupDirection | `grid`} mode. + * + * @internal + */ +const GRID_ROW_TOLERANCE_PX = 6; + +/** + * Name of the `CustomEvent` dispatched on the host when the roving tabindex active item changes. + * + * The event `bubbles` and is `composed`. Handlers read + * {@link FocusgroupNavigationActiveChangeDetail} from `event.detail`. + */ +export const focusgroupNavigationActiveChange = + 'swc-focusgroup-navigation-active-change'; + +/** + * `detail` object for the {@link focusgroupNavigationActiveChange} event. + */ +export type FocusgroupNavigationActiveChangeDetail = { + /** + * Element that now has `tabindex="0"` among managed items, or null when the group is empty. + */ + activeElement: HTMLElement | null; +}; + +/** + * **FocusgroupNavigation** — implements the roving `tabindex` pattern from the APG + * keyboard guide and directional navigation similar to the proposed `focusgroup` + * attribute (Open UI). The exported class name is `FocusgroupNavigationController`. + * + * The controller: + * - Keeps exactly one item in the tab order (`tabindex="0"`) per composite; sets + * `tabindex="-1"` on other items it manages. + * - Handles Arrow keys, Home, and End for focus movement (and optionally wrap). + * - Supports optional last-focused memory when re-entering via Tab. + * - Exposes {@link FocusgroupNavigationController.focusItem} for programmatic focus. + * + * Dispatches a bubbling, composed `CustomEvent` named + * {@link focusgroupNavigationActiveChange} when the active item changes. + * + * This is not a browser `focusgroup` implementation; it is a Lit reactive controller + * for custom elements until native `focusgroup` is available. + * + * @example + * ```typescript + * class MyToolbar extends LitElement { + * private readonly navigation = new FocusgroupNavigationController(this, { + * direction: 'horizontal', + * wrap: true, + * getItems: () => + * Array.from(this.renderRoot.querySelectorAll('button')), + * }); + * + * protected override firstUpdated(): void { + * super.firstUpdated(); + * this.navigation.refresh(); + * } + * } + * ``` + * + * @see https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#keyboardnavigationinsidecomponents + * @see https://open-ui.org/components/scoped-focusgroup.explainer/ + */ +export class FocusgroupNavigationController implements ReactiveController { + /** + * Lit reactive host this controller is attached to. + */ + private host: ReactiveElement; + + /** + * Effective options (defaults merged with the latest `setOptions` / constructor values). + */ + private options: FocusgroupNavigationOptions; + + /** + * Capture-phase `keydown` listener reference for removal on disconnect. + */ + private readonly boundKeydown = this.handleKeydown.bind(this); + + /** + * Capture-phase `focusin` listener reference for removal on disconnect. + */ + private readonly boundFocusin = this.handleFocusin.bind(this); + + /** + * Capture-phase `focusout` listener reference for removal on disconnect. + */ + private readonly boundFocusout = this.handleFocusout.bind(this); + + /** + * Cached item for {@link FocusgroupNavigationOptions.memory} when the user moves focus + * inside or out of the composite. Cleared when that node is no longer returned by + * `getItems` or when the group becomes empty. + */ + private lastFocused: HTMLElement | null = null; + + // ───────────────────────── + // PUBLIC API + // ───────────────────────── + + /** + * Registers this instance on `host` via `addController` and merges `options` with defaults. + * + * @param host - Reactive element that owns the composite (arrow keys and tab order apply within its subtree). + * @param options - `getItems`, `direction`, and optional behavior flags. + */ + constructor(host: ReactiveElement, options: FocusgroupNavigationOptions) { + this.host = host; + this.options = { ...DEFAULT_OPTIONS, ...options }; + host.addController(this); + } + + /** + * Merges `partial` into the current options and reapplies roving `tabindex` to the item set. + * + * @param partial - Fields to override; omitted keys keep their previous values. + */ + public setOptions(partial: Partial): void { + this.options = { ...this.options, ...partial }; + this.refresh(); + } + + /** + * Returns the managed item that currently participates in the sequential focus order + * (`tabindex="0"`), or null if none of the items from `getItems` have tab index zero. + * + * @returns The active roving item, or null. + */ + public getActiveItem(): HTMLElement | null { + for (const el of this.options.getItems()) { + if (el.tabIndex === 0) { + return el; + } + } + return null; + } + + /** + * Re-queries `getItems()`, recomputes eligibility, and syncs roving `tabindex`. + * + * Call after the item list or item eligibility changes (for example after Lit + * `updated()` or slot changes). When {@link FocusgroupNavigationOptions.memory} is true, + * prefers the stored last-focused item if it is still eligible; otherwise keeps the + * current active item or falls back to the first eligible item. + */ + public refresh(): void { + const items = this.getEligibleItems(); + if (items.length === 0) { + for (const el of this.getRawItems()) { + el.tabIndex = -1; + } + this.lastFocused = null; + this.dispatchActiveChange(null); + this.options.onActiveItemChange?.(null); + return; + } + + const preferred = + (this.options.memory && + this.lastFocused && + items.includes(this.lastFocused) + ? this.lastFocused + : null) ?? + this.getActiveItem() ?? + items[0]; + + this.applyRovingTabindex(preferred); + } + + /** + * Moves keyboard focus to `item`, updates roving tabindex on all managed items, + * and updates memory when enabled. + * + * @param item - Item to focus; must be returned by `getItems` and pass eligibility checks. + * @param focusOptions - Optional `focus()` options (e.g. `preventScroll`). + * @returns False if `item` is not in the current item list from `getItems`. + */ + public focusItem(item: HTMLElement, focusOptions?: FocusOptions): boolean { + const items = this.getEligibleItems(); + if (!items.includes(item)) { + return false; + } + this.applyRovingTabindex(item); + item.focus(focusOptions); + if (this.options.memory) { + this.lastFocused = item; + } + return true; + } + + /** + * Lit `ReactiveController` hook: registers capture-phase listeners on `host` and runs + * an initial {@link refresh}. + */ + public hostConnected(): void { + this.host.addEventListener('keydown', this.boundKeydown, true); + this.host.addEventListener('focusin', this.boundFocusin, true); + this.host.addEventListener('focusout', this.boundFocusout, true); + this.refresh(); + } + + /** + * Lit `ReactiveController` hook: removes listeners registered in {@link hostConnected}. + */ + public hostDisconnected(): void { + this.host.removeEventListener('keydown', this.boundKeydown, true); + this.host.removeEventListener('focusin', this.boundFocusin, true); + this.host.removeEventListener('focusout', this.boundFocusout, true); + } + + // ───────────────────────── + // IMPLEMENTATION + // ───────────────────────── + + /** + * Resolves `dir` from the shadow host, nearest `dir` ancestor, or `document.documentElement`. + * + * @returns True when horizontal arrow directions should follow RTL semantics. + */ + private isRtl(): boolean { + const root = this.host.getRootNode(); + if (root instanceof ShadowRoot) { + const dir = root.host.getAttribute('dir'); + if (dir === 'rtl' || dir === 'ltr') { + return dir === 'rtl'; + } + } + const scoped = this.host.closest('[dir]'); + const d = scoped?.getAttribute('dir'); + if (d === 'rtl') { + return true; + } + if (d === 'ltr') { + return false; + } + return document.documentElement.dir === 'rtl'; + } + + /** + * Items returned by `getItems` that lie within `host` (shadow-inclusive tree). + * + * @returns Candidates before eligibility filtering. + */ + private getRawItems(): HTMLElement[] { + return this.options.getItems().filter((el) => this.host.contains(el)); + } + + /** + * {@link getRawItems} filtered by {@link isNavigableItem}. + * + * @returns Items that participate in roving tabindex and arrow navigation. + */ + private getEligibleItems(): HTMLElement[] { + return this.getRawItems().filter((el) => this.isNavigableItem(el)); + } + + /** + * Whether `el` may participate in the focus group (connected, visible, not inert, + * and not skipped when {@link FocusgroupNavigationOptions.skipDisabled} is true). + * + * @param el - Candidate from `getItems`. + * @returns True if the element counts as navigable for this controller. + */ + private isNavigableItem(el: HTMLElement): boolean { + if (!el.isConnected) { + return false; + } + if (el.hasAttribute('inert') || el.closest('[inert]')) { + return false; + } + const style = getComputedStyle(el); + if (style.visibility === 'hidden' || style.display === 'none') { + return false; + } + if (this.options.skipDisabled && this.isDisabledForSkip(el)) { + return false; + } + return true; + } + + /** + * Whether `el` should be treated as disabled for {@link FocusgroupNavigationOptions.skipDisabled}. + * + * @param el - Element to test. + * @returns True if the native `disabled` property is true or `aria-disabled` is `"true"`. + */ + private isDisabledForSkip(el: HTMLElement): boolean { + if ('disabled' in el && (el as HTMLButtonElement).disabled) { + return true; + } + return el.getAttribute('aria-disabled') === 'true'; + } + + /** + * Sets `tabindex="-1"` on ineligible raw items, then assigns `tabindex="0"` to + * `active` (or the first eligible item if `active` is not eligible) and `-1` to the rest. + * Dispatches the active-change event and {@link FocusgroupNavigationOptions.onActiveItemChange}. + * + * @param active - Preferred item to mark as the single tab stop when eligible. + */ + private applyRovingTabindex(active: HTMLElement): void { + const items = this.getEligibleItems(); + const raw = this.getRawItems(); + for (const el of raw) { + if (!items.includes(el)) { + el.tabIndex = -1; + } + } + if (items.length === 0) { + return; + } + const safeActive = items.includes(active) ? active : items[0]; + for (const el of items) { + if (el === safeActive) { + el.tabIndex = 0; + } else { + el.tabIndex = -1; + } + } + this.dispatchActiveChange(safeActive); + this.options.onActiveItemChange?.(safeActive); + } + + /** + * Dispatches {@link focusgroupNavigationActiveChange} on the reactive host with the given detail. + * + * @param activeElement - New active item, or null when clearing selection. + */ + private dispatchActiveChange(activeElement: HTMLElement | null): void { + this.host.dispatchEvent( + new CustomEvent( + focusgroupNavigationActiveChange, + { + bubbles: true, + composed: true, + detail: { activeElement }, + } + ) + ); + } + + /** + * Capture-phase `focusin` handler: syncs roving `tabindex` when focus moves to a managed item + * (for example via pointer), and updates memory when enabled. + * + * @param event - Focus event whose target may be a group item. + */ + private handleFocusin(event: FocusEvent): void { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + const items = this.getEligibleItems(); + if (!items.includes(target)) { + return; + } + this.applyRovingTabindex(target); + if (this.options.memory) { + this.lastFocused = target; + } + } + + /** + * Capture-phase `focusout` handler: when focus leaves the host subtree, stores the + * previous target for {@link FocusgroupNavigationOptions.memory}. + * + * @param event - Focus event; `relatedTarget` stays inside the host when moving between items. + */ + private handleFocusout(event: FocusEvent): void { + const next = event.relatedTarget; + if (next instanceof Node && this.host.contains(next)) { + return; + } + const target = event.target; + if ( + this.options.memory && + target instanceof HTMLElement && + this.getRawItems().includes(target) + ) { + this.lastFocused = target; + } + } + + /** + * Capture-phase `keydown` handler: arrow keys and Home/End move focus among eligible items + * when the event target is managed; calls `preventDefault` when handling navigation. + * + * @param event - Keyboard event from the focused element inside the host. + */ + private handleKeydown(event: KeyboardEvent): void { + if ( + event.defaultPrevented || + event.altKey || + event.ctrlKey || + event.metaKey + ) { + return; + } + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + const items = this.getEligibleItems(); + if (!items.includes(target)) { + return; + } + + const rtl = this.isRtl(); + let next: HTMLElement | null = null; + + switch (this.options.direction) { + case 'horizontal': + next = this.navigateLinear(items, target, event.key, 'horizontal', rtl); + break; + case 'vertical': + next = this.navigateLinear(items, target, event.key, 'vertical', rtl); + break; + case 'grid': + next = this.navigateGrid(items, target, event.key, rtl); + break; + default: + break; + } + + if (next && next !== target) { + event.preventDefault(); + this.focusItem(next); + return; + } + + if (event.key === 'Home' || event.key === 'End') { + const ordered = + this.options.direction === 'grid' + ? this.buildRows(items).flat() + : items; + if (ordered.length === 0) { + return; + } + const boundary = + event.key === 'Home' ? ordered[0] : ordered[ordered.length - 1]; + if (boundary && boundary !== target) { + event.preventDefault(); + this.focusItem(boundary); + } + } + } + + /** + * Computes the next focus target for linear {@link FocusgroupDirection} modes. + * + * @param items - Eligible items in traversal order. + * @param current - Currently focused item. + * @param key - `KeyboardEvent.key` value. + * @param mode - `horizontal` (inline axis) or `vertical` (block axis). + * @param rtl - When true, horizontal Left/Right swap forward/backward. + * @returns Next item, or null if the key is not a navigation key or movement is blocked. + */ + private navigateLinear( + items: HTMLElement[], + current: HTMLElement, + key: string, + mode: 'horizontal' | 'vertical', + rtl: boolean + ): HTMLElement | null { + const idx = items.indexOf(current); + if (idx < 0) { + return null; + } + + let delta = 0; + if (mode === 'horizontal') { + if (key === 'ArrowLeft') { + delta = rtl ? 1 : -1; + } else if (key === 'ArrowRight') { + delta = rtl ? -1 : 1; + } + } else { + if (key === 'ArrowUp') { + delta = -1; + } else if (key === 'ArrowDown') { + delta = 1; + } + } + + if (delta === 0) { + return null; + } + + let nextIdx = idx + delta; + if (this.options.wrap) { + nextIdx = (nextIdx + items.length) % items.length; + } else if (nextIdx < 0 || nextIdx >= items.length) { + return null; + } + return items[nextIdx] ?? null; + } + + /** + * Computes the next focus target for `grid` {@link FocusgroupDirection} mode using + * row clustering and column indices. + * + * @param items - Eligible items (layout-derived rows may differ from DOM order). + * @param current - Currently focused item. + * @param key - `KeyboardEvent.key` value. + * @param rtl - When true, horizontal Left/Right swap column direction within a row. + * @returns Next cell item, or null if the key is not handled or movement is blocked. + */ + private navigateGrid( + items: HTMLElement[], + current: HTMLElement, + key: string, + rtl: boolean + ): HTMLElement | null { + const grid = this.buildRows(items); + const pos = this.findGridIndex(grid, current); + if (!pos) { + return null; + } + const { row, col } = pos; + const rowItems = grid[row] ?? []; + let nextRow = row; + let nextCol = col; + + switch (key) { + case 'ArrowLeft': + nextCol = rtl ? col + 1 : col - 1; + break; + case 'ArrowRight': + nextCol = rtl ? col - 1 : col + 1; + break; + case 'ArrowUp': + nextRow = row - 1; + break; + case 'ArrowDown': + nextRow = row + 1; + break; + default: + return null; + } + + if (key === 'ArrowLeft' || key === 'ArrowRight') { + if (nextCol >= 0 && nextCol < rowItems.length) { + return rowItems[nextCol] ?? null; + } + if (this.options.wrap && rowItems.length > 0) { + const wrappedCol = (nextCol + rowItems.length) % rowItems.length; + return rowItems[wrappedCol] ?? null; + } + return null; + } + + if (nextRow < 0 || nextRow >= grid.length) { + if (this.options.wrap && grid.length > 0) { + nextRow = (nextRow + grid.length) % grid.length; + } else { + return null; + } + } + + const targetRow = grid[nextRow]; + if (!targetRow?.length) { + return null; + } + const clampedCol = Math.min(col, targetRow.length - 1); + return targetRow[clampedCol] ?? null; + } + + /** + * Groups `items` into rows by similar `getBoundingClientRect().top`, then sorts each row by `left`. + * + * @param items - Eligible elements to lay out as a grid. + * @returns Row-major array of rows; each row is left-to-right. + */ + private buildRows(items: HTMLElement[]): HTMLElement[][] { + type RowAcc = { top: number; elements: HTMLElement[] }; + const rows: RowAcc[] = []; + + for (const el of items) { + const top = el.getBoundingClientRect().top; + let row = rows.find( + (r) => Math.abs(r.top - top) <= GRID_ROW_TOLERANCE_PX + ); + if (!row) { + row = { top, elements: [] }; + rows.push(row); + } + row.elements.push(el); + } + + rows.sort((a, b) => a.top - b.top); + return rows.map((r) => + r.elements.sort( + (a, b) => + a.getBoundingClientRect().left - b.getBoundingClientRect().left + ) + ); + } + + /** + * Locates `el` in a row-major grid built by {@link buildRows}. + * + * @param grid - Rows of elements. + * @param el - Element to find. + * @returns Row and column indices, or null if absent. + */ + private findGridIndex( + grid: HTMLElement[][], + el: HTMLElement + ): { row: number; col: number } | null { + for (let r = 0; r < grid.length; r++) { + const c = grid[r].indexOf(el); + if (c !== -1) { + return { row: r, col: c }; + } + } + return null; + } +} diff --git a/2nd-gen/packages/core/controllers/index.ts b/2nd-gen/packages/core/controllers/index.ts index a022b4457d6..5a880b5839a 100644 --- a/2nd-gen/packages/core/controllers/index.ts +++ b/2nd-gen/packages/core/controllers/index.ts @@ -10,6 +10,17 @@ * governing permissions and limitations under the License. */ +/** + * Public exports for Lit reactive controllers shared across 2nd-gen packages. + */ + +export { + focusgroupNavigationActiveChange, + FocusgroupNavigationController, + type FocusgroupDirection, + type FocusgroupNavigationActiveChangeDetail, + type FocusgroupNavigationOptions, +} from './focus-group-navigation-controller.js'; export { LanguageResolutionController, languageResolverUpdatedSymbol, diff --git a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts b/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts new file mode 100644 index 00000000000..aa0b736741b --- /dev/null +++ b/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts @@ -0,0 +1,415 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { css, html, LitElement, type TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import type { Meta, StoryObj } from '@storybook/web-components'; + +import { FocusgroupNavigationController } from '../focus-group-navigation-controller.js'; +import readme from '../focus-group-navigation-controller.md?raw'; + +// ───────────────────────── +// DEMO HOSTS +// ───────────────────────── + +/** + * @internal + * + * Storybook-only host demonstrating horizontal {@link FocusgroupNavigationController} usage. + */ +@customElement('demo-focusgroup-horizontal') +export class DemoFocusgroupHorizontal extends LitElement { + /** + * Shadow DOM styles for the inline toolbar demo. + */ + static override styles = css` + :host { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + button { + font: inherit; + padding: 8px 12px; + border-radius: 4px; + border: 1px solid var(--spectrum-gray-400, #ccc); + background: var(--spectrum-gray-75, #f5f5f5); + cursor: pointer; + } + button:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 2px; + } + `; + + /** + * Controller instance: horizontal direction with wrapping. + */ + private readonly navigation = new FocusgroupNavigationController(this, { + direction: 'horizontal', + wrap: true, + getItems: () => + Array.from(this.renderRoot.querySelectorAll('button')), + }); + + /** + * Runs after first render so `renderRoot` contains buttons before {@link FocusgroupNavigationController.refresh}. + */ + protected override firstUpdated(): void { + super.firstUpdated(); + this.navigation.refresh(); + } + + /** + * Renders formatting action buttons managed by the focus navigation controller. + */ + protected override render(): TemplateResult { + return html` + + + + + `; + } +} + +/** + * @internal + * + * Storybook-only host demonstrating vertical {@link FocusgroupNavigationController} usage. + */ +@customElement('demo-focusgroup-vertical') +export class DemoFocusgroupVertical extends LitElement { + /** + * Shadow DOM styles for the vertical menu demo. + */ + static override styles = css` + :host { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + max-width: 14rem; + padding: 8px; + border: 1px solid var(--spectrum-gray-300, #ddd); + border-radius: 4px; + background: var(--spectrum-gray-50, #fff); + } + button { + font: inherit; + text-align: start; + padding: 8px 12px; + border: none; + border-radius: 4px; + background: transparent; + cursor: pointer; + } + button:hover { + background: var(--spectrum-gray-200, #e8e8e8); + } + button:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 0; + } + button[disabled] { + opacity: 0.5; + cursor: not-allowed; + } + `; + + /** + * Controller instance: vertical direction with wrapping; disabled items stay focusable. + */ + private readonly navigation = new FocusgroupNavigationController(this, { + direction: 'vertical', + wrap: true, + skipDisabled: false, + getItems: () => + Array.from(this.renderRoot.querySelectorAll('button')), + }); + + /** + * Runs after first render so `renderRoot` contains buttons before {@link FocusgroupNavigationController.refresh}. + */ + protected override firstUpdated(): void { + super.firstUpdated(); + this.navigation.refresh(); + } + + /** + * Renders menu-like actions including one disabled control. + */ + protected override render(): TemplateResult { + return html` + + + + + `; + } +} + +/** + * @internal + * + * Storybook-only host demonstrating `grid` {@link FocusgroupNavigationController} usage. + */ +@customElement('demo-focusgroup-grid') +export class DemoFocusgroupGrid extends LitElement { + /** + * Shadow DOM styles for the 3×3 grid demo. + */ + static override styles = css` + :host { + display: block; + } + .grid { + display: grid; + grid-template-columns: repeat(3, 5rem); + gap: 8px; + } + button { + font: inherit; + height: 3rem; + border-radius: 4px; + border: 1px solid var(--spectrum-gray-400, #ccc); + background: var(--spectrum-gray-75, #f5f5f5); + cursor: pointer; + } + button:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 2px; + } + `; + + /** + * Controller instance: grid direction without row/column wrap. + */ + private readonly navigation = new FocusgroupNavigationController(this, { + direction: 'grid', + wrap: false, + getItems: () => + Array.from(this.renderRoot.querySelectorAll('.grid button')), + }); + + /** + * Runs after first render so grid buttons exist before {@link FocusgroupNavigationController.refresh}. + */ + protected override firstUpdated(): void { + super.firstUpdated(); + this.navigation.refresh(); + } + + /** + * Renders a `role="grid"` region with nine `role="gridcell"` buttons. + */ + protected override render(): TemplateResult { + const cells = Array.from({ length: 9 }, (_, i) => i + 1); + return html` +
+ ${cells.map( + (n) => html` + + ` + )} +
+ `; + } +} + +/** + * @internal + * + * Storybook-only host demonstrating {@link FocusgroupNavigationController.focusItem}. + */ +@customElement('demo-focusgroup-programmatic') +export class DemoFocusgroupProgrammatic extends LitElement { + /** + * Shadow DOM styles for the toolbar row and programmatic trigger control. + */ + static override styles = css` + :host { + display: flex; + flex-direction: column; + gap: 12px; + align-items: flex-start; + } + .demo-trigger { + margin-top: 4px; + font: inherit; + padding: 8px 12px; + border-radius: 4px; + border: 1px solid var(--spectrum-gray-400, #ccc); + background: var(--spectrum-gray-75, #f5f5f5); + cursor: pointer; + } + .demo-trigger:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 2px; + } + .row { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + button { + font: inherit; + padding: 8px 12px; + border-radius: 4px; + border: 1px solid var(--spectrum-gray-400, #ccc); + background: var(--spectrum-gray-75, #f5f5f5); + cursor: pointer; + } + button:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 2px; + } + `; + + /** + * Controller instance: horizontal without wrap; items are toolbar buttons with `data-item`. + */ + private readonly navigation = new FocusgroupNavigationController(this, { + direction: 'horizontal', + wrap: false, + getItems: () => + Array.from( + this.renderRoot.querySelectorAll('.row button[data-item]') + ), + }); + + /** + * Which `data-item` value {@link focusProgrammaticTarget} should focus. + * + * Reflected as attribute `focus-target` for the Storybook story. + */ + @property({ type: String, attribute: 'focus-target' }) + public focusTarget: 'a' | 'b' | 'c' = 'c'; + + /** + * Runs after first render so toolbar buttons exist before {@link FocusgroupNavigationController.refresh}. + */ + protected override firstUpdated(): void { + super.firstUpdated(); + this.navigation.refresh(); + } + + /** + * Focuses the toolbar button whose `data-item` matches {@link focusTarget}. + */ + public focusProgrammaticTarget(): void { + const sel = `[data-item="${this.focusTarget}"]`; + const el = this.renderRoot.querySelector(sel); + if (el) { + this.navigation.focusItem(el); + } + } + + /** + * Click handler for the demo trigger button; delegates to {@link focusProgrammaticTarget}. + */ + private handleProgrammaticDemoActivate(): void { + this.focusProgrammaticTarget(); + } + + /** + * Renders the toolbar row and external trigger used to test programmatic focus. + */ + protected override render(): TemplateResult { + return html` + + + `; + } +} + +// ───────────────────────── +// STORYBOOK +// ───────────────────────── + +/** + * Storybook metadata: documentation body comes from `focus-group-navigation-controller.md`. + */ +const meta: Meta = { + title: 'Focus group navigation controller', + tags: ['autodocs'], + parameters: { + docs: { + subtitle: `Roving tabindex and directional keys for composite widgets (APG-aligned, focusgroup-like).`, + description: { + component: readme, + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +/** + * Inline-axis arrows move between formatting controls; Tab yields one stop for the group. + */ +export const HorizontalToolbar: Story = { + render: () => html` + + `, +}; + +/** + * Block-axis arrows traverse menu-like items; disabled item stays in the focus order. + */ +export const VerticalMenu: Story = { + render: () => html` + + `, +}; + +/** + * Arrow keys move across a 3×3 grid; Home and End jump to the first and last cell in row-major order. + */ +export const Grid: Story = { + render: () => html` + + `, +}; + +/** + * Calls \`focusItem\` on a chosen button so tabindex stays consistent with keyboard navigation. + */ +export const ProgrammaticFocus: Story = { + render: () => html` + + `, +}; diff --git a/2nd-gen/packages/core/package.json b/2nd-gen/packages/core/package.json index 5e95c0d0282..d8dafd1170c 100644 --- a/2nd-gen/packages/core/package.json +++ b/2nd-gen/packages/core/package.json @@ -51,6 +51,10 @@ "types": "./dist/controllers/index.d.ts", "import": "./dist/controllers/index.js" }, + "./controllers/focus-group-navigation-controller.js": { + "types": "./dist/controllers/focus-group-navigation-controller.d.ts", + "import": "./dist/controllers/focus-group-navigation-controller.js" + }, "./controllers/language-resolution.js": { "types": "./dist/controllers/language-resolution.d.ts", "import": "./dist/controllers/language-resolution.js" @@ -152,6 +156,9 @@ "controllers/index.js": [ "dist/controllers/index.d.ts" ], + "controllers/focus-group-navigation-controller.js": [ + "dist/controllers/focus-group-navigation-controller.d.ts" + ], "controllers/language-resolution.js": [ "dist/controllers/language-resolution.d.ts" ], diff --git a/2nd-gen/packages/swc/.storybook/controllers/overview.mdx b/2nd-gen/packages/swc/.storybook/controllers/overview.mdx new file mode 100644 index 00000000000..3b3c857fbc1 --- /dev/null +++ b/2nd-gen/packages/swc/.storybook/controllers/overview.mdx @@ -0,0 +1,17 @@ +import { Meta } from '@storybook/blocks'; + + + +# Core controllers + +These pages document reactive controllers from `@spectrum-web-components/core/controllers`. Controllers implement cross-cutting behavior you can attach to Lit `ReactiveElement` hosts with `addController`. + +## Available documentation + +- **[Focus group navigation controller](../?path=/docs/controllers-focus-group-navigation-controller--readme)** — roving `tabindex`, arrow-key movement, and optional wrap/memory aligned with APG and the Open UI `focusgroup` explainer. + +Import paths use the package exports, for example: + +```typescript +import { FocusgroupNavigationController } from '@spectrum-web-components/core/controllers/focus-group-navigation-controller.js'; +``` diff --git a/2nd-gen/packages/swc/.storybook/main.ts b/2nd-gen/packages/swc/.storybook/main.ts index 451add679a0..71a20388162 100644 --- a/2nd-gen/packages/swc/.storybook/main.ts +++ b/2nd-gen/packages/swc/.storybook/main.ts @@ -58,6 +58,16 @@ const stories: StorybookConfig['stories'] = [ */ if (storybookMode !== 'ci-a11y') { stories.push( + { + directory: 'controllers', + files: '*.mdx', + titlePrefix: 'Controllers', + }, + { + directory: '../../core/controllers/stories', + files: '**/*.stories.ts', + titlePrefix: 'Controllers', + }, { directory: 'learn-about-swc', files: '*.mdx', diff --git a/2nd-gen/packages/swc/.storybook/storybook-env.d.ts b/2nd-gen/packages/swc/.storybook/storybook-env.d.ts index 5131d3a8b69..18e826ccd58 100644 --- a/2nd-gen/packages/swc/.storybook/storybook-env.d.ts +++ b/2nd-gen/packages/swc/.storybook/storybook-env.d.ts @@ -30,6 +30,11 @@ declare module '*.css' { export default content; } +declare module '*.md?raw' { + const content: string; + export default content; +} + // exports storybook-env.d.ts as a module so declare global can augment the Window export {}; diff --git a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md index 1375ae463dd..33447ae28e1 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md @@ -15,6 +15,7 @@ - [How controllers work](#how-controllers-work) - [Available controllers](#available-controllers) - [Planned controllers](#planned-controllers) +- [FocusgroupNavigationController](#focusgroupnavigationcontroller) - [LanguageResolutionController](#languageresolutioncontroller) - [Using a controller in a component](#using-a-controller-in-a-component) - [Controller vs mixin](#controller-vs-mixin) @@ -60,6 +61,7 @@ To attach a controller, call `host.addController(this)` in the constructor. This | Controller | Location | Purpose | |-----------|----------|---------| +| `FocusgroupNavigationController` | `core/controllers/focus-group-navigation-controller.ts` | Roving tabindex and directional keyboard navigation for composites | | `LanguageResolutionController` | `core/controllers/language-resolution.ts` | Resolve locale for formatting | ## Planned controllers @@ -68,7 +70,7 @@ The following controllers exist in 1st-gen and may be ported to 2nd-gen core: | Controller | 1st-gen location | Purpose | |-----------|-----------------|---------| -| `RovingTabindexController` | `1st-gen/packages/shared/` | Keyboard navigation | +| `RovingTabindexController` | `1st-gen/packages/shared/` | Keyboard navigation (see `FocusgroupNavigationController` in 2nd-gen for a related pattern) | | `PlacementController` | `1st-gen/packages/overlay/` | Overlay positioning | | `MatchMediaController` | `1st-gen/packages/picker/` | Device-adaptive behavior | | `PendingStateController` | `1st-gen/packages/button/` | Loading states | @@ -79,9 +81,25 @@ The following controllers exist in 1st-gen and may be ported to 2nd-gen core: | `ColorController` | `1st-gen/tools/reactive-controllers/` | Color validation/conversion | | `GridController` | `1st-gen/tools/grid/` | Grid layout with virtual scrolling | +## FocusgroupNavigationController + +**File:** `core/controllers/focus-group-navigation-controller.ts` + +**What it does:** + +1. Collapses roving `tabindex` to one tab stop in a composite (`tabindex="0"` on the active item, `-1` on others it manages). +2. Handles Arrow keys, Home, and End for horizontal, vertical, or grid layouts. +3. Optionally wraps at ends and remembers the last focused item for Tab re-entry (similar to Open UI `focusgroup` semantics). + +**Public API:** `setOptions`, `getActiveItem`, `refresh`, `focusItem`, plus `hostConnected` / `hostDisconnected` via `ReactiveController`. + +**Events:** Dispatches `swc-focusgroup-navigation-active-change` when the active item changes. + +**Docs:** See `core/controllers/focus-group-navigation-controller.md` and Storybook **Controllers / Focus group navigation controller**. + ## LanguageResolutionController -The main controller currently in 2nd-gen is `LanguageResolutionController`. It resolves the component's language/locale for formatting numbers, dates, and accessibility text. +The main controller for locale in 2nd-gen is `LanguageResolutionController`. It resolves the component's language/locale for formatting numbers, dates, and accessibility text. **File:** `core/controllers/language-resolution.ts` From 4f99c0845f58098502d5e07a88e34b2cd9548188 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Thu, 2 Apr 2026 20:22:50 -0400 Subject: [PATCH 07/21] fix: accountsr shadowDOM children --- .../focus-group-navigation-controller.ts | 84 +++++++++++++++++-- 1 file changed, 77 insertions(+), 7 deletions(-) diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts index 16d1ee4a8ca..97bddc7492e 100644 --- a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts @@ -327,13 +327,47 @@ export class FocusgroupNavigationController implements ReactiveController { return document.documentElement.dir === 'rtl'; } + /** + * Whether `node` is the host or reachable from it by walking `parentNode` and + * `ShadowRoot.host` (so shadow descendants count, including nested shadow roots). + * + * `Element.contains()` is not used because it returns false for nodes inside the + * host's shadow tree, which would drop every item for typical Lit components. + * + * @param node - Node to test (may be null). + * @returns True if `node` is in the host's shadow-inclusive subtree. + */ + private isNodeWithinHostScope(node: Node | null): boolean { + if (!node) { + return false; + } + const host = this.host; + let current: Node | null = node; + while (current) { + if (current === host) { + return true; + } + const parent: Node | null = current.parentNode; + if (parent) { + current = parent; + } else if (current instanceof ShadowRoot) { + current = current.host; + } else { + return false; + } + } + return false; + } + /** * Items returned by `getItems` that lie within `host` (shadow-inclusive tree). * * @returns Candidates before eligibility filtering. */ private getRawItems(): HTMLElement[] { - return this.options.getItems().filter((el) => this.host.contains(el)); + return this.options + .getItems() + .filter((el) => this.isNodeWithinHostScope(el)); } /** @@ -459,7 +493,7 @@ export class FocusgroupNavigationController implements ReactiveController { */ private handleFocusout(event: FocusEvent): void { const next = event.relatedTarget; - if (next instanceof Node && this.host.contains(next)) { + if (next instanceof Node && this.isNodeWithinHostScope(next)) { return; } const target = event.target; @@ -472,6 +506,45 @@ export class FocusgroupNavigationController implements ReactiveController { } } + /** + * Resolves which managed item should receive arrow / Home / End handling for this key event. + * + * Listeners on the shadow **host** often see a **retargeted** {@link KeyboardEvent.target} + * (the host) while focus is on a descendant inside the shadow tree, so matching + * `event.target` against `getItems()` fails. {@link Event.composedPath} still includes the + * focused node; we also fall back to {@link ShadowRoot.activeElement} when needed. + * + * @param event - Keyboard event dispatched while focus is in this composite. + * @param items - Current eligible items from {@link getEligibleItems}. + * @returns The managed element to treat as keydown target, or null. + */ + private resolveManagedKeydownTarget( + event: KeyboardEvent, + items: HTMLElement[] + ): HTMLElement | null { + if (items.length === 0) { + return null; + } + const set = new Set(items); + for (const node of event.composedPath()) { + if (!(node instanceof HTMLElement)) { + continue; + } + if (set.has(node)) { + return node; + } + if (node === this.host) { + break; + } + } + const root = this.host.shadowRoot; + const active = root?.activeElement; + if (active instanceof HTMLElement && set.has(active)) { + return active; + } + return null; + } + /** * Capture-phase `keydown` handler: arrow keys and Home/End move focus among eligible items * when the event target is managed; calls `preventDefault` when handling navigation. @@ -487,12 +560,9 @@ export class FocusgroupNavigationController implements ReactiveController { ) { return; } - const target = event.target; - if (!(target instanceof HTMLElement)) { - return; - } const items = this.getEligibleItems(); - if (!items.includes(target)) { + const target = this.resolveManagedKeydownTarget(event, items); + if (!target) { return; } From 49111942af68471e1122923545938c0d5a631d6c Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Thu, 2 Apr 2026 20:29:50 -0400 Subject: [PATCH 08/21] fix(controllers): fixed focusgroup navigation keyboard events and disabled items --- .../focus-group-navigation-controller.md | 2 - ...cus-group-navigation-controller.stories.ts | 41 +++++++++++++++++-- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md index 21a3e2c5268..fba5a829fb1 100644 --- a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md @@ -1,5 +1,3 @@ -# Focus group navigation controller - `FocusgroupNavigationController` implements the [roving `tabindex` pattern](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#managingfocuswithincomponentsusingarovingtabindex) from the ARIA Authoring Practices Guide (APG) and directional keyboard behavior aligned with the Open UI [`focusgroup` explainer](https://open-ui.org/components/scoped-focusgroup.explainer/). Use it inside Lit-based custom elements (or any `ReactiveElement`) until native `focusgroup` is widely available. ## What it does diff --git a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts b/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts index aa0b736741b..3e2ee00f9f5 100644 --- a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts @@ -120,12 +120,33 @@ export class DemoFocusgroupVertical extends LitElement { outline: 2px solid var(--spectrum-blue-800, #0265dc); outline-offset: 0; } - button[disabled] { + button[aria-disabled='true'] { opacity: 0.5; cursor: not-allowed; } `; + /** + * Blocks pointer activation for the menu item that uses `aria-disabled` instead of native + * `disabled` so it can stay in the roving focus order (native `disabled` is not focusable). + * + * @param event - Click event from the inert item. + */ + private handleInertMenuItemClick(event: Event): void { + event.preventDefault(); + } + + /** + * Prevents Enter/Space from activating the `aria-disabled` item like a normal button. + * + * @param event - Key event while the inert item is focused. + */ + private handleInertMenuItemKeydown(event: KeyboardEvent): void { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + } + } + /** * Controller instance: vertical direction with wrapping; disabled items stay focusable. */ @@ -146,13 +167,24 @@ export class DemoFocusgroupVertical extends LitElement { } /** - * Renders menu-like actions including one disabled control. + * Renders menu-like actions including one inactive control. + * + * Uses `aria-disabled="true"` instead of the `disabled` attribute so the item stays + * focusable while arrow keys move through the list; native `disabled` removes focusability + * and would block reaching items after it. */ protected override render(): TemplateResult { return html` - + `; } @@ -383,7 +415,8 @@ export const HorizontalToolbar: Story = { }; /** - * Block-axis arrows traverse menu-like items; disabled item stays in the focus order. + * Block-axis arrows traverse menu-like items; one item uses `aria-disabled` (not native + * `disabled`) so it stays focusable and items after it remain reachable. */ export const VerticalMenu: Story = { render: () => html` From acaeb0afc3ae6c23cef3b7b4938d3cfa63f88fdf Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Thu, 2 Apr 2026 20:30:20 -0400 Subject: [PATCH 09/21] docs(core): updated storybook docs for core --- 2nd-gen/packages/core/overview.mdx | 19 +++++++++++++++++++ .../swc/.storybook/controllers/overview.mdx | 17 ----------------- 2nd-gen/packages/swc/.storybook/main.ts | 12 ++++++------ .../14_controller-composition.md | 2 +- 4 files changed, 26 insertions(+), 24 deletions(-) create mode 100644 2nd-gen/packages/core/overview.mdx delete mode 100644 2nd-gen/packages/swc/.storybook/controllers/overview.mdx diff --git a/2nd-gen/packages/core/overview.mdx b/2nd-gen/packages/core/overview.mdx new file mode 100644 index 00000000000..2c8c795d3f5 --- /dev/null +++ b/2nd-gen/packages/core/overview.mdx @@ -0,0 +1,19 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Core + +These pages document **`@spectrum-web-components/core`**: shared element primitives, mixins, utilities, controllers, and base classes used by 2nd-gen components. + +## Available documentation + +- **[Focus group navigation controller](../?path=/docs/core-focus-group-navigation-controller--readme)** — roving `tabindex`, arrow-key movement, and optional wrap/memory aligned with APG and the Open UI `focusgroup` explainer. + +Add `.mdx` pages or `stories/*.stories.ts` anywhere under `packages/core` to surface them under **Core** in Storybook. + +Import paths use the package exports, for example: + +```typescript +import { FocusgroupNavigationController } from '@spectrum-web-components/core/controllers/focus-group-navigation-controller.js'; +``` diff --git a/2nd-gen/packages/swc/.storybook/controllers/overview.mdx b/2nd-gen/packages/swc/.storybook/controllers/overview.mdx deleted file mode 100644 index 3b3c857fbc1..00000000000 --- a/2nd-gen/packages/swc/.storybook/controllers/overview.mdx +++ /dev/null @@ -1,17 +0,0 @@ -import { Meta } from '@storybook/blocks'; - - - -# Core controllers - -These pages document reactive controllers from `@spectrum-web-components/core/controllers`. Controllers implement cross-cutting behavior you can attach to Lit `ReactiveElement` hosts with `addController`. - -## Available documentation - -- **[Focus group navigation controller](../?path=/docs/controllers-focus-group-navigation-controller--readme)** — roving `tabindex`, arrow-key movement, and optional wrap/memory aligned with APG and the Open UI `focusgroup` explainer. - -Import paths use the package exports, for example: - -```typescript -import { FocusgroupNavigationController } from '@spectrum-web-components/core/controllers/focus-group-navigation-controller.js'; -``` diff --git a/2nd-gen/packages/swc/.storybook/main.ts b/2nd-gen/packages/swc/.storybook/main.ts index 71a20388162..6e1e6fa0f49 100644 --- a/2nd-gen/packages/swc/.storybook/main.ts +++ b/2nd-gen/packages/swc/.storybook/main.ts @@ -59,14 +59,14 @@ const stories: StorybookConfig['stories'] = [ if (storybookMode !== 'ci-a11y') { stories.push( { - directory: 'controllers', - files: '*.mdx', - titlePrefix: 'Controllers', + directory: '../../core', + files: '**/*.mdx', + titlePrefix: 'Core', }, { - directory: '../../core/controllers/stories', - files: '**/*.stories.ts', - titlePrefix: 'Controllers', + directory: '../../core', + files: '**/stories/**/*.stories.ts', + titlePrefix: 'Core', }, { directory: 'learn-about-swc', diff --git a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md index 33447ae28e1..2ac716ebba6 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md @@ -95,7 +95,7 @@ The following controllers exist in 1st-gen and may be ported to 2nd-gen core: **Events:** Dispatches `swc-focusgroup-navigation-active-change` when the active item changes. -**Docs:** See `core/controllers/focus-group-navigation-controller.md` and Storybook **Controllers / Focus group navigation controller**. +**Docs:** See `core/controllers/focus-group-navigation-controller.md` and Storybook **Core / Focus group navigation controller**. ## LanguageResolutionController From e0d3e80223ebdb4ac51940ac561d823ed35b11dc Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Thu, 2 Apr 2026 20:38:08 -0400 Subject: [PATCH 10/21] feat: added row navigation for grids --- .../focus-group-navigation-controller.md | 3 +- .../focus-group-navigation-controller.ts | 48 +++++++++++++++---- ...cus-group-navigation-controller.stories.ts | 4 +- .../14_controller-composition.md | 2 +- 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md index fba5a829fb1..585120b9be6 100644 --- a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md @@ -5,6 +5,7 @@ - Collapses the tab sequence to **one** tab stop for the composite by setting `tabindex="0"` on the active item and `tabindex="-1"` on the others it manages. - Moves focus with **Arrow** keys according to `direction`: horizontal (inline axis), vertical (block axis), or **grid** (rows and columns from layout). - Supports **Home** / **End** to jump to the first or last item (for `grid`, order is visual row-major). +- In **`grid`** mode only, **Ctrl+Home** moves focus to the **first cell in the first row** and **Ctrl+End** to the **last cell in the last row** (rows are derived from layout; ragged last rows use the final cell in that row). - Optional **wrap** (end wraps to start) and **memory** (Tab returns to the last focused item), similar to `wrap` and `nomemory` concepts in the `focusgroup` proposal. ## Import @@ -77,7 +78,7 @@ this.navigation = new FocusgroupNavigationController(this, { ### Example (grid) -Use `direction: 'grid'` when items are laid out in rows (for example CSS Grid). The controller groups items into rows using bounding rectangles, then maps Arrow keys to cell movement. **Home** / **End** use visual row-major order. +Use `direction: 'grid'` when items are laid out in rows (for example CSS Grid). The controller groups items into rows using bounding rectangles, then maps Arrow keys to cell movement. **Home** / **End** use visual row-major order (first and last item in that flattened sequence). **Ctrl+Home** / **Ctrl+End** jump to the first cell of the top row or the last cell of the bottom row, which matches rectangular grids and differs from plain **End** only when the last row has fewer cells than earlier rows. ## API diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts index 97bddc7492e..a8cea2a36a8 100644 --- a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts @@ -22,7 +22,8 @@ import type { ReactiveController, ReactiveElement } from 'lit'; * * - **horizontal**: Arrow keys on the inline axis move focus (respects `dir`). * - **vertical**: Arrow keys on the block axis move focus. - * - **grid**: Arrow keys move in rows and columns using bounding-rect layout. + * - **grid**: Arrow keys move in rows and columns using bounding-rect layout; Ctrl+Home / Ctrl+End + * jump to the first cell of the first row or the last cell of the last row. */ export type FocusgroupDirection = 'horizontal' | 'vertical' | 'grid'; @@ -122,7 +123,9 @@ export type FocusgroupNavigationActiveChangeDetail = { * The controller: * - Keeps exactly one item in the tab order (`tabindex="0"`) per composite; sets * `tabindex="-1"` on other items it manages. - * - Handles Arrow keys, Home, and End for focus movement (and optionally wrap). + * - Handles Arrow keys, Home, and End for focus movement (and optionally wrap). In **`grid`** + * mode only, **Ctrl+Home** / **Ctrl+End** move to the first cell of the first row or the + * last cell of the last row (by layout-derived rows). * - Supports optional last-focused memory when re-entering via Tab. * - Exposes {@link FocusgroupNavigationController.focusItem} for programmatic focus. * @@ -507,7 +510,8 @@ export class FocusgroupNavigationController implements ReactiveController { } /** - * Resolves which managed item should receive arrow / Home / End handling for this key event. + * Resolves which managed item should receive arrow, Home, End, or grid Ctrl+Home / Ctrl+End + * handling for this key event. * * Listeners on the shadow **host** often see a **retargeted** {@link KeyboardEvent.target} * (the host) while focus is on a descendant inside the shadow tree, so matching @@ -549,23 +553,49 @@ export class FocusgroupNavigationController implements ReactiveController { * Capture-phase `keydown` handler: arrow keys and Home/End move focus among eligible items * when the event target is managed; calls `preventDefault` when handling navigation. * + * When {@link FocusgroupDirection | `direction`} is **`grid`**, **Ctrl+Home** focuses the + * first cell in the first row and **Ctrl+End** focuses the last cell in the last row (from + * {@link buildRows}); other modifier combinations are ignored except plain Home/End. + * * @param event - Keyboard event from the focused element inside the host. */ private handleKeydown(event: KeyboardEvent): void { - if ( - event.defaultPrevented || - event.altKey || - event.ctrlKey || - event.metaKey - ) { + if (event.defaultPrevented || event.altKey) { return; } + const items = this.getEligibleItems(); const target = this.resolveManagedKeydownTarget(event, items); if (!target) { return; } + if ( + this.options.direction === 'grid' && + event.ctrlKey && + !event.metaKey && + (event.key === 'Home' || event.key === 'End') + ) { + const grid = this.buildRows(items); + if (grid.length > 0) { + const firstRow = grid[0]; + const lastRow = grid[grid.length - 1]; + const boundary = + event.key === 'Home' + ? (firstRow?.[0] ?? null) + : (lastRow?.[lastRow.length - 1] ?? null); + if (boundary && boundary !== target) { + event.preventDefault(); + this.focusItem(boundary); + } + } + return; + } + + if (event.ctrlKey || event.metaKey) { + return; + } + const rtl = this.isRtl(); let next: HTMLElement | null = null; diff --git a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts b/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts index 3e2ee00f9f5..62604177737 100644 --- a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts @@ -428,7 +428,9 @@ export const VerticalMenu: Story = { }; /** - * Arrow keys move across a 3×3 grid; Home and End jump to the first and last cell in row-major order. + * Arrow keys move across a 3×3 grid; **Home** / **End** jump to the first and last cell in + * row-major order; **Ctrl+Home** / **Ctrl+End** jump to the first cell of the first row and + * the last cell of the last row (equivalent here to cells **1** and **9**). */ export const Grid: Story = { render: () => html` diff --git a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md index 2ac716ebba6..1c45386b2c5 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md @@ -88,7 +88,7 @@ The following controllers exist in 1st-gen and may be ported to 2nd-gen core: **What it does:** 1. Collapses roving `tabindex` to one tab stop in a composite (`tabindex="0"` on the active item, `-1` on others it manages). -2. Handles Arrow keys, Home, and End for horizontal, vertical, or grid layouts. +2. Handles Arrow keys, Home, and End for horizontal, vertical, or grid layouts; in **grid** mode, Ctrl+Home / Ctrl+End jump to the first cell of the first row or the last cell of the last row. 3. Optionally wraps at ends and remembers the last focused item for Tab re-entry (similar to Open UI `focusgroup` semantics). **Public API:** `setOptions`, `getActiveItem`, `refresh`, `focusItem`, plus `hostConnected` / `hostDisconnected` via `ReactiveController`. From c91738a47c934b86fb0faf4a19e749ea3d2e1537 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Thu, 2 Apr 2026 20:42:23 -0400 Subject: [PATCH 11/21] feat(core): added option for both sets of arrow keys to navigate --- .../focus-group-navigation-controller.md | 35 ++++++--- .../focus-group-navigation-controller.ts | 64 ++++++++++++++- ...cus-group-navigation-controller.stories.ts | 77 +++++++++++++++++++ 2nd-gen/packages/core/overview.mdx | 2 +- .../14_controller-composition.md | 2 +- 5 files changed, 164 insertions(+), 16 deletions(-) diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md index 585120b9be6..0e0309be3de 100644 --- a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md @@ -3,7 +3,7 @@ ## What it does - Collapses the tab sequence to **one** tab stop for the composite by setting `tabindex="0"` on the active item and `tabindex="-1"` on the others it manages. -- Moves focus with **Arrow** keys according to `direction`: horizontal (inline axis), vertical (block axis), or **grid** (rows and columns from layout). +- Moves focus with **Arrow** keys according to `direction`: horizontal (inline axis), vertical (block axis), **both** (horizontal and vertical arrows on the same linear order), or **grid** (rows and columns from layout). - Supports **Home** / **End** to jump to the first or last item (for `grid`, order is visual row-major). - In **`grid`** mode only, **Ctrl+Home** moves focus to the **first cell in the first row** and **Ctrl+End** to the **last cell in the last row** (rows are derived from layout; ragged last rows use the final cell in that row). - Optional **wrap** (end wraps to start) and **memory** (Tab returns to the last focused item), similar to `wrap` and `nomemory` concepts in the `focusgroup` proposal. @@ -62,6 +62,21 @@ export class MyFormatToolbar extends LitElement { } ``` +### Example (horizontal and vertical arrows, same order) + +Use `direction: 'both'` when controls are laid out in a line (or any single sequence) but you want **ArrowUp** / **ArrowDown** to move focus as well as **ArrowLeft** / **ArrowRight**. Inline arrows follow `dir` like `horizontal`; **ArrowUp** / **ArrowDown** step backward / forward in `getItems()` order. + +```typescript +this.navigation = new FocusgroupNavigationController(this, { + direction: 'both', + wrap: true, + getItems: () => + Array.from(this.renderRoot.querySelectorAll('button')), +}); +``` + +The Storybook story **Both axes linear** demonstrates this on a small toolbar. + ### Example (vertical list, skip disabled) ```typescript @@ -95,18 +110,18 @@ On the host, the controller dispatches **`swc-focusgroup-navigation-active-chang ### Options -| Option | Type | Default | Description | -| -------------------- | -------------------------------------- | ---------- | ----------------------------------------------- | -| `getItems` | `() => HTMLElement[]` | (required) | Current navigable items. | -| `direction` | `'horizontal' \| 'vertical' \| 'grid'` | (required) | Arrow-key mode. | -| `wrap` | `boolean` | `false` | Wrap at ends. | -| `memory` | `boolean` | `true` | Remember last focused for re-entry via Tab. | -| `skipDisabled` | `boolean` | `false` | Skip `disabled` / `aria-disabled="true"` items. | -| `onActiveItemChange` | `(el) => void` | — | Callback when active item changes. | +| Option | Type | Default | Description | +| -------------------- | ------------------------------------------------ | ---------- | ------------------------------------------------------------------------------------- | +| `getItems` | `() => HTMLElement[]` | (required) | Current navigable items. | +| `direction` | `'horizontal' \| 'vertical' \| 'both' \| 'grid'` | (required) | Arrow-key mode. **`both`**: Left/Right and Up/Down on the same `getItems()` sequence. | +| `wrap` | `boolean` | `false` | Wrap at ends. | +| `memory` | `boolean` | `true` | Remember last focused for re-entry via Tab. | +| `skipDisabled` | `boolean` | `false` | Skip `disabled` / `aria-disabled="true"` items. | +| `onActiveItemChange` | `(el) => void` | — | Callback when active item changes. | ## RTL and writing modes -For `horizontal`, **ArrowLeft** / **ArrowRight** follow the host’s resolved `dir` (`rtl` swaps forward/back). Vertical grid movement uses row geometry; column movement respects `dir` for left/right. +For `horizontal`, **ArrowLeft** / **ArrowRight** follow the host’s resolved `dir` (`rtl` swaps forward/back). For **`both`**, **ArrowLeft** / **ArrowRight** follow `dir` the same way, while **ArrowUp** / **ArrowDown** always step backward / forward in `getItems()` order. In **`grid`** mode, vertical movement uses row geometry; column movement respects `dir` for left/right. ## Relationship to native `focusgroup` diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts index a8cea2a36a8..e129401f8dd 100644 --- a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts @@ -22,10 +22,12 @@ import type { ReactiveController, ReactiveElement } from 'lit'; * * - **horizontal**: Arrow keys on the inline axis move focus (respects `dir`). * - **vertical**: Arrow keys on the block axis move focus. + * - **both**: **ArrowLeft** / **ArrowRight** move along `getItems()` order like **horizontal** + * (respects `dir`); **ArrowUp** / **ArrowDown** move backward / forward in the same order. * - **grid**: Arrow keys move in rows and columns using bounding-rect layout; Ctrl+Home / Ctrl+End * jump to the first cell of the first row or the last cell of the last row. */ -export type FocusgroupDirection = 'horizontal' | 'vertical' | 'grid'; +export type FocusgroupDirection = 'horizontal' | 'vertical' | 'both' | 'grid'; /** * Options for {@link FocusgroupNavigationController}. @@ -40,6 +42,7 @@ export type FocusgroupNavigationOptions = { /** * Determines which arrow keys move focus and how grid navigation is computed. + * Use **`both`** when the same linear order should respond to horizontal and vertical arrow keys. */ direction: FocusgroupDirection; @@ -123,9 +126,10 @@ export type FocusgroupNavigationActiveChangeDetail = { * The controller: * - Keeps exactly one item in the tab order (`tabindex="0"`) per composite; sets * `tabindex="-1"` on other items it manages. - * - Handles Arrow keys, Home, and End for focus movement (and optionally wrap). In **`grid`** - * mode only, **Ctrl+Home** / **Ctrl+End** move to the first cell of the first row or the - * last cell of the last row (by layout-derived rows). + * - Handles Arrow keys, Home, and End for focus movement (and optionally wrap). **`both`** + * direction accepts horizontal and vertical arrows on the same `getItems()` sequence. + * In **`grid`** mode only, **Ctrl+Home** / **Ctrl+End** move to the first cell of the first + * row or the last cell of the last row (by layout-derived rows). * - Supports optional last-focused memory when re-entering via Tab. * - Exposes {@link FocusgroupNavigationController.focusItem} for programmatic focus. * @@ -553,6 +557,9 @@ export class FocusgroupNavigationController implements ReactiveController { * Capture-phase `keydown` handler: arrow keys and Home/End move focus among eligible items * when the event target is managed; calls `preventDefault` when handling navigation. * + * When {@link FocusgroupDirection | `direction`} is **`both`**, **ArrowLeft** / **ArrowRight** + * and **ArrowUp** / **ArrowDown** all participate (see {@link navigateBothAxes}). + * * When {@link FocusgroupDirection | `direction`} is **`grid`**, **Ctrl+Home** focuses the * first cell in the first row and **Ctrl+End** focuses the last cell in the last row (from * {@link buildRows}); other modifier combinations are ignored except plain Home/End. @@ -606,6 +613,9 @@ export class FocusgroupNavigationController implements ReactiveController { case 'vertical': next = this.navigateLinear(items, target, event.key, 'vertical', rtl); break; + case 'both': + next = this.navigateBothAxes(items, target, event.key, rtl); + break; case 'grid': next = this.navigateGrid(items, target, event.key, rtl); break; @@ -686,6 +696,52 @@ export class FocusgroupNavigationController implements ReactiveController { return items[nextIdx] ?? null; } + /** + * Computes the next focus target when {@link FocusgroupDirection | `direction`} is **`both`**: + * inline arrows use the same deltas as {@link navigateLinear} `horizontal` mode; **ArrowUp** / + * **ArrowDown** step backward / forward in `getItems()` order (not flipped by `dir`). + * + * @param items - Eligible items in traversal order. + * @param current - Currently focused item. + * @param key - `KeyboardEvent.key` value. + * @param rtl - When true, horizontal Left/Right swap forward/backward. + * @returns Next item, or null if the key is not handled or movement is blocked. + */ + private navigateBothAxes( + items: HTMLElement[], + current: HTMLElement, + key: string, + rtl: boolean + ): HTMLElement | null { + const idx = items.indexOf(current); + if (idx < 0) { + return null; + } + + let delta = 0; + if (key === 'ArrowLeft') { + delta = rtl ? 1 : -1; + } else if (key === 'ArrowRight') { + delta = rtl ? -1 : 1; + } else if (key === 'ArrowUp') { + delta = -1; + } else if (key === 'ArrowDown') { + delta = 1; + } + + if (delta === 0) { + return null; + } + + let nextIdx = idx + delta; + if (this.options.wrap) { + nextIdx = (nextIdx + items.length) % items.length; + } else if (nextIdx < 0 || nextIdx >= items.length) { + return null; + } + return items[nextIdx] ?? null; + } + /** * Computes the next focus target for `grid` {@link FocusgroupDirection} mode using * row clustering and column indices. diff --git a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts b/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts index 62604177737..f464d4ae357 100644 --- a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts @@ -82,6 +82,69 @@ export class DemoFocusgroupHorizontal extends LitElement { } } +/** + * @internal + * + * Storybook-only host demonstrating `direction: 'both'` — horizontal and vertical arrows + * move along the same `getItems()` order. + */ +@customElement('demo-focusgroup-both-axes') +export class DemoFocusgroupBothAxes extends LitElement { + /** + * Shadow DOM styles for the inline toolbar demo (layout matches horizontal; keys differ). + */ + static override styles = css` + :host { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + button { + font: inherit; + padding: 8px 12px; + border-radius: 4px; + border: 1px solid var(--spectrum-gray-400, #ccc); + background: var(--spectrum-gray-75, #f5f5f5); + cursor: pointer; + } + button:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 2px; + } + `; + + /** + * Controller instance: both axes on one linear sequence with wrapping. + */ + private readonly navigation = new FocusgroupNavigationController(this, { + direction: 'both', + wrap: true, + getItems: () => + Array.from(this.renderRoot.querySelectorAll('button')), + }); + + /** + * Runs after first render so `renderRoot` contains buttons before {@link FocusgroupNavigationController.refresh}. + */ + protected override firstUpdated(): void { + super.firstUpdated(); + this.navigation.refresh(); + } + + /** + * Renders segment controls; **ArrowLeft** / **ArrowRight** and **ArrowUp** / **ArrowDown** + * all traverse this row in order. + */ + protected override render(): TemplateResult { + return html` + + + + + `; + } +} + /** * @internal * @@ -414,6 +477,20 @@ export const HorizontalToolbar: Story = { `, }; +/** + * **ArrowLeft** / **ArrowRight** and **ArrowUp** / **ArrowDown** all move along the same + * control order (LTR: Right and Down advance, Left and Up go back). Useful when layout is + * horizontal but users expect vertical arrow keys to work as well. + */ +export const BothAxesLinear: Story = { + render: () => html` + + `, +}; + /** * Block-axis arrows traverse menu-like items; one item uses `aria-disabled` (not native * `disabled`) so it stays focusable and items after it remain reachable. diff --git a/2nd-gen/packages/core/overview.mdx b/2nd-gen/packages/core/overview.mdx index 2c8c795d3f5..edfbcdba1f1 100644 --- a/2nd-gen/packages/core/overview.mdx +++ b/2nd-gen/packages/core/overview.mdx @@ -8,7 +8,7 @@ These pages document **`@spectrum-web-components/core`**: shared element primiti ## Available documentation -- **[Focus group navigation controller](../?path=/docs/core-focus-group-navigation-controller--readme)** — roving `tabindex`, arrow-key movement, and optional wrap/memory aligned with APG and the Open UI `focusgroup` explainer. +- **[Focus group navigation controller](../?path=/docs/core-focus-group-navigation-controller--readme)** — roving `tabindex`, arrow-key movement (horizontal, vertical, **both**, or grid), and optional wrap/memory aligned with APG and the Open UI `focusgroup` explainer. Add `.mdx` pages or `stories/*.stories.ts` anywhere under `packages/core` to surface them under **Core** in Storybook. diff --git a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md index 1c45386b2c5..ab7a0bafee7 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md @@ -88,7 +88,7 @@ The following controllers exist in 1st-gen and may be ported to 2nd-gen core: **What it does:** 1. Collapses roving `tabindex` to one tab stop in a composite (`tabindex="0"` on the active item, `-1` on others it manages). -2. Handles Arrow keys, Home, and End for horizontal, vertical, or grid layouts; in **grid** mode, Ctrl+Home / Ctrl+End jump to the first cell of the first row or the last cell of the last row. +2. Handles Arrow keys, Home, and End for horizontal, vertical, **`both`** (horizontal and vertical arrows on the same linear order), or **grid** layouts; in **grid** mode, Ctrl+Home / Ctrl+End jump to the first cell of the first row or the last cell of the last row. 3. Optionally wraps at ends and remembers the last focused item for Tab re-entry (similar to Open UI `focusgroup` semantics). **Public API:** `setOptions`, `getActiveItem`, `refresh`, `focusItem`, plus `hostConnected` / `hostDisconnected` via `ReactiveController`. From f0158fd3777bfb222c9e4294cf414ad48ac6e07e Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Thu, 2 Apr 2026 20:48:51 -0400 Subject: [PATCH 12/21] feat(core): added page up/down support --- .../focus-group-navigation-controller.md | 33 +++-- .../focus-group-navigation-controller.ts | 121 ++++++++++++++++++ ...cus-group-navigation-controller.stories.ts | 20 ++- 2nd-gen/packages/swc/.storybook/main.ts | 5 + 4 files changed, 164 insertions(+), 15 deletions(-) diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md index 0e0309be3de..5e26f5bbe0c 100644 --- a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md @@ -6,6 +6,7 @@ - Moves focus with **Arrow** keys according to `direction`: horizontal (inline axis), vertical (block axis), **both** (horizontal and vertical arrows on the same linear order), or **grid** (rows and columns from layout). - Supports **Home** / **End** to jump to the first or last item (for `grid`, order is visual row-major). - In **`grid`** mode only, **Ctrl+Home** moves focus to the **first cell in the first row** and **Ctrl+End** to the **last cell in the last row** (rows are derived from layout; ragged last rows use the final cell in that row). +- Optional **`pageStep`**: when set to a non-zero integer, **Page Up** / **Page Down** move that many items in `getItems()` order (linear modes) or that many **rows** in **`grid`** mode. - Optional **wrap** (end wraps to start) and **memory** (Tab returns to the last focused item), similar to `wrap` and `nomemory` concepts in the `focusgroup` proposal. ## Import @@ -91,10 +92,25 @@ this.navigation = new FocusgroupNavigationController(this, { }); ``` +### Example (Page Up / Page Down) + +```typescript +this.navigation = new FocusgroupNavigationController(this, { + direction: 'vertical', + pageStep: 3, + getItems: () => + Array.from(this.renderRoot.querySelectorAll('button')), +}); +``` + +With `pageStep: 3`, each **Page Down** advances three items in `getItems()` order; **Page Up** goes back three. For **`grid`**, use the same option to move three rows at a time. + ### Example (grid) Use `direction: 'grid'` when items are laid out in rows (for example CSS Grid). The controller groups items into rows using bounding rectangles, then maps Arrow keys to cell movement. **Home** / **End** use visual row-major order (first and last item in that flattened sequence). **Ctrl+Home** / **Ctrl+End** jump to the first cell of the top row or the last cell of the bottom row, which matches rectangular grids and differs from plain **End** only when the last row has fewer cells than earlier rows. +Set **`pageStep`** to a positive integer (for example `2`) so **Page Up** / **Page Down** move that many rows; the focused column index is clamped when a row has fewer cells (same rule as **ArrowUp** / **ArrowDown**). + ## API | Member | Description | @@ -110,14 +126,15 @@ On the host, the controller dispatches **`swc-focusgroup-navigation-active-chang ### Options -| Option | Type | Default | Description | -| -------------------- | ------------------------------------------------ | ---------- | ------------------------------------------------------------------------------------- | -| `getItems` | `() => HTMLElement[]` | (required) | Current navigable items. | -| `direction` | `'horizontal' \| 'vertical' \| 'both' \| 'grid'` | (required) | Arrow-key mode. **`both`**: Left/Right and Up/Down on the same `getItems()` sequence. | -| `wrap` | `boolean` | `false` | Wrap at ends. | -| `memory` | `boolean` | `true` | Remember last focused for re-entry via Tab. | -| `skipDisabled` | `boolean` | `false` | Skip `disabled` / `aria-disabled="true"` items. | -| `onActiveItemChange` | `(el) => void` | — | Callback when active item changes. | +| Option | Type | Default | Description | +| -------------------- | ------------------------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `getItems` | `() => HTMLElement[]` | (required) | Current navigable items. | +| `direction` | `'horizontal' \| 'vertical' \| 'both' \| 'grid'` | (required) | Arrow-key mode. **`both`**: Left/Right and Up/Down on the same `getItems()` sequence. | +| `wrap` | `boolean` | `false` | Wrap at ends. | +| `memory` | `boolean` | `true` | Remember last focused for re-entry via Tab. | +| `skipDisabled` | `boolean` | `false` | Skip `disabled` / `aria-disabled="true"` items. | +| `pageStep` | `number` | — | Non-zero: **Page Up** / **Page Down** move this many items (linear) or rows (**grid**); sign ignored. `0` / omitted / non-finite: disabled. | +| `onActiveItemChange` | `(el) => void` | — | Callback when active item changes. | ## RTL and writing modes diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts index e129401f8dd..669193a09df 100644 --- a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts @@ -74,6 +74,16 @@ export type FocusgroupNavigationOptions = { * The argument is the new active element, or null when the group has no eligible items. */ onActiveItemChange?: (active: HTMLElement | null) => void; + + /** + * When set to a **non-zero** integer, **Page Up** / **Page Down** move focus by that many + * positions in `getItems()` order for **`horizontal`**, **`vertical`**, and **`both`** modes + * (respects **`wrap`** the same way as single-step arrows). + * For **`grid`**, page keys move by that many **rows** (column index is clamped to each row’s + * length). Omitted, `0`, `NaN`, and non-finite values disable page keys. The sign of the + * number is ignored; only the magnitude is used. + */ + pageStep?: number; }; // ───────────────────────── @@ -130,6 +140,8 @@ export type FocusgroupNavigationActiveChangeDetail = { * direction accepts horizontal and vertical arrows on the same `getItems()` sequence. * In **`grid`** mode only, **Ctrl+Home** / **Ctrl+End** move to the first cell of the first * row or the last cell of the last row (by layout-derived rows). + * - Optional **`pageStep`**: **Page Up** / **Page Down** move by that many items (linear modes) + * or rows (**`grid`**). * - Supports optional last-focused memory when re-entering via Tab. * - Exposes {@link FocusgroupNavigationController.focusItem} for programmatic focus. * @@ -564,6 +576,9 @@ export class FocusgroupNavigationController implements ReactiveController { * first cell in the first row and **Ctrl+End** focuses the last cell in the last row (from * {@link buildRows}); other modifier combinations are ignored except plain Home/End. * + * When {@link FocusgroupNavigationOptions.pageStep} is a non-zero finite number, **Page Up** + * and **Page Down** are handled before arrow keys (see {@link navigatePage}). + * * @param event - Keyboard event from the focused element inside the host. */ private handleKeydown(event: KeyboardEvent): void { @@ -603,6 +618,23 @@ export class FocusgroupNavigationController implements ReactiveController { return; } + const pageMagnitude = this.getEffectivePageMagnitude(); + if ( + pageMagnitude !== null && + (event.key === 'PageUp' || event.key === 'PageDown') + ) { + const pageNext = this.navigatePage( + items, + target, + event.key === 'PageDown' ? pageMagnitude : -pageMagnitude + ); + if (pageNext && pageNext !== target) { + event.preventDefault(); + this.focusItem(pageNext); + } + return; + } + const rtl = this.isRtl(); let next: HTMLElement | null = null; @@ -646,6 +678,95 @@ export class FocusgroupNavigationController implements ReactiveController { } } + /** + * Positive step count for {@link FocusgroupNavigationOptions.pageStep}, or null when page keys + * are disabled. + */ + private getEffectivePageMagnitude(): number | null { + const raw = this.options.pageStep; + if (raw === undefined || raw === null) { + return null; + } + const n = Math.trunc(Number(raw)); + if (!Number.isFinite(n) || n === 0) { + return null; + } + return Math.abs(n); + } + + /** + * Target for **Page Up** / **Page Down** when {@link getEffectivePageMagnitude} is set. + * + * @param items - Eligible items. + * @param current - Focused item. + * @param signedDelta - `+magnitude` for Page Down or `-magnitude` for Page Up (items for + * linear modes, rows for `grid`). + */ + private navigatePage( + items: HTMLElement[], + current: HTMLElement, + signedDelta: number + ): HTMLElement | null { + if (this.options.direction === 'grid') { + return this.navigatePageGridRows(items, current, signedDelta); + } + return this.navigatePageLinearItems(items, current, signedDelta); + } + + /** + * Page Up/Down along `getItems()` order (used for `horizontal`, `vertical`, and `both`). + */ + private navigatePageLinearItems( + items: HTMLElement[], + current: HTMLElement, + deltaIdx: number + ): HTMLElement | null { + const idx = items.indexOf(current); + if (idx < 0 || items.length === 0) { + return null; + } + let nextIdx = idx + deltaIdx; + if (this.options.wrap) { + const len = items.length; + nextIdx = ((nextIdx % len) + len) % len; + } else { + nextIdx = Math.max(0, Math.min(items.length - 1, nextIdx)); + } + return items[nextIdx] ?? null; + } + + /** + * Page Up/Down by whole rows in `grid` mode (column clamped per {@link navigateGrid}). + */ + private navigatePageGridRows( + items: HTMLElement[], + current: HTMLElement, + rowDelta: number + ): HTMLElement | null { + const grid = this.buildRows(items); + if (grid.length === 0) { + return null; + } + const pos = this.findGridIndex(grid, current); + if (!pos) { + return null; + } + const { row, col } = pos; + let nextRow = row + rowDelta; + if (this.options.wrap) { + const n = grid.length; + nextRow = ((nextRow % n) + n) % n; + } else { + nextRow = Math.max(0, Math.min(grid.length - 1, nextRow)); + } + const targetRow = grid[nextRow]; + if (!targetRow?.length) { + return null; + } + const clampedCol = Math.min(col, targetRow.length - 1); + return targetRow[clampedCol] ?? null; + } + /** * Computes the next focus target for linear {@link FocusgroupDirection} modes. * diff --git a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts b/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts index f464d4ae357..172f8791975 100644 --- a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts @@ -211,11 +211,13 @@ export class DemoFocusgroupVertical extends LitElement { } /** - * Controller instance: vertical direction with wrapping; disabled items stay focusable. + * Controller instance: vertical direction with wrapping; disabled items stay focusable; + * **Page Up** / **Page Down** move two items at a time (`pageStep: 2`). */ private readonly navigation = new FocusgroupNavigationController(this, { direction: 'vertical', wrap: true, + pageStep: 2, skipDisabled: false, getItems: () => Array.from(this.renderRoot.querySelectorAll('button')), @@ -287,11 +289,13 @@ export class DemoFocusgroupGrid extends LitElement { `; /** - * Controller instance: grid direction without row/column wrap. + * Controller instance: grid direction without row/column wrap; **Page Up** / **Page Down** + * move two rows (`pageStep: 2`). */ private readonly navigation = new FocusgroupNavigationController(this, { direction: 'grid', wrap: false, + pageStep: 2, getItems: () => Array.from(this.renderRoot.querySelectorAll('.grid button')), }); @@ -492,8 +496,9 @@ export const BothAxesLinear: Story = { }; /** - * Block-axis arrows traverse menu-like items; one item uses `aria-disabled` (not native - * `disabled`) so it stays focusable and items after it remain reachable. + * Block-axis arrows traverse menu-like items; **Page Up** / **Page Down** skip two items. + * One control uses `aria-disabled` (not native `disabled`) so it stays focusable and items + * after it remain reachable. */ export const VerticalMenu: Story = { render: () => html` @@ -505,9 +510,10 @@ export const VerticalMenu: Story = { }; /** - * Arrow keys move across a 3×3 grid; **Home** / **End** jump to the first and last cell in - * row-major order; **Ctrl+Home** / **Ctrl+End** jump to the first cell of the first row and - * the last cell of the last row (equivalent here to cells **1** and **9**). + * Arrow keys move across a 3×3 grid; **Page Up** / **Page Down** move two rows at a time. + * **Home** / **End** jump to the first and last cell in row-major order; **Ctrl+Home** / + * **Ctrl+End** jump to the first cell of the first row and the last cell of the last row + * (equivalent here to cells **1** and **9**). */ export const Grid: Story = { render: () => html` diff --git a/2nd-gen/packages/swc/.storybook/main.ts b/2nd-gen/packages/swc/.storybook/main.ts index 6e1e6fa0f49..594530be0db 100644 --- a/2nd-gen/packages/swc/.storybook/main.ts +++ b/2nd-gen/packages/swc/.storybook/main.ts @@ -93,6 +93,11 @@ if (storybookMode === 'dev') { files: '**/*.test.ts', titlePrefix: 'Components', }); + stories.push({ + directory: '../../core', + files: '**/stories/**/*.test.ts', + titlePrefix: 'Core', + }); } /** From 676989b24f2f46bd44c608f665657211a5d3fdb7 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Thu, 2 Apr 2026 20:49:52 -0400 Subject: [PATCH 13/21] test(core): added controllet tests --- .../focus-group-navigation-controller.test.ts | 376 ++++++++++++++++++ .../14_controller-composition.md | 2 +- 2 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.test.ts diff --git a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.test.ts b/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.test.ts new file mode 100644 index 00000000000..aa7434b3fd9 --- /dev/null +++ b/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.test.ts @@ -0,0 +1,376 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { expect } from '@storybook/test'; +import type { Meta, StoryObj as Story } from '@storybook/web-components'; + +import { getComponent } from '../../../swc/utils/test-utils.js'; +import focusMeta, { + BothAxesLinear, + DemoFocusgroupProgrammatic, + Grid, + HorizontalToolbar, + ProgrammaticFocus, + VerticalMenu, +} from './focus-group-navigation-controller.stories.js'; + +type KeydownOptions = { + ctrlKey?: boolean; +}; + +/** + * Dispatches a composed `keydown` so listeners on the shadow host receive it like a real keystroke. + * + * @param target - Element to dispatch from (typically the focused control inside the host). + * @param key - `KeyboardEvent.key` value. + * @param options - Optional modifier keys. + */ +function keydown( + target: HTMLElement, + key: string, + options?: KeydownOptions +): void { + target.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + composed: true, + cancelable: true, + ctrlKey: options?.ctrlKey ?? false, + }) + ); +} + +/** Returns the focused element inside the host shadow root, if any. */ +function shadowActiveButton(host: HTMLElement): HTMLButtonElement | null { + const root = host.shadowRoot; + const active = root?.activeElement; + return active instanceof HTMLButtonElement ? active : null; +} + +export default { + ...focusMeta, + title: 'Focus group navigation controller/Tests', + parameters: { + ...focusMeta.parameters, + docs: { disable: true, page: null }, + }, + tags: ['!autodocs', 'dev'], +} as Meta; + +// ────────────────────────────────────────────────────────────── +// Horizontal toolbar (ArrowLeft / ArrowRight, wrap) +// ────────────────────────────────────────────────────────────── + +export const HorizontalToolbarArrowNavigation: Story = { + ...HorizontalToolbar, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-focusgroup-horizontal' + ); + + await step('ArrowRight moves forward along the toolbar', async () => { + const root = host.shadowRoot; + expect(root).toBeTruthy(); + const first = root!.querySelector('button'); + expect(first?.textContent?.trim()).toBe('Bold'); + first!.focus(); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Bold'); + + keydown(first!, 'ArrowRight'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Italic'); + + keydown(shadowActiveButton(host)!, 'ArrowRight'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Underline'); + + keydown(shadowActiveButton(host)!, 'ArrowRight'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe( + 'Strikethrough' + ); + }); + + await step('ArrowLeft moves backward', async () => { + keydown(shadowActiveButton(host)!, 'ArrowLeft'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Underline'); + }); + + await step('wrap: ArrowRight from last item returns to first', async () => { + while ( + shadowActiveButton(host)?.textContent?.trim() !== 'Strikethrough' + ) { + keydown(shadowActiveButton(host)!, 'ArrowRight'); + } + keydown(shadowActiveButton(host)!, 'ArrowRight'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Bold'); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// Both axes (horizontal + vertical arrows on one linear order) +// ────────────────────────────────────────────────────────────── + +export const BothAxesLinearArrowNavigation: Story = { + ...BothAxesLinear, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-focusgroup-both-axes' + ); + + await step( + 'ArrowRight and ArrowDown both advance in getItems() order', + async () => { + const root = host.shadowRoot; + expect(root).toBeTruthy(); + const first = root!.querySelector('button'); + expect(first?.textContent?.trim()).toBe('Start'); + first!.focus(); + + keydown(first!, 'ArrowRight'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Section A'); + + keydown(shadowActiveButton(host)!, 'ArrowDown'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Section B'); + } + ); + + await step('ArrowLeft and ArrowUp both move backward', async () => { + keydown(shadowActiveButton(host)!, 'ArrowLeft'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Section A'); + + keydown(shadowActiveButton(host)!, 'ArrowUp'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Start'); + }); + + await step('wrap: ArrowDown from last item returns to first', async () => { + while (shadowActiveButton(host)?.textContent?.trim() !== 'End') { + keydown(shadowActiveButton(host)!, 'ArrowDown'); + } + keydown(shadowActiveButton(host)!, 'ArrowDown'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Start'); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// Vertical menu (ArrowDown through aria-disabled item) +// ────────────────────────────────────────────────────────────── + +export const VerticalMenuArrowNavigation: Story = { + ...VerticalMenu, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-focusgroup-vertical' + ); + + await step( + 'ArrowDown reaches each item including aria-disabled and last item', + async () => { + const root = host.shadowRoot; + expect(root).toBeTruthy(); + const first = root!.querySelector('button'); + expect(first?.textContent?.trim()).toBe('Copy'); + first!.focus(); + + keydown(first!, 'ArrowDown'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Paste'); + + keydown(shadowActiveButton(host)!, 'ArrowDown'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe( + 'Cut (unavailable)' + ); + expect(shadowActiveButton(host)?.getAttribute('aria-disabled')).toBe( + 'true' + ); + + keydown(shadowActiveButton(host)!, 'ArrowDown'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe( + 'Select all' + ); + } + ); + + await step('ArrowUp moves back through the list', async () => { + keydown(shadowActiveButton(host)!, 'ArrowUp'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe( + 'Cut (unavailable)' + ); + }); + + await step( + 'Page Down skips two items; Page Up moves back two', + async () => { + const root = host.shadowRoot!; + root.querySelector('button')!.focus(); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Copy'); + + keydown(shadowActiveButton(host)!, 'PageDown'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe( + 'Cut (unavailable)' + ); + + root.querySelectorAll('button')[3]!.focus(); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe( + 'Select all' + ); + + keydown(shadowActiveButton(host)!, 'PageUp'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Paste'); + } + ); + }, +}; + +// ────────────────────────────────────────────────────────────── +// Grid (spatial arrows, Home / End) +// ────────────────────────────────────────────────────────────── + +export const GridArrowNavigation: Story = { + ...Grid, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-focusgroup-grid' + ); + + const cell = (label: string): HTMLButtonElement => { + const root = host.shadowRoot!; + const buttons = Array.from( + root.querySelectorAll('.grid button') + ); + const b = buttons.find((btn) => btn.textContent?.trim() === label); + expect(b).toBeTruthy(); + return b!; + }; + + await step( + 'from center cell 5, arrows move to geometric neighbors', + async () => { + const c5 = cell('5'); + c5.focus(); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('5'); + + keydown(c5, 'ArrowLeft'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('4'); + + keydown(shadowActiveButton(host)!, 'ArrowRight'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('5'); + + keydown(shadowActiveButton(host)!, 'ArrowUp'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('2'); + + keydown(shadowActiveButton(host)!, 'ArrowDown'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('5'); + + keydown(shadowActiveButton(host)!, 'ArrowDown'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('8'); + } + ); + + await step( + 'Home and End jump to first and last cell in row-major order', + async () => { + cell('5').focus(); + keydown(shadowActiveButton(host)!, 'Home'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('1'); + + keydown(shadowActiveButton(host)!, 'End'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('9'); + } + ); + + await step( + 'Ctrl+Home and Ctrl+End jump to first cell of first row and last cell of last row', + async () => { + cell('8').focus(); + keydown(shadowActiveButton(host)!, 'Home', { ctrlKey: true }); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('1'); + + cell('2').focus(); + keydown(shadowActiveButton(host)!, 'End', { ctrlKey: true }); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('9'); + } + ); + + await step( + 'Page Down moves two rows; Page Up moves back two rows', + async () => { + cell('1').focus(); + keydown(shadowActiveButton(host)!, 'PageDown'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('7'); + + keydown(shadowActiveButton(host)!, 'PageUp'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('1'); + } + ); + + await step( + 'Page Down past last row clamps to last row (same column clamped)', + async () => { + cell('5').focus(); + keydown(shadowActiveButton(host)!, 'PageDown'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('8'); + } + ); + }, +}; + +// ────────────────────────────────────────────────────────────── +// Programmatic focus + horizontal arrows without wrap +// ────────────────────────────────────────────────────────────── + +export const ProgrammaticFocusAndArrows: Story = { + ...ProgrammaticFocus, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-focusgroup-programmatic' + ); + + await step('ArrowRight moves only among toolbar items A–C', async () => { + const root = host.shadowRoot; + expect(root).toBeTruthy(); + const itemA = root!.querySelector('[data-item="a"]'); + expect(itemA).toBeTruthy(); + itemA!.focus(); + expect(shadowActiveButton(host)?.getAttribute('data-item')).toBe('a'); + + keydown(itemA!, 'ArrowRight'); + expect(shadowActiveButton(host)?.getAttribute('data-item')).toBe('b'); + + keydown(shadowActiveButton(host)!, 'ArrowRight'); + expect(shadowActiveButton(host)?.getAttribute('data-item')).toBe('c'); + }); + + await step( + 'no wrap: ArrowRight from last toolbar item stays on C', + async () => { + keydown(shadowActiveButton(host)!, 'ArrowRight'); + expect(shadowActiveButton(host)?.getAttribute('data-item')).toBe('c'); + } + ); + + await step( + 'focusItem updates roving tabindex and arrow navigation', + async () => { + host.focusProgrammaticTarget(); + expect(host.focusTarget).toBe('c'); + expect(shadowActiveButton(host)?.getAttribute('data-item')).toBe('c'); + + keydown(shadowActiveButton(host)!, 'ArrowLeft'); + expect(shadowActiveButton(host)?.getAttribute('data-item')).toBe('b'); + } + ); + }, +}; diff --git a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md index ab7a0bafee7..1578332e68f 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md @@ -88,7 +88,7 @@ The following controllers exist in 1st-gen and may be ported to 2nd-gen core: **What it does:** 1. Collapses roving `tabindex` to one tab stop in a composite (`tabindex="0"` on the active item, `-1` on others it manages). -2. Handles Arrow keys, Home, and End for horizontal, vertical, **`both`** (horizontal and vertical arrows on the same linear order), or **grid** layouts; in **grid** mode, Ctrl+Home / Ctrl+End jump to the first cell of the first row or the last cell of the last row. +2. Handles Arrow keys, Home, and End for horizontal, vertical, **`both`** (horizontal and vertical arrows on the same linear order), or **grid** layouts; optional **`pageStep`** enables Page Up / Page Down by that many items (linear) or rows (**grid**); in **grid** mode, Ctrl+Home / Ctrl+End jump to the first cell of the first row or the last cell of the last row. 3. Optionally wraps at ends and remembers the last focused item for Tab re-entry (similar to Open UI `focusgroup` semantics). **Public API:** `setOptions`, `getActiveItem`, `refresh`, `focusItem`, plus `hostConnected` / `hostDisconnected` via `ReactiveController`. From fdb9f3e8ab4764b503f20321f6355fde77c0a6e6 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Thu, 2 Apr 2026 20:54:52 -0400 Subject: [PATCH 14/21] feat(core): skip disabled items feature --- .../focus-group-navigation-controller.md | 19 ++- .../focus-group-navigation-controller.ts | 2 + ...cus-group-navigation-controller.stories.ts | 110 ++++++++++++++++++ .../focus-group-navigation-controller.test.ts | 75 ++++++++++++ .../14_controller-composition.md | 3 +- 5 files changed, 205 insertions(+), 4 deletions(-) diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md index 5e26f5bbe0c..d967d0e31f8 100644 --- a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md @@ -6,6 +6,7 @@ - Moves focus with **Arrow** keys according to `direction`: horizontal (inline axis), vertical (block axis), **both** (horizontal and vertical arrows on the same linear order), or **grid** (rows and columns from layout). - Supports **Home** / **End** to jump to the first or last item (for `grid`, order is visual row-major). - In **`grid`** mode only, **Ctrl+Home** moves focus to the **first cell in the first row** and **Ctrl+End** to the **last cell in the last row** (rows are derived from layout; ragged last rows use the final cell in that row). +- Optional **`skipDisabled`**: when `true`, elements with native **`disabled`** or **`aria-disabled="true"`** are excluded from roving `tabindex` and from arrow-key navigation (see story **Skip disabled menu**). - Optional **`pageStep`**: when set to a non-zero integer, **Page Up** / **Page Down** move that many items in `getItems()` order (linear modes) or that many **rows** in **`grid`** mode. - Optional **wrap** (end wraps to start) and **memory** (Tab returns to the last focused item), similar to `wrap` and `nomemory` concepts in the `focusgroup` proposal. @@ -80,18 +81,30 @@ The Storybook story **Both axes linear** demonstrates this on a small toolbar. ### Example (vertical list, skip disabled) +Items stay in the DOM (for example for layout or screen-reader context), but **`skipDisabled: true`** removes them from the roving tab stop and from arrow movement. Treat both native **`disabled`** and **`aria-disabled="true"`** as skipped. + ```typescript this.navigation = new FocusgroupNavigationController(this, { direction: 'vertical', wrap: true, skipDisabled: true, getItems: () => - Array.from( - this.renderRoot.querySelectorAll('[role="menuitem"]') - ), + Array.from(this.renderRoot.querySelectorAll('button')), }); ``` +```html + + + + + + + +``` + +The Storybook story **Skip disabled menu** walks **New → Open → Print → Help** with arrow keys only (Save and Close are never focused). + ### Example (Page Up / Page Down) ```typescript diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts index 669193a09df..50ca85aeb56 100644 --- a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts @@ -142,6 +142,8 @@ export type FocusgroupNavigationActiveChangeDetail = { * row or the last cell of the last row (by layout-derived rows). * - Optional **`pageStep`**: **Page Up** / **Page Down** move by that many items (linear modes) * or rows (**`grid`**). + * - Optional **`skipDisabled`**: omit **`disabled`** and **`aria-disabled="true"`** items from + * roving tabindex and arrow navigation. * - Supports optional last-focused memory when re-entering via Tab. * - Exposes {@link FocusgroupNavigationController.focusItem} for programmatic focus. * diff --git a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts b/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts index 172f8791975..12d9608ad3d 100644 --- a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts @@ -255,6 +255,102 @@ export class DemoFocusgroupVertical extends LitElement { } } +/** + * @internal + * + * Storybook-only host demonstrating {@link FocusgroupNavigationController} with + * `skipDisabled: true` so native `disabled` and `aria-disabled="true"` items are omitted + * from roving tabindex and arrow navigation. + */ +@customElement('demo-focusgroup-skip-disabled') +export class DemoFocusgroupSkipDisabled extends LitElement { + /** + * Shadow DOM styles for the menu demo (disabled styling for skipped items). + */ + static override styles = css` + :host { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + max-width: 16rem; + padding: 8px; + border: 1px solid var(--spectrum-gray-300, #ddd); + border-radius: 4px; + background: var(--spectrum-gray-50, #fff); + } + button { + font: inherit; + text-align: start; + padding: 8px 12px; + border: none; + border-radius: 4px; + background: transparent; + cursor: pointer; + } + button:hover:not([disabled]):not([aria-disabled='true']) { + background: var(--spectrum-gray-200, #e8e8e8); + } + button:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 0; + } + button[disabled], + button[aria-disabled='true'] { + opacity: 0.5; + cursor: not-allowed; + } + `; + + /** + * Prevents activating the `aria-disabled` item if it is clicked (not in arrow sequence). + * + * @param event - Click from the inactive item. + */ + private handleSkippedAriaDisabledClick(event: Event): void { + event.preventDefault(); + } + + /** + * Controller instance: vertical list; disabled and `aria-disabled` items are skipped. + */ + private readonly navigation = new FocusgroupNavigationController(this, { + direction: 'vertical', + wrap: true, + skipDisabled: true, + getItems: () => + Array.from(this.renderRoot.querySelectorAll('button')), + }); + + /** + * Runs after first render so `renderRoot` contains buttons before {@link FocusgroupNavigationController.refresh}. + */ + protected override firstUpdated(): void { + super.firstUpdated(); + this.navigation.refresh(); + } + + /** + * Renders a file menu with two skipped entries (native **disabled** and **aria-disabled**). + */ + protected override render(): TemplateResult { + return html` + + + + + + + `; + } +} + /** * @internal * @@ -509,6 +605,20 @@ export const VerticalMenu: Story = { `, }; +/** + * With `skipDisabled: true`, **Save** (`disabled`) and **Close** (`aria-disabled="true"`) are + * left out of the tab order and arrow sequence; **New**, **Open**, **Print**, and **Help** are + * all reachable with **ArrowDown** / **ArrowUp** (wrap on). + */ +export const SkipDisabledMenu: Story = { + render: () => html` + + `, +}; + /** * Arrow keys move across a 3×3 grid; **Page Up** / **Page Down** move two rows at a time. * **Home** / **End** jump to the first and last cell in row-major order; **Ctrl+Home** / diff --git a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.test.ts b/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.test.ts index aa7434b3fd9..87063d835f3 100644 --- a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.test.ts +++ b/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.test.ts @@ -19,6 +19,7 @@ import focusMeta, { Grid, HorizontalToolbar, ProgrammaticFocus, + SkipDisabledMenu, VerticalMenu, } from './focus-group-navigation-controller.stories.js'; @@ -232,6 +233,80 @@ export const VerticalMenuArrowNavigation: Story = { }, }; +// ────────────────────────────────────────────────────────────── +// Skip disabled (native disabled + aria-disabled omitted from arrows) +// ────────────────────────────────────────────────────────────── + +export const SkipDisabledMenuArrowNavigation: Story = { + ...SkipDisabledMenu, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-focusgroup-skip-disabled' + ); + const root = host.shadowRoot!; + const buttonByLabel = (label: string): HTMLButtonElement => { + const b = Array.from(root.querySelectorAll('button')).find( + (btn) => btn.textContent?.trim() === label + ); + expect(b).toBeTruthy(); + return b!; + }; + + await step( + 'skipped items use tabindex -1 and are disabled or aria-disabled', + async () => { + const save = buttonByLabel('Save'); + const close = buttonByLabel('Close'); + expect(save.disabled).toBe(true); + expect(close.getAttribute('aria-disabled')).toBe('true'); + expect(save.tabIndex).toBe(-1); + expect(close.tabIndex).toBe(-1); + } + ); + + await step( + 'ArrowDown visits every enabled item in order then wraps to first', + async () => { + buttonByLabel('New').focus(); + const visited: string[] = []; + for (let i = 0; i < 5; i++) { + visited.push(shadowActiveButton(host)!.textContent!.trim()); + keydown(shadowActiveButton(host)!, 'ArrowDown'); + } + expect(visited).toEqual(['New', 'Open', 'Print', 'Help', 'New']); + } + ); + + await step('ArrowUp from first enabled wraps to last enabled', async () => { + buttonByLabel('New').focus(); + keydown(shadowActiveButton(host)!, 'ArrowUp'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Help'); + }); + + await step( + 'many arrow steps never focus Save or Close', + async () => { + buttonByLabel('New').focus(); + for (let i = 0; i < 16; i++) { + const label = shadowActiveButton(host)?.textContent?.trim(); + expect(label).not.toBe('Save'); + expect(label).not.toBe('Close'); + keydown(shadowActiveButton(host)!, 'ArrowDown'); + } + } + ); + + await step('Home and End stay within eligible items only', async () => { + buttonByLabel('Print').focus(); + keydown(shadowActiveButton(host)!, 'Home'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('New'); + keydown(shadowActiveButton(host)!, 'End'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Help'); + }); + }, +}; + // ────────────────────────────────────────────────────────────── // Grid (spatial arrows, Home / End) // ────────────────────────────────────────────────────────────── diff --git a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md index 1578332e68f..a9b6b7a6675 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md @@ -89,7 +89,8 @@ The following controllers exist in 1st-gen and may be ported to 2nd-gen core: 1. Collapses roving `tabindex` to one tab stop in a composite (`tabindex="0"` on the active item, `-1` on others it manages). 2. Handles Arrow keys, Home, and End for horizontal, vertical, **`both`** (horizontal and vertical arrows on the same linear order), or **grid** layouts; optional **`pageStep`** enables Page Up / Page Down by that many items (linear) or rows (**grid**); in **grid** mode, Ctrl+Home / Ctrl+End jump to the first cell of the first row or the last cell of the last row. -3. Optionally wraps at ends and remembers the last focused item for Tab re-entry (similar to Open UI `focusgroup` semantics). +3. Optional **`skipDisabled`**: omit native **`disabled`** and **`aria-disabled="true"`** items from roving tabindex and arrow navigation (story **Skip disabled menu**). +4. Optionally wraps at ends and remembers the last focused item for Tab re-entry (similar to Open UI `focusgroup` semantics). **Public API:** `setOptions`, `getActiveItem`, `refresh`, `focusItem`, plus `hostConnected` / `hostDisconnected` via `ReactiveController`. From 2b13c37cac16b0f1c0736768248ad058c521abcf Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Thu, 2 Apr 2026 20:58:02 -0400 Subject: [PATCH 15/21] docs: added notes for when focusgroup has native support --- .../focus-group-navigation-controller.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts index 50ca85aeb56..085f447c74b 100644 --- a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts @@ -172,7 +172,40 @@ export type FocusgroupNavigationActiveChangeDetail = { * * @see https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#keyboardnavigationinsidecomponents * @see https://open-ui.org/components/scoped-focusgroup.explainer/ + * + * **Native `focusgroup` (future):** The comment block immediately below this class lists which + * parts of this file are the most likely candidates for deprecation or deletion once browsers + * ship built-in focus-group behavior that covers the same cases (especially roving tabindex and + * arrow-key focus moves). Some options (for example rect-based **grid**, **pageStep**, or + * **skipDisabled**) may remain useful longer if the platform surface stays narrower. */ +// ───────────────────────────────────────────────────────────────────────────── +// Native `focusgroup` (future) — likely deprecation candidates +// +// If/when browsers implement `focusgroup` (or equivalent) with behavior comparable to this +// controller for your targets, consider removing or shrinking the following areas first: +// +// 1. Roving tabindex — `applyRovingTabindex()`, the tabindex portions of `refresh()` and +// `focusItem()`, and assigning `tabIndex` to ineligible raw items. +// +// 2. Host keyboard interception — `handleKeydown()`, `hostConnected` / `hostDisconnected` +// `keydown` listeners, `resolveManagedKeydownTarget()` (shadow retargeting workaround), and +// navigation helpers: `navigateLinear`, `navigateBothAxes`, `navigateGrid`, `navigatePage`, +// `navigatePageLinearItems`, `navigatePageGridRows`, `getEffectivePageMagnitude`, plus +// Home/End and Ctrl+Home/Ctrl+End branches inside `handleKeydown`. +// +// 3. JS “memory” for Tab re-entry — `lastFocused`, `handleFocusin` / `handleFocusout` memory +// paths, and `refresh()`’s preference for `lastFocused` when native group memory replaces +// this pattern. +// +// Often slower to retire (verify against shipped HTML/Open UI behavior): `buildRows` and +// geometry-based **grid** navigation; **pageStep** (Page Up/Down magnitude); **skipDisabled** and +// `isDisabledForSkip`; `isNodeWithinHostScope` / `getRawItems` if declarative scoping differs in +// shadow DOM; `dispatchActiveChange`, `onActiveItemChange`, and the exported event name if +// products still want a single composed integration hook. `isRtl()` may duplicate or diverge +// from native axis mapping — revisit when testing RTL with native focusgroup. +// ───────────────────────────────────────────────────────────────────────────── + export class FocusgroupNavigationController implements ReactiveController { /** * Lit reactive host this controller is attached to. @@ -323,6 +356,9 @@ export class FocusgroupNavigationController implements ReactiveController { // ───────────────────────── // IMPLEMENTATION // ───────────────────────── + // + // Which parts may become redundant under native `focusgroup` is summarized in the + // “Native `focusgroup` (future)” comment block directly above the class declaration. /** * Resolves `dir` from the shadow host, nearest `dir` ancestor, or `document.documentElement`. From 2c505869c8ad49031f1d10d8ed50a633ad7e6738 Mon Sep 17 00:00:00 2001 From: Casey Eickhoff Date: Fri, 3 Apr 2026 09:43:04 -0600 Subject: [PATCH 16/21] chore: remove files for nikkis rovingtabindex --- 2nd-gen/packages/core/controllers/index.ts | 4 - .../core/controllers/roving-tabindex.ts | 561 ------------------ 2 files changed, 565 deletions(-) delete mode 100644 2nd-gen/packages/core/controllers/roving-tabindex.ts diff --git a/2nd-gen/packages/core/controllers/index.ts b/2nd-gen/packages/core/controllers/index.ts index 2d60923ad31..a022b4457d6 100644 --- a/2nd-gen/packages/core/controllers/index.ts +++ b/2nd-gen/packages/core/controllers/index.ts @@ -14,7 +14,3 @@ export { LanguageResolutionController, languageResolverUpdatedSymbol, } from './language-resolution.js'; -export { - RovingTabindexController, - type RovingTabindexConfig, -} from './roving-tabindex.js'; diff --git a/2nd-gen/packages/core/controllers/roving-tabindex.ts b/2nd-gen/packages/core/controllers/roving-tabindex.ts deleted file mode 100644 index 6dcb42f2825..00000000000 --- a/2nd-gen/packages/core/controllers/roving-tabindex.ts +++ /dev/null @@ -1,561 +0,0 @@ -/** - * Copyright 2026 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import type { ReactiveController, ReactiveElement } from 'lit'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -type DirectionTypes = 'horizontal' | 'vertical' | 'both' | 'grid'; - -export interface RovingTabindexConfig { - /** Function returning the current list of focusable elements. */ - elements: () => T[]; - - /** Navigation direction. Default: 'both'. */ - direction?: DirectionTypes | (() => DirectionTypes); - - /** - * Which element index to focus when entering the group. - * Accepts a static number or a function. Default: first element (0). - */ - focusInIndex?: number | ((elements: T[]) => number); - - /** Filter to determine if an element is currently focusable. */ - isFocusableElement?: (el: T) => boolean; - - /** Called before focusing an element (e.g., to auto-select in radio groups). */ - elementEnterAction?: (el: T) => void; - - /** - * When true, arrow key events will stop propagation after being handled. - * This prevents parent elements from also reacting to arrow keys. - * - * @default false - */ - stopKeyEventPropagation?: boolean; - - /** - * Scope element for event listeners. Accepts a static element or a function. - * Default: host.renderRoot (shadow root) for better shadow DOM encapsulation. - */ - listenerScope?: HTMLElement | (() => HTMLElement); - - /** Whether host uses delegatesFocus. @default false */ - hostDelegatesFocus?: boolean; - - /** - * Number of items per row in grid mode. - * Required when direction is 'grid' — no arbitrary default. - */ - directionLength?: number; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** - * Normalizes a config value that may be a static value or a function. - * Always returns a function for consistent internal access. - */ -function normalize( - value: TFn | TStatic | undefined, - staticType: string, - fallback: TFn -): TFn { - if (typeof value === 'function') { - return value as TFn; - } - if (typeof value === staticType) { - return (() => value) as unknown as TFn; - } - return fallback; -} - -// --------------------------------------------------------------------------- -// Controller -// --------------------------------------------------------------------------- - -/** - * A reactive controller implementing the WAI-ARIA roving tabindex pattern. - * - * Manages keyboard navigation (arrow keys, Home/End) and tabindex attributes - * across a group of elements so that only the current element has - * `tabindex="0"` and all others have `tabindex="-1"`. The group appears as - * a single tab stop. - * - * Consolidates the 1st-gen `FocusGroupController` + `RovingTabindexController` - * into a single, self-contained file. - * - * @see https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex - */ -export class RovingTabindexController< - T extends HTMLElement, -> implements ReactiveController { - // ---- State --------------------------------------------------------------- - - private host: ReactiveElement; - private _elements: () => T[]; - private _direction: () => DirectionTypes; - private _focusInIndex: (elements: T[]) => number; - private _listenerScope: () => HTMLElement; - - isFocusableElement: (el: T) => boolean; - elementEnterAction: (el: T) => void; - stopKeyEventPropagation: boolean; - hostDelegatesFocus: boolean; - directionLength: number; - - private cachedElements?: T[]; - private _currentIndex = -1; - private prevIndex = -1; - private _focused = false; - private managed = true; - private recentlyConnected = false; - private manageIndexesAnimationFrame = 0; - - private mutationObserver: MutationObserver; - - // ---- Constructor --------------------------------------------------------- - - constructor(host: ReactiveElement, config: RovingTabindexConfig) { - this.host = host; - this.host.addController(this); - - this._elements = config.elements; - - this._direction = normalize<() => DirectionTypes, DirectionTypes>( - config.direction, - 'string', - () => 'both' - ); - - this._focusInIndex = normalize<(els: T[]) => number, number>( - config.focusInIndex, - 'number', - () => 0 - ); - - this._listenerScope = normalize<() => HTMLElement, HTMLElement>( - config.listenerScope, - 'object', - () => (this.host.renderRoot as unknown as HTMLElement) ?? this.host - ); - - this.isFocusableElement = config.isFocusableElement ?? (() => true); - this.elementEnterAction = config.elementEnterAction ?? (() => {}); - this.stopKeyEventPropagation = config.stopKeyEventPropagation ?? false; - this.hostDelegatesFocus = config.hostDelegatesFocus ?? false; - this.directionLength = config.directionLength ?? 1; - - this.mutationObserver = new MutationObserver(() => { - this.handleItemMutation(); - }); - } - - // ---- Public getters ------------------------------------------------------ - - get direction(): DirectionTypes { - return this._direction(); - } - - get elements(): T[] { - if (!this.cachedElements) { - this.cachedElements = this._elements(); - } - return this.cachedElements; - } - - get currentIndex(): number { - if (this._currentIndex === -1) { - this._currentIndex = this.focusInIndex; - } - return this._currentIndex; - } - - set currentIndex(index: number) { - this._currentIndex = index; - } - - get focusInIndex(): number { - return this._focusInIndex(this.elements); - } - - get focusInElement(): T { - return this.elements[this.focusInIndex]; - } - - private get focused(): boolean { - return this._focused; - } - - private set focused(value: boolean) { - if (value === this._focused) { - return; - } - this._focused = value; - this.manageTabindexes(); - } - - // ---- Public methods ------------------------------------------------------ - - /** - * Focus the current element in the group, advancing circularly if needed. - */ - focus(options?: FocusOptions): void { - const elements = this.elements; - if (!elements.length) { - return; - } - - let focusElement = elements[this.currentIndex]; - if (!focusElement || !this.isFocusableElement(focusElement)) { - this.setCurrentIndexCircularly(1); - focusElement = elements[this.currentIndex]; - } - if (focusElement && this.isFocusableElement(focusElement)) { - if ( - !this.hostDelegatesFocus || - elements[this.prevIndex] !== focusElement - ) { - elements[this.prevIndex]?.setAttribute('tabindex', '-1'); - } - focusElement.tabIndex = 0; - focusElement.focus(options); - if (this.hostDelegatesFocus && !this.focused) { - this.hostContainsFocus(); - } - } - } - - /** - * Focus a specific item in the group. - */ - focusOnItem(item?: T, options?: FocusOptions): void { - const elements = this.elements || []; - const newIndex = - !item || !this.isFocusableElement(item) ? -1 : elements.indexOf(item); - if (newIndex > -1) { - this.currentIndex = newIndex; - elements[this.prevIndex]?.setAttribute('tabindex', '-1'); - } - this.focus(options); - } - - /** - * Reset focus tracking to the initial element. - */ - reset(): void { - const elements = this.elements; - if (!elements.length) { - return; - } - - this.setCurrentIndexCircularly(this.focusInIndex - this.currentIndex); - let focusElement = elements[this.currentIndex]; - if (this.currentIndex < 0) { - return; - } - - if (!focusElement || !this.isFocusableElement(focusElement)) { - this.setCurrentIndexCircularly(1); - focusElement = elements[this.currentIndex]; - } - if (focusElement && this.isFocusableElement(focusElement)) { - elements[this.prevIndex]?.setAttribute('tabindex', '-1'); - focusElement.setAttribute('tabindex', '0'); - } - } - - /** - * Clear the element cache. Call when the set of managed elements changes - * dynamically (e.g., items added/removed via slots). - */ - clearElementCache(): void { - cancelAnimationFrame(this.manageIndexesAnimationFrame); - this.mutationObserver.disconnect(); - delete this.cachedElements; - - requestAnimationFrame(() => { - this.elements.forEach((element) => { - this.mutationObserver.observe(element, { - attributes: true, - attributeFilter: ['disabled', 'aria-disabled'], - }); - }); - }); - - if (!this.managed) { - return; - } - this.manageIndexesAnimationFrame = requestAnimationFrame(() => - this.manageTabindexes() - ); - } - - /** - * Replace the element supplier and re-initialize. - */ - update(config: Pick, 'elements'>): void { - this.unmanage(); - this._elements = config.elements; - this.clearElementCache(); - this.manage(); - } - - /** - * Start managing tabindexes and listening for events. - */ - manage(): void { - this.managed = true; - this.manageTabindexes(); - this.addEventListeners(); - } - - /** - * Stop managing — restore all elements to `tabindex="0"`. - */ - unmanage(): void { - this.managed = false; - this.elements.forEach((el) => { - el.tabIndex = 0; - }); - this.removeEventListeners(); - } - - // ---- Circular index calculation ------------------------------------------ - - setCurrentIndexCircularly(diff: number): void { - const { length } = this.elements; - let steps = length; - this.prevIndex = this.currentIndex; - let nextIndex = (length + this.currentIndex + diff) % length; - while ( - steps && - this.elements[nextIndex] && - !this.isFocusableElement(this.elements[nextIndex]) - ) { - nextIndex = (length + nextIndex + diff) % length; - steps -= 1; - } - this.currentIndex = nextIndex; - } - - // ---- Tabindex management ------------------------------------------------- - - private manageTabindexes(): void { - if (this.focused && !this.hostDelegatesFocus) { - // While focused, only the current element keeps tabindex="0" - // (set by focus()), all others get -1. - this.elements.forEach((el) => { - if (el !== this.elements[this.currentIndex]) { - el.tabIndex = -1; - } - }); - } else { - // When not focused (or host delegates focus), the focusInElement - // gets tabindex="0" so Tab lands there; all others get -1. - this.elements.forEach((el) => { - el.tabIndex = el === this.focusInElement ? 0 : -1; - }); - } - } - - // ---- Event handling ------------------------------------------------------ - - private isEventWithinListenerScope(event: Event): boolean { - const scope = this._listenerScope(); - if (scope === this.host) { - return true; - } - return event.composedPath().includes(scope); - } - - private isRelatedTargetAnElementOrChild(event: FocusEvent): boolean { - const relatedTarget = event.relatedTarget as null | Element; - const isElement = this.elements.includes(relatedTarget as T); - const isChild = this.elements.some((el) => el.contains(relatedTarget)); - return !(isElement || isChild); - } - - private handleFocusin = (event: FocusEvent): void => { - if (!this.isEventWithinListenerScope(event)) { - return; - } - - const path = event.composedPath() as T[]; - let targetIndex = -1; - path.find((el) => { - targetIndex = this.elements.indexOf(el); - return targetIndex !== -1; - }); - this.prevIndex = this.currentIndex; - this.currentIndex = targetIndex > -1 ? targetIndex : this.currentIndex; - - if (this.isRelatedTargetAnElementOrChild(event)) { - this.hostContainsFocus(); - } - }; - - private handleFocusout = (event: FocusEvent): void => { - if (this.isRelatedTargetAnElementOrChild(event)) { - this.hostNoLongerContainsFocus(); - } - }; - - private handleKeydown = (event: KeyboardEvent): void => { - if (!this.acceptsEventKey(event.key) || event.defaultPrevented) { - return; - } - - let diff = 0; - this.prevIndex = this.currentIndex; - switch (event.key) { - case 'ArrowRight': - diff += 1; - break; - case 'ArrowDown': - diff += this.direction === 'grid' ? this.directionLength : 1; - break; - case 'ArrowLeft': - diff -= 1; - break; - case 'ArrowUp': - diff -= this.direction === 'grid' ? this.directionLength : 1; - break; - case 'End': - this.currentIndex = 0; - diff -= 1; - break; - case 'Home': - this.currentIndex = this.elements.length - 1; - diff += 1; - break; - } - - event.preventDefault(); - if (this.stopKeyEventPropagation) { - event.stopPropagation(); - } - - // Grid mode: clamp rather than wrap - if (this.direction === 'grid' && this.currentIndex + diff < 0) { - this.currentIndex = 0; - } else if ( - this.direction === 'grid' && - this.currentIndex + diff > this.elements.length - 1 - ) { - this.currentIndex = this.elements.length - 1; - } else { - this.setCurrentIndexCircularly(diff); - } - - // Enter action before focus so callbacks can read "after" state - this.elementEnterAction(this.elements[this.currentIndex]); - this.focus(); - }; - - private acceptsEventKey(key: string): boolean { - if (key === 'End' || key === 'Home') { - return true; - } - switch (this.direction) { - case 'horizontal': - return key === 'ArrowLeft' || key === 'ArrowRight'; - case 'vertical': - return key === 'ArrowUp' || key === 'ArrowDown'; - case 'both': - case 'grid': - return key.startsWith('Arrow'); - } - } - - // ---- Focus containment tracking ------------------------------------------ - - private hostContainsFocus(): void { - this.host.addEventListener('focusout', this.handleFocusout); - this.host.addEventListener('keydown', this.handleKeydown); - this.focused = true; - } - - private hostNoLongerContainsFocus(): void { - this.host.addEventListener('focusin', this.handleFocusin); - this.host.removeEventListener('focusout', this.handleFocusout); - this.host.removeEventListener('keydown', this.handleKeydown); - this.focused = false; - } - - // ---- Mutation handling --------------------------------------------------- - - private handleItemMutation(): void { - if ( - this._currentIndex === -1 || - this.elements.length <= this._elements().length - ) { - return; - } - const focusedElement = this.elements[this.currentIndex]; - this.clearElementCache(); - if (this.elements.includes(focusedElement)) { - return; - } - - const moveToNext = this.currentIndex !== this.elements.length; - const diff = moveToNext ? 1 : -1; - if (moveToNext) { - this.setCurrentIndexCircularly(-1); - } - this.setCurrentIndexCircularly(diff); - this.focus(); - } - - // ---- Listener lifecycle -------------------------------------------------- - - private addEventListeners(): void { - this.host.addEventListener('focusin', this.handleFocusin); - } - - private removeEventListeners(): void { - this.host.removeEventListener('focusin', this.handleFocusin); - this.host.removeEventListener('focusout', this.handleFocusout); - this.host.removeEventListener('keydown', this.handleKeydown); - } - - // ---- Reactive controller lifecycle --------------------------------------- - - hostConnected(): void { - this.recentlyConnected = true; - this.addEventListeners(); - } - - hostDisconnected(): void { - this.mutationObserver.disconnect(); - this.removeEventListeners(); - } - - hostUpdated(): void { - if (this.recentlyConnected) { - this.recentlyConnected = false; - this.elements.forEach((element) => { - this.mutationObserver.observe(element, { - attributes: true, - attributeFilter: ['disabled', 'aria-disabled'], - }); - }); - } - if (!this.host.hasUpdated) { - this.manageTabindexes(); - } - } -} From 7aad51f8f338a53f0568178a75842d7820b880d1 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Fri, 3 Apr 2026 11:55:55 -0400 Subject: [PATCH 17/21] feat(core): printable character navigation support --- .../focus-group-navigation-controller.md | 29 ++- .../focus-group-navigation-controller.ts | 88 ++++++++- ...cus-group-navigation-controller.stories.ts | 175 ++++++++++++++++++ .../focus-group-navigation-controller.test.ts | 53 ++++++ 2nd-gen/packages/core/overview.mdx | 2 +- .../14_controller-composition.md | 2 +- 6 files changed, 335 insertions(+), 14 deletions(-) diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md index d967d0e31f8..ad123f7b9c3 100644 --- a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md @@ -7,6 +7,7 @@ - Supports **Home** / **End** to jump to the first or last item (for `grid`, order is visual row-major). - In **`grid`** mode only, **Ctrl+Home** moves focus to the **first cell in the first row** and **Ctrl+End** to the **last cell in the last row** (rows are derived from layout; ragged last rows use the final cell in that row). - Optional **`skipDisabled`**: when `true`, elements with native **`disabled`** or **`aria-disabled="true"`** are excluded from roving `tabindex` and from arrow-key navigation (see story **Skip disabled menu**). +- **`focusFirstItemByTextPrefix(prefix)`** updates roving `tabindex` to the first eligible item whose typeahead label starts with `prefix` (case-insensitive), in `getItems()` order — label uses **`aria-label`**, then **`aria-labelledby`** text, then **`textContent`**. It does **not** call `focus()`; call **`getActiveItem()?.focus()`** yourself (story **Text prefix focus**). - Optional **`pageStep`**: when set to a non-zero integer, **Page Up** / **Page Down** move that many items in `getItems()` order (linear modes) or that many **rows** in **`grid`** mode. - Optional **wrap** (end wraps to start) and **memory** (Tab returns to the last focused item), similar to `wrap` and `nomemory` concepts in the `focusgroup` proposal. @@ -118,6 +119,21 @@ this.navigation = new FocusgroupNavigationController(this, { With `pageStep: 3`, each **Page Down** advances three items in `getItems()` order; **Page Up** goes back three. For **`grid`**, use the same option to move three rows at a time. +### Example (focus by text prefix / typeahead) + +Call **`focusFirstItemByTextPrefix`** when the user types into a composite (often from a capturing `keydown` or debounced `input`). Matching uses each item’s typeahead label — trimmed **`aria-label`** if set, otherwise text from **`aria-labelledby`** references (in order), otherwise trimmed **`textContent`** — with a **case-insensitive** prefix test, and only **eligible** items (respects **`skipDisabled`**). The first match in `getItems()` order becomes the roving tab stop; **`focus()` is not called** by the controller. + +Move focus yourself on **`getActiveItem()`**. From a **`click`** handler on another control, defer `focus()` with **`queueMicrotask`** (or similar) so the browser does not move focus back to the clicked element after your handler returns. + +```typescript +// Example: after the user types into your menu search buffer `buffer` +if (this.navigation.focusFirstItemByTextPrefix(buffer)) { + queueMicrotask(() => { + this.navigation.getActiveItem()?.focus(); + }); +} +``` + ### Example (grid) Use `direction: 'grid'` when items are laid out in rows (for example CSS Grid). The controller groups items into rows using bounding rectangles, then maps Arrow keys to cell movement. **Home** / **End** use visual row-major order (first and last item in that flattened sequence). **Ctrl+Home** / **Ctrl+End** jump to the first cell of the top row or the last cell of the bottom row, which matches rectangular grids and differs from plain **End** only when the last row has fewer cells than earlier rows. @@ -126,12 +142,13 @@ Set **`pageStep`** to a positive integer (for example `2`) so **Page Up** / **Pa ## API -| Member | Description | -| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `setOptions(partial)` | Merge new options and reapply roving tabindex. | -| `refresh()` | Re-query items and sync tabindex (call after DOM changes). | -| `focusItem(element, focusOptions?)` | Programmatically focus an item and update roving tabindex. Returns `false` if the element is not in the current item list. | -| `getActiveItem()` | Returns the item with `tabindex="0"`, if any. | +| Member | Description | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `setOptions(partial)` | Merge new options and reapply roving tabindex. | +| `refresh()` | Re-query items and sync tabindex (call after DOM changes). | +| `focusItem(element, focusOptions?)` | Programmatically focus an item and update roving tabindex. Returns `false` if the element is not in the current item list. | +| `focusFirstItemByTextPrefix(prefix)` | Set roving `tabindex` to the first eligible item whose typeahead label (`aria-label`, then `aria-labelledby`, then `textContent`) starts with `prefix` (case-insensitive). Does **not** call `focus()`. Returns `false` if `prefix` is whitespace-only or there is no match. | +| `getActiveItem()` | Returns the eligible item with `tabindex="0"`, if any. | ### Events diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts index 085f447c74b..e3c04836cbf 100644 --- a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts @@ -145,7 +145,10 @@ export type FocusgroupNavigationActiveChangeDetail = { * - Optional **`skipDisabled`**: omit **`disabled`** and **`aria-disabled="true"`** items from * roving tabindex and arrow navigation. * - Supports optional last-focused memory when re-entering via Tab. - * - Exposes {@link FocusgroupNavigationController.focusItem} for programmatic focus. + * - Exposes {@link FocusgroupNavigationController.focusItem} for programmatic focus and + * {@link FocusgroupNavigationController.focusFirstItemByTextPrefix} for typeahead-style roving + * `tabindex` (call {@link FocusgroupNavigationController.getActiveItem} and `focus()` yourself + * when you want keyboard focus to move). * * Dispatches a bubbling, composed `CustomEvent` named * {@link focusgroupNavigationActiveChange} when the active item changes. @@ -202,8 +205,10 @@ export type FocusgroupNavigationActiveChangeDetail = { // geometry-based **grid** navigation; **pageStep** (Page Up/Down magnitude); **skipDisabled** and // `isDisabledForSkip`; `isNodeWithinHostScope` / `getRawItems` if declarative scoping differs in // shadow DOM; `dispatchActiveChange`, `onActiveItemChange`, and the exported event name if -// products still want a single composed integration hook. `isRtl()` may duplicate or diverge -// from native axis mapping — revisit when testing RTL with native focusgroup. +// products still want a single composed integration hook; `focusFirstItemByTextPrefix` for +// typeahead roving tabindex (callers focus `getActiveItem()` unless the platform adds an equivalent). +// `isRtl()` may +// duplicate or diverge from native axis mapping — revisit when testing RTL with native focusgroup. // ───────────────────────────────────────────────────────────────────────────── export class FocusgroupNavigationController implements ReactiveController { @@ -266,13 +271,13 @@ export class FocusgroupNavigationController implements ReactiveController { } /** - * Returns the managed item that currently participates in the sequential focus order - * (`tabindex="0"`), or null if none of the items from `getItems` have tab index zero. + * Returns the eligible managed item that currently participates in the sequential focus order + * (`tabindex="0"`), or null if no eligible item has tab index zero. * * @returns The active roving item, or null. */ public getActiveItem(): HTMLElement | null { - for (const el of this.options.getItems()) { + for (const el of this.getEligibleItems()) { if (el.tabIndex === 0) { return el; } @@ -333,6 +338,42 @@ export class FocusgroupNavigationController implements ReactiveController { return true; } + /** + * Updates roving `tabindex` so the first **eligible** item (same set as arrow navigation) + * whose typeahead label starts with `prefix` becomes the active tab stop (`tabindex="0"`). + * Matching is **case-insensitive**. The label is the first non-empty of: trimmed + * **`aria-label`**, trimmed text from **`aria-labelledby`** references (in order, space-joined), + * or trimmed **`textContent`**. Search order matches arrow-key traversal. + * + * Does **not** call `focus()`. After this returns `true`, call `focus()` on + * {@link FocusgroupNavigationController.getActiveItem} (for example `getActiveItem()?.focus()`), + * often from a **microtask** when the caller runs from a pointer handler so focus is not + * overwritten by the clicked control. + * + * Typical use: menu typeahead; wire `keydown` or `input` at the host and debounce as needed. + * + * @param prefix - String to match as a leading substring after `trim`; whitespace-only yields + * no match and returns `false`. + * @returns True if a matching item was found and roving tabindex was applied. + */ + public focusFirstItemByTextPrefix(prefix: string): boolean { + const trimmed = prefix.trim(); + if (trimmed === '') { + return false; + } + const needle = trimmed.toLowerCase(); + const items = this.getEligibleItems(); + const match = items.find((el) => { + const label = this.getItemTypeaheadLabel(el).toLowerCase(); + return label.startsWith(needle); + }); + if (!match) { + return false; + } + this.applyRovingTabindex(match); + return true; + } + /** * Lit `ReactiveController` hook: registers capture-phase listeners on `host` and runs * an initial {@link refresh}. @@ -473,6 +514,41 @@ export class FocusgroupNavigationController implements ReactiveController { return el.getAttribute('aria-disabled') === 'true'; } + /** + * String used for {@link focusFirstItemByTextPrefix}: prefers **`aria-label`**, then text from + * **`aria-labelledby`** (IDs resolved in the shadow root or document), else **`textContent`**. + * All branches are trimmed; empty strings fall through to the next source. + */ + private getItemTypeaheadLabel(el: HTMLElement): string { + const fromAria = el.getAttribute('aria-label')?.trim(); + if (fromAria) { + return fromAria; + } + const labelledBy = el.getAttribute('aria-labelledby')?.trim(); + if (labelledBy) { + const root = el.getRootNode(); + const chunks: string[] = []; + for (const id of labelledBy.split(/\s+/)) { + if (!id) { + continue; + } + const ref = + root instanceof ShadowRoot + ? root.getElementById(id) ?? el.ownerDocument.getElementById(id) + : el.ownerDocument.getElementById(id); + const t = ref?.textContent?.trim(); + if (t) { + chunks.push(t); + } + } + const joined = chunks.join(' ').trim(); + if (joined) { + return joined; + } + } + return el.textContent?.trim() ?? ''; + } + /** * Sets `tabindex="-1"` on ineligible raw items, then assigns `tabindex="0"` to * `active` (or the first eligible item if `active` is not eligible) and `-1` to the rest. diff --git a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts b/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts index 12d9608ad3d..1b8317a37cc 100644 --- a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts @@ -541,6 +541,170 @@ export class DemoFocusgroupProgrammatic extends LitElement { } } +/** + * @internal + * + * Storybook-only host demonstrating {@link FocusgroupNavigationController.focusFirstItemByTextPrefix}. + */ +@customElement('demo-focusgroup-text-prefix') +export class DemoFocusgroupTextPrefix extends LitElement { + /** + * Shadow DOM styles for the vertical menu and demo triggers outside the roving group. + */ + static override styles = css` + :host { + display: flex; + flex-direction: column; + gap: 12px; + align-items: flex-start; + max-width: 18rem; + } + .menu { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + width: 100%; + padding: 8px; + border: 1px solid var(--spectrum-gray-300, #ddd); + border-radius: 4px; + background: var(--spectrum-gray-50, #fff); + } + .menu button { + font: inherit; + text-align: start; + padding: 8px 12px; + border: none; + border-radius: 4px; + background: transparent; + cursor: pointer; + } + .menu button:hover { + background: var(--spectrum-gray-200, #e8e8e8); + } + .menu button:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 0; + } + .menu button.typeahead-icon { + font-size: 1.125rem; + line-height: 1.2; + } + .triggers { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .demo-trigger { + font: inherit; + padding: 8px 12px; + border-radius: 4px; + border: 1px solid var(--spectrum-gray-400, #ccc); + background: var(--spectrum-gray-75, #f5f5f5); + cursor: pointer; + } + .demo-trigger:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 2px; + } + `; + + /** + * Controller instance: only `.menu button` elements participate (triggers are outside). + */ + private readonly navigation = new FocusgroupNavigationController(this, { + direction: 'vertical', + wrap: true, + getItems: () => + Array.from(this.renderRoot.querySelectorAll('.menu button')), + }); + + /** + * Runs after first render so menu buttons exist before {@link FocusgroupNavigationController.refresh}. + */ + protected override firstUpdated(): void { + super.firstUpdated(); + this.navigation.refresh(); + } + + /** + * Applies roving tabindex via {@link FocusgroupNavigationController.focusFirstItemByTextPrefix}, + * then focuses the active item (for Storybook tests; production code can call those separately). + * + * @param prefix - Prefix to match against each item’s typeahead label. + * @returns Whether a matching item was found and focused. + */ + public focusByTextPrefix(prefix: string): boolean { + if (!this.navigation.focusFirstItemByTextPrefix(prefix)) { + return false; + } + this.navigation.getActiveItem()?.focus(); + return true; + } + + /** + * Demo: roving tabindex for **Paste** via prefix `Pas`, then move focus after the click target + * has finished activation. + */ + private handleDemoPrefixPas(): void { + if (!this.navigation.focusFirstItemByTextPrefix('Pas')) { + return; + } + queueMicrotask(() => { + this.navigation.getActiveItem()?.focus(); + }); + } + + /** + * Demo: roving tabindex for **Cut** via prefix `cu`, then move focus after activation. + */ + private handleDemoPrefixCu(): void { + if (!this.navigation.focusFirstItemByTextPrefix('cu')) { + return; + } + queueMicrotask(() => { + this.navigation.getActiveItem()?.focus(); + }); + } + + /** + * Renders a small menu plus trigger buttons that call prefix-based focus on the controller. + */ + protected override render(): TemplateResult { + return html` + +
+ + +
+ `; + } +} + // ───────────────────────── // STORYBOOK // ───────────────────────── @@ -641,3 +805,14 @@ export const ProgrammaticFocus: Story = { > `, }; + +/** + * The controller’s **focusFirstItemByTextPrefix** only syncs roving `tabindex` to the first label + * match; the demo triggers then call `focus()` on {@link FocusgroupNavigationController.getActiveItem} + * in a microtask. Call the same pattern from application code (for example on `keydown` for typeahead). + */ +export const TextPrefixFocus: Story = { + render: () => html` + + `, +}; diff --git a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.test.ts b/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.test.ts index 87063d835f3..09e2b366bb5 100644 --- a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.test.ts +++ b/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.test.ts @@ -16,10 +16,12 @@ import { getComponent } from '../../../swc/utils/test-utils.js'; import focusMeta, { BothAxesLinear, DemoFocusgroupProgrammatic, + DemoFocusgroupTextPrefix, Grid, HorizontalToolbar, ProgrammaticFocus, SkipDisabledMenu, + TextPrefixFocus, VerticalMenu, } from './focus-group-navigation-controller.stories.js'; @@ -449,3 +451,54 @@ export const ProgrammaticFocusAndArrows: Story = { ); }, }; + +// ────────────────────────────────────────────────────────────── +// Text prefix / typeahead (focusFirstItemByTextPrefix) +// ────────────────────────────────────────────────────────────── + +export const TextPrefixFocusNavigation: Story = { + ...TextPrefixFocus, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-focusgroup-text-prefix' + ); + + await step( + 'prefix "c" focuses Copy (first eligible match in order)', + async () => { + expect(host.focusByTextPrefix('c')).toBe(true); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Copy'); + } + ); + + await step('longer prefix "cu" focuses Cut', async () => { + expect(host.focusByTextPrefix('cu')).toBe(true); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Cut'); + }); + + await step('prefix is case-insensitive and trim-aware', async () => { + expect(host.focusByTextPrefix(' PAS ')).toBe(true); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Paste'); + + expect(host.focusByTextPrefix('SEL')).toBe(true); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Select all'); + }); + + await step('aria-label is used when present (icon-only item)', async () => { + expect(host.focusByTextPrefix('un')).toBe(true); + expect(shadowActiveButton(host)?.getAttribute('aria-label')).toBe('Undo'); + }); + + await step('whitespace-only prefix returns false without changing focus', async () => { + host.focusByTextPrefix('Paste'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Paste'); + expect(host.focusByTextPrefix(' ')).toBe(false); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Paste'); + }); + + await step('no match returns false', async () => { + expect(host.focusByTextPrefix('zzz')).toBe(false); + }); + }, +}; diff --git a/2nd-gen/packages/core/overview.mdx b/2nd-gen/packages/core/overview.mdx index edfbcdba1f1..f181fbc7678 100644 --- a/2nd-gen/packages/core/overview.mdx +++ b/2nd-gen/packages/core/overview.mdx @@ -8,7 +8,7 @@ These pages document **`@spectrum-web-components/core`**: shared element primiti ## Available documentation -- **[Focus group navigation controller](../?path=/docs/core-focus-group-navigation-controller--readme)** — roving `tabindex`, arrow-key movement (horizontal, vertical, **both**, or grid), and optional wrap/memory aligned with APG and the Open UI `focusgroup` explainer. +- **[Focus group navigation controller](../?path=/docs/core-focus-group-navigation-controller--readme)** — roving `tabindex`, arrow-key movement (horizontal, vertical, **both**, or grid), optional wrap/memory, prefix / typeahead focus, aligned with APG and the Open UI `focusgroup` explainer. Add `.mdx` pages or `stories/*.stories.ts` anywhere under `packages/core` to surface them under **Core** in Storybook. diff --git a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md index a9b6b7a6675..8817a8072b7 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md @@ -92,7 +92,7 @@ The following controllers exist in 1st-gen and may be ported to 2nd-gen core: 3. Optional **`skipDisabled`**: omit native **`disabled`** and **`aria-disabled="true"`** items from roving tabindex and arrow navigation (story **Skip disabled menu**). 4. Optionally wraps at ends and remembers the last focused item for Tab re-entry (similar to Open UI `focusgroup` semantics). -**Public API:** `setOptions`, `getActiveItem`, `refresh`, `focusItem`, plus `hostConnected` / `hostDisconnected` via `ReactiveController`. +**Public API:** `setOptions`, `getActiveItem`, `refresh`, `focusItem`, `focusFirstItemByTextPrefix` (sets roving `tabindex` to the first typeahead label match only — call `getActiveItem()?.focus()` to move focus), plus `hostConnected` / `hostDisconnected` via `ReactiveController`. **Events:** Dispatches `swc-focusgroup-navigation-active-change` when the active item changes. From 012595e1b5c70b5f6da239c73ada5df3f8026b94 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Fri, 3 Apr 2026 11:58:58 -0400 Subject: [PATCH 18/21] chore(core): improved folder structure --- .../focus-group-navigation-controller.md | 0 .../src}/focus-group-navigation-controller.ts | 2 +- ...cus-group-navigation-controller.stories.ts | 6 +-- .../focus-group-navigation-controller.test.ts | 40 +++++++++---------- 4 files changed, 22 insertions(+), 26 deletions(-) rename 2nd-gen/packages/core/controllers/{ => focus-group-navigation-controller}/focus-group-navigation-controller.md (100%) rename 2nd-gen/packages/core/controllers/{ => focus-group-navigation-controller/src}/focus-group-navigation-controller.ts (99%) rename 2nd-gen/packages/core/controllers/{ => focus-group-navigation-controller}/stories/focus-group-navigation-controller.stories.ts (99%) rename 2nd-gen/packages/core/controllers/{stories => focus-group-navigation-controller/test}/focus-group-navigation-controller.test.ts (95%) diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.md b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/focus-group-navigation-controller.md similarity index 100% rename from 2nd-gen/packages/core/controllers/focus-group-navigation-controller.md rename to 2nd-gen/packages/core/controllers/focus-group-navigation-controller/focus-group-navigation-controller.md diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/src/focus-group-navigation-controller.ts similarity index 99% rename from 2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts rename to 2nd-gen/packages/core/controllers/focus-group-navigation-controller/src/focus-group-navigation-controller.ts index e3c04836cbf..9784fc5ff18 100644 --- a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/src/focus-group-navigation-controller.ts @@ -534,7 +534,7 @@ export class FocusgroupNavigationController implements ReactiveController { } const ref = root instanceof ShadowRoot - ? root.getElementById(id) ?? el.ownerDocument.getElementById(id) + ? (root.getElementById(id) ?? el.ownerDocument.getElementById(id)) : el.ownerDocument.getElementById(id); const t = ref?.textContent?.trim(); if (t) { diff --git a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/stories/focus-group-navigation-controller.stories.ts similarity index 99% rename from 2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts rename to 2nd-gen/packages/core/controllers/focus-group-navigation-controller/stories/focus-group-navigation-controller.stories.ts index 1b8317a37cc..3e3a66e4b39 100644 --- a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/stories/focus-group-navigation-controller.stories.ts @@ -677,11 +677,7 @@ export class DemoFocusgroupTextPrefix extends LitElement { - diff --git a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.test.ts b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/test/focus-group-navigation-controller.test.ts similarity index 95% rename from 2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.test.ts rename to 2nd-gen/packages/core/controllers/focus-group-navigation-controller/test/focus-group-navigation-controller.test.ts index 09e2b366bb5..d48725be404 100644 --- a/2nd-gen/packages/core/controllers/stories/focus-group-navigation-controller.test.ts +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/test/focus-group-navigation-controller.test.ts @@ -248,9 +248,9 @@ export const SkipDisabledMenuArrowNavigation: Story = { ); const root = host.shadowRoot!; const buttonByLabel = (label: string): HTMLButtonElement => { - const b = Array.from(root.querySelectorAll('button')).find( - (btn) => btn.textContent?.trim() === label - ); + const b = Array.from( + root.querySelectorAll('button') + ).find((btn) => btn.textContent?.trim() === label); expect(b).toBeTruthy(); return b!; }; @@ -286,18 +286,15 @@ export const SkipDisabledMenuArrowNavigation: Story = { expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Help'); }); - await step( - 'many arrow steps never focus Save or Close', - async () => { - buttonByLabel('New').focus(); - for (let i = 0; i < 16; i++) { - const label = shadowActiveButton(host)?.textContent?.trim(); - expect(label).not.toBe('Save'); - expect(label).not.toBe('Close'); - keydown(shadowActiveButton(host)!, 'ArrowDown'); - } + await step('many arrow steps never focus Save or Close', async () => { + buttonByLabel('New').focus(); + for (let i = 0; i < 16; i++) { + const label = shadowActiveButton(host)?.textContent?.trim(); + expect(label).not.toBe('Save'); + expect(label).not.toBe('Close'); + keydown(shadowActiveButton(host)!, 'ArrowDown'); } - ); + }); await step('Home and End stay within eligible items only', async () => { buttonByLabel('Print').focus(); @@ -490,12 +487,15 @@ export const TextPrefixFocusNavigation: Story = { expect(shadowActiveButton(host)?.getAttribute('aria-label')).toBe('Undo'); }); - await step('whitespace-only prefix returns false without changing focus', async () => { - host.focusByTextPrefix('Paste'); - expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Paste'); - expect(host.focusByTextPrefix(' ')).toBe(false); - expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Paste'); - }); + await step( + 'whitespace-only prefix returns false without changing focus', + async () => { + host.focusByTextPrefix('Paste'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Paste'); + expect(host.focusByTextPrefix(' ')).toBe(false); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Paste'); + } + ); await step('no match returns false', async () => { expect(host.focusByTextPrefix('zzz')).toBe(false); From 39a1a10a96bac26032a95a065721d585fb4e8d95 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Fri, 3 Apr 2026 12:03:56 -0400 Subject: [PATCH 19/21] fix(core): revised method for setting active item --- .../focus-group-navigation-controller.md | 3 +- .../src/focus-group-navigation-controller.ts | 41 +++++++++++-------- ...cus-group-navigation-controller.stories.ts | 16 +++++--- .../focus-group-navigation-controller.test.ts | 3 +- .../14_controller-composition.md | 2 +- 5 files changed, 40 insertions(+), 25 deletions(-) diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller/focus-group-navigation-controller.md b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/focus-group-navigation-controller.md index ad123f7b9c3..a1eae03b55c 100644 --- a/2nd-gen/packages/core/controllers/focus-group-navigation-controller/focus-group-navigation-controller.md +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/focus-group-navigation-controller.md @@ -7,6 +7,7 @@ - Supports **Home** / **End** to jump to the first or last item (for `grid`, order is visual row-major). - In **`grid`** mode only, **Ctrl+Home** moves focus to the **first cell in the first row** and **Ctrl+End** to the **last cell in the last row** (rows are derived from layout; ragged last rows use the final cell in that row). - Optional **`skipDisabled`**: when `true`, elements with native **`disabled`** or **`aria-disabled="true"`** are excluded from roving `tabindex` and from arrow-key navigation (see story **Skip disabled menu**). +- **`setActiveItem(element)`** updates roving `tabindex` to a chosen eligible item only; it does **not** call `focus()` — call **`getActiveItem()?.focus()`** afterward (story **Programmatic focus** defers `focus()` with **`queueMicrotask`** when invoked from a trigger `click`). - **`focusFirstItemByTextPrefix(prefix)`** updates roving `tabindex` to the first eligible item whose typeahead label starts with `prefix` (case-insensitive), in `getItems()` order — label uses **`aria-label`**, then **`aria-labelledby`** text, then **`textContent`**. It does **not** call `focus()`; call **`getActiveItem()?.focus()`** yourself (story **Text prefix focus**). - Optional **`pageStep`**: when set to a non-zero integer, **Page Up** / **Page Down** move that many items in `getItems()` order (linear modes) or that many **rows** in **`grid`** mode. - Optional **wrap** (end wraps to start) and **memory** (Tab returns to the last focused item), similar to `wrap` and `nomemory` concepts in the `focusgroup` proposal. @@ -146,7 +147,7 @@ Set **`pageStep`** to a positive integer (for example `2`) so **Page Up** / **Pa | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `setOptions(partial)` | Merge new options and reapply roving tabindex. | | `refresh()` | Re-query items and sync tabindex (call after DOM changes). | -| `focusItem(element, focusOptions?)` | Programmatically focus an item and update roving tabindex. Returns `false` if the element is not in the current item list. | +| `setActiveItem(element)` | Set roving `tabindex` to the given eligible item only (does **not** call `focus()`). Returns `false` if the element is not eligible. | | `focusFirstItemByTextPrefix(prefix)` | Set roving `tabindex` to the first eligible item whose typeahead label (`aria-label`, then `aria-labelledby`, then `textContent`) starts with `prefix` (case-insensitive). Does **not** call `focus()`. Returns `false` if `prefix` is whitespace-only or there is no match. | | `getActiveItem()` | Returns the eligible item with `tabindex="0"`, if any. | diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller/src/focus-group-navigation-controller.ts b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/src/focus-group-navigation-controller.ts index 9784fc5ff18..d0d8debb6c1 100644 --- a/2nd-gen/packages/core/controllers/focus-group-navigation-controller/src/focus-group-navigation-controller.ts +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/src/focus-group-navigation-controller.ts @@ -145,10 +145,11 @@ export type FocusgroupNavigationActiveChangeDetail = { * - Optional **`skipDisabled`**: omit **`disabled`** and **`aria-disabled="true"`** items from * roving tabindex and arrow navigation. * - Supports optional last-focused memory when re-entering via Tab. - * - Exposes {@link FocusgroupNavigationController.focusItem} for programmatic focus and - * {@link FocusgroupNavigationController.focusFirstItemByTextPrefix} for typeahead-style roving - * `tabindex` (call {@link FocusgroupNavigationController.getActiveItem} and `focus()` yourself - * when you want keyboard focus to move). + * - Exposes {@link FocusgroupNavigationController.setActiveItem} to choose the roving tab stop + * without calling `focus()`, and {@link FocusgroupNavigationController.focusFirstItemByTextPrefix} + * for typeahead-style roving `tabindex` (call {@link FocusgroupNavigationController.getActiveItem} + * and `focus()` yourself when you want keyboard focus to move). Arrow-key handling calls + * `setActiveItem` and `focus()` together. * * Dispatches a bubbling, composed `CustomEvent` named * {@link focusgroupNavigationActiveChange} when the active item changes. @@ -189,7 +190,7 @@ export type FocusgroupNavigationActiveChangeDetail = { // controller for your targets, consider removing or shrinking the following areas first: // // 1. Roving tabindex — `applyRovingTabindex()`, the tabindex portions of `refresh()` and -// `focusItem()`, and assigning `tabIndex` to ineligible raw items. +// `setActiveItem()`, and assigning `tabIndex` to ineligible raw items. // // 2. Host keyboard interception — `handleKeydown()`, `hostConnected` / `hostDisconnected` // `keydown` listeners, `resolveManagedKeydownTarget()` (shadow retargeting workaround), and @@ -318,20 +319,19 @@ export class FocusgroupNavigationController implements ReactiveController { } /** - * Moves keyboard focus to `item`, updates roving tabindex on all managed items, - * and updates memory when enabled. + * Sets roving `tabindex` so `item` is the active tab stop (`tabindex="0"`) and others in the + * group are `-1`. Does **not** call `focus()`. When {@link FocusgroupNavigationOptions.memory} + * is true, updates the stored last-focused item so Tab re-entry can target this item. * - * @param item - Item to focus; must be returned by `getItems` and pass eligibility checks. - * @param focusOptions - Optional `focus()` options (e.g. `preventScroll`). - * @returns False if `item` is not in the current item list from `getItems`. + * @param item - Item to mark active; must be returned by `getItems` and pass eligibility checks. + * @returns False if `item` is not in the current eligible item list. */ - public focusItem(item: HTMLElement, focusOptions?: FocusOptions): boolean { + public setActiveItem(item: HTMLElement): boolean { const items = this.getEligibleItems(); if (!items.includes(item)) { return false; } this.applyRovingTabindex(item); - item.focus(focusOptions); if (this.options.memory) { this.lastFocused = item; } @@ -722,7 +722,7 @@ export class FocusgroupNavigationController implements ReactiveController { : (lastRow?.[lastRow.length - 1] ?? null); if (boundary && boundary !== target) { event.preventDefault(); - this.focusItem(boundary); + this.moveKeyNavigationFocusTo(boundary); } } return; @@ -744,7 +744,7 @@ export class FocusgroupNavigationController implements ReactiveController { ); if (pageNext && pageNext !== target) { event.preventDefault(); - this.focusItem(pageNext); + this.moveKeyNavigationFocusTo(pageNext); } return; } @@ -771,7 +771,7 @@ export class FocusgroupNavigationController implements ReactiveController { if (next && next !== target) { event.preventDefault(); - this.focusItem(next); + this.moveKeyNavigationFocusTo(next); return; } @@ -787,11 +787,20 @@ export class FocusgroupNavigationController implements ReactiveController { event.key === 'Home' ? ordered[0] : ordered[ordered.length - 1]; if (boundary && boundary !== target) { event.preventDefault(); - this.focusItem(boundary); + this.moveKeyNavigationFocusTo(boundary); } } } + /** + * Applies roving tabindex to `item` and moves DOM focus; used for keyboard navigation only. + */ + private moveKeyNavigationFocusTo(item: HTMLElement): void { + if (this.setActiveItem(item)) { + item.focus(); + } + } + /** * Positive step count for {@link FocusgroupNavigationOptions.pageStep}, or null when page keys * are disabled. diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller/stories/focus-group-navigation-controller.stories.ts b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/stories/focus-group-navigation-controller.stories.ts index 3e3a66e4b39..34604897c85 100644 --- a/2nd-gen/packages/core/controllers/focus-group-navigation-controller/stories/focus-group-navigation-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/stories/focus-group-navigation-controller.stories.ts @@ -14,7 +14,7 @@ import { css, html, LitElement, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import type { Meta, StoryObj } from '@storybook/web-components'; -import { FocusgroupNavigationController } from '../focus-group-navigation-controller.js'; +import { FocusgroupNavigationController } from '../../focus-group-navigation-controller.js'; import readme from '../focus-group-navigation-controller.md?raw'; // ───────────────────────── @@ -424,7 +424,8 @@ export class DemoFocusgroupGrid extends LitElement { /** * @internal * - * Storybook-only host demonstrating {@link FocusgroupNavigationController.focusItem}. + * Storybook-only host demonstrating {@link FocusgroupNavigationController.setActiveItem} plus + * explicit `focus()` from the demo. */ @customElement('demo-focusgroup-programmatic') export class DemoFocusgroupProgrammatic extends LitElement { @@ -499,13 +500,16 @@ export class DemoFocusgroupProgrammatic extends LitElement { } /** - * Focuses the toolbar button whose `data-item` matches {@link focusTarget}. + * Sets roving tabindex to the toolbar button whose `data-item` matches {@link focusTarget}, + * then moves focus (deferred so a trigger `click` does not overwrite focus). */ public focusProgrammaticTarget(): void { const sel = `[data-item="${this.focusTarget}"]`; const el = this.renderRoot.querySelector(sel); - if (el) { - this.navigation.focusItem(el); + if (el && this.navigation.setActiveItem(el)) { + queueMicrotask(() => { + el.focus(); + }); } } @@ -792,7 +796,7 @@ export const Grid: Story = { }; /** - * Calls \`focusItem\` on a chosen button so tabindex stays consistent with keyboard navigation. + * Demo calls \`setActiveItem\` then \`focus()\` so the roving tab stop matches keyboard navigation. */ export const ProgrammaticFocus: Story = { render: () => html` diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller/test/focus-group-navigation-controller.test.ts b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/test/focus-group-navigation-controller.test.ts index d48725be404..13fe0522c7f 100644 --- a/2nd-gen/packages/core/controllers/focus-group-navigation-controller/test/focus-group-navigation-controller.test.ts +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/test/focus-group-navigation-controller.test.ts @@ -436,9 +436,10 @@ export const ProgrammaticFocusAndArrows: Story = { ); await step( - 'focusItem updates roving tabindex and arrow navigation', + 'setActiveItem plus focus updates roving tabindex and arrow navigation', async () => { host.focusProgrammaticTarget(); + await Promise.resolve(); expect(host.focusTarget).toBe('c'); expect(shadowActiveButton(host)?.getAttribute('data-item')).toBe('c'); diff --git a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md index 8f42d908f4e..49215ef9c09 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md @@ -92,7 +92,7 @@ The following controllers exist in 1st-gen and may be recreated in 2nd-gen core 3. Optional **`skipDisabled`**: omit native **`disabled`** and **`aria-disabled="true"`** items from roving tabindex and arrow navigation (story **Skip disabled menu**). 4. Optionally wraps at ends and remembers the last focused item for Tab re-entry (similar to Open UI `focusgroup` semantics). -**Public API:** `setOptions`, `getActiveItem`, `refresh`, `focusItem`, `focusFirstItemByTextPrefix` (sets roving `tabindex` to the first typeahead label match only — call `getActiveItem()?.focus()` to move focus), plus `hostConnected` / `hostDisconnected` via `ReactiveController`. +**Public API:** `setOptions`, `getActiveItem`, `refresh`, `setActiveItem` (roving `tabindex` only — call `getActiveItem()?.focus()` to move focus), `focusFirstItemByTextPrefix` (typeahead label match for roving `tabindex` only — same follow-up), plus `hostConnected` / `hostDisconnected` via `ReactiveController`. **Events:** Dispatches `swc-focusgroup-navigation-active-change` when the active item changes. From 9d30f6d6f8a5db345a6c6d244a331a52d02f2886 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Fri, 3 Apr 2026 12:12:16 -0400 Subject: [PATCH 20/21] fix(core): fixes broken paths from renaming --- .../focus-group-navigation-controller.ts | 23 +++++++++++++++++++ 2nd-gen/packages/core/package.json | 8 +++---- .../14_controller-composition.md | 2 +- 3 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts new file mode 100644 index 00000000000..e026187bada --- /dev/null +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts @@ -0,0 +1,23 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * Package entry for `@spectrum-web-components/core/controllers/focus-group-navigation-controller.js`. + * Implementation lives under `focus-group-navigation-controller/src/` next to demos and tests. + */ +export { + focusgroupNavigationActiveChange, + FocusgroupNavigationController, + type FocusgroupDirection, + type FocusgroupNavigationActiveChangeDetail, + type FocusgroupNavigationOptions, +} from './focus-group-navigation-controller/src/focus-group-navigation-controller.js'; diff --git a/2nd-gen/packages/core/package.json b/2nd-gen/packages/core/package.json index d8dafd1170c..1e83da80686 100644 --- a/2nd-gen/packages/core/package.json +++ b/2nd-gen/packages/core/package.json @@ -52,8 +52,8 @@ "import": "./dist/controllers/index.js" }, "./controllers/focus-group-navigation-controller.js": { - "types": "./dist/controllers/focus-group-navigation-controller.d.ts", - "import": "./dist/controllers/focus-group-navigation-controller.js" + "types": "./dist/controllers/focus-group-navigation-controller/ocus-group-navigation-controller.d.ts", + "import": "./dist/controllers/ocus-group-navigation-controller/focus-group-navigation-controller.js" }, "./controllers/language-resolution.js": { "types": "./dist/controllers/language-resolution.d.ts", @@ -156,8 +156,8 @@ "controllers/index.js": [ "dist/controllers/index.d.ts" ], - "controllers/focus-group-navigation-controller.js": [ - "dist/controllers/focus-group-navigation-controller.d.ts" + "controllers/ocus-group-navigation-controller/focus-group-navigation-controller.js": [ + "dist/controllers/ocus-group-navigation-controller/focus-group-navigation-controller.d.ts" ], "controllers/language-resolution.js": [ "dist/controllers/language-resolution.d.ts" diff --git a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md index 49215ef9c09..9a33804bc21 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md @@ -96,7 +96,7 @@ The following controllers exist in 1st-gen and may be recreated in 2nd-gen core **Events:** Dispatches `swc-focusgroup-navigation-active-change` when the active item changes. -**Docs:** See `core/controllers/focus-group-navigation-controller.md` and Storybook **Core / Focus group navigation controller**. +**Docs:** See `core/controllers/focus-group-navigation-demos/focus-group-navigation-controller.md` and Storybook **Core / Focus group navigation controller**. ## LanguageResolutionController From 50e5d28e3a3beb3c38fd884116fda272e69593af Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Fri, 3 Apr 2026 14:06:19 -0400 Subject: [PATCH 21/21] test(core): fixed focusgroup stories --- .../focus-group-navigation-controller.stories.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller/stories/focus-group-navigation-controller.stories.ts b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/stories/focus-group-navigation-controller.stories.ts index 34604897c85..114d9ab2e30 100644 --- a/2nd-gen/packages/core/controllers/focus-group-navigation-controller/stories/focus-group-navigation-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/stories/focus-group-navigation-controller.stories.ts @@ -725,7 +725,19 @@ const meta: Meta = { }, }; -export default meta; +/** Lit demo hosts are exported for unit tests; exclude from CSF so Vitest does not run them as stories. */ +export default { + ...meta, + excludeStories: [ + 'DemoFocusgroupHorizontal', + 'DemoFocusgroupBothAxes', + 'DemoFocusgroupVertical', + 'DemoFocusgroupSkipDisabled', + 'DemoFocusgroupGrid', + 'DemoFocusgroupProgrammatic', + 'DemoFocusgroupTextPrefix', + ], +} as Meta; type Story = StoryObj;