Skip to content

feat(cdk-experimental/ui-patterns): tree #31308

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 2 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 src/cdk-experimental/ui-patterns/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ ts_project(
"//src/cdk-experimental/ui-patterns/listbox",
"//src/cdk-experimental/ui-patterns/radio",
"//src/cdk-experimental/ui-patterns/tabs",
"//src/cdk-experimental/ui-patterns/tree",
],
)
6 changes: 2 additions & 4 deletions src/cdk-experimental/ui-patterns/accordion/accordion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {SignalLike} from '../behaviors/signal-like/signal-like';
export type AccordionGroupInputs = Omit<
ListNavigationInputs<AccordionTriggerPattern> &
ListFocusInputs<AccordionTriggerPattern> &
ListExpansionInputs<AccordionTriggerPattern>,
Omit<ListExpansionInputs, 'items'>,
'focusMode'
>;

Expand All @@ -43,7 +43,7 @@ export class AccordionGroupPattern {
focusManager: ListFocus<AccordionTriggerPattern>;

/** Controls expansion for the group. */
expansionManager: ListExpansion<AccordionTriggerPattern>;
expansionManager: ListExpansion;

constructor(readonly inputs: AccordionGroupInputs) {
this.wrap = inputs.wrap;
Expand All @@ -66,8 +66,6 @@ export class AccordionGroupPattern {
});
this.expansionManager = new ListExpansion({
...inputs,
focusMode,
focusManager: this.focusManager,
});
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,28 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {Signal, WritableSignal, signal} from '@angular/core';
import {WritableSignal, signal} from '@angular/core';
import {ListExpansion, ListExpansionInputs, ExpansionItem} from './expansion';
import {ListFocus, ListFocusInputs, ListFocusItem} from '../list-focus/list-focus';
import {getListFocus as getListFocusManager} from '../list-focus/list-focus.spec';

type TestItem = ListFocusItem &
ExpansionItem & {
id: WritableSignal<string>;
disabled: WritableSignal<boolean>;
element: WritableSignal<HTMLElement>;
expandable: WritableSignal<boolean>;
expansionId: WritableSignal<string>;
};

type TestInputs = Partial<Omit<ListExpansionInputs<TestItem>, 'items' | 'focusManager'>> &
Partial<
Pick<ListFocusInputs<TestItem>, 'focusMode' | 'disabled' | 'activeIndex' | 'skipDisabled'>
> & {
numItems?: number;
initialExpandedIds?: string[];
};

type TestItem = ExpansionItem & {
id: WritableSignal<string>;
disabled: WritableSignal<boolean>;
expandable: WritableSignal<boolean>;
expansionId: WritableSignal<string>;
};

type TestInputs = Partial<Omit<ListExpansionInputs, 'items'>> & {
numItems?: number;
initialExpandedIds?: string[];
expansionDisabled?: boolean;
};

function createItems(length: number): WritableSignal<TestItem[]> {
return signal(
Array.from({length}).map((_, i) => {
const itemId = `item-${i}`;
return {
id: signal(itemId),
element: signal(document.createElement('div') as HTMLElement),
disabled: signal(false),
expandable: signal(true),
expansionId: signal(itemId),
Expand All @@ -44,39 +37,24 @@ function createItems(length: number): WritableSignal<TestItem[]> {
}

function getExpansion(inputs: TestInputs = {}): {
expansion: ListExpansion<TestItem>;
expansion: ListExpansion;
items: TestItem[];
focusManager: ListFocus<TestItem>;
} {
const numItems = inputs.numItems ?? 3;
const items = createItems(numItems);

const listFocusManagerInputs: Partial<ListFocusInputs<TestItem>> & {items: Signal<TestItem[]>} = {
items: items,
activeIndex: inputs.activeIndex ?? signal(0),
disabled: inputs.disabled ?? signal(false),
skipDisabled: inputs.skipDisabled ?? signal(true),
focusMode: inputs.focusMode ?? signal('roving'),
};

const focusManager = getListFocusManager(listFocusManagerInputs as any) as ListFocus<TestItem>;

const expansion = new ListExpansion<TestItem>({
const expansion = new ListExpansion({
items: items,
activeIndex: focusManager.inputs.activeIndex,
disabled: focusManager.inputs.disabled,
skipDisabled: focusManager.inputs.skipDisabled,
focusMode: focusManager.inputs.focusMode,
multiExpandable: inputs.multiExpandable ?? signal(false),
expandedIds: signal([]),
focusManager,
disabled: signal(inputs.expansionDisabled ?? false),
multiExpandable: signal(inputs.multiExpandable?.() ?? false),
expandedIds: signal<string[]>([]),
});

if (inputs.initialExpandedIds) {
expansion.expandedIds.set(inputs.initialExpandedIds);
}

return {expansion, items: items(), focusManager};
return {expansion, items: items()};
}

describe('Expansion', () => {
Expand Down Expand Up @@ -112,8 +90,8 @@ describe('Expansion', () => {
expect(expansion.expandedIds()).toEqual([]);
});

it('should not open an item if it is not focusable (disabled and skipDisabled is true)', () => {
const {expansion, items} = getExpansion({skipDisabled: signal(true)});
it('should not open an item if it is disabled', () => {
const {expansion, items} = getExpansion();
items[1].disabled.set(true);
expansion.open(items[1]);
expect(expansion.expandedIds()).toEqual([]);
Expand All @@ -134,11 +112,8 @@ describe('Expansion', () => {
expect(expansion.expandedIds()).toEqual(['item-0']);
});

it('should not close an item if it is not focusable (disabled and skipDisabled is true)', () => {
const {expansion, items} = getExpansion({
initialExpandedIds: ['item-0'],
skipDisabled: signal(true),
});
it('should not close an item if it is disabled', () => {
const {expansion, items} = getExpansion({initialExpandedIds: ['item-0']});
items[0].disabled.set(true);
expansion.close(items[0]);
expect(expansion.expandedIds()).toEqual(['item-0']);
Expand Down Expand Up @@ -181,7 +156,7 @@ describe('Expansion', () => {
expect(expansion.expandedIds()).toEqual(['item-0', 'item-2']);
});

it('should not expand items that are not focusable (disabled and skipDisabled is true)', () => {
it('should not expand items that are disabled', () => {
const {expansion, items} = getExpansion({
numItems: 3,
multiExpandable: signal(true),
Expand Down Expand Up @@ -222,9 +197,8 @@ describe('Expansion', () => {
expect(expansion.expandedIds()).toEqual(['item-1']);
});

it('should not close items that are not focusable (disabled and skipDisabled is true)', () => {
it('should not close items that are disabled', () => {
const {expansion, items} = getExpansion({
skipDisabled: signal(true),
multiExpandable: signal(true),
initialExpandedIds: ['item-0', 'item-1', 'item-2'],
});
Expand All @@ -235,22 +209,21 @@ describe('Expansion', () => {
});

describe('#isExpandable', () => {
it('should return true if an item is focusable and expandable is true', () => {
it('should return true if an item is not disabled and expandable is true', () => {
const {expansion, items} = getExpansion();
items[0].expandable.set(true);
items[0].disabled.set(false);
expect(expansion.isExpandable(items[0])).toBeTrue();
});

it('should return false if an item is disabled and skipDisabled is false', () => {
const {expansion, items} = getExpansion({skipDisabled: signal(false)});
it('should return false if an item is disabled', () => {
const {expansion, items} = getExpansion();
items[0].disabled.set(true);
expect(expansion.isExpandable(items[0])).toBeFalse();
});

it('should return false if an item is disabled and skipDisabled is true', () => {
const {expansion, items} = getExpansion({skipDisabled: signal(true)});
items[0].disabled.set(true);
it('should return false if the expansion behavior is disabled', () => {
const {expansion, items} = getExpansion({expansionDisabled: true});
expect(expansion.isExpandable(items[0])).toBeFalse();
});

Expand Down
47 changes: 24 additions & 23 deletions src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@
* 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 {computed, signal} from '@angular/core';
import {computed} from '@angular/core';
import {SignalLike, WritableSignalLike} from '../signal-like/signal-like';
import {ListFocus, ListFocusInputs, ListFocusItem} from '../list-focus/list-focus';

/** Represents an item that can be expanded or collapsed. */
export interface ExpansionItem extends ListFocusItem {
export interface ExpansionItem {
/** Whether the item is expandable. */
expandable: SignalLike<boolean>;

/** Used to uniquely identify an expansion item. */
expansionId: SignalLike<string>;

/** Whether the expansion is disabled. */
disabled: SignalLike<boolean>;
}

export interface ExpansionControl extends ExpansionItem {}
Expand All @@ -30,10 +32,9 @@ export class ExpansionControl {
/** Whether this item can be expanded. */
readonly isExpandable = computed(() => this.inputs.expansionManager.isExpandable(this));

constructor(readonly inputs: ExpansionItem & {expansionManager: ListExpansion<ExpansionItem>}) {
constructor(readonly inputs: ExpansionItem & {expansionManager: ListExpansion}) {
this.expansionId = inputs.expansionId;
this.expandable = inputs.expandable;
this.element = inputs.element;
this.disabled = inputs.disabled;
}

Expand All @@ -54,28 +55,31 @@ export class ExpansionControl {
}

/** Represents the required inputs for an expansion behavior. */
export interface ListExpansionInputs<T extends ExpansionItem> extends ListFocusInputs<T> {
export interface ListExpansionInputs {
/** Whether multiple items can be expanded at once. */
multiExpandable: SignalLike<boolean>;

/** An array of ids of the currently expanded items. */
expandedIds: WritableSignalLike<string[]>;

/** An array of expansion items. */
items: SignalLike<ExpansionItem[]>;

/** Whether all expansions are disabled. */
disabled: SignalLike<boolean>;
}

/** Manages the expansion state of a list of items. */
export class ListExpansion<T extends ExpansionItem> {
export class ListExpansion {
/** A signal holding an array of ids of the currently expanded items. */
expandedIds: WritableSignalLike<string[]>;

/** The currently active (focused) item in the list. */
activeItem = computed(() => this.inputs.focusManager.activeItem());

constructor(readonly inputs: ListExpansionInputs<T> & {focusManager: ListFocus<T>}) {
this.expandedIds = inputs.expandedIds ?? signal([]);
constructor(readonly inputs: ListExpansionInputs) {
this.expandedIds = inputs.expandedIds;
}

/** Opens the specified item, or the currently active item if none is specified. */
open(item: T = this.activeItem()) {
/** Opens the specified item. */
open(item: ExpansionItem) {
if (!this.isExpandable(item)) return;
if (this.isExpanded(item)) return;
if (!this.inputs.multiExpandable()) {
Expand All @@ -84,18 +88,15 @@ export class ListExpansion<T extends ExpansionItem> {
this.expandedIds.update(ids => ids.concat(item.expansionId()));
}

/** Closes the specified item, or the currently active item if none is specified. */
close(item: T = this.activeItem()) {
/** Closes the specified item. */
close(item: ExpansionItem) {
if (this.isExpandable(item)) {
this.expandedIds.update(ids => ids.filter(id => id !== item.expansionId()));
}
}

/**
* Toggles the expansion state of the specified item,
* or the currently active item if none is specified.
*/
toggle(item: T = this.activeItem()) {
/** Toggles the expansion state of the specified item. */
toggle(item: ExpansionItem) {
this.expandedIds().includes(item.expansionId()) ? this.close(item) : this.open(item);
}

Expand All @@ -116,12 +117,12 @@ export class ListExpansion<T extends ExpansionItem> {
}

/** Checks whether the specified item is expandable / collapsible. */
isExpandable(item: T) {
isExpandable(item: ExpansionItem) {
return !this.inputs.disabled() && !item.disabled() && item.expandable();
}

/** Checks whether the specified item is currently expanded. */
isExpanded(item: T): boolean {
isExpanded(item: ExpansionItem): boolean {
return this.expandedIds().includes(item.expansionId());
}
}
9 changes: 3 additions & 6 deletions src/cdk-experimental/ui-patterns/tabs/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,7 @@ interface SelectOptions {
export type TabListInputs = ListNavigationInputs<TabPattern> &
Omit<ListSelectionInputs<TabPattern, string>, 'multi'> &
ListFocusInputs<TabPattern> &
Omit<ListExpansionInputs<TabPattern>, 'multiExpandable' | 'expandedIds'> & {
disabled: SignalLike<boolean>;
};
Omit<ListExpansionInputs, 'multiExpandable' | 'expandedIds' | 'items'>;

/** Controls the state of a tablist. */
export class TabListPattern {
Expand All @@ -144,7 +142,7 @@ export class TabListPattern {
focusManager: ListFocus<TabPattern>;

/** Controls expansion for the tablist. */
expansionManager: ListExpansion<TabPattern>;
expansionManager: ListExpansion;

/** Whether the tablist is vertically or horizontally oriented. */
orientation: SignalLike<'vertical' | 'horizontal'>;
Expand Down Expand Up @@ -210,7 +208,6 @@ export class TabListPattern {
...inputs,
multiExpandable: () => false,
expandedIds: this.inputs.value,
focusManager: this.focusManager,
});
}

Expand Down Expand Up @@ -266,7 +263,7 @@ export class TabListPattern {
private _select(opts?: SelectOptions) {
if (opts?.select) {
this.selection.selectOne();
this.expansionManager.open();
this.expansionManager.open(this.focusManager.activeItem());
}
}

Expand Down
39 changes: 39 additions & 0 deletions src/cdk-experimental/ui-patterns/tree/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project")

package(default_visibility = ["//visibility:public"])

ts_project(
name = "tree",
srcs = [
"tree.ts",
],
deps = [
"//:node_modules/@angular/core",
"//src/cdk-experimental/ui-patterns/behaviors/event-manager",
"//src/cdk-experimental/ui-patterns/behaviors/expansion",
"//src/cdk-experimental/ui-patterns/behaviors/list-focus",
"//src/cdk-experimental/ui-patterns/behaviors/list-navigation",
"//src/cdk-experimental/ui-patterns/behaviors/list-selection",
"//src/cdk-experimental/ui-patterns/behaviors/list-typeahead",
"//src/cdk-experimental/ui-patterns/behaviors/signal-like",
],
)

ts_project(
name = "unit_test_sources",
testonly = True,
srcs = [
"tree.spec.ts",
],
deps = [
":tree",
"//:node_modules/@angular/core",
"//src/cdk/keycodes",
"//src/cdk/testing/private",
],
)

ng_web_test_suite(
name = "unit_tests",
deps = [":unit_test_sources"],
)
Loading
Loading