From 23fad80464a923f9b1c9ae256762f10e7b9439a8 Mon Sep 17 00:00:00 2001 From: Rachelle De Man Date: Mon, 11 Aug 2025 18:11:01 +0000 Subject: [PATCH 1/5] feat(cdk-experimental/toolbar): add toolbar directive and demo --- src/cdk-experimental/config.bzl | 1 + src/cdk-experimental/radio-group/BUILD.bazel | 1 + .../radio-group/radio-group.ts | 43 +++- src/cdk-experimental/toolbar/BUILD.bazel | 17 ++ src/cdk-experimental/toolbar/index.ts | 9 + src/cdk-experimental/toolbar/public-api.ts | 9 + src/cdk-experimental/toolbar/toolbar.ts | 218 ++++++++++++++++++ .../cdk-experimental/toolbar/BUILD.bazel | 30 +++ .../cdk-toolbar-configurable-example.html | 120 ++++++++++ .../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 + 19 files changed, 697 insertions(+), 4 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/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 9b87d15c61b3..e01132dd55fc 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,7 +139,9 @@ export class CdkRadioGroup { value: this._value, activeItem: signal(undefined), textDirection: this.textDirection, - toolbar: signal(undefined), // placeholder until Toolbar CDK is added + toolbar: this._toolbarPattern, + focusMode: this._toolbarPattern()?.inputs.focusMode ?? this.focusMode, + skipDisabled: this._toolbarPattern()?.inputs.skipDisabled ?? this.skipDisabled, }); /** Whether the radio group has received focus yet. */ @@ -148,15 +158,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. */ @@ -173,7 +202,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); @@ -193,7 +222,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}); @@ -206,4 +235,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/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..81879e4b6553 --- /dev/null +++ b/src/components-examples/cdk-experimental/toolbar/cdk-toolbar-configurable/cdk-toolbar-configurable-example.html @@ -0,0 +1,120 @@ +
+

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 }} +
  • + } +
+ + + +
+ \ No newline at end of file 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..e3c79368ef32 --- /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); +} \ No newline at end of file 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..23f3adc23c7e --- /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; +} \ No newline at end of file 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..9be25e4eea57 --- /dev/null +++ b/src/dev-app/cdk-experimental-toolbar/cdk-toolbar-demo.html @@ -0,0 +1,8 @@ +
+
+
+

Configurable CDK Toolbar

+ +
+
+
\ No newline at end of file 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..e48ec9c21309 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -65,6 +65,7 @@ export class DevAppLayout { {name: 'CDK Experimental Tabs', route: '/cdk-experimental-tabs'}, {name: 'CDK Experimental Accordion', route: '/cdk-experimental-accordion'}, {name: 'CDK Experimental Tree', route: '/cdk-experimental-tree'}, + {name: 'CDK Experimental Toolbar', route: '/cdk-experimental-toolbar'}, {name: 'CDK Listbox', route: '/cdk-listbox'}, {name: 'CDK Menu', route: '/cdk-menu'}, {name: 'Autocomplete', route: '/autocomplete'}, 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 0b8e7ecc89ecd8c673e6f4631ac9932bfa3f91af Mon Sep 17 00:00:00 2001 From: Rachelle De Man Date: Mon, 11 Aug 2025 19:11:35 +0000 Subject: [PATCH 2/5] fix(cdk-experimental/toolbar): lint correction --- .../cdk-experimental/toolbar/toolbar-common.css | 2 +- src/dev-app/cdk-experimental-toolbar/cdk-toolbar-demo.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components-examples/cdk-experimental/toolbar/toolbar-common.css b/src/components-examples/cdk-experimental/toolbar/toolbar-common.css index e3c79368ef32..7936afc0b28c 100644 --- a/src/components-examples/cdk-experimental/toolbar/toolbar-common.css +++ b/src/components-examples/cdk-experimental/toolbar/toolbar-common.css @@ -130,4 +130,4 @@ border-radius: var(--mat-sys-corner-extra-small); background-color: var(--mat-sys-on-surface); opacity: var(--mat-sys-focus-state-layer-opacity); -} \ No newline at end of file +} diff --git a/src/dev-app/cdk-experimental-toolbar/cdk-toolbar-demo.css b/src/dev-app/cdk-experimental-toolbar/cdk-toolbar-demo.css index 23f3adc23c7e..20307209d88a 100644 --- a/src/dev-app/cdk-experimental-toolbar/cdk-toolbar-demo.css +++ b/src/dev-app/cdk-experimental-toolbar/cdk-toolbar-demo.css @@ -17,4 +17,4 @@ h4 { height: 36px; -} \ No newline at end of file +} From 59860c5bd5d746325ec08f1b30f64894a38ce585 Mon Sep 17 00:00:00 2001 From: Rachelle De Man Date: Mon, 11 Aug 2025 23:33:53 +0000 Subject: [PATCH 3/5] fix(cdk-experimental/toolbar): focus mode removal --- src/cdk-experimental/radio-group/radio-group.ts | 1 + src/cdk-experimental/toolbar/toolbar.ts | 5 +---- .../cdk-toolbar-configurable-example.html | 9 --------- .../cdk-toolbar-configurable-example.ts | 1 - 4 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/cdk-experimental/radio-group/radio-group.ts b/src/cdk-experimental/radio-group/radio-group.ts index e01132dd55fc..e6f67f06a276 100644 --- a/src/cdk-experimental/radio-group/radio-group.ts +++ b/src/cdk-experimental/radio-group/radio-group.ts @@ -163,6 +163,7 @@ export class CdkRadioGroup { } }); + // TODO: Refactor to be handled within list behavior afterRenderEffect(() => { if (this.toolbar) { const radioButtons = this._cdkRadioButtons(); diff --git a/src/cdk-experimental/toolbar/toolbar.ts b/src/cdk-experimental/toolbar/toolbar.ts index ec25ac4d71c9..c31dac747187 100644 --- a/src/cdk-experimental/toolbar/toolbar.ts +++ b/src/cdk-experimental/toolbar/toolbar.ts @@ -97,9 +97,6 @@ export class CdkToolbar { /** 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}); @@ -111,7 +108,7 @@ export class CdkToolbar { ...this, activeItem: signal(undefined), textDirection: this.textDirection, - focusMode: this.focusMode, + focusMode: signal('roving'), }); /** Whether the toolbar has received focus yet. */ 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 index 81879e4b6553..d28572709e9c 100644 --- 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 @@ -11,14 +11,6 @@

