Skip to content

Commit 23fad80

Browse files
committed
feat(cdk-experimental/toolbar): add toolbar directive and demo
1 parent 8281276 commit 23fad80

File tree

19 files changed

+697
-4
lines changed

19 files changed

+697
-4
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: 39 additions & 4 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?.pattern);
107+
100108
/** The RadioButton UIPatterns of the child CdkRadioButtons. */
101109
protected items = computed(() => this._cdkRadioButtons().map(radio => radio.pattern));
102110

@@ -131,7 +139,9 @@ export class CdkRadioGroup<V> {
131139
value: this._value,
132140
activeItem: signal(undefined),
133141
textDirection: this.textDirection,
134-
toolbar: signal(undefined), // placeholder until Toolbar CDK is added
142+
toolbar: this._toolbarPattern,
143+
focusMode: this._toolbarPattern()?.inputs.focusMode ?? this.focusMode,
144+
skipDisabled: this._toolbarPattern()?.inputs.skipDisabled ?? this.skipDisabled,
135145
});
136146

137147
/** Whether the radio group has received focus yet. */
@@ -148,15 +158,34 @@ export class CdkRadioGroup<V> {
148158
});
149159

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

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

162191
/** A selectable radio button in a CdkRadioGroup. */
@@ -173,7 +202,7 @@ export class CdkRadioGroup<V> {
173202
'[id]': 'pattern.id()',
174203
},
175204
})
176-
export class CdkRadioButton<V> {
205+
export class CdkRadioButton<V> implements OnDestroy {
177206
/** A reference to the radio button element. */
178207
private readonly _elementRef = inject(ElementRef);
179208

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

195224
/** A reference to the radio button element to be focused on navigation. */
196-
protected element = computed(() => this._elementRef.nativeElement);
225+
element = computed(() => this._elementRef.nativeElement);
197226

198227
/** Whether the radio button is disabled. */
199228
disabled = input(false, {transform: booleanAttribute});
@@ -206,4 +235,10 @@ export class CdkRadioButton<V> {
206235
group: this.group,
207236
element: this.element,
208237
});
238+
239+
ngOnDestroy() {
240+
if (this._cdkRadioGroup.toolbar) {
241+
this._cdkRadioGroup.toolbarButtonDeregister(this);
242+
}
243+
}
209244
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
load("//tools:defaults.bzl", "ng_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+
)
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: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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+
activeItem: signal(undefined),
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+
'[attr.aria-disabled]': 'pattern.disabled()',
174+
'[id]': 'pattern.id()',
175+
},
176+
})
177+
export class CdkToolbarWidget implements OnInit, OnDestroy {
178+
/** A reference to the widget element. */
179+
private readonly _elementRef = inject(ElementRef);
180+
181+
/** The parent CdkToolbar. */
182+
private readonly _cdkToolbar = inject(CdkToolbar);
183+
184+
/** A unique identifier for the widget. */
185+
private readonly _generatedId = inject(_IdGenerator).getId('cdk-toolbar-widget-');
186+
187+
/** A unique identifier for the widget. */
188+
protected id = computed(() => this._generatedId);
189+
190+
/** The parent Toolbar UIPattern. */
191+
protected parentToolbar = computed(() => this._cdkToolbar.pattern);
192+
193+
/** A reference to the widget element to be focused on navigation. */
194+
element = computed(() => this._elementRef.nativeElement);
195+
196+
/** Whether the widget is disabled. */
197+
disabled = input(false, {transform: booleanAttribute});
198+
199+
readonly hardDisabled = computed(
200+
() => this.pattern.disabled() && this._cdkToolbar.skipDisabled(),
201+
);
202+
203+
pattern = new ToolbarWidgetPattern({
204+
...this,
205+
id: this.id,
206+
element: this.element,
207+
disabled: computed(() => this._cdkToolbar.disabled() || this.disabled()),
208+
parentToolbar: this.parentToolbar,
209+
});
210+
211+
ngOnInit() {
212+
this._cdkToolbar.register(this);
213+
}
214+
215+
ngOnDestroy() {
216+
this._cdkToolbar.deregister(this);
217+
}
218+
}

0 commit comments

Comments
 (0)