Skip to content

Commit 9f5b154

Browse files
authored
refactor(cdk-experimental/ui-patterns): move active index operations … (#30922)
* refactor(cdk-experimental/ui-patterns): move active index operations into list-focus * fixup! refactor(cdk-experimental/ui-patterns): move active index operations into list-focus
1 parent 9ae960d commit 9f5b154

File tree

14 files changed

+458
-622
lines changed

14 files changed

+458
-622
lines changed

src/cdk-experimental/ui-patterns/behaviors/list-focus/BUILD.bazel

+1-3
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ ts_project(
1010
exclude = ["**/*.spec.ts"],
1111
),
1212
deps = [
13-
"//src/cdk-experimental/ui-patterns/behaviors/list-navigation",
13+
"//:node_modules/@angular/core",
1414
"//src/cdk-experimental/ui-patterns/behaviors/signal-like",
1515
],
1616
)
@@ -22,8 +22,6 @@ ts_project(
2222
deps = [
2323
":list-focus",
2424
"//:node_modules/@angular/core",
25-
"//src/cdk-experimental/ui-patterns/behaviors/list-navigation",
26-
"//src/cdk-experimental/ui-patterns/behaviors/signal-like",
2725
],
2826
)
2927

src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts

+91-118
Original file line numberDiff line numberDiff line change
@@ -6,152 +6,125 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {computed, signal} from '@angular/core';
10-
import {SignalLike} from '../signal-like/signal-like';
11-
import {ListNavigation, ListNavigationInputs} from '../list-navigation/list-navigation';
9+
import {Signal, signal, WritableSignal} from '@angular/core';
1210
import {ListFocus, ListFocusInputs, ListFocusItem} from './list-focus';
1311

