Skip to content

Commit a2081c7

Browse files
committed
feat(cdk-experimental/toolbar): create toolbar, toolbar widget and UI pattern
1 parent 94ab09a commit a2081c7

File tree

26 files changed

+1001
-6
lines changed

26 files changed

+1001
-6
lines changed

src/cdk-experimental/config.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ CDK_EXPERIMENTAL_ENTRYPOINTS = [
1010
"scrolling",
1111
"selection",
1212
"tabs",
13+
"toolbar",
1314
"tree",
1415
"table-scroll-container",
1516
"ui-patterns",

src/cdk-experimental/radio-group/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ ng_project(
1010
),
1111
deps = [
1212
"//:node_modules/@angular/core",
13+
"//src/cdk-experimental/toolbar",
1314
"//src/cdk-experimental/ui-patterns",
1415
"//src/cdk/a11y",
1516
"//src/cdk/bidi",

src/cdk-experimental/radio-group/radio-group.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@ import {
1919
model,
2020
signal,
2121
WritableSignal,
22+
OnDestroy,
2223
} from '@angular/core';
2324
import {RadioButtonPattern, RadioGroupPattern} from '../ui-patterns';
2425
import {Directionality} from '@angular/cdk/bidi';
2526
import {_IdGenerator} from '@angular/cdk/a11y';
27+
import {CdkToolbar} from '../toolbar';
2628

2729
// TODO: Move mapSignal to it's own file so it can be reused across components.
2830

@@ -97,6 +99,12 @@ export class CdkRadioGroup<V> {
9799
/** A signal wrapper for directionality. */
98100
protected textDirection = inject(Directionality).valueSignal;
99101

102+
/** A signal wrapper for toolbar. */
103+
toolbar = inject(CdkToolbar, {optional: true});
104+
105+
/** Toolbar pattern if applicable */
106+
private readonly _toolbarPattern = computed(() => (this.toolbar ? this.toolbar.pattern : null));
107+
100108
/** The RadioButton UIPatterns of the child CdkRadioButtons. */
101109
protected items = computed(() => this._cdkRadioButtons().map(radio => radio.pattern));
102110

@@ -131,6 +139,8 @@ export class CdkRadioGroup<V> {
131139
value: this._value,
132140
activeItem: signal(undefined),
133141
textDirection: this.textDirection,
142+
toolbar: this._toolbarPattern,
143+
focusMode: this._toolbarPattern() ? this._toolbarPattern()!!.inputs.focusMode : this.focusMode,
134144
});
135145

136146
/** Whether the radio group has received focus yet. */
@@ -147,15 +157,34 @@ export class CdkRadioGroup<V> {
147157
});
148158

149159
afterRenderEffect(() => {
150-
if (!this._hasFocused()) {
160+
if (!this._hasFocused() && !this.toolbar) {
151161
this.pattern.setDefaultState();
152162
}
153163
});
164+
165+
afterRenderEffect(() => {
166+
if (this.toolbar) {
167+
const radioButtons = this._cdkRadioButtons();
168+
// If the group is disabled and the toolbar is set to skip disabled items,
169+
// the radio buttons should not be part of the toolbar's navigation.
170+
if (this.disabled() && this.toolbar.skipDisabled()) {
171+
radioButtons.forEach(radio => this.toolbar!.deregister(radio));
172+
} else {
173+
radioButtons.forEach(radio => this.toolbar!.register(radio));
174+
}
175+
}
176+
});
154177
}
155178

156179
onFocus() {
157180
this._hasFocused.set(true);
158181
}
182+
183+
toolbarButtonDeregister(radio: CdkRadioButton<V>) {
184+
if (this.toolbar) {
185+
this.toolbar.deregister(radio);
186+
}
187+
}
159188
}
160189

