diff --git a/.ng-dev/commit-message.mts b/.ng-dev/commit-message.mts index 16ec612231f8..314d1e3c36f0 100644 --- a/.ng-dev/commit-message.mts +++ b/.ng-dev/commit-message.mts @@ -19,6 +19,7 @@ export const commitMessage: CommitMessageConfig = { 'cdk-experimental/selection', 'cdk-experimental/table-scroll-container', 'cdk-experimental/tabs', + 'cdk-experimental/toolbar', 'cdk-experimental/tree', 'cdk-experimental/ui-patterns', 'cdk/a11y', diff --git a/CHANGELOG.md b/CHANGELOG.md index 128dae9adcc4..8eea8a364329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ + +# 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 +| 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 diff --git a/goldens/material/chips/index.api.md b/goldens/material/chips/index.api.md index d16b9b97b32e..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) @@ -84,6 +83,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/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/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:", 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..f7ed891bf314 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?.pattern); + /** 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()?.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..667f7b86dd99 --- /dev/null +++ b/src/cdk-experimental/toolbar/BUILD.bazel @@ -0,0 +1,17 @@ +load("//tools:defaults.bzl", "ng_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", + ], +) 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..ec25ac4d71c9 --- /dev/null +++ b/src/cdk-experimental/toolbar/toolbar.ts @@ -0,0 +1,218 @@ +/** + * @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, + activeItem: signal(undefined), + 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', + '[attr.aria-disabled]': 'pattern.disabled()', + '[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._cdkToolbar.skipDisabled(), + ); + + pattern = new ToolbarWidgetPattern({ + ...this, + id: this.id, + element: this.element, + disabled: computed(() => this._cdkToolbar.disabled() || this.disabled()), + 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/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/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..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,9 +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 ToolbarWidgetLike = { + 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 | ToolbarWidgetLike, 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. */ @@ -34,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); 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..f1f7a956e041 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts @@ -22,12 +22,28 @@ export type RadioGroupInputs = Omit< /** Whether the radio group is readonly. */ readonly: SignalLike; + /** Parent toolbar of radio group */ + toolbar: SignalLike | undefined>; }; +type ToolbarWidgetLike = { + id: SignalLike; + index: SignalLike; + element: SignalLike; + disabled: SignalLike; + searchTerm: SignalLike; + value: SignalLike; +}; + +interface ToolbarLike { + 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, V>; + readonly listBehavior: List | ToolbarWidgetLike, V>; /** Whether the radio group is vertically or horizontally oriented. */ orientation: SignalLike<'vertical' | 'horizontal'>; @@ -41,8 +57,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 +83,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 @@ -91,6 +112,11 @@ export class RadioGroupPattern { pointerdown = computed(() => { const manager = new PointerEventManager(); + // When within a toolbar relinquish pointer control + if (this.inputs.toolbar()) { + return manager; + } + if (this.readonly()) { // Navigate focus only in readonly mode. return manager.on(e => this.listBehavior.goto(this._getItem(e)!)); @@ -101,13 +127,15 @@ export class RadioGroupPattern { }); constructor(readonly inputs: RadioGroupInputs) { - this.orientation = inputs.orientation; + this.orientation = + inputs.toolbar() !== undefined ? inputs.toolbar()!.orientation : inputs.orientation; 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/radio-group/radio.spec.ts b/src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts index 2aed6abfaa1f..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,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(undefined), }); } 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..81637be2ba91 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/toolbar/BUILD.bazel @@ -0,0 +1,19 @@ +load("//tools:defaults.bzl", "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", + ], +) 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..6fdaed8ae229 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts @@ -0,0 +1,254 @@ +/** + * @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, signal} from '@angular/core'; +import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; + +import {RadioButtonPattern} from '../radio-group/radio-button'; + +import {List, ListInputs, ListItem} from '../behaviors/list/list'; + +export type ToolbarInputs = Omit< + ListInputs, V>, + 'multi' | 'typeaheadDelay' | 'value' | 'selectionMode' +>; + +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.listBehavior.disabled()); + + /** The tabindex of the toolbar (if using activedescendant). */ + tabindex = computed(() => this.listBehavior.tabindex()); + + /** The id of the current active widget (if using activedescendant). */ + activedescendant = computed(() => this.listBehavior.activedescendant()); + + /** 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 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(); + manager.options = { + preventDefault: false, + stopPropagation: true, + }; + + return manager + .on(' ', () => this.toolbarSelectOverride()) + .on('Enter', () => this.toolbarSelectOverride()) + .on(this.prevKey, () => this.listBehavior.prev()) + .on(this.nextKey, () => this.listBehavior.next()) + .on(this.altNextKey, () => { + const activeItem = this.inputs.activeItem(); + if (activeItem instanceof RadioButtonPattern && activeItem.group()) { + activeItem.group()?.listBehavior.next(); + } else { + 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()); + }); + + toolbarSelectOverride() { + 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.disabled()) { + group.listBehavior.selectOne(); + } + } + } + + /** 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); + if (!item) return; + + if (item instanceof RadioButtonPattern) { + const group = item.group(); + if (group && !group.readonly() && !group.disabled()) { + group.listBehavior.goto(item, {selectOne: true}); + } + } else { + this.listBehavior.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): RadioButtonPattern | 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.listBehavior = new List({ + ...inputs, + multi: () => false, + selectionMode: () => 'explicit', + value: signal([] as any), + typeaheadDelay: () => 0, // Toolbar widgets do not support typeahead. + }); + } + + /** + * 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: RadioButtonPattern | ToolbarWidgetPattern | null = null; + + for (const item of this.inputs.items()) { + if (this.listBehavior.isFocusable(item)) { + if (!firstItem) { + firstItem = item; + } + if (item instanceof RadioButtonPattern && item.selected()) { + this.inputs.activeItem.set(item); + return; + } + } + } + + if (firstItem) { + this.inputs.activeItem.set(firstItem); + } + } + /** 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; + } +} + +/** 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. */ + 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().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()?.inputs.items().indexOf(this) ?? -1); + + /** Whether the widget is currently the active one (focused). */ + active = computed(() => this.parentToolbar()?.inputs.activeItem() === this); + + constructor(readonly inputs: ToolbarWidgetInputs) { + this.id = inputs.id; + this.element = inputs.element; + this.disabled = inputs.disabled; + this.parentToolbar = inputs.parentToolbar; + } +} 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..b5fabf612f58 --- /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 { + 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']; + + // Radio group controls + disabled = new FormControl(false, {nonNullable: true}); + readonly = new FormControl(false, {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), 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; } 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 @@ } {{name}} - + @if (removable) { + + } @if (useCustomEditInput) { } + @if (showTrailingIcon) { + trailing + } @@ -529,6 +556,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..ea062e091791 100644 --- a/src/material/chips/chip.ts +++ b/src/material/chips/chip.ts @@ -166,9 +166,6 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck /** ARIA description for the content of the chip. */ @Input('aria-description') ariaDescription: string | null = null; - /** Id of a span that contains this chip's aria description. */ - _ariaDescriptionId = `${this.id}-aria-description`; - /** Whether the chip list is disabled. */ _chipListDisabled: boolean = false; @@ -331,6 +328,7 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck this.disableRipple || this._animationsDisabled || this._isBasicChip || + !this._hasInteractiveActions() || !!this._globalRippleOptions?.disabled ); } @@ -400,6 +398,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. 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,