Skip to content

feat(cdk-experimental/ui-patterns): toolbar and toolbar widget #31670

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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 .ng-dev/commit-message.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/cdk-experimental/radio-group/radio-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export class CdkRadioGroup<V> {
value: this._value,
activeItem: signal(undefined),
textDirection: this.textDirection,
toolbar: signal(undefined), // placeholder until Toolbar CDK is added
});

/** Whether the radio group has received focus yet. */
Expand Down
1 change: 1 addition & 0 deletions src/cdk-experimental/ui-patterns/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
)
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,14 @@ export class ListFocus<T extends ListFocusItem> {
prevActiveItem = signal<T | undefined>(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<T>) {}

Expand Down
1 change: 1 addition & 0 deletions src/cdk-experimental/ui-patterns/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
23 changes: 21 additions & 2 deletions src/cdk-experimental/ui-patterns/radio-group/radio-button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,30 @@ import {computed} from '@angular/core';
import {SignalLike} from '../behaviors/signal-like/signal-like';
import {List, ListItem} from '../behaviors/list/list';

/**
* Represents the properties exposed by a toolbar widget that need to be accessed by a radio group.
* This exists to avoid circular dependency errors between the toolbar and radio button.
*/
type ToolbarWidgetLike = {
id: SignalLike<string>;
index: SignalLike<number>;
element: SignalLike<HTMLElement>;
disabled: SignalLike<boolean>;
searchTerm: SignalLike<any>;
value: SignalLike<any>;
};

/**
* 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.
*/
interface RadioGroupLike<V> {
/** The list behavior for the radio group. */
listBehavior: List<RadioButtonPattern<V>, V>;
listBehavior: List<RadioButtonPattern<V> | ToolbarWidgetLike, V>;
/** Whether the list is readonly */
readonly: SignalLike<boolean>;
/** Whether the radio group is disabled. */
disabled: SignalLike<boolean>;
}

/** Represents the required inputs for a radio button in a radio group. */
Expand All @@ -34,7 +51,9 @@ export class RadioButtonPattern<V> {
value: SignalLike<V>;

/** The position of the radio button within the group. */
index = computed(() => this.group()?.listBehavior.inputs.items().indexOf(this) ?? -1);
index: SignalLike<number> = 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);
Expand Down
49 changes: 43 additions & 6 deletions src/cdk-experimental/ui-patterns/radio-group/radio-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,37 @@ export type RadioGroupInputs<V> = Omit<

/** Whether the radio group is readonly. */
readonly: SignalLike<boolean>;
/** Parent toolbar of radio group */
toolbar: SignalLike<ToolbarLike<V> | undefined>;
};

/**
* Represents the properties exposed by a toolbar widget that need to be accessed by a radio group.
* This exists to avoid circular dependency errors between the toolbar and radio button.
*/
type ToolbarWidgetLike = {
id: SignalLike<string>;
index: SignalLike<number>;
element: SignalLike<HTMLElement>;
disabled: SignalLike<boolean>;
searchTerm: SignalLike<any>;
value: SignalLike<any>;
};

/**
* Represents the properties exposed by a toolbar that need to be accessed by a radio group.
* This exists to avoid circular dependency errors between the toolbar and radio button.
*/
export interface ToolbarLike<V> {
listBehavior: List<RadioButtonPattern<V> | ToolbarWidgetLike, V>;
orientation: SignalLike<'vertical' | 'horizontal'>;
disabled: SignalLike<boolean>;
}

/** Controls the state of a radio group. */
export class RadioGroupPattern<V> {
/** The list behavior for the radio group. */
readonly listBehavior: List<RadioButtonPattern<V>, V>;
readonly listBehavior: List<RadioButtonPattern<V> | ToolbarWidgetLike, V>;

/** Whether the radio group is vertically or horizontally oriented. */
orientation: SignalLike<'vertical' | 'horizontal'>;
Expand All @@ -41,8 +66,8 @@ export class RadioGroupPattern<V> {
/** 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. */
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());
Expand All @@ -67,6 +92,11 @@ export class RadioGroupPattern<V> {
keydown = computed(() => {
const manager = new KeyboardEventManager();

// When 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
Expand All @@ -91,6 +121,11 @@ export class RadioGroupPattern<V> {
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)!));
Expand All @@ -101,13 +136,15 @@ export class RadioGroupPattern<V> {
});

constructor(readonly inputs: RadioGroupInputs<V>) {
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.
});
}
Expand Down
48 changes: 47 additions & 1 deletion src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import {signal, WritableSignal} from '@angular/core';
import {RadioGroupInputs, RadioGroupPattern} from './radio-group';
import {RadioGroupInputs, RadioGroupPattern, ToolbarLike} from './radio-group';
import {RadioButtonPattern} from './radio-button';
import {createKeyboardEvent} from '@angular/cdk/testing/private';
import {ModifierKeys} from '@angular/cdk/testing';
Expand Down Expand Up @@ -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),
});
}

Expand Down Expand Up @@ -303,4 +304,49 @@ describe('RadioGroup Pattern', () => {
expect(violations.length).toBe(1);
});
});

describe('toolbar', () => {
let radioGroup: TestRadioGroup;
let radioButtons: TestRadio[];
let toolbar: ToolbarLike<string>;

beforeEach(() => {
const patterns = getDefaultPatterns();
radioGroup = patterns.radioGroup;
radioButtons = patterns.radioButtons;
toolbar = {
listBehavior: radioGroup.listBehavior,
orientation: radioGroup.orientation,
disabled: radioGroup.disabled,
};
radioGroup.inputs.toolbar = signal(toolbar);
});

it('should ignore keyboard navigation when within a toolbar', () => {
const initialActive = radioGroup.inputs.activeItem();
radioGroup.onKeydown(down());
expect(radioGroup.inputs.activeItem()).toBe(initialActive);
});

it('should ignore keyboard selection when within a toolbar', () => {
expect(radioGroup.inputs.value()).toEqual([]);
radioGroup.onKeydown(space());
expect(radioGroup.inputs.value()).toEqual([]);
radioGroup.onKeydown(enter());
expect(radioGroup.inputs.value()).toEqual([]);
});

it('should ignore pointer events when within a toolbar', () => {
const initialActive = radioGroup.inputs.activeItem();
expect(radioGroup.inputs.value()).toEqual([]);

const clickEvent = {
target: radioButtons[1].element(),
} as unknown as PointerEvent;
radioGroup.onPointerdown(clickEvent);

expect(radioGroup.inputs.activeItem()).toBe(initialActive);
expect(radioGroup.inputs.value()).toEqual([]);
});
});
});
17 changes: 17 additions & 0 deletions src/cdk-experimental/ui-patterns/toolbar/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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",
"//src/cdk-experimental/ui-patterns/behaviors/signal-like",
"//src/cdk-experimental/ui-patterns/radio-group",
],
)
Loading
Loading