Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/cdk-experimental/config.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ CDK_EXPERIMENTAL_ENTRYPOINTS = [
"scrolling",
"selection",
"tabs",
"toolbar",
"tree",
"table-scroll-container",
"ui-patterns",
Expand Down
1 change: 1 addition & 0 deletions src/cdk-experimental/radio-group/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
43 changes: 39 additions & 4 deletions src/cdk-experimental/radio-group/radio-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -97,6 +99,12 @@ export class CdkRadioGroup<V> {
/** 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));

Expand Down Expand Up @@ -131,7 +139,9 @@ export class CdkRadioGroup<V> {
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. */
Expand All @@ -148,15 +158,34 @@ export class CdkRadioGroup<V> {
});

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<V>) {
if (this.toolbar) {
this.toolbar.deregister(radio);
}
}
}

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

Expand All @@ -193,7 +222,7 @@ export class CdkRadioButton<V> {
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});
Expand All @@ -206,4 +235,10 @@ export class CdkRadioButton<V> {
group: this.group,
element: this.element,
});

ngOnDestroy() {
if (this._cdkRadioGroup.toolbar) {
this._cdkRadioGroup.toolbarButtonDeregister(this);
}
}
}
17 changes: 17 additions & 0 deletions src/cdk-experimental/toolbar/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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",
],
)
9 changes: 9 additions & 0 deletions src/cdk-experimental/toolbar/index.ts
Original file line number Diff line number Diff line change
@@ -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';
9 changes: 9 additions & 0 deletions src/cdk-experimental/toolbar/public-api.ts
Original file line number Diff line number Diff line change
@@ -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';
218 changes: 218 additions & 0 deletions src/cdk-experimental/toolbar/toolbar.ts
Original file line number Diff line number Diff line change
@@ -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<V> {
/** The HTML element associated with the radio button. */
element: Signal<HTMLElement>;
/** Whether the radio button is disabled. */
disabled: Signal<boolean>;

pattern: RadioButtonPattern<V>;
}

interface HasElement {
element: Signal<HTMLElement>;
}

/**
* 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
* <div cdkToolbar>
* <button cdkToolbarWidget>Button</button>
* <div cdkRadioGroup>
* <label cdkRadioButton value="1">Option 1</label>
* <label cdkRadioButton value="2">Option 2</label>
* <label cdkRadioButton value="3">Option 3</label>
* </div>
* </div>
* ```
*/
@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<V> {
/** The CdkTabList nested inside of the container. */
private readonly _cdkWidgets = signal(new Set<CdkRadioButtonInterface<V> | 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<V> = new ToolbarPattern<V>({
...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<V> | CdkToolbarWidget) {
const widgets = this._cdkWidgets();
if (!widgets.has(widget)) {
widgets.add(widget);
this._cdkWidgets.set(new Set(widgets));
}
}

deregister(widget: CdkRadioButtonInterface<V> | 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);
}
}
Loading
Loading