Skip to content

Commit ce145fa

Browse files
authored
feat(cdk-experimental/ui-patterns): create the grid focus behavior (angular#31055)
1 parent 218b879 commit ce145fa

File tree

3 files changed

+558
-0
lines changed

3 files changed

+558
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ts_project(
6+
name = "grid-focus",
7+
srcs = ["grid-focus.ts"],
8+
deps = [
9+
"//:node_modules/@angular/core",
10+
"//src/cdk-experimental/ui-patterns/behaviors/signal-like",
11+
],
12+
)
13+
14+
ts_project(
15+
name = "unit_test_sources",
16+
testonly = True,
17+
srcs = ["grid-focus.spec.ts"],
18+
deps = [
19+
":grid-focus",
20+
"//:node_modules/@angular/core",
21+
],
22+
)
23+
24+
ng_web_test_suite(
25+
name = "unit_tests",
26+
deps = [":unit_test_sources"],
27+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
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 {computed, Signal, signal, WritableSignal} from '@angular/core';
10+
import {GridFocus, GridFocusInputs, GridFocusCell} from './grid-focus';
11+
12+
// Helper type for test cells, extending GridFocusCell
13+
interface TestGridCell extends GridFocusCell {
14+
id: WritableSignal<string>;
15+
element: WritableSignal<HTMLElement>;
16+
disabled: WritableSignal<boolean>;
17+
}
18+
19+
// Helper type for configuring GridFocus inputs in tests
20+
type TestSetupInputs = Partial<GridFocusInputs<TestGridCell>> & {
21+
numRows?: number;
22+
numCols?: number;
23+
gridFocus?: WritableSignal<GridFocus<TestGridCell> | undefined>;
24+
};
25+
26+
function createTestCell(
27+
gridFocus: Signal<GridFocus<TestGridCell> | undefined>,
28+
opts: {id: string; rowspan?: number; colspan?: number},
29+
): TestGridCell {
30+
const el = document.createElement('div');
31+
spyOn(el, 'focus').and.callThrough();
32+
let coordinates: Signal<{row: number; column: number}> = signal({row: -1, column: -1});
33+
const cell: TestGridCell = {
34+
id: signal(opts.id),
35+
element: signal(el as HTMLElement),
36+
disabled: signal(false),
37+
rowspan: signal(opts.rowspan ?? 1),
38+
colspan: signal(opts.rowspan ?? 1),
39+
rowindex: signal(-1),
40+
colindex: signal(-1),
41+
};
42+
coordinates = computed(() => gridFocus()?.getCoordinates(cell) ?? {row: -1, column: -1});
43+
cell.rowindex = computed(() => coordinates().row);
44+
cell.colindex = computed(() => coordinates().column);
45+
return cell;
46+
}
47+
48+
function createTestCells(
49+
gridFocus: Signal<GridFocus<TestGridCell> | undefined>,
50+
numRows: number,
51+
numCols: number,
52+
): WritableSignal<TestGridCell[][]> {
53+
return signal(
54+
Array.from({length: numRows}).map((_, r) =>
55+
Array.from({length: numCols}).map((_, c) => {
56+
return createTestCell(gridFocus, {id: `cell-${r}-${c}`});
57+
}),
58+
),
59+
);
60+
}
61+
62+
// Main helper function to instantiate GridFocus and its dependencies for testing
63+
function setupGridFocus(inputs: TestSetupInputs = {}): {
64+
cells: TestGridCell[][];
65+
gridFocus: GridFocus<TestGridCell>;
66+
} {
67+
const numRows = inputs.numRows ?? 3;
68+
const numCols = inputs.numCols ?? 3;
69+
70+
const gridFocus = inputs.gridFocus ?? signal<GridFocus<TestGridCell> | undefined>(undefined);
71+
const cells = inputs.cells ?? createTestCells(gridFocus, numRows, numCols);
72+
73+
const activeCoords = inputs.activeCoords ?? signal({row: 0, column: 0});
74+
const focusMode = signal<'roving' | 'activedescendant'>(
75+
inputs.focusMode ? inputs.focusMode() : 'roving',
76+
);
77+
const disabled = signal(inputs.disabled ? inputs.disabled() : false);
78+
const skipDisabled = signal(inputs.skipDisabled ? inputs.skipDisabled() : true);
79+
80+
gridFocus.set(
81+
new GridFocus<TestGridCell>({
82+
cells: cells,
83+
activeCoords: activeCoords,
84+
focusMode: focusMode,
85+
disabled: disabled,
86+
skipDisabled: skipDisabled,
87+
}),
88+
);
89+
90+
return {
91+
cells: cells(),
92+
gridFocus: gridFocus()!,
93+
};
94+
}
95+
96+
describe('GridFocus', () => {
97+
describe('Initialization', () => {
98+
it('should initialize with activeCell at {row: 0, column: 0} by default', () => {
99+
const {gridFocus} = setupGridFocus();
100+
expect(gridFocus.inputs.activeCoords()).toEqual({row: 0, column: 0});
101+
});
102+
103+
it('should compute activeCell based on activeCell', () => {
104+
const {gridFocus, cells} = setupGridFocus({
105+
activeCoords: signal({row: 1, column: 1}),
106+
});
107+
expect(gridFocus.activeCell()).toBe(cells[1][1]);
108+
});
109+
110+
it('should compute activeCell correctly when rowspan and colspan are set', () => {
111+
const activeCoords = signal({row: 0, column: 0});
112+
const gridFocusSignal = signal<GridFocus<TestGridCell> | undefined>(undefined);
113+
114+
// Visualization of this irregular grid.
115+
//
116+
// +---+---+---+
117+
// | |0,2|
118+
// + 0,0 +---+
119+
// | |1,2|
120+
// +---+---+---+
121+
//
122+
const cell_0_0 = createTestCell(gridFocusSignal, {id: `cell-0-0`, rowspan: 2, colspan: 2});
123+
const cell_0_2 = createTestCell(gridFocusSignal, {id: `cell-0-2`});
124+
const cell_1_2 = createTestCell(gridFocusSignal, {id: `cell-1-2`});
125+
const cells = signal<TestGridCell[][]>([[cell_0_0, cell_0_2], [cell_1_2]]);
126+
127+
const {gridFocus} = setupGridFocus({
128+
cells,
129+
activeCoords,
130+
gridFocus: gridFocusSignal,
131+
});
132+
133+
activeCoords.set({row: 0, column: 0});
134+
expect(gridFocus.activeCell()).toBe(cell_0_0);
135+
activeCoords.set({row: 0, column: 1});
136+
expect(gridFocus.activeCell()).toBe(cell_0_0);
137+
activeCoords.set({row: 1, column: 0});
138+
expect(gridFocus.activeCell()).toBe(cell_0_0);
139+
activeCoords.set({row: 1, column: 1});
140+
expect(gridFocus.activeCell()).toBe(cell_0_0);
141+
142+
activeCoords.set({row: 0, column: 2});
143+
expect(gridFocus.activeCell()).toBe(cell_0_2);
144+
145+
activeCoords.set({row: 1, column: 2});
146+
expect(gridFocus.activeCell()).toBe(cell_1_2);
147+
});
148+
});
149+
150+
describe('isGridDisabled()', () => {
151+
it('should return true if inputs.disabled is true', () => {
152+
const {gridFocus} = setupGridFocus({disabled: signal(true)});
153+
expect(gridFocus.isGridDisabled()).toBeTrue();
154+
});
155+
156+
it('should return true if all cells are disabled', () => {
157+
const {gridFocus, cells} = setupGridFocus({numRows: 2, numCols: 1});
158+
cells.forEach(row => row.forEach(cell => cell.disabled.set(true)));
159+
expect(gridFocus.isGridDisabled()).toBeTrue();
160+
});
161+
162+
it('should return true if inputs.cells is empty', () => {
163+
const {gridFocus} = setupGridFocus({numRows: 0, numCols: 0});
164+
expect(gridFocus.isGridDisabled()).toBeTrue();
165+
});
166+
167+
it('should return true if the grid contains only empty rows', () => {
168+
const cells = signal<TestGridCell[][]>([[], []]);
169+
const {gridFocus} = setupGridFocus({cells: cells});
170+
expect(gridFocus.isGridDisabled()).toBeTrue();
171+
});
172+
});
173+
174+
describe('getActiveDescendant()', () => {
175+
it('should return undefined if focusMode is "roving"', () => {
176+
const {gridFocus} = setupGridFocus({focusMode: signal('roving')});
177+
expect(gridFocus.getActiveDescendant()).toBeUndefined();
178+
});
179+
180+
it('should return undefined if the grid is disabled', () => {
181+
const {gridFocus} = setupGridFocus({
182+
disabled: signal(true),
183+
focusMode: signal('activedescendant'),
184+
});
185+
expect(gridFocus.getActiveDescendant()).toBeUndefined();
186+
});
187+
188+
it('should return the activeCell id if focusMode is "activedescendant"', () => {
189+
const {gridFocus, cells} = setupGridFocus({
190+
focusMode: signal('activedescendant'),
191+
activeCoords: signal({row: 2, column: 2}),
192+
});
193+
expect(gridFocus.getActiveDescendant()).toBe(cells[2][2].id());
194+
});
195+
});
196+
197+
describe('getGridTabindex()', () => {
198+
it('should return 0 if grid is disabled', () => {
199+
const {gridFocus} = setupGridFocus({disabled: signal(true)});
200+
expect(gridFocus.getGridTabindex()).toBe(0);
201+
});
202+
203+
it('should return -1 if focusMode is "roving" and grid is not disabled', () => {
204+
const {gridFocus} = setupGridFocus({focusMode: signal('roving')});
205+
expect(gridFocus.getGridTabindex()).toBe(-1);
206+
});
207+
208+
it('should return 0 if focusMode is "activedescendant" and grid is not disabled', () => {
209+
const {gridFocus} = setupGridFocus({focusMode: signal('activedescendant')});
210+
expect(gridFocus.getGridTabindex()).toBe(0);
211+
});
212+
});
213+
214+
describe('getCellTabindex(cell)', () => {
215+
it('should return -1 if grid is disabled', () => {
216+
const {gridFocus, cells} = setupGridFocus({
217+
numRows: 1,
218+
numCols: 3,
219+
disabled: signal(true),
220+
});
221+
expect(gridFocus.getCellTabindex(cells[0][0])).toBe(-1);
222+
expect(gridFocus.getCellTabindex(cells[0][1])).toBe(-1);
223+
expect(gridFocus.getCellTabindex(cells[0][2])).toBe(-1);
224+
});
225+
226+
it('should return -1 if focusMode is "activedescendant"', () => {
227+
const {gridFocus, cells} = setupGridFocus({
228+
numRows: 1,
229+
numCols: 3,
230+
focusMode: signal('activedescendant'),
231+
});
232+
expect(gridFocus.getCellTabindex(cells[0][0])).toBe(-1);
233+
expect(gridFocus.getCellTabindex(cells[0][1])).toBe(-1);
234+
expect(gridFocus.getCellTabindex(cells[0][2])).toBe(-1);
235+
});
236+
237+
it('should return 0 if focusMode is "roving" and cell is the activeCell', () => {
238+
const {gridFocus, cells} = setupGridFocus({
239+
numRows: 1,
240+
numCols: 3,
241+
focusMode: signal('roving'),
242+
});
243+
244+
expect(gridFocus.getCellTabindex(cells[0][0])).toBe(0);
245+
expect(gridFocus.getCellTabindex(cells[0][1])).toBe(-1);
246+
expect(gridFocus.getCellTabindex(cells[0][2])).toBe(-1);
247+
});
248+
});
249+
250+
describe('isFocusable(cell)', () => {
251+
it('should return true if cell is not disabled', () => {
252+
const {gridFocus, cells} = setupGridFocus({
253+
numRows: 1,
254+
numCols: 3,
255+
});
256+
expect(gridFocus.isFocusable(cells[0][0])).toBeTrue();
257+
expect(gridFocus.isFocusable(cells[0][1])).toBeTrue();
258+
expect(gridFocus.isFocusable(cells[0][2])).toBeTrue();
259+
});
260+
261+
it('should return false if cell is disabled and skipDisabled is true', () => {
262+
const {gridFocus, cells} = setupGridFocus({
263+
numRows: 1,
264+
numCols: 3,
265+
skipDisabled: signal(true),
266+
});
267+
cells[0][1].disabled.set(true);
268+
expect(gridFocus.isFocusable(cells[0][0])).toBeTrue();
269+
expect(gridFocus.isFocusable(cells[0][1])).toBeFalse();
270+
expect(gridFocus.isFocusable(cells[0][2])).toBeTrue();
271+
});
272+
273+
it('should return true if cell is disabled but skipDisabled is false', () => {
274+
const {gridFocus, cells} = setupGridFocus({
275+
numRows: 1,
276+
numCols: 3,
277+
skipDisabled: signal(false),
278+
});
279+
cells[0][1].disabled.set(true);
280+
expect(gridFocus.isFocusable(cells[0][0])).toBeTrue();
281+
expect(gridFocus.isFocusable(cells[0][1])).toBeTrue();
282+
expect(gridFocus.isFocusable(cells[0][2])).toBeTrue();
283+
});
284+
});
285+
286+
describe('focus(cell)', () => {
287+
it('should return false and not change state if grid is disabled', () => {
288+
const activeCoords = signal({row: 0, column: 0});
289+
const {gridFocus, cells} = setupGridFocus({
290+
activeCoords,
291+
disabled: signal(true),
292+
});
293+
294+
const success = gridFocus.focus({row: 1, column: 0});
295+
296+
expect(success).toBeFalse();
297+
expect(activeCoords()).toEqual({row: 0, column: 0});
298+
expect(cells[1][0].element().focus).not.toHaveBeenCalled();
299+
});
300+
301+
it('should return false and not change state if cell is not focusable', () => {
302+
const activeCoords = signal({row: 0, column: 0});
303+
const {gridFocus, cells} = setupGridFocus({activeCoords});
304+
cells[1][0].disabled.set(true);
305+
306+
const success = gridFocus.focus({row: 1, column: 0});
307+
308+
expect(success).toBeFalse();
309+
expect(activeCoords()).toEqual({row: 0, column: 0});
310+
expect(cells[1][0].element().focus).not.toHaveBeenCalled();
311+
});
312+
313+
it('should focus cell, update activeCell and prevActiveCell in "roving" mode', () => {
314+
const activeCoords = signal({row: 0, column: 0});
315+
const {gridFocus, cells} = setupGridFocus({
316+
activeCoords,
317+
focusMode: signal('roving'),
318+
});
319+
320+
const success = gridFocus.focus({row: 1, column: 0});
321+
322+
expect(success).toBeTrue();
323+
expect(activeCoords()).toEqual({row: 1, column: 0});
324+
expect(cells[1][0].element().focus).toHaveBeenCalled();
325+
326+
expect(gridFocus.activeCell()).toBe(cells[1][0]);
327+
expect(gridFocus.prevActiveCoords()).toEqual({row: 0, column: 0});
328+
});
329+
330+
it('should update activeCell and prevActiveCell but not call element.focus in "activedescendant" mode', () => {
331+
const activeCoords = signal({row: 0, column: 0});
332+
const {gridFocus, cells} = setupGridFocus({
333+
activeCoords,
334+
focusMode: signal('activedescendant'),
335+
});
336+
337+
const success = gridFocus.focus({row: 1, column: 0});
338+
339+
expect(success).toBeTrue();
340+
expect(activeCoords()).toEqual({row: 1, column: 0});
341+
expect(cells[1][0].element().focus).not.toHaveBeenCalled();
342+
343+
expect(gridFocus.activeCell()).toBe(cells[1][0]);
344+
expect(gridFocus.prevActiveCoords()).toEqual({row: 0, column: 0});
345+
});
346+
});
347+
});

0 commit comments

Comments
 (0)