From a2081c754f2a3f936081d04ffc5ba57f1325c4a3 Mon Sep 17 00:00:00 2001 From: "rachelledeman@icloud.com" Date: Tue, 29 Jul 2025 06:00:15 +0000 Subject: [PATCH 01/13] feat(cdk-experimental/toolbar): create toolbar, toolbar widget and UI pattern --- src/cdk-experimental/config.bzl | 1 + src/cdk-experimental/radio-group/BUILD.bazel | 1 + .../radio-group/radio-group.ts | 41 +++- src/cdk-experimental/toolbar/BUILD.bazel | 34 +++ src/cdk-experimental/toolbar/index.ts | 9 + src/cdk-experimental/toolbar/public-api.ts | 9 + src/cdk-experimental/toolbar/toolbar.ts | 215 ++++++++++++++++ src/cdk-experimental/ui-patterns/BUILD.bazel | 1 + .../ui-patterns/public-api.ts | 1 + .../ui-patterns/radio-group/radio-button.ts | 8 + .../ui-patterns/radio-group/radio-group.ts | 17 +- .../ui-patterns/radio-group/radio.spec.ts | 1 + .../ui-patterns/toolbar/BUILD.bazel | 36 +++ .../ui-patterns/toolbar/toolbar.ts | 229 ++++++++++++++++++ .../cdk-experimental/toolbar/BUILD.bazel | 30 +++ .../cdk-toolbar-configurable-example.html | 121 +++++++++ .../cdk-toolbar-configurable-example.ts | 49 ++++ .../cdk-experimental/toolbar/index.ts | 1 + .../toolbar/toolbar-common.css | 133 ++++++++++ src/dev-app/BUILD.bazel | 1 + .../cdk-experimental-toolbar/BUILD.bazel | 16 ++ .../cdk-toolbar-demo.css | 20 ++ .../cdk-toolbar-demo.html | 8 + .../cdk-toolbar-demo.ts | 19 ++ src/dev-app/dev-app/dev-app-layout.ts | 1 + src/dev-app/routes.ts | 5 + 26 files changed, 1001 insertions(+), 6 deletions(-) create mode 100644 src/cdk-experimental/toolbar/BUILD.bazel create mode 100644 src/cdk-experimental/toolbar/index.ts create mode 100644 src/cdk-experimental/toolbar/public-api.ts create mode 100644 src/cdk-experimental/toolbar/toolbar.ts create mode 100644 src/cdk-experimental/ui-patterns/toolbar/BUILD.bazel create mode 100644 src/cdk-experimental/ui-patterns/toolbar/toolbar.ts create mode 100644 src/components-examples/cdk-experimental/toolbar/BUILD.bazel create mode 100644 src/components-examples/cdk-experimental/toolbar/cdk-toolbar-configurable/cdk-toolbar-configurable-example.html create mode 100644 src/components-examples/cdk-experimental/toolbar/cdk-toolbar-configurable/cdk-toolbar-configurable-example.ts create mode 100644 src/components-examples/cdk-experimental/toolbar/index.ts create mode 100644 src/components-examples/cdk-experimental/toolbar/toolbar-common.css create mode 100644 src/dev-app/cdk-experimental-toolbar/BUILD.bazel create mode 100644 src/dev-app/cdk-experimental-toolbar/cdk-toolbar-demo.css create mode 100644 src/dev-app/cdk-experimental-toolbar/cdk-toolbar-demo.html create mode 100644 src/dev-app/cdk-experimental-toolbar/cdk-toolbar-demo.ts diff --git a/src/cdk-experimental/config.bzl b/src/cdk-experimental/config.bzl index d716ca243f5a..ee0ac847aba5 100644 --- a/src/cdk-experimental/config.bzl +++ b/src/cdk-experimental/config.bzl @@ -10,6 +10,7 @@ CDK_EXPERIMENTAL_ENTRYPOINTS = [ "scrolling", "selection", "tabs", + "toolbar", "tree", "table-scroll-container", "ui-patterns", diff --git a/src/cdk-experimental/radio-group/BUILD.bazel b/src/cdk-experimental/radio-group/BUILD.bazel index cd303316332f..f6a42a80955d 100644 --- a/src/cdk-experimental/radio-group/BUILD.bazel +++ b/src/cdk-experimental/radio-group/BUILD.bazel @@ -10,6 +10,7 @@ ng_project( ), deps = [ "//:node_modules/@angular/core", + "//src/cdk-experimental/toolbar", "//src/cdk-experimental/ui-patterns", "//src/cdk/a11y", "//src/cdk/bidi", diff --git a/src/cdk-experimental/radio-group/radio-group.ts b/src/cdk-experimental/radio-group/radio-group.ts index 8029142a6fda..2fb1180496c5 100644 --- a/src/cdk-experimental/radio-group/radio-group.ts +++ b/src/cdk-experimental/radio-group/radio-group.ts @@ -19,10 +19,12 @@ import { model, signal, WritableSignal, + OnDestroy, } from '@angular/core'; import {RadioButtonPattern, RadioGroupPattern} from '../ui-patterns'; import {Directionality} from '@angular/cdk/bidi'; import {_IdGenerator} from '@angular/cdk/a11y'; +import {CdkToolbar} from '../toolbar'; // TODO: Move mapSignal to it's own file so it can be reused across components. @@ -97,6 +99,12 @@ export class CdkRadioGroup { /** A signal wrapper for directionality. */ protected textDirection = inject(Directionality).valueSignal; + /** A signal wrapper for toolbar. */ + toolbar = inject(CdkToolbar, {optional: true}); + + /** Toolbar pattern if applicable */ + private readonly _toolbarPattern = computed(() => (this.toolbar ? this.toolbar.pattern : null)); + /** The RadioButton UIPatterns of the child CdkRadioButtons. */ protected items = computed(() => this._cdkRadioButtons().map(radio => radio.pattern)); @@ -131,6 +139,8 @@ export class CdkRadioGroup { value: this._value, activeItem: signal(undefined), textDirection: this.textDirection, + toolbar: this._toolbarPattern, + focusMode: this._toolbarPattern() ? this._toolbarPattern()!!.inputs.focusMode : this.focusMode, }); /** Whether the radio group has received focus yet. */ @@ -147,15 +157,34 @@ export class CdkRadioGroup { }); afterRenderEffect(() => { - if (!this._hasFocused()) { + if (!this._hasFocused() && !this.toolbar) { this.pattern.setDefaultState(); } }); + + afterRenderEffect(() => { + if (this.toolbar) { + const radioButtons = this._cdkRadioButtons(); + // If the group is disabled and the toolbar is set to skip disabled items, + // the radio buttons should not be part of the toolbar's navigation. + if (this.disabled() && this.toolbar.skipDisabled()) { + radioButtons.forEach(radio => this.toolbar!.deregister(radio)); + } else { + radioButtons.forEach(radio => this.toolbar!.register(radio)); + } + } + }); } onFocus() { this._hasFocused.set(true); } + + toolbarButtonDeregister(radio: CdkRadioButton) { + if (this.toolbar) { + this.toolbar.deregister(radio); + } + } } /** A selectable radio button in a CdkRadioGroup. */ @@ -172,7 +201,7 @@ export class CdkRadioGroup { '[id]': 'pattern.id()', }, }) -export class CdkRadioButton { +export class CdkRadioButton implements OnDestroy { /** A reference to the radio button element. */ private readonly _elementRef = inject(ElementRef); @@ -192,7 +221,7 @@ export class CdkRadioButton { protected group = computed(() => this._cdkRadioGroup.pattern); /** A reference to the radio button element to be focused on navigation. */ - protected element = computed(() => this._elementRef.nativeElement); + element = computed(() => this._elementRef.nativeElement); /** Whether the radio button is disabled. */ disabled = input(false, {transform: booleanAttribute}); @@ -205,4 +234,10 @@ export class CdkRadioButton { group: this.group, element: this.element, }); + + ngOnDestroy() { + if (this._cdkRadioGroup.toolbar) { + this._cdkRadioGroup.toolbarButtonDeregister(this); + } + } } diff --git a/src/cdk-experimental/toolbar/BUILD.bazel b/src/cdk-experimental/toolbar/BUILD.bazel new file mode 100644 index 000000000000..30cdf3da5c9b --- /dev/null +++ b/src/cdk-experimental/toolbar/BUILD.bazel @@ -0,0 +1,34 @@ +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "toolbar", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns", + "//src/cdk/a11y", + "//src/cdk/bidi", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = glob( + ["**/*.spec.ts"], + ), + deps = [ + ":toolbar", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/toolbar/index.ts b/src/cdk-experimental/toolbar/index.ts new file mode 100644 index 000000000000..52b3c7a5156f --- /dev/null +++ b/src/cdk-experimental/toolbar/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './public-api'; diff --git a/src/cdk-experimental/toolbar/public-api.ts b/src/cdk-experimental/toolbar/public-api.ts new file mode 100644 index 000000000000..ea524ae5a225 --- /dev/null +++ b/src/cdk-experimental/toolbar/public-api.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export {CdkToolbar, CdkToolbarWidget} from './toolbar'; diff --git a/src/cdk-experimental/toolbar/toolbar.ts b/src/cdk-experimental/toolbar/toolbar.ts new file mode 100644 index 000000000000..843b8e4cf4ae --- /dev/null +++ b/src/cdk-experimental/toolbar/toolbar.ts @@ -0,0 +1,215 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + afterRenderEffect, + Directive, + ElementRef, + inject, + computed, + input, + booleanAttribute, + signal, + Signal, + OnInit, + OnDestroy, +} from '@angular/core'; +import {ToolbarPattern, RadioButtonPattern, ToolbarWidgetPattern} from '../ui-patterns'; +import {Directionality} from '@angular/cdk/bidi'; +import {_IdGenerator} from '@angular/cdk/a11y'; + +/** Interface for a radio button that can be used with a toolbar. Based on radio-button in ui-patterns */ +interface CdkRadioButtonInterface { + /** The HTML element associated with the radio button. */ + element: Signal; + /** Whether the radio button is disabled. */ + disabled: Signal; + + pattern: RadioButtonPattern; +} + +interface HasElement { + element: Signal; +} + +/** + * Sort directives by their document order. + */ +function sortDirectives(a: HasElement, b: HasElement) { + return (a.element().compareDocumentPosition(b.element()) & Node.DOCUMENT_POSITION_PRECEDING) > 0 + ? 1 + : -1; +} + +/** + * A toolbar widget container. + * + * Widgets such as radio groups or buttons are nested within a toolbar to allow for a single + * place of reference for focus and navigation. The CdkToolbar is meant to be used in conjunction + * with CdkToolbarWidget and CdkRadioGroup as follows: + * + * ```html + *
+ * + *
+ * + * + * + *
+ *
+ * ``` + */ +@Directive({ + selector: '[cdkToolbar]', + exportAs: 'cdkToolbar', + host: { + 'role': 'toolbar', + 'class': 'cdk-toolbar', + '[attr.tabindex]': 'pattern.tabindex()', + '[attr.aria-disabled]': 'pattern.disabled()', + '[attr.aria-orientation]': 'pattern.orientation()', + '[attr.aria-activedescendant]': 'pattern.activedescendant()', + '(keydown)': 'pattern.onKeydown($event)', + '(pointerdown)': 'pattern.onPointerdown($event)', + '(focusin)': 'onFocus()', + }, +}) +export class CdkToolbar { + /** The CdkTabList nested inside of the container. */ + private readonly _cdkWidgets = signal(new Set | CdkToolbarWidget>()); + + /** A signal wrapper for directionality. */ + textDirection = inject(Directionality).valueSignal; + + /** Sorted UIPatterns of the child widgets */ + items = computed(() => + [...this._cdkWidgets()].sort(sortDirectives).map(widget => widget.pattern), + ); + + /** Whether the toolbar is vertically or horizontally oriented. */ + orientation = input<'vertical' | 'horizontal'>('horizontal'); + + /** Whether disabled items in the group should be skipped when navigating. */ + skipDisabled = input(true, {transform: booleanAttribute}); + + /** The focus strategy used by the toolbar. */ + focusMode = input<'roving' | 'activedescendant'>('roving'); + + /** Whether the toolbar is disabled. */ + disabled = input(false, {transform: booleanAttribute}); + + /** Whether focus should wrap when navigating. */ + readonly wrap = input(true, {transform: booleanAttribute}); + + /** The toolbar UIPattern. */ + pattern: ToolbarPattern = new ToolbarPattern({ + ...this, + activeIndex: signal(0), + textDirection: this.textDirection, + focusMode: this.focusMode, + }); + + /** Whether the toolbar has received focus yet. */ + private _hasFocused = signal(false); + + onFocus() { + this._hasFocused.set(true); + } + + constructor() { + afterRenderEffect(() => { + if (!this._hasFocused()) { + this.pattern.setDefaultState(); + } + }); + + afterRenderEffect(() => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + const violations = this.pattern.validate(); + for (const violation of violations) { + console.error(violation); + } + } + }); + } + + register(widget: CdkRadioButtonInterface | CdkToolbarWidget) { + const widgets = this._cdkWidgets(); + if (!widgets.has(widget)) { + widgets.add(widget); + this._cdkWidgets.set(new Set(widgets)); + } + } + + deregister(widget: CdkRadioButtonInterface | CdkToolbarWidget) { + const widgets = this._cdkWidgets(); + if (widgets.delete(widget)) { + this._cdkWidgets.set(new Set(widgets)); + } + } +} + +/** + * A widget within a toolbar. + * + * A widget is anything that is within a toolbar. It should be applied to any native HTML element + * that has the purpose of acting as a widget navigatable within a toolbar. + */ +@Directive({ + selector: '[cdkToolbarWidget]', + exportAs: 'cdkToolbarWidget', + host: { + 'role': 'button', + 'class': 'cdk-toolbar-widget', + '[class.cdk-active]': 'pattern.active()', + '[attr.tabindex]': 'pattern.tabindex()', + '[attr.inert]': 'hardDisabled() ? true : null', + '[attr.disabled]': 'hardDisabled() ? true : null', + '[id]': 'pattern.id()', + }, +}) +export class CdkToolbarWidget implements OnInit, OnDestroy { + /** A reference to the widget element. */ + private readonly _elementRef = inject(ElementRef); + + /** The parent CdkToolbar. */ + private readonly _cdkToolbar = inject(CdkToolbar); + + /** A unique identifier for the widget. */ + private readonly _generatedId = inject(_IdGenerator).getId('cdk-toolbar-widget-'); + + /** A unique identifier for the widget. */ + protected id = computed(() => this._generatedId); + + /** The parent Toolbar UIPattern. */ + protected parentToolbar = computed(() => this._cdkToolbar.pattern); + + /** A reference to the widget element to be focused on navigation. */ + element = computed(() => this._elementRef.nativeElement); + + /** Whether the widget is disabled. */ + disabled = input(false, {transform: booleanAttribute}); + + readonly hardDisabled = computed(() => this.pattern.disabled() && this.pattern.tabindex() < 0); + + pattern = new ToolbarWidgetPattern({ + ...this, + id: this.id, + element: this.element, + disabled: computed(() => this._cdkToolbar.disabled() || this.disabled() || false), + parentToolbar: this.parentToolbar, + }); + + ngOnInit() { + this._cdkToolbar.register(this); + } + + ngOnDestroy() { + this._cdkToolbar.deregister(this); + } +} diff --git a/src/cdk-experimental/ui-patterns/BUILD.bazel b/src/cdk-experimental/ui-patterns/BUILD.bazel index 0d4d60260c23..f58daabc832b 100644 --- a/src/cdk-experimental/ui-patterns/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/BUILD.bazel @@ -15,6 +15,7 @@ ts_project( "//src/cdk-experimental/ui-patterns/listbox", "//src/cdk-experimental/ui-patterns/radio-group", "//src/cdk-experimental/ui-patterns/tabs", + "//src/cdk-experimental/ui-patterns/toolbar", "//src/cdk-experimental/ui-patterns/tree", ], ) diff --git a/src/cdk-experimental/ui-patterns/public-api.ts b/src/cdk-experimental/ui-patterns/public-api.ts index 79f121e9f1e1..dd1c225735bb 100644 --- a/src/cdk-experimental/ui-patterns/public-api.ts +++ b/src/cdk-experimental/ui-patterns/public-api.ts @@ -13,3 +13,4 @@ export * from './radio-group/radio-button'; export * from './behaviors/signal-like/signal-like'; export * from './tabs/tabs'; export * from './accordion/accordion'; +export * from './toolbar/toolbar'; diff --git a/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts b/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts index 8d3e1f1b5fce..4b6acd7b98aa 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts @@ -14,6 +14,12 @@ import {List, ListItem} from '../behaviors/list/list'; * Represents the properties exposed by a radio group that need to be accessed by a radio button. * This exists to avoid circular dependency errors between the radio group and radio button. */ +type GeneralWidget = { + id: SignalLike; + element: SignalLike; + disabled: SignalLike; +}; + interface RadioGroupLike { /** The list behavior for the radio group. */ listBehavior: List, V>; @@ -67,3 +73,5 @@ export class RadioButtonPattern { this.disabled = inputs.disabled; } } + +export type RadioButtonPatternType = InstanceType>; diff --git a/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts b/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts index 6945034909b8..40c61da539af 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts @@ -22,8 +22,14 @@ export type RadioGroupInputs = Omit< /** Whether the radio group is readonly. */ readonly: SignalLike; + /** Parent toolbar of radio group */ + toolbar: SignalLike | null>; }; - +interface ToolbarLike { + focusManager: ListFocus | GeneralWidget>; + navigation: ListNavigation | GeneralWidget>; + orientation: SignalLike<'vertical' | 'horizontal'>; +} /** Controls the state of a radio group. */ export class RadioGroupPattern { /** The list behavior for the radio group. */ @@ -41,8 +47,8 @@ export class RadioGroupPattern { /** Whether the radio group is readonly. */ readonly = computed(() => this.selectedItem()?.disabled() || this.inputs.readonly()); - /** The tabindex of the radio group (if using activedescendant). */ - tabindex = computed(() => this.listBehavior.tabindex()); + /** The tabindex of the radio group (if using activedescendant or if in toolbar). */ + tabindex = computed(() => (this.inputs.toolbar() ? -1 : this.listBehavior.tabindex())); /** The id of the current active radio button (if using activedescendant). */ activedescendant = computed(() => this.listBehavior.activedescendant()); @@ -67,6 +73,11 @@ export class RadioGroupPattern { keydown = computed(() => { const manager = new KeyboardEventManager(); + // If within a toolbar relinquish keyboard control + if (this.inputs.toolbar()) { + return manager; + } + // Readonly mode allows navigation but not selection changes. if (this.readonly()) { return manager diff --git a/src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts b/src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts index 2aed6abfaa1f..ca54ae79d3ca 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts @@ -39,6 +39,7 @@ describe('RadioGroup Pattern', () => { focusMode: inputs.focusMode ?? signal('roving'), textDirection: inputs.textDirection ?? signal('ltr'), orientation: inputs.orientation ?? signal('vertical'), + toolbar: inputs.toolbar ?? signal(null), }); } diff --git a/src/cdk-experimental/ui-patterns/toolbar/BUILD.bazel b/src/cdk-experimental/ui-patterns/toolbar/BUILD.bazel new file mode 100644 index 000000000000..ad20b8c55988 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/toolbar/BUILD.bazel @@ -0,0 +1,36 @@ +load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "toolbar", + srcs = [ + "toolbar.ts", + ], + deps = [ + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/behaviors/event-manager", + "//src/cdk-experimental/ui-patterns/behaviors/list-focus", + "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", + "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + "//src/cdk-experimental/ui-patterns/radio-group", + "//src/cdk/bidi", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":toolbar", + "//:node_modules/@angular/core", + "//src/cdk/keycodes", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts b/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts new file mode 100644 index 000000000000..4bb1fa36da53 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts @@ -0,0 +1,229 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {computed} from '@angular/core'; +import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager'; +import {ListFocus, ListFocusInputs, ListFocusItem} from '../behaviors/list-focus/list-focus'; +import { + ListNavigation, + ListNavigationInputs, + ListNavigationItem, +} from '../behaviors/list-navigation/list-navigation'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; + +import {RadioButtonPatternType, RadioButtonPattern} from '../radio-group/radio-button'; + +export type ToolbarInputs = ListNavigationInputs< + RadioButtonPatternType | ToolbarWidgetPattern +> & + ListFocusInputs | ToolbarWidgetPattern> & { + /** Whether the toolbar is disabled. */ + disabled: SignalLike; + }; + +export class ToolbarPattern { + /** Controls navigation for the toolbar. */ + navigation: ListNavigation | ToolbarWidgetPattern>; + + /** Controls focus for the toolbar. */ + focusManager: ListFocus | ToolbarWidgetPattern>; + + /** Whether the tablist is vertically or horizontally oriented. */ + readonly orientation: SignalLike<'vertical' | 'horizontal'>; + + /** Whether the toolbar is disabled. */ + disabled = computed(() => this.inputs.disabled() || this.focusManager.isListDisabled()); + + /** The tabindex of the toolbar (if using activedescendant). */ + tabindex = computed(() => this.focusManager.getListTabindex()); + + /** The id of the current active widget (if using activedescendant). */ + activedescendant = computed(() => this.focusManager.getActiveDescendant()); + + /** The key used to navigate to the previous widget. */ + prevKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return 'ArrowUp'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + }); + + /** The key used to navigate to the next widget. */ + nextKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return 'ArrowDown'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + }); + + /** The keydown event manager for the toolbar. */ + keydown = computed(() => { + const manager = new KeyboardEventManager(); + + return manager + .on(' ', () => this.toolbarSelectOverride()) + .on('Enter', () => this.toolbarSelectOverride()) + .on(this.prevKey, () => this.navigation.prev()) + .on(this.nextKey, () => this.navigation.next()) + .on('Home', () => this.navigation.first()) + .on('End', () => this.navigation.last()); + }); + + toolbarSelectOverride() { + const activeItem = this.focusManager.activeItem(); + + /** If the active item is a Radio Button, indicate to the group the selection */ + if (activeItem instanceof RadioButtonPattern) { + const group = activeItem.group(); + if (group && !group.readonly()) { + group.selection.selectOne(); + } + } else { + /** Item is a Toolbar Widget, manually select it */ + if (activeItem.element()) activeItem.element().click(); + } + } + + /** The pointerdown event manager for the toolbar. */ + pointerdown = computed(() => { + const manager = new PointerEventManager(); + + // Default behavior: navigate and select on click. + return manager.on(e => this.goto(e)); + }); + + /** Navigates to the widget associated with the given pointer event. */ + goto(event: PointerEvent) { + const item = this._getItem(event); + + this.navigation.goto(item); + } + + /** Handles keydown events for the toolbar. */ + onKeydown(event: KeyboardEvent) { + if (!this.disabled()) { + this.keydown().handle(event); + } + } + + /** Handles pointerdown events for the toolbar. */ + onPointerdown(event: PointerEvent) { + if (!this.disabled()) { + this.pointerdown().handle(event); + } + } + + /** Finds the Toolbar Widget associated with a pointer event target. */ + private _getItem(e: PointerEvent): RadioButtonPatternType | ToolbarWidgetPattern | undefined { + if (!(e.target instanceof HTMLElement)) { + return undefined; + } + + // Assumes the target or its ancestor has role="radio" or role="button" + const element = e.target.closest('[role="button"], [role="radio"]'); + return this.inputs.items().find(i => i.element() === element); + } + + constructor(readonly inputs: ToolbarInputs) { + this.orientation = inputs.orientation; + + this.focusManager = new ListFocus(inputs); + this.navigation = new ListNavigation({ + ...inputs, + focusManager: this.focusManager, + }); + } + + /** + * Sets the toolbar to its default initial state. + * + * Sets the active index to the selected widget if one exists and is focusable. + * Otherwise, sets the active index to the first focusable widget. + */ + setDefaultState() { + let firstItem: RadioButtonPatternType | ToolbarWidgetPattern | null = null; + + for (const item of this.inputs.items()) { + if (this.focusManager.isFocusable(item)) { + if (!firstItem) { + firstItem = item; + } + if (item instanceof RadioButtonPattern && item.selected()) { + this.inputs.activeIndex.set(item.index()); + return; + } + } + } + + if (firstItem) { + this.inputs.activeIndex.set(firstItem.index()); + } + } + /** Validates the state of the toolbar and returns a list of accessibility violations. */ + validate(): string[] { + const violations: string[] = []; + + if (this.inputs.skipDisabled()) { + for (const item of this.inputs.items()) { + if (item instanceof RadioButtonPattern && item.selected() && item.disabled()) { + violations.push( + "Accessibility Violation: A selected radio button inside the toolbar is disabled while 'skipDisabled' is true, making the selection unreachable via keyboard.", + ); + } + } + } + return violations; + } +} + +export type ToolbarWidget = { + id: SignalLike; + element: SignalLike; + disabled: SignalLike; +}; + +/** Represents the required inputs for a toolbar widget in a toolbar. */ +export interface ToolbarWidgetInputs extends ListNavigationItem, ListFocusItem { + /** A reference to the parent toolbar. */ + parentToolbar: SignalLike>; +} + +export class ToolbarWidgetPattern { + /** A unique identifier for the widget. */ + id: SignalLike; + /** The html element that should receive focus. */ // might not be needed + readonly element: SignalLike; + /** Whether the widget is disabled. */ + disabled: SignalLike; + + /** A reference to the parent toolbar. */ + parentToolbar: SignalLike | undefined>; + + /** The tabindex of the widgdet. */ + tabindex = computed(() => this.inputs.parentToolbar().focusManager.getItemTabindex(this)); + + /** The position of the widget within the group. */ + index = computed( + () => + this.parentToolbar() + ?.navigation.inputs.items() + .findIndex(i => i.id() === this.id()) ?? -1, + ); + + /** Whether the widhet is currently the active one (focused). */ + active = computed(() => this.inputs.parentToolbar().focusManager.activeItem() === this); + + constructor(readonly inputs: ToolbarWidgetInputs) { + this.id = inputs.id; + this.element = inputs.element; + this.disabled = inputs.disabled; + this.parentToolbar = inputs.parentToolbar; + } +} + +export type ToolbarPatternType = InstanceType>; diff --git a/src/components-examples/cdk-experimental/toolbar/BUILD.bazel b/src/components-examples/cdk-experimental/toolbar/BUILD.bazel new file mode 100644 index 000000000000..379b361607b8 --- /dev/null +++ b/src/components-examples/cdk-experimental/toolbar/BUILD.bazel @@ -0,0 +1,30 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "toolbar", + srcs = glob(["**/*.ts"]), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + deps = [ + "//:node_modules/@angular/core", + "//:node_modules/@angular/forms", + "//src/cdk-experimental/radio-group", + "//src/cdk-experimental/toolbar", + "//src/material/checkbox", + "//src/material/form-field", + "//src/material/select", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.ts", + ]), +) diff --git a/src/components-examples/cdk-experimental/toolbar/cdk-toolbar-configurable/cdk-toolbar-configurable-example.html b/src/components-examples/cdk-experimental/toolbar/cdk-toolbar-configurable/cdk-toolbar-configurable-example.html new file mode 100644 index 000000000000..3108dc6eb1a6 --- /dev/null +++ b/src/components-examples/cdk-experimental/toolbar/cdk-toolbar-configurable/cdk-toolbar-configurable-example.html @@ -0,0 +1,121 @@ +
+

Toolbar Controls

+
+ Skip Disabled + Wrap + Disabled + + Orientation + + Vertical + Horizontal + + + + + Focus strategy + + Roving Tabindex + Active Descendant + + +
+

Radio Group Controls

+
+ Disabled + Readonly + + Disabled Radio Options + + @for (fruit of fruits; track fruit) { + {{fruit}} + } + + +
+

Button

+
+ + Disabled Buttons + + @for (fruit of buttonFruits; track fruit) { + {{fruit}} + } + + +
+
+ + +
+ +
    + @for (fruit of fruits; track fruit) { + @let optionDisabled = disabledOptions.includes(fruit); +
  • + + {{ fruit }} +
  • + } +
+
    + @for (fruit of fruits; track fruit) { + @let optionDisabled = disabledOptions.includes(fruit); +
  • + + {{ fruit }} +
  • + } +
+ + + +
+ diff --git a/src/components-examples/cdk-experimental/toolbar/cdk-toolbar-configurable/cdk-toolbar-configurable-example.ts b/src/components-examples/cdk-experimental/toolbar/cdk-toolbar-configurable/cdk-toolbar-configurable-example.ts new file mode 100644 index 000000000000..3b123a9e2930 --- /dev/null +++ b/src/components-examples/cdk-experimental/toolbar/cdk-toolbar-configurable/cdk-toolbar-configurable-example.ts @@ -0,0 +1,49 @@ +import {Component} from '@angular/core'; +import {CdkRadioGroup, CdkRadioButton} from '@angular/cdk-experimental/radio-group'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatSelectModule} from '@angular/material/select'; +import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {CdkToolbar, CdkToolbarWidget} from '@angular/cdk-experimental/toolbar'; + +/** @title Configurable CDK Radio Group */ +@Component({ + selector: 'cdk-toolbar-configurable-example', + exportAs: 'cdkToolbarConfigurableExample', + templateUrl: 'cdk-toolbar-configurable-example.html', + styleUrl: '../toolbar-common.css', + standalone: true, + imports: [ + CdkRadioGroup, + CdkRadioButton, + CdkToolbar, + CdkToolbarWidget, + MatCheckboxModule, + MatFormFieldModule, + MatSelectModule, + FormsModule, + ReactiveFormsModule, + ], +}) +export class CdkToolbarConfigurableExample { + orientation: 'vertical' | 'horizontal' = 'vertical'; + disabled = new FormControl(false, {nonNullable: true}); + toolbarDisabled = new FormControl(false, {nonNullable: true}); + + fruits = ['Apple', 'Apricot', 'Banana']; + buttonFruits = ['Pear', 'Blueberry', 'Cherry', 'Date']; + + // New controls + readonly = new FormControl(false, {nonNullable: true}); + skipDisabled = new FormControl(true, {nonNullable: true}); + focusMode: 'roving' | 'activedescendant' = 'roving'; + wrap = new FormControl(true, {nonNullable: true}); + + // Control for which radio options are individually disabled + disabledOptions: string[] = ['Banana']; + disabledButtonOptions: string[] = ['Pear']; + + test(x: String) { + console.log(x); + } +} diff --git a/src/components-examples/cdk-experimental/toolbar/index.ts b/src/components-examples/cdk-experimental/toolbar/index.ts new file mode 100644 index 000000000000..cc12535901e8 --- /dev/null +++ b/src/components-examples/cdk-experimental/toolbar/index.ts @@ -0,0 +1 @@ +export {CdkToolbarConfigurableExample} from './cdk-toolbar-configurable/cdk-toolbar-configurable-example'; diff --git a/src/components-examples/cdk-experimental/toolbar/toolbar-common.css b/src/components-examples/cdk-experimental/toolbar/toolbar-common.css new file mode 100644 index 000000000000..7936afc0b28c --- /dev/null +++ b/src/components-examples/cdk-experimental/toolbar/toolbar-common.css @@ -0,0 +1,133 @@ +.example-container { + padding-bottom: 32px; +} + +.example-heading { + margin: 16px 0 4px; +} + +.example-toolbar-controls { + display: flex; + align-items: center; + gap: 16px; + padding-bottom: 4px; +} + +.example-toolbar { + display: flex; + flex-direction: column; + padding: 8px; + width: 50%; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); + gap: 16px; +} +.example-toolbar[aria-orientation='horizontal'] { + flex-direction: row; + width: 100%; +} + +.example-radio-group { + gap: 4px; + margin: 0; + padding: 8px; + max-height: 300px; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); + display: flex; + list-style: none; + flex-direction: column; + overflow: scroll; +} + +.example-radio-group[aria-orientation='horizontal'] { + flex-direction: row; + width: 100%; +} + +.example-radio-group[aria-disabled='true'] { + background-color: var(--mat-sys-surface-dim); + pointer-events: none; +} + +.example-radio-group label { + padding: 16px; + flex-shrink: 0; +} + +.example-radio-button { + gap: 16px; + padding: 16px; + display: flex; + cursor: pointer; + position: relative; + align-items: center; + border-radius: var(--mat-sys-corner-extra-small); +} + +/* Basic visual indicator for the radio button */ +.example-radio-indicator { + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid var(--mat-sys-outline); + display: inline-block; + position: relative; +} + +.example-radio-button[aria-checked='true'] .example-radio-indicator { + border-color: var(--mat-sys-primary); +} + +.example-radio-button[aria-checked='true'] .example-radio-indicator::after { + content: ''; + display: block; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--mat-sys-primary); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.example-radio-button[aria-disabled='true'][aria-checked='true'] .example-radio-indicator::after { + background-color: var(--mat-sys-outline); +} + +.example-radio-button.cdk-active, +.example-radio-button[aria-disabled='false']:hover { + outline: 2px solid var(--mat-sys-outline); + background: var(--mat-sys-surface-container); +} + +.example-radio-button[aria-disabled='false']:focus-within { + outline: 2px solid var(--mat-sys-primary); + background: var(--mat-sys-surface-container); +} + +.example-radio-button.cdk-active[aria-disabled='true'], +.example-radio-button[aria-disabled='true']:focus-within { + outline: 2px solid var(--mat-sys-outline); +} + +.example-radio-button[aria-disabled='true'] { + cursor: default; +} + +.example-radio-button[aria-disabled='true'] span:not(.example-radio-indicator) { + opacity: 0.3; +} + +.example-radio-button[aria-disabled='true']::before { + content: ''; + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + border-radius: var(--mat-sys-corner-extra-small); + background-color: var(--mat-sys-on-surface); + opacity: var(--mat-sys-focus-state-layer-opacity); +} diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index 187cabcd0107..dc0655768284 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -38,6 +38,7 @@ ng_project( "//src/dev-app/cdk-experimental-listbox", "//src/dev-app/cdk-experimental-radio-group", "//src/dev-app/cdk-experimental-tabs", + "//src/dev-app/cdk-experimental-toolbar", "//src/dev-app/cdk-experimental-tree", "//src/dev-app/cdk-listbox", "//src/dev-app/cdk-menu", diff --git a/src/dev-app/cdk-experimental-toolbar/BUILD.bazel b/src/dev-app/cdk-experimental-toolbar/BUILD.bazel new file mode 100644 index 000000000000..28fb3d984405 --- /dev/null +++ b/src/dev-app/cdk-experimental-toolbar/BUILD.bazel @@ -0,0 +1,16 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "cdk-experimental-toolbar", + srcs = glob(["**/*.ts"]), + assets = [ + "cdk-toolbar-demo.html", + ":cdk-toolbar-demo.css", + ], + deps = [ + "//:node_modules/@angular/core", + "//src/components-examples/cdk-experimental/toolbar", + ], +) diff --git a/src/dev-app/cdk-experimental-toolbar/cdk-toolbar-demo.css b/src/dev-app/cdk-experimental-toolbar/cdk-toolbar-demo.css new file mode 100644 index 000000000000..20307209d88a --- /dev/null +++ b/src/dev-app/cdk-experimental-toolbar/cdk-toolbar-demo.css @@ -0,0 +1,20 @@ +.example-radio-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(500px, 1fr)); + gap: 20px; +} + +.example-radio-container { + width: 500px; + display: flex; + flex-direction: column; + justify-content: flex-start; +} + +.example-configurable-radio-container { + padding-top: 40px; +} + +h4 { + height: 36px; +} diff --git a/src/dev-app/cdk-experimental-toolbar/cdk-toolbar-demo.html b/src/dev-app/cdk-experimental-toolbar/cdk-toolbar-demo.html new file mode 100644 index 000000000000..b0824b5f6c92 --- /dev/null +++ b/src/dev-app/cdk-experimental-toolbar/cdk-toolbar-demo.html @@ -0,0 +1,8 @@ +
+
+
+

Configurable CDK Toolbar

+ +
+
+
diff --git a/src/dev-app/cdk-experimental-toolbar/cdk-toolbar-demo.ts b/src/dev-app/cdk-experimental-toolbar/cdk-toolbar-demo.ts new file mode 100644 index 000000000000..bc58ca58b5cf --- /dev/null +++ b/src/dev-app/cdk-experimental-toolbar/cdk-toolbar-demo.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core'; +import {CdkToolbarConfigurableExample} from '@angular/components-examples/cdk-experimental/toolbar'; + +@Component({ + templateUrl: 'cdk-toolbar-demo.html', + imports: [CdkToolbarConfigurableExample], + styleUrl: './cdk-toolbar-demo.css', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkExperimentalToolbarDemo {} diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index 378922ef62f4..9e6bb67ee5e1 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -64,6 +64,7 @@ export class DevAppLayout { {name: 'CDK Experimental Listbox', route: '/cdk-experimental-listbox'}, {name: 'CDK Experimental Tabs', route: '/cdk-experimental-tabs'}, {name: 'CDK Experimental Accordion', route: '/cdk-experimental-accordion'}, + {name: 'CDK Experimental Toolbar', route: '/cdk-experimental-toolbar'}, {name: 'CDK Experimental Tree', route: '/cdk-experimental-tree'}, {name: 'CDK Listbox', route: '/cdk-listbox'}, {name: 'CDK Menu', route: '/cdk-menu'}, diff --git a/src/dev-app/routes.ts b/src/dev-app/routes.ts index b17cfc1e3254..298a14cb59ba 100644 --- a/src/dev-app/routes.ts +++ b/src/dev-app/routes.ts @@ -74,6 +74,11 @@ export const DEV_APP_ROUTES: Routes = [ loadComponent: () => import('./cdk-experimental-tree/cdk-tree-demo').then(m => m.CdkExperimentalTreeDemo), }, + { + path: 'cdk-experimental-toolbar', + loadComponent: () => + import('./cdk-experimental-toolbar/cdk-toolbar-demo').then(m => m.CdkExperimentalToolbarDemo), + }, { path: 'cdk-dialog', loadComponent: () => import('./cdk-dialog/dialog-demo').then(m => m.DialogDemo), From 6371abeea93f5035d71a411f8f316960d183052a Mon Sep 17 00:00:00 2001 From: Rachelle De Man Date: Wed, 6 Aug 2025 18:05:23 +0000 Subject: [PATCH 02/13] fix(cdk-experimental/ui-patterns) toolbar fix for focus change --- src/cdk-experimental/toolbar/toolbar.ts | 2 +- .../ui-patterns/radio-group/radio-button.ts | 2 + .../ui-patterns/radio-group/radio-group.ts | 22 +++- .../ui-patterns/toolbar/toolbar.ts | 120 +++++++++++------- 4 files changed, 94 insertions(+), 52 deletions(-) diff --git a/src/cdk-experimental/toolbar/toolbar.ts b/src/cdk-experimental/toolbar/toolbar.ts index 843b8e4cf4ae..5341d7476da0 100644 --- a/src/cdk-experimental/toolbar/toolbar.ts +++ b/src/cdk-experimental/toolbar/toolbar.ts @@ -109,7 +109,7 @@ export class CdkToolbar { /** The toolbar UIPattern. */ pattern: ToolbarPattern = new ToolbarPattern({ ...this, - activeIndex: signal(0), + activeItem: signal(undefined), textDirection: this.textDirection, focusMode: this.focusMode, }); diff --git a/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts b/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts index 4b6acd7b98aa..ed88f73d628a 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts @@ -23,6 +23,8 @@ type GeneralWidget = { interface RadioGroupLike { /** The list behavior for the radio group. */ listBehavior: List, V>; + /** Whether the list is readonly */ + readonly: SignalLike; } /** Represents the required inputs for a radio button in a radio group. */ diff --git a/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts b/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts index 40c61da539af..19dcc5c97db4 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts @@ -25,10 +25,20 @@ export type RadioGroupInputs = Omit< /** Parent toolbar of radio group */ toolbar: SignalLike | null>; }; + +type ToolbarWidget = { + id: SignalLike; + index: SignalLike; + element: SignalLike; + disabled: SignalLike; + searchTerm: SignalLike; + value: SignalLike; +}; + interface ToolbarLike { - focusManager: ListFocus | GeneralWidget>; - navigation: ListNavigation | GeneralWidget>; + listBehavior: List | ToolbarWidget, V>; orientation: SignalLike<'vertical' | 'horizontal'>; + disabled: SignalLike; } /** Controls the state of a radio group. */ export class RadioGroupPattern { @@ -102,6 +112,11 @@ export class RadioGroupPattern { pointerdown = computed(() => { const manager = new PointerEventManager(); + // // If within a disabled toolbar relinquish pointer control + // if (this.inputs.toolbar() && this.inputs.toolbar()!.disabled()) { + // return manager; + // } + if (this.readonly()) { // Navigate focus only in readonly mode. return manager.on(e => this.listBehavior.goto(this._getItem(e)!)); @@ -112,7 +127,8 @@ export class RadioGroupPattern { }); constructor(readonly inputs: RadioGroupInputs) { - this.orientation = inputs.orientation; + this.orientation = + inputs.toolbar() !== null ? inputs.toolbar()!.orientation : inputs.orientation; this.listBehavior = new List({ ...inputs, diff --git a/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts b/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts index 4bb1fa36da53..95fc21b50b3c 100644 --- a/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts +++ b/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts @@ -6,44 +6,42 @@ * found in the LICENSE file at https://angular.dev/license */ -import {computed} from '@angular/core'; +import {computed, signal} from '@angular/core'; import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager'; -import {ListFocus, ListFocusInputs, ListFocusItem} from '../behaviors/list-focus/list-focus'; -import { - ListNavigation, - ListNavigationInputs, - ListNavigationItem, -} from '../behaviors/list-navigation/list-navigation'; +// import {ListFocus, ListFocusInputs, ListFocusItem} from '../behaviors/list-focus/list-focus'; +// import { +// ListNavigation, +// ListNavigationInputs, +// ListNavigationItem, +// } from '../behaviors/list-navigation/list-navigation'; import {SignalLike} from '../behaviors/signal-like/signal-like'; import {RadioButtonPatternType, RadioButtonPattern} from '../radio-group/radio-button'; -export type ToolbarInputs = ListNavigationInputs< - RadioButtonPatternType | ToolbarWidgetPattern -> & - ListFocusInputs | ToolbarWidgetPattern> & { - /** Whether the toolbar is disabled. */ - disabled: SignalLike; - }; +import {List, ListInputs, ListItem} from '../behaviors/list/list'; -export class ToolbarPattern { - /** Controls navigation for the toolbar. */ - navigation: ListNavigation | ToolbarWidgetPattern>; +// remove typeahead etc. +export type ToolbarInputs = Omit< + ListInputs, V>, + 'multi' | 'typeaheadDelay' | 'value' | 'selectionMode' +>; +// ListInputs, V>; - /** Controls focus for the toolbar. */ - focusManager: ListFocus | ToolbarWidgetPattern>; +export class ToolbarPattern { + /** The list behavior for the toolbar. */ + listBehavior: List, V>; /** Whether the tablist is vertically or horizontally oriented. */ readonly orientation: SignalLike<'vertical' | 'horizontal'>; /** Whether the toolbar is disabled. */ - disabled = computed(() => this.inputs.disabled() || this.focusManager.isListDisabled()); + disabled = computed(() => this.inputs.disabled() || this.listBehavior.disabled()); /** The tabindex of the toolbar (if using activedescendant). */ - tabindex = computed(() => this.focusManager.getListTabindex()); + tabindex = computed(() => this.listBehavior.tabindex()); /** The id of the current active widget (if using activedescendant). */ - activedescendant = computed(() => this.focusManager.getActiveDescendant()); + activedescendant = computed(() => this.listBehavior.activedescendant()); /** The key used to navigate to the previous widget. */ prevKey = computed(() => { @@ -64,28 +62,44 @@ export class ToolbarPattern { /** The keydown event manager for the toolbar. */ keydown = computed(() => { const manager = new KeyboardEventManager(); + console.log(' all curent itmes', this.inputs.items()); return manager .on(' ', () => this.toolbarSelectOverride()) .on('Enter', () => this.toolbarSelectOverride()) - .on(this.prevKey, () => this.navigation.prev()) - .on(this.nextKey, () => this.navigation.next()) - .on('Home', () => this.navigation.first()) - .on('End', () => this.navigation.last()); + .on(this.prevKey, () => this.listBehavior.prev()) + .on(this.nextKey, () => { + console.log('next'); + this.next(); + }) + .on('Home', () => this.listBehavior.first()) + .on('End', () => this.listBehavior.last()); }); + next() { + const activeItem = this.inputs.activeItem(); + // if (activeItem instanceof RadioButtonPattern && activeItem.group()!!) { + // console.log('let the group move itself'); + // activeItem.group()!!.listBehavior.next(); + // } else + this.listBehavior.next(); + // find what is the next item + // console.log('next item', this.listBehavior.nextItem()); + } toolbarSelectOverride() { - const activeItem = this.focusManager.activeItem(); + const activeItem = this.inputs.activeItem(); /** If the active item is a Radio Button, indicate to the group the selection */ if (activeItem instanceof RadioButtonPattern) { const group = activeItem.group(); if (group && !group.readonly()) { - group.selection.selectOne(); + group.listBehavior.selectOne(); } + // todo fix } else { /** Item is a Toolbar Widget, manually select it */ - if (activeItem.element()) activeItem.element().click(); + if (activeItem && activeItem.element() && !activeItem.disabled()) + activeItem.element().click(); } } @@ -100,8 +114,12 @@ export class ToolbarPattern { /** Navigates to the widget associated with the given pointer event. */ goto(event: PointerEvent) { const item = this._getItem(event); + if (!item) return; - this.navigation.goto(item); + if (item instanceof RadioButtonPattern) { + // have the radio group handle the selection + } + this.listBehavior.goto(item); } /** Handles keydown events for the toolbar. */ @@ -113,13 +131,14 @@ export class ToolbarPattern { /** Handles pointerdown events for the toolbar. */ onPointerdown(event: PointerEvent) { + console.log('this disabled', this.disabled()); if (!this.disabled()) { this.pointerdown().handle(event); } } /** Finds the Toolbar Widget associated with a pointer event target. */ - private _getItem(e: PointerEvent): RadioButtonPatternType | ToolbarWidgetPattern | undefined { + private _getItem(e: PointerEvent): RadioButtonPattern | ToolbarWidgetPattern | undefined { if (!(e.target instanceof HTMLElement)) { return undefined; } @@ -132,10 +151,12 @@ export class ToolbarPattern { constructor(readonly inputs: ToolbarInputs) { this.orientation = inputs.orientation; - this.focusManager = new ListFocus(inputs); - this.navigation = new ListNavigation({ + this.listBehavior = new List({ ...inputs, - focusManager: this.focusManager, + multi: () => false, + selectionMode: () => 'explicit', + value: signal([] as any), + typeaheadDelay: () => 0, // Toolbar widgets do not support typeahead. }); } @@ -146,22 +167,23 @@ export class ToolbarPattern { * Otherwise, sets the active index to the first focusable widget. */ setDefaultState() { - let firstItem: RadioButtonPatternType | ToolbarWidgetPattern | null = null; + let firstItem: RadioButtonPattern | ToolbarWidgetPattern | null = null; for (const item of this.inputs.items()) { - if (this.focusManager.isFocusable(item)) { + if (this.listBehavior.isFocusable(item)) { if (!firstItem) { firstItem = item; } if (item instanceof RadioButtonPattern && item.selected()) { - this.inputs.activeIndex.set(item.index()); + this.inputs.activeItem.set(item); return; } } } if (firstItem) { - this.inputs.activeIndex.set(firstItem.index()); + console.log('setting active item to', firstItem); + this.inputs.activeItem.set(firstItem); } } /** Validates the state of the toolbar and returns a list of accessibility violations. */ @@ -183,12 +205,13 @@ export class ToolbarPattern { export type ToolbarWidget = { id: SignalLike; + index: SignalLike; element: SignalLike; disabled: SignalLike; }; /** Represents the required inputs for a toolbar widget in a toolbar. */ -export interface ToolbarWidgetInputs extends ListNavigationItem, ListFocusItem { +export interface ToolbarWidgetInputs extends Omit, 'searchTerm' | 'value' | 'index'> { /** A reference to the parent toolbar. */ parentToolbar: SignalLike>; } @@ -205,18 +228,18 @@ export class ToolbarWidgetPattern { parentToolbar: SignalLike | undefined>; /** The tabindex of the widgdet. */ - tabindex = computed(() => this.inputs.parentToolbar().focusManager.getItemTabindex(this)); + tabindex = computed(() => this.inputs.parentToolbar().listBehavior.getItemTabindex(this)); + + /** The text used by the typeahead search. */ + readonly searchTerm = () => ''; // Unused because toolbar does not support typeahead. + + readonly value = () => '' as any; // Unused because toolbar does not support selection. /** The position of the widget within the group. */ - index = computed( - () => - this.parentToolbar() - ?.navigation.inputs.items() - .findIndex(i => i.id() === this.id()) ?? -1, - ); + index = computed(() => this.parentToolbar()?.inputs.items().indexOf(this) ?? -1); - /** Whether the widhet is currently the active one (focused). */ - active = computed(() => this.inputs.parentToolbar().focusManager.activeItem() === this); + /** Whether the widget is currently the active one (focused). */ + active = computed(() => this.parentToolbar()?.inputs.activeItem() === this); constructor(readonly inputs: ToolbarWidgetInputs) { this.id = inputs.id; @@ -226,4 +249,5 @@ export class ToolbarWidgetPattern { } } +// can remove later export type ToolbarPatternType = InstanceType>; From 0aa99c09fd5c2b7e77325d99df62ea83ca053809 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Wed, 6 Aug 2025 14:43:57 -0400 Subject: [PATCH 03/13] fixup! fix(cdk-experimental/ui-patterns) toolbar fix for focus change --- .../behaviors/list-focus/list-focus.ts | 8 +++++-- .../ui-patterns/radio-group/radio-group.ts | 7 ++++--- .../ui-patterns/toolbar/toolbar.ts | 21 +++++++------------ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts index 6da57eeb3b8b..57f9b17bde0e 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts @@ -48,10 +48,14 @@ export class ListFocus { prevActiveItem = signal(undefined); /** The index of the last item that was active. */ - prevActiveIndex = computed(() => this.prevActiveItem()?.index() ?? -1); + prevActiveIndex = computed(() => { + return this.prevActiveItem() ? this.inputs.items().indexOf(this.prevActiveItem()!) : -1; + }); /** The current active index in the list. */ - activeIndex = computed(() => this.inputs.activeItem()?.index() ?? -1); + activeIndex = computed(() => { + return this.inputs.activeItem() ? this.inputs.items().indexOf(this.inputs.activeItem()!) : -1; + }); constructor(readonly inputs: ListFocusInputs) {} diff --git a/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts b/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts index 19dcc5c97db4..d5f41f6823a4 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts @@ -43,7 +43,7 @@ interface ToolbarLike { /** Controls the state of a radio group. */ export class RadioGroupPattern { /** The list behavior for the radio group. */ - readonly listBehavior: List, V>; + readonly listBehavior: List | ToolbarWidget, V>; /** Whether the radio group is vertically or horizontally oriented. */ orientation: SignalLike<'vertical' | 'horizontal'>; @@ -132,9 +132,10 @@ export class RadioGroupPattern { this.listBehavior = new List({ ...inputs, - wrap: () => false, + activeItem: inputs.toolbar()?.listBehavior.inputs.activeItem ?? inputs.activeItem, + wrap: () => !!inputs.toolbar(), multi: () => false, - selectionMode: () => 'follow', + selectionMode: () => (inputs.toolbar() ? 'explicit' : 'follow'), typeaheadDelay: () => 0, // Radio groups do not support typeahead. }); } diff --git a/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts b/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts index 95fc21b50b3c..2735c766aa37 100644 --- a/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts +++ b/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts @@ -68,23 +68,18 @@ export class ToolbarPattern { .on(' ', () => this.toolbarSelectOverride()) .on('Enter', () => this.toolbarSelectOverride()) .on(this.prevKey, () => this.listBehavior.prev()) - .on(this.nextKey, () => { - console.log('next'); - this.next(); + .on('ArrowDown', () => { + const activeItem = this.inputs.activeItem(); + if (activeItem instanceof RadioButtonPattern && activeItem.group()!!) { + activeItem.group()?.listBehavior.next(); + } else { + this.listBehavior.next(); + } }) + .on('ArrowRight', () => this.listBehavior.next()) .on('Home', () => this.listBehavior.first()) .on('End', () => this.listBehavior.last()); }); - next() { - const activeItem = this.inputs.activeItem(); - // if (activeItem instanceof RadioButtonPattern && activeItem.group()!!) { - // console.log('let the group move itself'); - // activeItem.group()!!.listBehavior.next(); - // } else - this.listBehavior.next(); - // find what is the next item - // console.log('next item', this.listBehavior.nextItem()); - } toolbarSelectOverride() { const activeItem = this.inputs.activeItem(); From da7c65414940aeb8b4776cc5c8b30ce1c680f432 Mon Sep 17 00:00:00 2001 From: Rachelle De Man Date: Wed, 6 Aug 2025 20:24:50 +0000 Subject: [PATCH 04/13] feat(cdk-experimental/ui-patterns) toolbar inner group wrap navigation --- .../ui-patterns/radio-group/radio-button.ts | 15 ++++--- .../ui-patterns/radio-group/radio-group.ts | 8 ++-- .../ui-patterns/toolbar/toolbar.ts | 44 ++++++++++++++----- 3 files changed, 48 insertions(+), 19 deletions(-) diff --git a/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts b/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts index ed88f73d628a..3d04833611af 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts @@ -14,17 +14,22 @@ import {List, ListItem} from '../behaviors/list/list'; * Represents the properties exposed by a radio group that need to be accessed by a radio button. * This exists to avoid circular dependency errors between the radio group and radio button. */ -type GeneralWidget = { +type ToolbarWidget = { id: SignalLike; + index: SignalLike; element: SignalLike; disabled: SignalLike; + searchTerm: SignalLike; + value: SignalLike; }; interface RadioGroupLike { /** The list behavior for the radio group. */ - listBehavior: List, V>; + listBehavior: List | ToolbarWidget, V>; /** Whether the list is readonly */ readonly: SignalLike; + /** Whether the radio group is disabled. */ + disabled: SignalLike; } /** Represents the required inputs for a radio button in a radio group. */ @@ -42,7 +47,9 @@ export class RadioButtonPattern { value: SignalLike; /** The position of the radio button within the group. */ - index = computed(() => this.group()?.listBehavior.inputs.items().indexOf(this) ?? -1); + index: SignalLike = computed( + () => this.group()?.listBehavior.inputs.items().indexOf(this) ?? -1, + ); /** Whether the radio button is currently the active one (focused). */ active = computed(() => this.group()?.listBehavior.inputs.activeItem() === this); @@ -75,5 +82,3 @@ export class RadioButtonPattern { this.disabled = inputs.disabled; } } - -export type RadioButtonPatternType = InstanceType>; diff --git a/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts b/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts index d5f41f6823a4..fc317981a16a 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts @@ -112,10 +112,10 @@ export class RadioGroupPattern { pointerdown = computed(() => { const manager = new PointerEventManager(); - // // If within a disabled toolbar relinquish pointer control - // if (this.inputs.toolbar() && this.inputs.toolbar()!.disabled()) { - // return manager; - // } + // When within a toolbar relinquish pointer control + if (this.inputs.toolbar()) { + return manager; + } if (this.readonly()) { // Navigate focus only in readonly mode. diff --git a/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts b/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts index 2735c766aa37..e9eeb0d5657c 100644 --- a/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts +++ b/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts @@ -16,7 +16,7 @@ import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-mana // } from '../behaviors/list-navigation/list-navigation'; import {SignalLike} from '../behaviors/signal-like/signal-like'; -import {RadioButtonPatternType, RadioButtonPattern} from '../radio-group/radio-button'; +import {RadioButtonPattern} from '../radio-group/radio-button'; import {List, ListInputs, ListItem} from '../behaviors/list/list'; @@ -59,16 +59,32 @@ export class ToolbarPattern { return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; }); + /** The alternate key used to navigate to the previous widget */ + altPrevKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + } + return 'ArrowUp'; + }); + + /** The alternate key used to navigate to the next widget. */ + altNextKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + } + return 'ArrowDown'; + }); + /** The keydown event manager for the toolbar. */ keydown = computed(() => { const manager = new KeyboardEventManager(); - console.log(' all curent itmes', this.inputs.items()); return manager .on(' ', () => this.toolbarSelectOverride()) .on('Enter', () => this.toolbarSelectOverride()) .on(this.prevKey, () => this.listBehavior.prev()) - .on('ArrowDown', () => { + .on(this.nextKey, () => this.listBehavior.next()) + .on(this.altNextKey, () => { const activeItem = this.inputs.activeItem(); if (activeItem instanceof RadioButtonPattern && activeItem.group()!!) { activeItem.group()?.listBehavior.next(); @@ -76,7 +92,14 @@ export class ToolbarPattern { this.listBehavior.next(); } }) - .on('ArrowRight', () => this.listBehavior.next()) + .on(this.altPrevKey, () => { + const activeItem = this.inputs.activeItem(); + if (activeItem instanceof RadioButtonPattern && activeItem.group()!!) { + activeItem.group()?.listBehavior.prev(); + } else { + this.listBehavior.prev(); + } + }) .on('Home', () => this.listBehavior.first()) .on('End', () => this.listBehavior.last()); }); @@ -87,10 +110,9 @@ export class ToolbarPattern { /** If the active item is a Radio Button, indicate to the group the selection */ if (activeItem instanceof RadioButtonPattern) { const group = activeItem.group(); - if (group && !group.readonly()) { + if (group && !group.readonly() && !group.disabled()) { group.listBehavior.selectOne(); } - // todo fix } else { /** Item is a Toolbar Widget, manually select it */ if (activeItem && activeItem.element() && !activeItem.disabled()) @@ -112,9 +134,13 @@ export class ToolbarPattern { if (!item) return; if (item instanceof RadioButtonPattern) { - // have the radio group handle the selection + const group = item.group(); + if (group && !group.readonly() && !group.disabled()) { + group.listBehavior.goto(item, {selectOne: true}); + } + } else { + this.listBehavior.goto(item); } - this.listBehavior.goto(item); } /** Handles keydown events for the toolbar. */ @@ -126,7 +152,6 @@ export class ToolbarPattern { /** Handles pointerdown events for the toolbar. */ onPointerdown(event: PointerEvent) { - console.log('this disabled', this.disabled()); if (!this.disabled()) { this.pointerdown().handle(event); } @@ -177,7 +202,6 @@ export class ToolbarPattern { } if (firstItem) { - console.log('setting active item to', firstItem); this.inputs.activeItem.set(firstItem); } } From d50e6e31a4ab967d12d987af45dba30f78b42bd4 Mon Sep 17 00:00:00 2001 From: Rachelle De Man Date: Wed, 6 Aug 2025 22:15:44 +0000 Subject: [PATCH 05/13] fix(cdk-experimental/ui-patterns) toolbar logic and readibility improvement --- .../radio-group/radio-group.ts | 4 ++-- src/cdk-experimental/toolbar/toolbar.ts | 2 +- .../ui-patterns/radio-group/radio-button.ts | 4 ++-- .../ui-patterns/radio-group/radio-group.ts | 10 ++++---- .../ui-patterns/radio-group/radio.spec.ts | 2 +- .../ui-patterns/toolbar/toolbar.ts | 24 +++---------------- 6 files changed, 14 insertions(+), 32 deletions(-) diff --git a/src/cdk-experimental/radio-group/radio-group.ts b/src/cdk-experimental/radio-group/radio-group.ts index 2fb1180496c5..f7ed891bf314 100644 --- a/src/cdk-experimental/radio-group/radio-group.ts +++ b/src/cdk-experimental/radio-group/radio-group.ts @@ -103,7 +103,7 @@ export class CdkRadioGroup { toolbar = inject(CdkToolbar, {optional: true}); /** Toolbar pattern if applicable */ - private readonly _toolbarPattern = computed(() => (this.toolbar ? this.toolbar.pattern : null)); + private readonly _toolbarPattern = computed(() => this.toolbar?.pattern); /** The RadioButton UIPatterns of the child CdkRadioButtons. */ protected items = computed(() => this._cdkRadioButtons().map(radio => radio.pattern)); @@ -140,7 +140,7 @@ export class CdkRadioGroup { activeItem: signal(undefined), textDirection: this.textDirection, toolbar: this._toolbarPattern, - focusMode: this._toolbarPattern() ? this._toolbarPattern()!!.inputs.focusMode : this.focusMode, + focusMode: this._toolbarPattern()?.inputs.focusMode ?? this.focusMode, }); /** Whether the radio group has received focus yet. */ diff --git a/src/cdk-experimental/toolbar/toolbar.ts b/src/cdk-experimental/toolbar/toolbar.ts index 5341d7476da0..dd9e05256e3e 100644 --- a/src/cdk-experimental/toolbar/toolbar.ts +++ b/src/cdk-experimental/toolbar/toolbar.ts @@ -201,7 +201,7 @@ export class CdkToolbarWidget implements OnInit, OnDestroy { ...this, id: this.id, element: this.element, - disabled: computed(() => this._cdkToolbar.disabled() || this.disabled() || false), + disabled: computed(() => this._cdkToolbar.disabled() || this.disabled()), parentToolbar: this.parentToolbar, }); diff --git a/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts b/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts index 3d04833611af..06762e035e93 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts @@ -14,7 +14,7 @@ import {List, ListItem} from '../behaviors/list/list'; * Represents the properties exposed by a radio group that need to be accessed by a radio button. * This exists to avoid circular dependency errors between the radio group and radio button. */ -type ToolbarWidget = { +type ToolbarWidgetLike = { id: SignalLike; index: SignalLike; element: SignalLike; @@ -25,7 +25,7 @@ type ToolbarWidget = { interface RadioGroupLike { /** The list behavior for the radio group. */ - listBehavior: List | ToolbarWidget, V>; + listBehavior: List | ToolbarWidgetLike, V>; /** Whether the list is readonly */ readonly: SignalLike; /** Whether the radio group is disabled. */ diff --git a/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts b/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts index fc317981a16a..f1f7a956e041 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts @@ -23,10 +23,10 @@ export type RadioGroupInputs = Omit< /** Whether the radio group is readonly. */ readonly: SignalLike; /** Parent toolbar of radio group */ - toolbar: SignalLike | null>; + toolbar: SignalLike | undefined>; }; -type ToolbarWidget = { +type ToolbarWidgetLike = { id: SignalLike; index: SignalLike; element: SignalLike; @@ -36,14 +36,14 @@ type ToolbarWidget = { }; interface ToolbarLike { - listBehavior: List | ToolbarWidget, V>; + listBehavior: List | ToolbarWidgetLike, V>; orientation: SignalLike<'vertical' | 'horizontal'>; disabled: SignalLike; } /** Controls the state of a radio group. */ export class RadioGroupPattern { /** The list behavior for the radio group. */ - readonly listBehavior: List | ToolbarWidget, V>; + readonly listBehavior: List | ToolbarWidgetLike, V>; /** Whether the radio group is vertically or horizontally oriented. */ orientation: SignalLike<'vertical' | 'horizontal'>; @@ -128,7 +128,7 @@ export class RadioGroupPattern { constructor(readonly inputs: RadioGroupInputs) { this.orientation = - inputs.toolbar() !== null ? inputs.toolbar()!.orientation : inputs.orientation; + inputs.toolbar() !== undefined ? inputs.toolbar()!.orientation : inputs.orientation; this.listBehavior = new List({ ...inputs, diff --git a/src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts b/src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts index ca54ae79d3ca..0f4e24573281 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts @@ -39,7 +39,7 @@ describe('RadioGroup Pattern', () => { focusMode: inputs.focusMode ?? signal('roving'), textDirection: inputs.textDirection ?? signal('ltr'), orientation: inputs.orientation ?? signal('vertical'), - toolbar: inputs.toolbar ?? signal(null), + toolbar: inputs.toolbar ?? signal(undefined), }); } diff --git a/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts b/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts index e9eeb0d5657c..90a1a7676e62 100644 --- a/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts +++ b/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts @@ -8,24 +8,16 @@ import {computed, signal} from '@angular/core'; import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager'; -// import {ListFocus, ListFocusInputs, ListFocusItem} from '../behaviors/list-focus/list-focus'; -// import { -// ListNavigation, -// ListNavigationInputs, -// ListNavigationItem, -// } from '../behaviors/list-navigation/list-navigation'; import {SignalLike} from '../behaviors/signal-like/signal-like'; import {RadioButtonPattern} from '../radio-group/radio-button'; import {List, ListInputs, ListItem} from '../behaviors/list/list'; -// remove typeahead etc. export type ToolbarInputs = Omit< ListInputs, V>, 'multi' | 'typeaheadDelay' | 'value' | 'selectionMode' >; -// ListInputs, V>; export class ToolbarPattern { /** The list behavior for the toolbar. */ @@ -35,7 +27,7 @@ export class ToolbarPattern { readonly orientation: SignalLike<'vertical' | 'horizontal'>; /** Whether the toolbar is disabled. */ - disabled = computed(() => this.inputs.disabled() || this.listBehavior.disabled()); + disabled = computed(() => this.listBehavior.disabled()); /** The tabindex of the toolbar (if using activedescendant). */ tabindex = computed(() => this.listBehavior.tabindex()); @@ -86,7 +78,7 @@ export class ToolbarPattern { .on(this.nextKey, () => this.listBehavior.next()) .on(this.altNextKey, () => { const activeItem = this.inputs.activeItem(); - if (activeItem instanceof RadioButtonPattern && activeItem.group()!!) { + if (activeItem instanceof RadioButtonPattern && activeItem.group()) { activeItem.group()?.listBehavior.next(); } else { this.listBehavior.next(); @@ -94,7 +86,7 @@ export class ToolbarPattern { }) .on(this.altPrevKey, () => { const activeItem = this.inputs.activeItem(); - if (activeItem instanceof RadioButtonPattern && activeItem.group()!!) { + if (activeItem instanceof RadioButtonPattern && activeItem.group()) { activeItem.group()?.listBehavior.prev(); } else { this.listBehavior.prev(); @@ -222,13 +214,6 @@ export class ToolbarPattern { } } -export type ToolbarWidget = { - id: SignalLike; - index: SignalLike; - element: SignalLike; - disabled: SignalLike; -}; - /** Represents the required inputs for a toolbar widget in a toolbar. */ export interface ToolbarWidgetInputs extends Omit, 'searchTerm' | 'value' | 'index'> { /** A reference to the parent toolbar. */ @@ -267,6 +252,3 @@ export class ToolbarWidgetPattern { this.parentToolbar = inputs.parentToolbar; } } - -// can remove later -export type ToolbarPatternType = InstanceType>; From 664a27bc4ed233c007a35edffd774ad9855a00ca Mon Sep 17 00:00:00 2001 From: Rachelle De Man Date: Wed, 6 Aug 2025 23:42:59 +0000 Subject: [PATCH 06/13] fix(cdk-experimental/toolbar) improved demo settings --- .../cdk-toolbar-configurable-example.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components-examples/cdk-experimental/toolbar/cdk-toolbar-configurable/cdk-toolbar-configurable-example.ts b/src/components-examples/cdk-experimental/toolbar/cdk-toolbar-configurable/cdk-toolbar-configurable-example.ts index 3b123a9e2930..b5fabf612f58 100644 --- a/src/components-examples/cdk-experimental/toolbar/cdk-toolbar-configurable/cdk-toolbar-configurable-example.ts +++ b/src/components-examples/cdk-experimental/toolbar/cdk-toolbar-configurable/cdk-toolbar-configurable-example.ts @@ -26,18 +26,18 @@ import {CdkToolbar, CdkToolbarWidget} from '@angular/cdk-experimental/toolbar'; ], }) export class CdkToolbarConfigurableExample { - orientation: 'vertical' | 'horizontal' = 'vertical'; - disabled = new FormControl(false, {nonNullable: true}); + skipDisabled = new FormControl(true, {nonNullable: true}); + wrap = new FormControl(true, {nonNullable: true}); toolbarDisabled = new FormControl(false, {nonNullable: true}); + orientation: 'vertical' | 'horizontal' = 'horizontal'; + focusMode: 'roving' | 'activedescendant' = 'roving'; fruits = ['Apple', 'Apricot', 'Banana']; buttonFruits = ['Pear', 'Blueberry', 'Cherry', 'Date']; - // New controls + // Radio group controls + disabled = new FormControl(false, {nonNullable: true}); readonly = new FormControl(false, {nonNullable: true}); - skipDisabled = new FormControl(true, {nonNullable: true}); - focusMode: 'roving' | 'activedescendant' = 'roving'; - wrap = new FormControl(true, {nonNullable: true}); // Control for which radio options are individually disabled disabledOptions: string[] = ['Banana']; From 785fee3d30d945d7b354101b2595de7143dea027 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Wed, 6 Aug 2025 09:52:51 -0700 Subject: [PATCH 07/13] fix(material/button-toggle): skip keyboard navigation when modifier key is pressed (#31651) --- .../button-toggle/button-toggle.spec.ts | 96 ++++++++++++++++++- src/material/button-toggle/button-toggle.ts | 12 ++- 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/src/material/button-toggle/button-toggle.spec.ts b/src/material/button-toggle/button-toggle.spec.ts index 461d6628d658..cd2b587a8bc3 100644 --- a/src/material/button-toggle/button-toggle.spec.ts +++ b/src/material/button-toggle/button-toggle.spec.ts @@ -1,6 +1,14 @@ -import {dispatchMouseEvent} from '@angular/cdk/testing/private'; +import {createKeyboardEvent, dispatchEvent, dispatchMouseEvent} from '@angular/cdk/testing/private'; +import {DOWN_ARROW, LEFT_ARROW, RIGHT_ARROW, UP_ARROW} from '@angular/cdk/keycodes'; import {Component, DebugElement, QueryList, ViewChild, ViewChildren} from '@angular/core'; -import {ComponentFixture, TestBed, fakeAsync, flush, tick} from '@angular/core/testing'; +import { + ComponentFixture, + TestBed, + fakeAsync, + flush, + tick, + waitForAsync, +} from '@angular/core/testing'; import {FormControl, FormsModule, NgModel, ReactiveFormsModule} from '@angular/forms'; import {By} from '@angular/platform-browser'; import { @@ -574,6 +582,90 @@ describe('MatButtonToggle without forms', () => { ).length, ).toBe(1); }); + + describe('keyboard events', () => { + let LEFT_ARROW_EVENT: KeyboardEvent; + let RIGHT_ARROW_EVENT: KeyboardEvent; + let UP_ARROW_EVENT: KeyboardEvent; + let DOWN_ARROW_EVENT: KeyboardEvent; + + beforeEach(waitForAsync(async () => { + LEFT_ARROW_EVENT = createKeyboardEvent('keydown', LEFT_ARROW); + RIGHT_ARROW_EVENT = createKeyboardEvent('keydown', RIGHT_ARROW); + UP_ARROW_EVENT = createKeyboardEvent('keydown', UP_ARROW); + DOWN_ARROW_EVENT = createKeyboardEvent('keydown', DOWN_ARROW); + })); + + it('should not change selection on arrow key press with a modifier key', () => { + expect(groupInstance.value).toBeFalsy(); + expect(buttonToggleInstances[0].checked).toBe(false); + + [LEFT_ARROW, RIGHT_ARROW, UP_ARROW, DOWN_ARROW].forEach(keyCode => { + const event = createKeyboardEvent('keydown', keyCode, undefined, {alt: true}); + dispatchEvent(innerButtons[0], event); + fixture.detectChanges(); + + // Nothing should happen and the default should not be prevented. + expect(groupInstance.value) + .withContext(`Expected no value change for ${keyCode}`) + .toBeFalsy(); + expect(buttonToggleInstances[0].checked) + .withContext(`Expected no checked change for ${keyCode}`) + .toBe(false); + expect(event.defaultPrevented) + .withContext(`Expected no default prevention for ${keyCode}`) + .toBe(false); + }); + }); + + it('should change selection on RIGHT_ARROW press', () => { + expect(groupInstance.value).toBeFalsy(); + + dispatchEvent(innerButtons[0], RIGHT_ARROW_EVENT); + fixture.detectChanges(); + + expect(groupInstance.value).toBe('test2'); + expect(buttonToggleInstances[1].checked).toBe(true); + expect(RIGHT_ARROW_EVENT.defaultPrevented).toBe(true); + }); + + it('should change selection on LEFT_ARROW press', () => { + innerButtons[1].click(); + fixture.detectChanges(); + expect(groupInstance.value).toBe('test2'); + + dispatchEvent(innerButtons[1], LEFT_ARROW_EVENT); + fixture.detectChanges(); + + expect(groupInstance.value).toBe('test1'); + expect(buttonToggleInstances[0].checked).toBe(true); + expect(LEFT_ARROW_EVENT.defaultPrevented).toBe(true); + }); + + it('should change selection on DOWN_ARROW press', () => { + expect(groupInstance.value).toBeFalsy(); + + dispatchEvent(innerButtons[0], DOWN_ARROW_EVENT); + fixture.detectChanges(); + + expect(groupInstance.value).toBe('test2'); + expect(buttonToggleInstances[1].checked).toBe(true); + expect(DOWN_ARROW_EVENT.defaultPrevented).toBe(true); + }); + + it('should change selection on UP_ARROW press', () => { + innerButtons[1].click(); + fixture.detectChanges(); + expect(groupInstance.value).toBe('test2'); + + dispatchEvent(innerButtons[1], UP_ARROW_EVENT); + fixture.detectChanges(); + + expect(groupInstance.value).toBe('test1'); + expect(buttonToggleInstances[0].checked).toBe(true); + expect(UP_ARROW_EVENT.defaultPrevented).toBe(true); + }); + }); }); describe('with initial value and change event', () => { diff --git a/src/material/button-toggle/button-toggle.ts b/src/material/button-toggle/button-toggle.ts index 283b2d1e7d87..002b8a133b46 100644 --- a/src/material/button-toggle/button-toggle.ts +++ b/src/material/button-toggle/button-toggle.ts @@ -9,7 +9,15 @@ import {_IdGenerator, FocusMonitor} from '@angular/cdk/a11y'; import {Direction, Directionality} from '@angular/cdk/bidi'; import {SelectionModel} from '@angular/cdk/collections'; -import {DOWN_ARROW, ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE, UP_ARROW} from '@angular/cdk/keycodes'; +import { + DOWN_ARROW, + ENTER, + LEFT_ARROW, + RIGHT_ARROW, + SPACE, + UP_ARROW, + hasModifierKey, +} from '@angular/cdk/keycodes'; import {_CdkPrivateStyleLoader} from '@angular/cdk/private'; import { AfterContentInit, @@ -331,7 +339,7 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After /** Handle keydown event calling to single-select button toggle. */ protected _keydown(event: KeyboardEvent) { - if (this.multiple || this.disabled) { + if (this.multiple || this.disabled || hasModifierKey(event)) { return; } From bdd8b994ca970db9fd2334d5223d10810c815fed Mon Sep 17 00:00:00 2001 From: Andrew Seguin Date: Wed, 6 Aug 2025 13:48:31 -0600 Subject: [PATCH 08/13] docs: release notes for the v20.1.5 release --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 128dae9adcc4..898dac36f5ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ + +# 20.1.5 "plastic-car" (2025-08-06) +### material +| Commit | Type | Description | +| -- | -- | -- | +| [dbdcc7dcb7](https://github.com/angular/components/commit/dbdcc7dcb77a1f86446e8ced2359717d3af00e1f) | fix | **autocomplete:** default to transparent backdrop ([#31647](https://github.com/angular/components/pull/31647)) | +| [ae9e8d2f84](https://github.com/angular/components/commit/ae9e8d2f846f605dc77154e7f3d7df75cc22ae06) | fix | **chips:** focus not moved on destroy ([#31653](https://github.com/angular/components/pull/31653)) | +| [24ae377723](https://github.com/angular/components/commit/24ae3777233f63da35ba9106bf554d6dba20bb88) | fix | **form-field:** resolve memory leak ([#31643](https://github.com/angular/components/pull/31643)) | + + + # 20.2.0-next.2 "archerite-asparagus" (2025-07-30) ### cdk From 5ffd0f9fe54277a414aeda7a1c9eb0c29037d809 Mon Sep 17 00:00:00 2001 From: Andrew Seguin Date: Wed, 6 Aug 2025 15:14:41 -0600 Subject: [PATCH 09/13] release: cut the v20.2.0-next.3 release --- CHANGELOG.md | 19 +++++++++++++++++++ package.json | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 898dac36f5ea..8eea8a364329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ + +# 20.2.0-next.3 "metal-monkey" (2025-08-06) +### material +| Commit | Type | Description | +| -- | -- | -- | +| [845a6910a6](https://github.com/angular/components/commit/845a6910a60a652dd7d171ee026ee8a8887a2459) | fix | **autocomplete:** default to transparent backdrop ([#31647](https://github.com/angular/components/pull/31647)) | +| [11ad09ff3e](https://github.com/angular/components/commit/11ad09ff3e67d1825e2c6ce3d211d5192cf8e354) | fix | **button-toggle:** skip keyboard navigation when modifier key is pressed ([#31651](https://github.com/angular/components/pull/31651)) | +| [4bf8ebf5f3](https://github.com/angular/components/commit/4bf8ebf5f3b874ae2f512dc684506f017f022f69) | fix | **chips:** focus not moved on destroy ([#31653](https://github.com/angular/components/pull/31653)) | +| [b2c7abfbad](https://github.com/angular/components/commit/b2c7abfbadb5d12900c4bf9705b871183d4b2af7) | fix | **core:** add key validation to m2-theme ([#31631](https://github.com/angular/components/pull/31631)) | +| [839aa3e375](https://github.com/angular/components/commit/839aa3e37521e7b8739a47ea7a4b54845cf62731) | fix | **core:** fix m2 system color values ([#31632](https://github.com/angular/components/pull/31632)) | +| [7674e5872c](https://github.com/angular/components/commit/7674e5872c627998bd093994e8ef3f94427417c8) | fix | **core:** special-case icon button color token ([#31625](https://github.com/angular/components/pull/31625)) | +| [96117bceda](https://github.com/angular/components/commit/96117bcedad66f21257e043f83e90a77dc56deef) | fix | **form-field:** resolve memory leak ([#31643](https://github.com/angular/components/pull/31643)) | +### cdk-experimental +| Commit | Type | Description | +| -- | -- | -- | +| [228aaf1fa3](https://github.com/angular/components/commit/228aaf1fa395e805d7b581b9d02102d65f0a1562) | feat | **ui-patterns:** create List behavior ([#31601](https://github.com/angular/components/pull/31601)) | + + + # 20.1.5 "plastic-car" (2025-08-06) ### material diff --git a/package.json b/package.json index e49b6eb992ab..ee03513ff335 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "ci-docs-monitor-test": "node --no-warnings=ExperimentalWarning --loader ts-node/esm/transpile-only scripts/docs-deploy/monitoring/ci-test.mts", "prepare": "husky" }, - "version": "20.2.0-next.2", + "version": "20.2.0-next.3", "dependencies": { "@angular-devkit/core": "catalog:", "@angular-devkit/schematics": "catalog:", From b4dbc6f60689662ad02be0d424abe1f0cb46de62 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 7 Aug 2025 15:50:10 +0200 Subject: [PATCH 10/13] fix(material/stepper): handle empty label in horizontal stepper (#31665) Fixes that the horizontal stepper was leaving a blank space if the step's label is empty. Fixes #31655. --- goldens/material/stepper/index.api.md | 6 ++++++ src/material/stepper/step-header.html | 4 ++-- src/material/stepper/step-header.scss | 4 ++++ src/material/stepper/step-header.ts | 18 ++++++++++++++++++ src/material/stepper/stepper.scss | 4 ++++ 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/goldens/material/stepper/index.api.md b/goldens/material/stepper/index.api.md index b511e57b2aae..21ee4ffd5695 100644 --- a/goldens/material/stepper/index.api.md +++ b/goldens/material/stepper/index.api.md @@ -82,6 +82,12 @@ export class MatStepHeader extends CdkStepHeader implements AfterViewInit, OnDes // (undocumented) _getDefaultTextForState(state: StepState): string; _getHostElement(): HTMLElement; + // (undocumented) + protected _hasEmptyLabel(): boolean; + // (undocumented) + protected _hasErrorLabel(): boolean; + // (undocumented) + protected _hasOptionalLabel(): boolean; iconOverrides: { [key: string]: TemplateRef; }; diff --git a/src/material/stepper/step-header.html b/src/material/stepper/step-header.html index 99b65a5643a4..0835eff4349d 100644 --- a/src/material/stepper/step-header.html +++ b/src/material/stepper/step-header.html @@ -41,11 +41,11 @@
{{label}}
} - @if (optional && state != 'error') { + @if (_hasOptionalLabel()) {
{{_intl.optionalLabel}}
} - @if (state === 'error') { + @if (_hasErrorLabel()) {
{{errorMessage}}
} diff --git a/src/material/stepper/step-header.scss b/src/material/stepper/step-header.scss index 5c4f025dbf8c..346e8ea3f09a 100644 --- a/src/material/stepper/step-header.scss +++ b/src/material/stepper/step-header.scss @@ -143,6 +143,10 @@ $fallbacks: m3-stepper.get-tokens(); font-size: token-utils.slot(stepper-header-selected-state-label-text-size, $fallbacks); font-weight: token-utils.slot(stepper-header-selected-state-label-text-weight, $fallbacks); } + + .mat-step-header-empty-label & { + min-width: 0; + } } .mat-step-text-label { diff --git a/src/material/stepper/step-header.ts b/src/material/stepper/step-header.ts index 998e5d3610ad..eb95f01ad708 100644 --- a/src/material/stepper/step-header.ts +++ b/src/material/stepper/step-header.ts @@ -34,6 +34,7 @@ import {_CdkPrivateStyleLoader, _VisuallyHiddenLoader} from '@angular/cdk/privat styleUrl: 'step-header.css', host: { 'class': 'mat-step-header', + '[class.mat-step-header-empty-label]': '_hasEmptyLabel()', '[class]': '"mat-" + (color || "primary")', 'role': 'tab', }, @@ -140,4 +141,21 @@ export class MatStepHeader extends CdkStepHeader implements AfterViewInit, OnDes } return state; } + + protected _hasEmptyLabel() { + return ( + !this._stringLabel() && + !this._templateLabel() && + !this._hasOptionalLabel() && + !this._hasErrorLabel() + ); + } + + protected _hasOptionalLabel() { + return this.optional && this.state !== 'error'; + } + + protected _hasErrorLabel() { + return this.state === 'error'; + } } diff --git a/src/material/stepper/stepper.scss b/src/material/stepper/stepper.scss index eace322adeb1..7dc209dd91e5 100644 --- a/src/material/stepper/stepper.scss +++ b/src/material/stepper/stepper.scss @@ -81,6 +81,10 @@ $fallbacks: m3-stepper.get-tokens(); } } + &.mat-step-header-empty-label .mat-step-icon { + margin: 0; + } + $vertical-padding: _get-vertical-padding-calc(); &::before, From 14bb555f4b53f8bac08fa3cf5a40f81a83534052 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 7 Aug 2025 10:36:52 -0700 Subject: [PATCH 11/13] fix(material/chips): static chips should disable ripple (#31652) --- goldens/material/chips/index.api.md | 1 + src/material/chips/chip-row.spec.ts | 45 ++++++++++++++++++++++++++++- src/material/chips/chip.spec.ts | 12 ++++++++ src/material/chips/chip.ts | 6 ++++ 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/goldens/material/chips/index.api.md b/goldens/material/chips/index.api.md index d16b9b97b32e..d25d49bc2bcb 100644 --- a/goldens/material/chips/index.api.md +++ b/goldens/material/chips/index.api.md @@ -84,6 +84,7 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck _handlePrimaryActionInteraction(): void; // (undocumented) _hasFocus(): boolean; + _hasInteractiveActions(): boolean; _hasTrailingIcon(): boolean; highlighted: boolean; id: string; diff --git a/src/material/chips/chip-row.spec.ts b/src/material/chips/chip-row.spec.ts index 229692a2843d..22c1aa7dcd06 100644 --- a/src/material/chips/chip-row.spec.ts +++ b/src/material/chips/chip-row.spec.ts @@ -436,6 +436,43 @@ describe('Row Chips', () => { })); }); + describe('_hasInteractiveActions', () => { + it('should return true if the chip has a remove icon', () => { + testComponent.removable = true; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + expect(chipInstance._hasInteractiveActions()).toBe(true); + }); + + it('should return true if the chip has an edit icon', () => { + testComponent.editable = true; + testComponent.showEditIcon = true; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + expect(chipInstance._hasInteractiveActions()).toBe(true); + }); + + it('should return true even with a non-interactive trailing icon', () => { + testComponent.showTrailingIcon = true; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + expect(chipInstance._hasInteractiveActions()).toBe(true); + }); + + it('should return false if all actions are non-interactive', () => { + // Make primary action non-interactive for testing purposes. + chipInstance.primaryAction.isInteractive = false; + testComponent.showTrailingIcon = true; + testComponent.removable = false; // remove icon is interactive + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + // The trailing icon is not interactive. + expect(chipInstance.trailingIcon.isInteractive).toBe(false); + expect(chipInstance._hasInteractiveActions()).toBe(false); + }); + }); + describe('with edit icon', () => { beforeEach(async () => { testComponent.showEditIcon = true; @@ -507,10 +544,15 @@ describe('Row Chips', () => { } {{name}} - + @if (removable) { + + } @if (useCustomEditInput) { } + @if (showTrailingIcon) { + trailing + } @@ -529,6 +571,7 @@ class SingleChip { editable: boolean = false; showEditIcon: boolean = false; useCustomEditInput: boolean = true; + showTrailingIcon = false; ariaLabel: string | null = null; ariaDescription: string | null = null; diff --git a/src/material/chips/chip.spec.ts b/src/material/chips/chip.spec.ts index b9748576396d..750afb2f4db6 100644 --- a/src/material/chips/chip.spec.ts +++ b/src/material/chips/chip.spec.ts @@ -117,6 +117,18 @@ describe('MatChip', () => { expect(primaryAction.hasAttribute('tabindex')).toBe(false); }); + it('should disable the ripple if there are no interactive actions', () => { + // expect(chipInstance._isRippleDisabled()).toBe(false); TODO(andreyd) + + // Make primary action non-interactive for testing purposes. + chipInstance.primaryAction.isInteractive = false; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + expect(chipInstance._hasInteractiveActions()).toBe(false); + expect(chipInstance._isRippleDisabled()).toBe(true); + }); + it('should return the chip text if value is undefined', () => { expect(chipInstance.value.trim()).toBe(fixture.componentInstance.name); }); diff --git a/src/material/chips/chip.ts b/src/material/chips/chip.ts index 57671670a4de..58bbd9822b52 100644 --- a/src/material/chips/chip.ts +++ b/src/material/chips/chip.ts @@ -331,6 +331,7 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck this.disableRipple || this._animationsDisabled || this._isBasicChip || + !this._hasInteractiveActions() || !!this._globalRippleOptions?.disabled ); } @@ -400,6 +401,11 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck // Empty here, but is overwritten in child classes. } + /** Returns whether the chip has any interactive actions. */ + _hasInteractiveActions(): boolean { + return this._getActions().some(a => a.isInteractive); + } + /** Handles interactions with the edit action of the chip. */ _edit(event: Event) { // Empty here, but is overwritten in child classes. From 63ce060ccdb0afdc2c84a44ccc20f065c828e154 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 7 Aug 2025 10:41:46 -0700 Subject: [PATCH 12/13] fix(material/chips): remove extra span for aria-description (#31609) --- goldens/material/chips/index.api.md | 1 - src/material/chips/chip-option.html | 6 ++---- src/material/chips/chip-option.spec.ts | 21 +++------------------ src/material/chips/chip-row.html | 6 ++---- src/material/chips/chip-row.spec.ts | 21 +++------------------ src/material/chips/chip.ts | 3 --- 6 files changed, 10 insertions(+), 48 deletions(-) diff --git a/goldens/material/chips/index.api.md b/goldens/material/chips/index.api.md index d25d49bc2bcb..d4cb5e82385b 100644 --- a/goldens/material/chips/index.api.md +++ b/goldens/material/chips/index.api.md @@ -59,7 +59,6 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck protected _allTrailingIcons: QueryList; _animationsDisabled: boolean; ariaDescription: string | null; - _ariaDescriptionId: string; ariaLabel: string | null; protected basicChipAttrName: string; // (undocumented) diff --git a/src/material/chips/chip-option.html b/src/material/chips/chip-option.html index 5df5e6a6e110..a24ad97fd148 100644 --- a/src/material/chips/chip-option.html +++ b/src/material/chips/chip-option.html @@ -4,9 +4,9 @@