Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit c5b2f0a

Browse files
authoredJun 5, 2025··
feat(cdk-experimental/ui-patterns): create grid navigation behavior (angular#31290)
* feat(cdk-experimental/ui-patterns): create grid navigation behavior * fixup! feat(cdk-experimental/ui-patterns): create grid navigation behavior
1 parent 394336c commit c5b2f0a

File tree

5 files changed

+1626
-52
lines changed

5 files changed

+1626
-52
lines changed
 

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

Lines changed: 135 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import {computed, Signal, signal, WritableSignal} from '@angular/core';
10-
import {GridFocus, GridFocusInputs, GridFocusCell} from './grid-focus';
10+
import {GridFocus, GridFocusInputs, GridFocusCell, RowCol} from './grid-focus';
1111

1212
// Helper type for test cells, extending GridFocusCell
1313
interface TestGridCell extends GridFocusCell {
@@ -23,13 +23,13 @@ type TestSetupInputs = Partial<GridFocusInputs<TestGridCell>> & {
2323
gridFocus?: WritableSignal<GridFocus<TestGridCell> | undefined>;
2424
};
2525

26-
function createTestCell(
26+
export function createTestCell(
2727
gridFocus: Signal<GridFocus<TestGridCell> | undefined>,
2828
opts: {id: string; rowspan?: number; colspan?: number},
2929
): TestGridCell {
3030
const el = document.createElement('div');
3131
spyOn(el, 'focus').and.callThrough();
32-
let coordinates: Signal<{row: number; column: number}> = signal({row: -1, column: -1});
32+
let coordinates: Signal<RowCol> = signal({row: -1, col: -1});
3333
const cell: TestGridCell = {
3434
id: signal(opts.id),
3535
element: signal(el as HTMLElement),
@@ -39,13 +39,13 @@ function createTestCell(
3939
rowindex: signal(-1),
4040
colindex: signal(-1),
4141
};
42-
coordinates = computed(() => gridFocus()?.getCoordinates(cell) ?? {row: -1, column: -1});
42+
coordinates = computed(() => gridFocus()?.getCoordinates(cell) ?? {row: -1, col: -1});
4343
cell.rowindex = computed(() => coordinates().row);
44-
cell.colindex = computed(() => coordinates().column);
44+
cell.colindex = computed(() => coordinates().col);
4545
return cell;
4646
}
4747

48-
function createTestCells(
48+
export function createTestCells(
4949
gridFocus: Signal<GridFocus<TestGridCell> | undefined>,
5050
numRows: number,
5151
numCols: number,
@@ -60,7 +60,7 @@ function createTestCells(
6060
}
6161

6262
// Main helper function to instantiate GridFocus and its dependencies for testing
63-
function setupGridFocus(inputs: TestSetupInputs = {}): {
63+
export function setupGridFocus(inputs: TestSetupInputs = {}): {
6464
cells: TestGridCell[][];
6565
gridFocus: GridFocus<TestGridCell>;
6666
} {
@@ -70,7 +70,7 @@ function setupGridFocus(inputs: TestSetupInputs = {}): {
7070
const gridFocus = inputs.gridFocus ?? signal<GridFocus<TestGridCell> | undefined>(undefined);
7171
const cells = inputs.cells ?? createTestCells(gridFocus, numRows, numCols);
7272

73-
const activeCoords = inputs.activeCoords ?? signal({row: 0, column: 0});
73+
const activeCoords = inputs.activeCoords ?? signal({row: 0, col: 0});
7474
const focusMode = signal<'roving' | 'activedescendant'>(
7575
inputs.focusMode ? inputs.focusMode() : 'roving',
7676
);
@@ -95,20 +95,20 @@ function setupGridFocus(inputs: TestSetupInputs = {}): {
9595

9696
describe('GridFocus', () => {
9797
describe('Initialization', () => {
98-
it('should initialize with activeCell at {row: 0, column: 0} by default', () => {
98+
it('should initialize with activeCell at {row: 0, col: 0} by default', () => {
9999
const {gridFocus} = setupGridFocus();
100-
expect(gridFocus.inputs.activeCoords()).toEqual({row: 0, column: 0});
100+
expect(gridFocus.inputs.activeCoords()).toEqual({row: 0, col: 0});
101101
});
102102

103103
it('should compute activeCell based on activeCell', () => {
104104
const {gridFocus, cells} = setupGridFocus({
105-
activeCoords: signal({row: 1, column: 1}),
105+
activeCoords: signal({row: 1, col: 1}),
106106
});
107107
expect(gridFocus.activeCell()).toBe(cells[1][1]);
108108
});
109109

110110
it('should compute activeCell correctly when rowspan and colspan are set', () => {
111-
const activeCoords = signal({row: 0, column: 0});
111+
const activeCoords = signal({row: 0, col: 0});
112112
const gridFocusSignal = signal<GridFocus<TestGridCell> | undefined>(undefined);
113113

114114
// Visualization of this irregular grid.
@@ -130,24 +130,58 @@ describe('GridFocus', () => {
130130
gridFocus: gridFocusSignal,
131131
});
132132

133-
activeCoords.set({row: 0, column: 0});
133+
activeCoords.set({row: 0, col: 0});
134134
expect(gridFocus.activeCell()).toBe(cell_0_0);
135-
activeCoords.set({row: 0, column: 1});
135+
activeCoords.set({row: 0, col: 1});
136136
expect(gridFocus.activeCell()).toBe(cell_0_0);
137-
activeCoords.set({row: 1, column: 0});
137+
activeCoords.set({row: 1, col: 0});
138138
expect(gridFocus.activeCell()).toBe(cell_0_0);
139-
activeCoords.set({row: 1, column: 1});
139+
activeCoords.set({row: 1, col: 1});
140140
expect(gridFocus.activeCell()).toBe(cell_0_0);
141141

142-
activeCoords.set({row: 0, column: 2});
142+
activeCoords.set({row: 0, col: 2});
143143
expect(gridFocus.activeCell()).toBe(cell_0_2);
144144

145-
activeCoords.set({row: 1, column: 2});
145+
activeCoords.set({row: 1, col: 2});
146146
expect(gridFocus.activeCell()).toBe(cell_1_2);
147147
});
148+
149+
it('should compute rowCount and colCount correctly', () => {
150+
const {gridFocus} = setupGridFocus({
151+
numRows: 2,
152+
numCols: 3,
153+
});
154+
expect(gridFocus.rowCount()).toBe(2);
155+
expect(gridFocus.colCount()).toBe(3);
156+
});
157+
158+
it('should compute rowCount and colCount correctly when rowspan and colspan are set', () => {
159+
const gridFocusSignal = signal<GridFocus<TestGridCell> | undefined>(undefined);
160+
161+
// Visualization of this irregular grid.
162+
//
163+
// +---+---+---+
164+
// | |0,2|
165+
// + 0,0 +---+
166+
// | |1,2|
167+
// +---+---+---+
168+
//
169+
const cell_0_0 = createTestCell(gridFocusSignal, {id: `cell-0-0`, rowspan: 2, colspan: 2});
170+
const cell_0_2 = createTestCell(gridFocusSignal, {id: `cell-0-2`});
171+
const cell_1_2 = createTestCell(gridFocusSignal, {id: `cell-1-2`});
172+
const cells = signal<TestGridCell[][]>([[cell_0_0, cell_0_2], [cell_1_2]]);
173+
174+
const {gridFocus} = setupGridFocus({
175+
cells,
176+
gridFocus: gridFocusSignal,
177+
});
178+
179+
expect(gridFocus.rowCount()).toBe(2);
180+
expect(gridFocus.colCount()).toBe(3);
181+
});
148182
});
149183

150-
describe('isGridDisabled()', () => {
184+
describe('isGridDisabled', () => {
151185
it('should return true if inputs.disabled is true', () => {
152186
const {gridFocus} = setupGridFocus({disabled: signal(true)});
153187
expect(gridFocus.isGridDisabled()).toBeTrue();
@@ -171,7 +205,7 @@ describe('GridFocus', () => {
171205
});
172206
});
173207

174-
describe('getActiveDescendant()', () => {
208+
describe('getActiveDescendant', () => {
175209
it('should return undefined if focusMode is "roving"', () => {
176210
const {gridFocus} = setupGridFocus({focusMode: signal('roving')});
177211
expect(gridFocus.getActiveDescendant()).toBeUndefined();
@@ -188,13 +222,13 @@ describe('GridFocus', () => {
188222
it('should return the activeCell id if focusMode is "activedescendant"', () => {
189223
const {gridFocus, cells} = setupGridFocus({
190224
focusMode: signal('activedescendant'),
191-
activeCoords: signal({row: 2, column: 2}),
225+
activeCoords: signal({row: 2, col: 2}),
192226
});
193227
expect(gridFocus.getActiveDescendant()).toBe(cells[2][2].id());
194228
});
195229
});
196230

197-
describe('getGridTabindex()', () => {
231+
describe('getGridTabindex', () => {
198232
it('should return 0 if grid is disabled', () => {
199233
const {gridFocus} = setupGridFocus({disabled: signal(true)});
200234
expect(gridFocus.getGridTabindex()).toBe(0);
@@ -211,7 +245,7 @@ describe('GridFocus', () => {
211245
});
212246
});
213247

214-
describe('getCellTabindex(cell)', () => {
248+
describe('getCellTabindex', () => {
215249
it('should return -1 if grid is disabled', () => {
216250
const {gridFocus, cells} = setupGridFocus({
217251
numRows: 1,
@@ -247,7 +281,7 @@ describe('GridFocus', () => {
247281
});
248282
});
249283

250-
describe('isFocusable(cell)', () => {
284+
describe('isFocusable', () => {
251285
it('should return true if cell is not disabled', () => {
252286
const {gridFocus, cells} = setupGridFocus({
253287
numRows: 1,
@@ -283,65 +317,127 @@ describe('GridFocus', () => {
283317
});
284318
});
285319

286-
describe('focus(cell)', () => {
320+
describe('focusCoordinates', () => {
321+
it('should return false and not change state if grid is disabled', () => {
322+
const activeCoords = signal({row: 0, col: 0});
323+
const {gridFocus, cells} = setupGridFocus({
324+
activeCoords,
325+
disabled: signal(true),
326+
});
327+
328+
const success = gridFocus.focusCoordinates({row: 1, col: 0});
329+
330+
expect(success).toBeFalse();
331+
expect(activeCoords()).toEqual({row: 0, col: 0});
332+
expect(cells[1][0].element().focus).not.toHaveBeenCalled();
333+
});
334+
335+
it('should return false and not change state if cell is not focusable', () => {
336+
const activeCoords = signal({row: 0, col: 0});
337+
const {gridFocus, cells} = setupGridFocus({activeCoords});
338+
cells[1][0].disabled.set(true);
339+
340+
const success = gridFocus.focusCoordinates({row: 1, col: 0});
341+
342+
expect(success).toBeFalse();
343+
expect(activeCoords()).toEqual({row: 0, col: 0});
344+
expect(cells[1][0].element().focus).not.toHaveBeenCalled();
345+
});
346+
347+
it('should focus cell, update activeCell and prevActiveCell in "roving" mode', () => {
348+
const activeCoords = signal({row: 0, col: 0});
349+
const {gridFocus, cells} = setupGridFocus({
350+
activeCoords,
351+
focusMode: signal('roving'),
352+
});
353+
354+
const success = gridFocus.focusCoordinates({row: 1, col: 0});
355+
356+
expect(success).toBeTrue();
357+
expect(activeCoords()).toEqual({row: 1, col: 0});
358+
expect(cells[1][0].element().focus).toHaveBeenCalled();
359+
360+
expect(gridFocus.activeCell()).toBe(cells[1][0]);
361+
expect(gridFocus.prevActiveCoords()).toEqual({row: 0, col: 0});
362+
});
363+
364+
it('should update activeCell and prevActiveCell but not call element.focus in "activedescendant" mode', () => {
365+
const activeCoords = signal({row: 0, col: 0});
366+
const {gridFocus, cells} = setupGridFocus({
367+
activeCoords,
368+
focusMode: signal('activedescendant'),
369+
});
370+
371+
const success = gridFocus.focusCoordinates({row: 1, col: 0});
372+
373+
expect(success).toBeTrue();
374+
expect(activeCoords()).toEqual({row: 1, col: 0});
375+
expect(cells[1][0].element().focus).not.toHaveBeenCalled();
376+
377+
expect(gridFocus.activeCell()).toBe(cells[1][0]);
378+
expect(gridFocus.prevActiveCoords()).toEqual({row: 0, col: 0});
379+
});
380+
});
381+
382+
describe('focusCell', () => {
287383
it('should return false and not change state if grid is disabled', () => {
288-
const activeCoords = signal({row: 0, column: 0});
384+
const activeCoords = signal({row: 0, col: 0});
289385
const {gridFocus, cells} = setupGridFocus({
290386
activeCoords,
291387
disabled: signal(true),
292388
});
293389

294-
const success = gridFocus.focus({row: 1, column: 0});
390+
const success = gridFocus.focusCell(cells[1][0]);
295391

296392
expect(success).toBeFalse();
297-
expect(activeCoords()).toEqual({row: 0, column: 0});
393+
expect(activeCoords()).toEqual({row: 0, col: 0});
298394
expect(cells[1][0].element().focus).not.toHaveBeenCalled();
299395
});
300396

301397
it('should return false and not change state if cell is not focusable', () => {
302-
const activeCoords = signal({row: 0, column: 0});
398+
const activeCoords = signal({row: 0, col: 0});
303399
const {gridFocus, cells} = setupGridFocus({activeCoords});
304400
cells[1][0].disabled.set(true);
305401

306-
const success = gridFocus.focus({row: 1, column: 0});
402+
const success = gridFocus.focusCell(cells[1][0]);
307403

308404
expect(success).toBeFalse();
309-
expect(activeCoords()).toEqual({row: 0, column: 0});
405+
expect(activeCoords()).toEqual({row: 0, col: 0});
310406
expect(cells[1][0].element().focus).not.toHaveBeenCalled();
311407
});
312408

313409
it('should focus cell, update activeCell and prevActiveCell in "roving" mode', () => {
314-
const activeCoords = signal({row: 0, column: 0});
410+
const activeCoords = signal({row: 0, col: 0});
315411
const {gridFocus, cells} = setupGridFocus({
316412
activeCoords,
317413
focusMode: signal('roving'),
318414
});
319415

320-
const success = gridFocus.focus({row: 1, column: 0});
416+
const success = gridFocus.focusCell(cells[1][0]);
321417

322418
expect(success).toBeTrue();
323-
expect(activeCoords()).toEqual({row: 1, column: 0});
419+
expect(activeCoords()).toEqual({row: 1, col: 0});
324420
expect(cells[1][0].element().focus).toHaveBeenCalled();
325421

326422
expect(gridFocus.activeCell()).toBe(cells[1][0]);
327-
expect(gridFocus.prevActiveCoords()).toEqual({row: 0, column: 0});
423+
expect(gridFocus.prevActiveCoords()).toEqual({row: 0, col: 0});
328424
});
329425

330426
it('should update activeCell and prevActiveCell but not call element.focus in "activedescendant" mode', () => {
331-
const activeCoords = signal({row: 0, column: 0});
427+
const activeCoords = signal({row: 0, col: 0});
332428
const {gridFocus, cells} = setupGridFocus({
333429
activeCoords,
334430
focusMode: signal('activedescendant'),
335431
});
336432

337-
const success = gridFocus.focus({row: 1, column: 0});
433+
const success = gridFocus.focusCell(cells[1][0]);
338434

339435
expect(success).toBeTrue();
340-
expect(activeCoords()).toEqual({row: 1, column: 0});
436+
expect(activeCoords()).toEqual({row: 1, col: 0});
341437
expect(cells[1][0].element().focus).not.toHaveBeenCalled();
342438

343439
expect(gridFocus.activeCell()).toBe(cells[1][0]);
344-
expect(gridFocus.prevActiveCoords()).toEqual({row: 0, column: 0});
440+
expect(gridFocus.prevActiveCoords()).toEqual({row: 0, col: 0});
345441
});
346442
});
347443
});

‎src/cdk-experimental/ui-patterns/behaviors/grid-focus/grid-focus.ts

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,37 @@ export interface GridFocusInputs<T extends GridFocusCell> {
4545
cells: SignalLike<T[][]>;
4646

4747
/** The coordinates (row and column) of the current active cell. */
48-
activeCoords: WritableSignalLike<{row: number; column: number}>;
48+
activeCoords: WritableSignalLike<RowCol>;
4949

5050
/** Whether disabled cells in the grid should be skipped when navigating. */
5151
skipDisabled: SignalLike<boolean>;
5252
}
5353

54+
/** Represents coordinates in a grid. */
55+
export interface RowCol {
56+
/** The row index. */
57+
row: number;
58+
59+
/** The column index. */
60+
col: number;
61+
}
62+
5463
/** Controls focus for a 2D grid of cells. */
5564
export class GridFocus<T extends GridFocusCell> {
5665
/** The last active cell coordinates. */
57-
prevActiveCoords = signal<{row: number; column: number}>({row: 0, column: 0});
66+
prevActiveCoords = signal<RowCol>({row: 0, col: 0});
5867

5968
/** The current active cell based on `activeCoords` coordinates. */
6069
activeCell = computed(() => this.getCell(this.inputs.activeCoords()));
6170

71+
/** The number of rows in the grid. */
72+
rowCount = computed(() => this.inputs.cells().length);
73+
74+
/** The number of columns in the grid. */
75+
colCount = computed(() => {
76+
return this.inputs.cells()[0].reduce((count, curr) => count + curr.colspan(), 0);
77+
});
78+
6279
constructor(readonly inputs: GridFocusInputs<T>) {}
6380

6481
/** The id of the current active cell, for ARIA activedescendant. */
@@ -98,28 +115,50 @@ export class GridFocus<T extends GridFocusCell> {
98115
return this.activeCell() === cell ? 0 : -1;
99116
}
100117

118+
/** Focuses the given cell. */
119+
focusCell(cell: T): boolean {
120+
if (this.isGridDisabled()) {
121+
return false;
122+
}
123+
124+
if (!this.isFocusable(cell)) {
125+
return false;
126+
}
127+
128+
this.prevActiveCoords.set(this.inputs.activeCoords());
129+
this.inputs.activeCoords.set({row: cell.rowindex(), col: cell.colindex()});
130+
this._focus(cell);
131+
132+
return true;
133+
}
134+
101135
/** Moves focus to the cell at the given coordinates if it's part of a focusable cell. */
102-
focus(coordinates: {row: number; column: number}): boolean {
136+
focusCoordinates(coordinates: RowCol): boolean {
103137
if (this.isGridDisabled()) {
104138
return false;
105139
}
106140

107141
const cell = this.getCell(coordinates);
108142

109143
if (!cell || !this.isFocusable(cell)) {
110-
return false; // No cell at coordinates, or cell is not focusable.
144+
return false;
111145
}
112146

113147
this.prevActiveCoords.set(this.inputs.activeCoords());
114-
this.inputs.activeCoords.set(coordinates); // Set activeCoords to the exact coordinates
148+
this.inputs.activeCoords.set(coordinates);
149+
this._focus(cell);
115150

151+
return true;
152+
}
153+
154+
/** Handles conditionally calling `focus` on the HTML element of the cell. */
155+
private _focus(cell: T) {
116156
if (this.inputs.focusMode() === 'roving') {
117-
const element = cell.element(); // Element of the cell that *covers* these coordinates
157+
const element = cell.element();
118158
if (element && typeof element.focus === 'function') {
119159
element.focus();
120160
}
121161
}
122-
return true;
123162
}
124163

125164
/** Returns true if the given cell can be navigated to. */
@@ -128,9 +167,9 @@ export class GridFocus<T extends GridFocusCell> {
128167
}
129168

130169
/** Finds the top-left anchor coordinates of a given cell instance in the grid. */
131-
getCoordinates(cellToFind: T): {row: number; column: number} | void {
170+
getCoordinates(cellToFind: T): RowCol | void {
132171
const grid = this.inputs.cells();
133-
const occupiedCells = new Set<string>(); // Stores "row,column" string keys for occupied cells.
172+
const occupiedCells = new Set<string>();
134173

135174
for (let rowindex = 0; rowindex < grid.length; rowindex++) {
136175
let colindex = 0;
@@ -144,7 +183,7 @@ export class GridFocus<T extends GridFocusCell> {
144183

145184
// Check if this is the cell we're looking for.
146185
if (gridCell === cellToFind) {
147-
return {row: rowindex, column: colindex};
186+
return {row: rowindex, col: colindex};
148187
}
149188

150189
const rowspan = gridCell.rowspan();
@@ -167,14 +206,14 @@ export class GridFocus<T extends GridFocusCell> {
167206
}
168207

169208
/** Gets the cell that covers the given coordinates, considering rowspan and colspan. */
170-
getCell(coords: {row: number; column: number}): T | void {
209+
getCell(coords: RowCol): T | void {
171210
for (const row of this.inputs.cells()) {
172211
for (const cell of row) {
173212
if (
174213
coords.row >= cell.rowindex() &&
175214
coords.row <= cell.rowindex() + cell.rowspan() - 1 &&
176-
coords.column >= cell.colindex() &&
177-
coords.column <= cell.colindex() + cell.colspan() - 1
215+
coords.col >= cell.colindex() &&
216+
coords.col <= cell.colindex() + cell.colspan() - 1
178217
) {
179218
return cell;
180219
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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-navigation",
7+
srcs = ["grid-navigation.ts"],
8+
deps = [
9+
"//:node_modules/@angular/core",
10+
"//src/cdk-experimental/ui-patterns/behaviors/grid-focus",
11+
"//src/cdk-experimental/ui-patterns/behaviors/signal-like",
12+
],
13+
)
14+
15+
ts_project(
16+
name = "unit_test_sources",
17+
testonly = True,
18+
srcs = ["grid-navigation.spec.ts"],
19+
deps = [
20+
":grid-navigation",
21+
"//:node_modules/@angular/core",
22+
],
23+
)
24+
25+
ng_web_test_suite(
26+
name = "unit_tests",
27+
deps = [":unit_test_sources"],
28+
)

‎src/cdk-experimental/ui-patterns/behaviors/grid-navigation/grid-navigation.spec.ts

Lines changed: 1253 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
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 {SignalLike} from '../signal-like/signal-like';
10+
import {GridFocus, GridFocusCell, GridFocusInputs, RowCol} from '../grid-focus/grid-focus';
11+
import {computed} from '@angular/core';
12+
13+
/** Represents an item in a collection, such as a listbox option, than can be navigated to. */
14+
export interface GridNavigationCell extends GridFocusCell {}
15+
16+
/** Represents the required inputs for a collection that has navigable items. */
17+
export interface GridNavigationInputs<T extends GridNavigationCell> extends GridFocusInputs<T> {
18+
gridFocus: GridFocus<T>;
19+
wrap: SignalLike<boolean>;
20+
wrapBehavior: SignalLike<'continuous' | 'loop'>;
21+
}
22+
23+
/** Controls navigation for a grid of items. */
24+
export class GridNavigation<T extends GridNavigationCell> {
25+
rowcount = computed(() => this.inputs.gridFocus.rowCount());
26+
colcount = computed(() => this.inputs.gridFocus.colCount());
27+
28+
constructor(readonly inputs: GridNavigationInputs<T>) {}
29+
30+
/** Navigates to the given item. */
31+
gotoCell(cell?: T): boolean {
32+
return cell ? this.inputs.gridFocus.focusCell(cell) : false;
33+
}
34+
35+
/** Navigates to the given coordinates. */
36+
gotoCoords(coords: RowCol): boolean {
37+
return this.inputs.gridFocus.focusCoordinates(coords);
38+
}
39+
40+
/** Navigates to the item above the current item. */
41+
up(): boolean {
42+
return this._advance((cell: T, {col}: RowCol) => {
43+
const rowindex = cell.rowindex();
44+
const isRowWrapping = this.inputs.wrap() && rowindex - 1 < 0;
45+
const isColumnWrapping = isRowWrapping && this.inputs.wrapBehavior() === 'continuous';
46+
47+
const nextCoords = {
48+
row: isRowWrapping
49+
? (rowindex - 1 + this.rowcount()) % this.rowcount()
50+
: Math.max(rowindex - 1, 0),
51+
col: isColumnWrapping ? (col - 1 + this.colcount()) % this.colcount() : col,
52+
};
53+
54+
const nextCell = this.inputs.gridFocus.getCell(nextCoords)!;
55+
56+
return {
57+
row: nextCell.rowindex(),
58+
col: nextCoords.col,
59+
};
60+
});
61+
}
62+
63+
/** Navigates to the item below the current item. */
64+
down(): boolean {
65+
return this._advance((cell: T, {col}: RowCol) => {
66+
const rowspan = cell.rowspan();
67+
const rowindex = cell.rowindex();
68+
const isRowWrapping = this.inputs.wrap() && rowindex + rowspan >= this.rowcount();
69+
const isColumnWrapping = isRowWrapping && this.inputs.wrapBehavior() === 'continuous';
70+
71+
return {
72+
row: isRowWrapping
73+
? (rowindex + rowspan) % this.rowcount()
74+
: Math.min(rowindex + rowspan, this.rowcount() - 1),
75+
col: isColumnWrapping ? (col + 1 + this.colcount()) % this.colcount() : col,
76+
};
77+
});
78+
}
79+
80+
/** Navigates to the item to the left of the current item. */
81+
left(): boolean {
82+
return this._advance((cell: T, {row, col}: RowCol) => {
83+
const colindex = cell.colindex();
84+
const isColumnWrapping = this.inputs.wrap() && colindex - 1 < 0;
85+
const isRowWrapping = isColumnWrapping && this.inputs.wrapBehavior() === 'continuous';
86+
87+
const nextCoords = {
88+
row: isRowWrapping ? (row - 1 + this.rowcount()) % this.rowcount() : row,
89+
col: isColumnWrapping
90+
? (colindex - 1 + this.colcount()) % this.colcount()
91+
: Math.max(colindex - 1, 0),
92+
};
93+
94+
const nextCell = this.inputs.gridFocus.getCell(nextCoords)!;
95+
96+
return {
97+
row: nextCoords.row,
98+
col: nextCell.colindex(),
99+
};
100+
});
101+
}
102+
103+
/** Navigates to the item to the right of the current item. */
104+
right(): boolean {
105+
return this._advance((cell: T, {row}: RowCol) => {
106+
const colspan = cell.colspan();
107+
const colindex = cell.colindex();
108+
const isColumnWrapping = this.inputs.wrap() && colindex + colspan >= this.colcount();
109+
const isRowWrapping = isColumnWrapping && this.inputs.wrapBehavior() === 'continuous';
110+
111+
return {
112+
row: isRowWrapping ? (row + 1 + this.rowcount()) % this.rowcount() : row,
113+
col: isColumnWrapping
114+
? (colindex + colspan + this.colcount()) % this.colcount()
115+
: Math.min(colindex + colspan, this.colcount() - 1),
116+
};
117+
});
118+
}
119+
120+
/**
121+
* Continuously calls the given stepFn starting at the given coordinates
122+
* until either a new focusable cell is reached or the grid fully loops.
123+
*/
124+
private _advance(stepFn: (cell: T, coords: RowCol) => RowCol) {
125+
const startCoords = this.inputs.activeCoords();
126+
let prevCoords = {row: startCoords.row, col: startCoords.col};
127+
let nextCoords = {row: startCoords.row, col: startCoords.col};
128+
let nextCell = this.inputs.gridFocus.activeCell()!;
129+
130+
while (true) {
131+
prevCoords = {row: nextCoords.row, col: nextCoords.col};
132+
nextCoords = stepFn(nextCell, nextCoords);
133+
134+
// The step did not result in any change in coordinates.
135+
//
136+
// This will happen if the user is at a boundary (start/end row or col)
137+
// and tries to advance past it while `wrap` is false.
138+
if (nextCoords.row === prevCoords.row && nextCoords.col === prevCoords.col) {
139+
return false;
140+
}
141+
142+
// The step has resulted in arriving back to the original coordinates.
143+
//
144+
// This will happen if the other cells in the grid are unfocusable and `wrap`
145+
// is true. The `stepFn` will eventually loop all the way back to the original cells.
146+
if (nextCoords.row === startCoords.row && nextCoords.col === startCoords.col) {
147+
return false;
148+
}
149+
150+
nextCell = this.inputs.gridFocus.getCell(nextCoords)!;
151+
152+
// The `stepFn` has successfully reached a cell that is focusable.
153+
if (this.inputs.gridFocus.isFocusable(nextCell)) {
154+
return this.gotoCoords(nextCoords);
155+
}
156+
}
157+
}
158+
}

0 commit comments

Comments
 (0)
Please sign in to comment.