161190
/** A selectable radio button in a CdkRadioGroup. */
@@ -172,7 +201,7 @@ export class CdkRadioGroup<V> {
172201
'[id]': 'pattern.id()',
173202
},
174203
})
175-
export class CdkRadioButton<V> {
204+
export class CdkRadioButton<V> implements OnDestroy {
176205
/** A reference to the radio button element. */
177206
private readonly _elementRef = inject(ElementRef);
178207

@@ -192,7 +221,7 @@ export class CdkRadioButton<V> {
192221
protected group = computed(() => this._cdkRadioGroup.pattern);
193222

194223
/** A reference to the radio button element to be focused on navigation. */
195-
protected element = computed(() => this._elementRef.nativeElement);
224+
element = computed(() => this._elementRef.nativeElement);
196225

197226
/** Whether the radio button is disabled. */
198227
disabled = input(false, {transform: booleanAttribute});
@@ -205,4 +234,10 @@ export class CdkRadioButton<V> {
205234
group: this.group,
206235
element: this.element,
207236
});
237+
238+
ngOnDestroy() {
239+
if (this._cdkRadioGroup.toolbar) {
240+
this._cdkRadioGroup.toolbarButtonDeregister(this);
241+
}
242+
}
208243
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ng_project(
6+
name = "toolbar",
7+
srcs = glob(
8+
["**/*.ts"],
9+
exclude = ["**/*.spec.ts"],
10+
),
11+
deps = [
12+
"//:node_modules/@angular/core",
13+
"//src/cdk-experimental/ui-patterns",
14+
"//src/cdk/a11y",
15+
"//src/cdk/bidi",
16+
],
17+
)
18+
19+
ts_project(
20+
name = "unit_test_sources",
21+
testonly = True,
22+
srcs = glob(
23+
["**/*.spec.ts"],
24+
),
25+
deps = [
26+
":toolbar",
27+
"//src/cdk/testing/private",
28+
],
29+
)
30+
31+
ng_web_test_suite(
32+
name = "unit_tests",
33+
deps = [":unit_test_sources"],
34+
)

src/cdk-experimental/toolbar/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
export * from './public-api';
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
export {CdkToolbar, CdkToolbarWidget} from './toolbar';
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {
10+
afterRenderEffect,
11+
Directive,
12+
ElementRef,
13+
inject,
14+
computed,
15+
input,
16+
booleanAttribute,
17+
signal,
18+
Signal,
19+
OnInit,
20+
OnDestroy,
21+
} from '@angular/core';
22+
import {ToolbarPattern, RadioButtonPattern, ToolbarWidgetPattern} from '../ui-patterns';
23+
import {Directionality} from '@angular/cdk/bidi';
24+
import {_IdGenerator} from '@angular/cdk/a11y';
25+
26+
/** Interface for a radio button that can be used with a toolbar. Based on radio-button in ui-patterns */
27+
interface CdkRadioButtonInterface<V> {
28+
/** The HTML element associated with the radio button. */
29+
element: Signal<HTMLElement>;
30+
/** Whether the radio button is disabled. */
31+
disabled: Signal<boolean>;
32+
33+
pattern: RadioButtonPattern<V>;
34+
}
35+
36+
interface HasElement {
37+
element: Signal<HTMLElement>;
38+
}
39+
40+
/**
41+
* Sort directives by their document order.
42+
*/
43+
function sortDirectives(a: HasElement, b: HasElement) {
44+
return (a.element().compareDocumentPosition(b.element()) & Node.DOCUMENT_POSITION_PRECEDING) > 0
45+
? 1
46+
: -1;
47+
}
48+
49+
/**
50+
* A toolbar widget container.
51+
*
52+
* Widgets such as radio groups or buttons are nested within a toolbar to allow for a single
53+
* place of reference for focus and navigation. The CdkToolbar is meant to be used in conjunction
54+
* with CdkToolbarWidget and CdkRadioGroup as follows:
55+
*
56+
* ```html
57+
* <div cdkToolbar>
58+
* <button cdkToolbarWidget>Button</button>
59+
* <div cdkRadioGroup>
60+
* <label cdkRadioButton value="1">Option 1</label>
61+
* <label cdkRadioButton value="2">Option 2</label>
62+
* <label cdkRadioButton value="3">Option 3</label>
63+
* </div>
64+
* </div>
65+
* ```
66+
*/
67+
@Directive({
68+
selector: '[cdkToolbar]',
69+
exportAs: 'cdkToolbar',
70+
host: {
71+
'role': 'toolbar',
72+
'class': 'cdk-toolbar',
73+
'[attr.tabindex]': 'pattern.tabindex()',
74+
'[attr.aria-disabled]': 'pattern.disabled()',
75+
'[attr.aria-orientation]': 'pattern.orientation()',
76+
'[attr.aria-activedescendant]': 'pattern.activedescendant()',
77+
'(keydown)': 'pattern.onKeydown($event)',
78+
'(pointerdown)': 'pattern.onPointerdown($event)',
79+
'(focusin)': 'onFocus()',
80+
},
81+
})
82+
export class CdkToolbar<V> {
83+
/** The CdkTabList nested inside of the container. */
84+
private readonly _cdkWidgets = signal(new Set<CdkRadioButtonInterface<V> | CdkToolbarWidget>());
85+
86+
/** A signal wrapper for directionality. */
87+
textDirection = inject(Directionality).valueSignal;
88+
89+
/** Sorted UIPatterns of the child widgets */
90+
items = computed(() =>
91+
[...this._cdkWidgets()].sort(sortDirectives).map(widget => widget.pattern),
92+
);
93+
94+
/** Whether the toolbar is vertically or horizontally oriented. */
95+
orientation = input<'vertical' | 'horizontal'>('horizontal');
96+
97+
/** Whether disabled items in the group should be skipped when navigating. */
98+
skipDisabled = input(true, {transform: booleanAttribute});
99+
100+
/** The focus strategy used by the toolbar. */
101+
focusMode = input<'roving' | 'activedescendant'>('roving');
102+
103+
/** Whether the toolbar is disabled. */
104+
disabled = input(false, {transform: booleanAttribute});
105+
106+
/** Whether focus should wrap when navigating. */
107+
readonly wrap = input(true, {transform: booleanAttribute});
108+
109+
/** The toolbar UIPattern. */
110+
pattern: ToolbarPattern<V> = new ToolbarPattern<V>({
111+
...this,
112+
activeIndex: signal(0),
113+
textDirection: this.textDirection,
114+
focusMode: this.focusMode,
115+
});
116+
117+
/** Whether the toolbar has received focus yet. */
118+
private _hasFocused = signal(false);
119+
120+
onFocus() {
121+
this._hasFocused.set(true);
122+
}
123+
124+
constructor() {
125+
afterRenderEffect(() => {
126+
if (!this._hasFocused()) {
127+
this.pattern.setDefaultState();
128+
}
129+
});
130+
131+
afterRenderEffect(() => {
132+
if (typeof ngDevMode === 'undefined' || ngDevMode) {
133+
const violations = this.pattern.validate();
134+
for (const violation of violations) {
135+
console.error(violation);
136+
}
137+
}
138+
});
139+
}
140+
141+
register(widget: CdkRadioButtonInterface<V> | CdkToolbarWidget) {
142+
const widgets = this._cdkWidgets();
143+
if (!widgets.has(widget)) {
144+
widgets.add(widget);
145+
this._cdkWidgets.set(new Set(widgets));
146+
}
147+
}
148+
149+
deregister(widget: CdkRadioButtonInterface<V> | CdkToolbarWidget) {
150+
const widgets = this._cdkWidgets();
151+
if (widgets.delete(widget)) {
152+
this._cdkWidgets.set(new Set(widgets));
153+
}
154+
}
155+
}
156+
157+
/**
158+
* A widget within a toolbar.
159+
*
160+
* A widget is anything that is within a toolbar. It should be applied to any native HTML element
161+
* that has the purpose of acting as a widget navigatable within a toolbar.
162+
*/
163+
@Directive({
164+
selector: '[cdkToolbarWidget]',
165+
exportAs: 'cdkToolbarWidget',
166+
host: {
167+
'role': 'button',
168+
'class': 'cdk-toolbar-widget',
169+
'[class.cdk-active]': 'pattern.active()',
170+
'[attr.tabindex]': 'pattern.tabindex()',
171+
'[attr.inert]': 'hardDisabled() ? true : null',
172+
'[attr.disabled]': 'hardDisabled() ? true : null',
173+
'[id]': 'pattern.id()',
174+
},
175+
})
176+
export class CdkToolbarWidget implements OnInit, OnDestroy {
177+
/** A reference to the widget element. */
178+
private readonly _elementRef = inject(ElementRef);
179+
180+
/** The parent CdkToolbar. */
181+
private readonly _cdkToolbar = inject(CdkToolbar);
182+
183+
/** A unique identifier for the widget. */
184+
private readonly _generatedId = inject(_IdGenerator).getId('cdk-toolbar-widget-');
185+
186+
/** A unique identifier for the widget. */
187+
protected id = computed(() => this._generatedId);
188+
189+
/** The parent Toolbar UIPattern. */
190+
protected parentToolbar = computed(() => this._cdkToolbar.pattern);
191+
192+
/** A reference to the widget element to be focused on navigation. */
193+
element = computed(() => this._elementRef.nativeElement);
194+
195+
/** Whether the widget is disabled. */
196+
disabled = input(false, {transform: booleanAttribute});
197+
198+
readonly hardDisabled = computed(() => this.pattern.disabled() && this.pattern.tabindex() < 0);
199+
200+
pattern = new ToolbarWidgetPattern({
201+
...this,
202+
id: this.id,
203+
element: this.element,
204+
disabled: computed(() => this._cdkToolbar.disabled() || this.disabled() || false),
205+
parentToolbar: this.parentToolbar,
206+
});
207+
208+
ngOnInit() {
209+
this._cdkToolbar.register(this);
210+
}
211+
212+
ngOnDestroy() {
213+
this._cdkToolbar.deregister(this);
214+
}
215+
}

src/cdk-experimental/ui-patterns/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ ts_project(
1515
"//src/cdk-experimental/ui-patterns/listbox",
1616
"//src/cdk-experimental/ui-patterns/radio-group",
1717
"//src/cdk-experimental/ui-patterns/tabs",
18+
"//src/cdk-experimental/ui-patterns/toolbar",
1819
"//src/cdk-experimental/ui-patterns/tree",
1920
],
2021
)

src/cdk-experimental/ui-patterns/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ export * from './radio-group/radio-button';
1313
export * from './behaviors/signal-like/signal-like';
1414
export * from './tabs/tabs';
1515
export * from './accordion/accordion';
16+
export * from './toolbar/toolbar';

0 commit comments

Comments
 (0)