14-
describe('List Focus', () => {
15-
interface TestItem extends ListFocusItem {
16-
tabindex: SignalLike<-1 | 0>;
17-
}
18-
19-
function getItems(length: number): SignalLike<TestItem[]> {
20-
return signal(
21-
Array.from({length}).map((_, i) => ({
22-
index: signal(i),
12+
type TestItem = ListFocusItem & {
13+
disabled: WritableSignal<boolean>;
14+
};
15+
16+
type TestInputs = Partial<ListFocusInputs<ListFocusItem>> & {
17+
numItems?: number;
18+
};
19+
20+
export function getListFocus(inputs: TestInputs = {}): ListFocus<ListFocusItem> {
21+
return new ListFocus({
22+
activeIndex: signal(0),
23+
disabled: signal(false),
24+
skipDisabled: signal(false),
25+
focusMode: signal('roving'),
26+
items: getItems(inputs.numItems ?? 5),
27+
...inputs,
28+
});
29+
}
30+
31+
function getItems(length: number): Signal<ListFocusItem[]> {
32+
return signal(
33+
Array.from({length}).map((_, i) => {
34+
return {
2335
id: signal(`${i}`),
24-
tabindex: signal(-1),
2536
disabled: signal(false),
2637
element: signal({focus: () => {}} as HTMLElement),
27-
})),
28-
);
29-
}
30-
31-
function getNavigation<T extends TestItem>(
32-
items: SignalLike<T[]>,
33-
args: Partial<ListNavigationInputs<T>> = {},
34-
): ListNavigation<T> {
35-
return new ListNavigation({
36-
items,
37-
wrap: signal(false),
38-
activeIndex: signal(0),
39-
skipDisabled: signal(false),
40-
textDirection: signal('ltr'),
41-
orientation: signal('vertical'),
42-
...args,
43-
});
44-
}
45-
46-
function getFocus<T extends TestItem>(
47-
navigation: ListNavigation<T>,
48-
args: Partial<ListFocusInputs<T>> = {},
49-
): ListFocus<T> {
50-
return new ListFocus({
51-
navigation,
52-
focusMode: signal('roving'),
53-
...args,
54-
});
55-
}
38+
};
39+
}),
40+
);
41+
}
5642

43+
describe('List Focus', () => {
5744
describe('roving', () => {
45+
let focusManager: ListFocus<ListFocusItem>;
46+
47+
beforeEach(() => {
48+
focusManager = getListFocus({focusMode: signal('roving')});
49+
});
50+
5851
it('should set the list tabindex to -1', () => {
59-
const items = getItems(5);
60-
const nav = getNavigation(items);
61-
const focus = getFocus(nav);
62-
const tabindex = computed(() => focus.getListTabindex());
63-
expect(tabindex()).toBe(-1);
52+
expect(focusManager.getListTabindex()).toBe(-1);
6453
});
6554

6655
it('should set the activedescendant to undefined', () => {
67-
const items = getItems(5);
68-
const nav = getNavigation(items);
69-
const focus = getFocus(nav);
70-
expect(focus.getActiveDescendant()).toBeUndefined();
56+
expect(focusManager.getActiveDescendant()).toBeUndefined();
7157
});
7258

73-
it('should set the first items tabindex to 0', () => {
74-
const items = getItems(5);
75-
const nav = getNavigation(items);
76-
const focus = getFocus(nav);
77-
78-
items().forEach(i => {
79-
i.tabindex = computed(() => focus.getItemTabindex(i));
80-
});
81-
82-
expect(items()[0].tabindex()).toBe(0);
83-
expect(items()[1].tabindex()).toBe(-1);
84-
expect(items()[2].tabindex()).toBe(-1);
85-
expect(items()[3].tabindex()).toBe(-1);
86-
expect(items()[4].tabindex()).toBe(-1);
59+
it('should set the tabindex based on the active index', () => {
60+
const items = focusManager.inputs.items() as TestItem[];
61+
focusManager.inputs.activeIndex.set(2);
62+
expect(focusManager.getItemTabindex(items[0])).toBe(-1);
63+
expect(focusManager.getItemTabindex(items[1])).toBe(-1);
64+
expect(focusManager.getItemTabindex(items[2])).toBe(0);
65+
expect(focusManager.getItemTabindex(items[3])).toBe(-1);
66+
expect(focusManager.getItemTabindex(items[4])).toBe(-1);
8767
});
68+
});
8869

89-
it('should update the tabindex of the active item when navigating', () => {
90-
const items = getItems(5);
91-
const nav = getNavigation(items);
92-
const focus = getFocus(nav);
93-
94-
items().forEach(i => {
95-
i.tabindex = computed(() => focus.getItemTabindex(i));
96-
});
97-
98-
nav.next();
70+
describe('activedescendant', () => {
71+
let focusManager: ListFocus<ListFocusItem>;
9972

100-
expect(items()[0].tabindex()).toBe(-1);
101-
expect(items()[1].tabindex()).toBe(0);
102-
expect(items()[2].tabindex()).toBe(-1);
103-
expect(items()[3].tabindex()).toBe(-1);
104-
expect(items()[4].tabindex()).toBe(-1);
73+
beforeEach(() => {
74+
focusManager = getListFocus({focusMode: signal('activedescendant')});
10575
});
106-
});
10776

108-
describe('activedescendant', () => {
10977
it('should set the list tabindex to 0', () => {
110-
const items = getItems(5);
111-
const nav = getNavigation(items);
112-
const focus = getFocus(nav, {
113-
focusMode: signal('activedescendant'),
114-
});
115-
const tabindex = computed(() => focus.getListTabindex());
116-
expect(tabindex()).toBe(0);
78+
expect(focusManager.getListTabindex()).toBe(0);
11779
});
11880

11981
it('should set the activedescendant to the active items id', () => {
120-
const items = getItems(5);
121-
const nav = getNavigation(items);
122-
const focus = getFocus(nav, {
123-
focusMode: signal('activedescendant'),
124-
});
125-
expect(focus.getActiveDescendant()).toBe(items()[0].id());
82+
expect(focusManager.getActiveDescendant()).toBe(focusManager.inputs.items()[0].id());
12683
});
12784

12885
it('should set the tabindex of all items to -1', () => {
129-
const items = getItems(5);
130-
const nav = getNavigation(items);
131-
const focus = getFocus(nav, {
132-
focusMode: signal('activedescendant'),
133-
});
134-
135-
items().forEach(i => {
136-
i.tabindex = computed(() => focus.getItemTabindex(i));
137-
});
138-
139-
expect(items()[0].tabindex()).toBe(-1);
140-
expect(items()[1].tabindex()).toBe(-1);
141-
expect(items()[2].tabindex()).toBe(-1);
142-
expect(items()[3].tabindex()).toBe(-1);
143-
expect(items()[4].tabindex()).toBe(-1);
86+
const items = focusManager.inputs.items() as TestItem[];
87+
focusManager.inputs.activeIndex.set(0);
88+
expect(focusManager.getItemTabindex(items[0])).toBe(-1);
89+
expect(focusManager.getItemTabindex(items[1])).toBe(-1);
90+
expect(focusManager.getItemTabindex(items[2])).toBe(-1);
91+
expect(focusManager.getItemTabindex(items[3])).toBe(-1);
92+
expect(focusManager.getItemTabindex(items[4])).toBe(-1);
14493
});
14594

14695
it('should update the activedescendant of the list when navigating', () => {
147-
const items = getItems(5);
148-
const nav = getNavigation(items);
149-
const focus = getFocus(nav, {
150-
focusMode: signal('activedescendant'),
151-
});
152-
153-
nav.next();
154-
expect(focus.getActiveDescendant()).toBe(items()[1].id());
96+
focusManager.inputs.activeIndex.set(1);
97+
expect(focusManager.getActiveDescendant()).toBe(focusManager.inputs.items()[1].id());
98+
});
99+
});
100+
101+
describe('#isFocusable', () => {
102+
it('should return true for enabled items', () => {
103+
const focusManager = getListFocus({skipDisabled: signal(true)});
104+
const items = focusManager.inputs.items() as TestItem[];
105+
expect(focusManager.isFocusable(items[0])).toBeTrue();
106+
expect(focusManager.isFocusable(items[1])).toBeTrue();
107+
expect(focusManager.isFocusable(items[2])).toBeTrue();
108+
});
109+
110+
it('should return false for disabled items', () => {
111+
const focusManager = getListFocus({skipDisabled: signal(true)});
112+
const items = focusManager.inputs.items() as TestItem[];
113+
items[1].disabled.set(true);
114+
115+
expect(focusManager.isFocusable(items[0])).toBeTrue();
116+
expect(focusManager.isFocusable(items[1])).toBeFalse();
117+
expect(focusManager.isFocusable(items[2])).toBeTrue();
118+
});
119+
120+
it('should return true for disabled items if skip disabled is false', () => {
121+
const focusManager = getListFocus({skipDisabled: signal(false)});
122+
const items = focusManager.inputs.items() as TestItem[];
123+
items[1].disabled.set(true);
124+
125+
expect(focusManager.isFocusable(items[0])).toBeTrue();
126+
expect(focusManager.isFocusable(items[1])).toBeTrue();
127+
expect(focusManager.isFocusable(items[2])).toBeTrue();
155128
});
156129
});
157130
});

src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts

+57-19
Original file line numberDiff line numberDiff line change
@@ -6,65 +6,103 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {SignalLike} from '../signal-like/signal-like';
10-
import {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation';
9+
import {computed, signal} from '@angular/core';
10+
import {SignalLike, WritableSignalLike} from '../signal-like/signal-like';
1111

1212
/** Represents an item in a collection, such as a listbox option, than may receive focus. */
13-
export interface ListFocusItem extends ListNavigationItem {
13+
export interface ListFocusItem {
1414
/** A unique identifier for the item. */
1515
id: SignalLike<string>;
1616

1717
/** The html element that should receive focus. */
1818
element: SignalLike<HTMLElement>;
19+
20+
/** Whether an item is disabled. */
21+
disabled: SignalLike<boolean>;
1922
}
2023

2124
/** Represents the required inputs for a collection that contains focusable items. */
2225
export interface ListFocusInputs<T extends ListFocusItem> {
2326
/** The focus strategy used by the list. */
2427
focusMode: SignalLike<'roving' | 'activedescendant'>;
28+
29+
/** Whether the list is disabled. */
30+
disabled: SignalLike<boolean>;
31+
32+
/** The items in the list. */
33+
items: SignalLike<T[]>;
34+
35+
/** The index of the current active item. */
36+
activeIndex: WritableSignalLike<number>;
37+
38+
/** Whether disabled items in the list should be skipped when navigating. */
39+
skipDisabled: SignalLike<boolean>;
2540
}
2641

2742
/** Controls focus for a list of items. */
2843
export class ListFocus<T extends ListFocusItem> {
29-
/** The navigation controller of the parent list. */
30-
navigation: ListNavigation<ListFocusItem>;
44+
/** The last index that was active. */
45+
prevActiveIndex = signal(0);
3146

32-
constructor(readonly inputs: ListFocusInputs<T> & {navigation: ListNavigation<T>}) {
33-
this.navigation = inputs.navigation;
47+
/** The current active item. */
48+
activeItem = computed(() => this.inputs.items()[this.inputs.activeIndex()]);
49+
50+
constructor(readonly inputs: ListFocusInputs<T>) {}
51+
52+
/** Whether the list is in a disabled state. */
53+
isListDisabled(): boolean {
54+
return this.inputs.disabled() || this.inputs.items().every(i => i.disabled());
3455
}
3556

3657
/** The id of the current active item. */
3758
getActiveDescendant(): string | undefined {
38-
if (this.inputs.focusMode() === 'roving') {
59+
if (this.isListDisabled()) {
3960
return undefined;
4061
}
41-
if (this.navigation.inputs.items().length) {
42-
return this.navigation.inputs.items()[this.navigation.inputs.activeIndex()].id();
62+
if (this.inputs.focusMode() === 'roving') {
63+
return undefined;
4364
}
44-
return undefined;
65+
return this.inputs.items()[this.inputs.activeIndex()].id();
4566
}
4667

4768
/** The tabindex for the list. */
4869
getListTabindex(): -1 | 0 {
70+
if (this.isListDisabled()) {
71+
return 0;
72+
}
4973
return this.inputs.focusMode() === 'activedescendant' ? 0 : -1;
5074
}
5175

5276
/** Returns the tabindex for the given item. */
5377
getItemTabindex(item: T): -1 | 0 {
78+
if (this.inputs.disabled()) {
79+
return -1;
80+
}
5481
if (this.inputs.focusMode() === 'activedescendant') {
5582
return -1;
5683
}
57-
const index = this.navigation.inputs.items().indexOf(item);
58-
return this.navigation.inputs.activeIndex() === index ? 0 : -1;
84+
return this.activeItem() === item ? 0 : -1;
5985
}
6086

61-
/** Focuses the current active item. */
62-
focus() {
63-
if (this.inputs.focusMode() === 'activedescendant') {
64-
return;
87+
/** Moves focus to the given item if it is focusable. */
88+
focus(item: T): boolean {
89+
if (this.isListDisabled() || !this.isFocusable(item)) {
90+
return false;
91+
}
92+
93+
this.prevActiveIndex.set(this.inputs.activeIndex());
94+
const index = this.inputs.items().indexOf(item);
95+
this.inputs.activeIndex.set(index);
96+
97+
if (this.inputs.focusMode() === 'roving') {
98+
item.element().focus();
6599
}
66100

67-
const item = this.navigation.inputs.items()[this.navigation.inputs.activeIndex()];
68-
item.element().focus();
101+
return true;
102+
}
103+
104+
/** Returns true if the given item can be navigated to. */
105+
isFocusable(item: T): boolean {
106+
return !item.disabled() || !this.inputs.skipDisabled();
69107
}
70108
}

0 commit comments

Comments
 (0)