Toolbar Controls

Horizontal - - - Focus strategy - - Roving Tabindex - Active Descendant - -

Radio Group Controls

@@ -50,7 +42,6 @@

Button

Date: Tue, 12 Aug 2025 00:32:02 +0000 Subject: [PATCH 4/5] fix(cdk-experimental/toolbar): skip disabled default false --- src/cdk-experimental/toolbar/toolbar.ts | 2 +- .../cdk-toolbar-configurable-example.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cdk-experimental/toolbar/toolbar.ts b/src/cdk-experimental/toolbar/toolbar.ts index c31dac747187..5c9854b1574e 100644 --- a/src/cdk-experimental/toolbar/toolbar.ts +++ b/src/cdk-experimental/toolbar/toolbar.ts @@ -95,7 +95,7 @@ export class CdkToolbar { orientation = input<'vertical' | 'horizontal'>('horizontal'); /** Whether disabled items in the group should be skipped when navigating. */ - skipDisabled = input(true, {transform: booleanAttribute}); + skipDisabled = input(false, {transform: booleanAttribute}); /** Whether the toolbar is disabled. */ disabled = input(false, {transform: booleanAttribute}); 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 b6245b0231a3..47579da92457 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,7 +26,7 @@ import {CdkToolbar, CdkToolbarWidget} from '@angular/cdk-experimental/toolbar'; ], }) export class CdkToolbarConfigurableExample { - skipDisabled = new FormControl(true, {nonNullable: true}); + skipDisabled = new FormControl(false, {nonNullable: true}); wrap = new FormControl(true, {nonNullable: true}); toolbarDisabled = new FormControl(false, {nonNullable: true}); orientation: 'vertical' | 'horizontal' = 'horizontal'; From c70b0343324878ceaf065af86c6592085da516d4 Mon Sep 17 00:00:00 2001 From: Rachelle De Man Date: Tue, 12 Aug 2025 17:48:14 +0000 Subject: [PATCH 5/5] fix(cdk-experimental/toolbar): uregister wording --- src/cdk-experimental/radio-group/radio-group.ts | 8 ++++---- src/cdk-experimental/toolbar/toolbar.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cdk-experimental/radio-group/radio-group.ts b/src/cdk-experimental/radio-group/radio-group.ts index e6f67f06a276..ba9586a83a7f 100644 --- a/src/cdk-experimental/radio-group/radio-group.ts +++ b/src/cdk-experimental/radio-group/radio-group.ts @@ -170,7 +170,7 @@ export class CdkRadioGroup { // 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)); + radioButtons.forEach(radio => this.toolbar!.unregister(radio)); } else { radioButtons.forEach(radio => this.toolbar!.register(radio)); } @@ -182,9 +182,9 @@ export class CdkRadioGroup { this._hasFocused.set(true); } - toolbarButtonDeregister(radio: CdkRadioButton) { + toolbarButtonUnregister(radio: CdkRadioButton) { if (this.toolbar) { - this.toolbar.deregister(radio); + this.toolbar.unregister(radio); } } } @@ -239,7 +239,7 @@ export class CdkRadioButton implements OnDestroy { ngOnDestroy() { if (this._cdkRadioGroup.toolbar) { - this._cdkRadioGroup.toolbarButtonDeregister(this); + this._cdkRadioGroup.toolbarButtonUnregister(this); } } } diff --git a/src/cdk-experimental/toolbar/toolbar.ts b/src/cdk-experimental/toolbar/toolbar.ts index 5c9854b1574e..4e7433bacbf0 100644 --- a/src/cdk-experimental/toolbar/toolbar.ts +++ b/src/cdk-experimental/toolbar/toolbar.ts @@ -143,7 +143,7 @@ export class CdkToolbar { } } - deregister(widget: CdkRadioButtonInterface | CdkToolbarWidget) { + unregister(widget: CdkRadioButtonInterface | CdkToolbarWidget) { const widgets = this._cdkWidgets(); if (widgets.delete(widget)) { this._cdkWidgets.set(new Set(widgets)); @@ -210,6 +210,6 @@ export class CdkToolbarWidget implements OnInit, OnDestroy { } ngOnDestroy() { - this._cdkToolbar.deregister(this); + this._cdkToolbar.unregister(this); } }