From 506c8297740de630449f6a9ca4ce4da339b4bfe7 Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Wed, 22 Oct 2025 06:31:59 +0000 Subject: [PATCH] fix(aria/grid): fix navigation bugs and add grid behavior unit tests --- src/aria/grid/grid.ts | 2 +- .../ui-patterns/behaviors/grid/BUILD.bazel | 17 +- .../behaviors/grid/grid-data.spec.ts | 337 +++ .../ui-patterns/behaviors/grid/grid-data.ts | 3 - .../behaviors/grid/grid-focus.spec.ts | 380 ++++ .../behaviors/grid/grid-navigation.spec.ts | 1924 +++++++++++++++++ .../behaviors/grid/grid-navigation.ts | 7 + .../behaviors/grid/grid-selection.spec.ts | 210 ++ .../ui-patterns/behaviors/grid/grid.spec.ts | 307 +++ src/aria/ui-patterns/grid/grid.ts | 7 +- 10 files changed, 3183 insertions(+), 11 deletions(-) create mode 100644 src/aria/ui-patterns/behaviors/grid/grid-data.spec.ts create mode 100644 src/aria/ui-patterns/behaviors/grid/grid-focus.spec.ts create mode 100644 src/aria/ui-patterns/behaviors/grid/grid-navigation.spec.ts create mode 100644 src/aria/ui-patterns/behaviors/grid/grid-selection.spec.ts create mode 100644 src/aria/ui-patterns/behaviors/grid/grid.spec.ts diff --git a/src/aria/grid/grid.ts b/src/aria/grid/grid.ts index 72adc8c974a4..dea2be0b027f 100644 --- a/src/aria/grid/grid.ts +++ b/src/aria/grid/grid.ts @@ -36,7 +36,7 @@ import {GridPattern, GridRowPattern, GridCellPattern, GridCellWidgetPattern} fro '(pointerdown)': 'pattern.onPointerdown($event)', '(pointermove)': 'pattern.onPointermove($event)', '(pointerup)': 'pattern.onPointerup($event)', - '(focusin)': 'pattern.onFocusIn($event)', + '(focusin)': 'pattern.onFocusIn()', '(focusout)': 'pattern.onFocusOut($event)', }, }) diff --git a/src/aria/ui-patterns/behaviors/grid/BUILD.bazel b/src/aria/ui-patterns/behaviors/grid/BUILD.bazel index 551c00f0bfb3..2897933bb80f 100644 --- a/src/aria/ui-patterns/behaviors/grid/BUILD.bazel +++ b/src/aria/ui-patterns/behaviors/grid/BUILD.bazel @@ -1,4 +1,4 @@ -load("//tools:defaults.bzl", "ts_project") +load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project") package(default_visibility = ["//visibility:public"]) @@ -13,3 +13,18 @@ ts_project( "//src/aria/ui-patterns/behaviors/signal-like", ], ) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":grid", + "//:node_modules/@angular/core", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/aria/ui-patterns/behaviors/grid/grid-data.spec.ts b/src/aria/ui-patterns/behaviors/grid/grid-data.spec.ts new file mode 100644 index 000000000000..2702a0584139 --- /dev/null +++ b/src/aria/ui-patterns/behaviors/grid/grid-data.spec.ts @@ -0,0 +1,337 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * 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 {signal, Signal, WritableSignal} from '@angular/core'; +import {BaseGridCell, GridData} from './grid-data'; + +export interface TestBaseGridCell extends BaseGridCell { + rowSpan: WritableSignal; + colSpan: WritableSignal; + id: Signal; +} + +/** + * GRID A: + * ┌─────┬─────┬─────┐ + * │ 0,0 │ 0,1 │ 0,2 │ + * ├─────┼─────┼─────┤ + * │ 1,0 │ 1,1 │ 1,2 │ + * ├─────┼─────┼─────┤ + * │ 2,0 │ 2,1 │ 2,2 │ + * └─────┴─────┴─────┘ + */ +export function createGridA(): TestBaseGridCell[][] { + return [ + [ + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-0-0')}, + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-0-1')}, + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-0-2')}, + ], + [ + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-1-0')}, + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-1-1')}, + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-1-2')}, + ], + [ + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-2-0')}, + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-2-1')}, + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-2-2')}, + ], + ]; +} + +/** + * GRID B: + * ┌─────┬─────┬─────┐ + * │ 0,0 │ 0,1 │ 0,2 │ + * ├─────┼─────┤ │ + * │ 1,0 │ 1,1 │ │ + * ├─────┤ ├─────┤ + * │ 2,0 │ │ 2,2 │ + * │ ├─────┼─────┤ + * │ │ 3,1 │ 3,2 │ + * └─────┴─────┴─────┘ + */ +export function createGridB(): TestBaseGridCell[][] { + return [ + [ + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-0-0')}, + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-0-1')}, + {rowSpan: signal(2), colSpan: signal(1), id: signal('cell-0-2')}, + ], + [ + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-1-0')}, + {rowSpan: signal(2), colSpan: signal(1), id: signal('cell-1-1')}, + ], + [ + {rowSpan: signal(2), colSpan: signal(1), id: signal('cell-2-0')}, + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-2-2')}, + ], + [ + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-3-1')}, + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-3-2')}, + ], + ]; +} + +/** + * GRID C: + * ┌───────────┬─────┬─────┐ + * │ 0,0 │ 0,2 │ 0,3 │ + * ├─────┬─────┴─────┼─────┤ + * │ 1,0 │ 1,1 │ 1,3 │ + * ├─────┼─────┬─────┴─────┤ + * │ 2,0 │ 2,1 │ 2,2 │ + * └─────┴─────┴───────────┘ + */ +export function createGridC(): TestBaseGridCell[][] { + return [ + [ + {rowSpan: signal(1), colSpan: signal(2), id: signal('cell-0-0')}, + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-0-2')}, + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-0-3')}, + ], + [ + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-1-0')}, + {rowSpan: signal(1), colSpan: signal(2), id: signal('cell-1-1')}, + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-1-3')}, + ], + [ + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-2-0')}, + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-2-1')}, + {rowSpan: signal(1), colSpan: signal(2), id: signal('cell-2-2')}, + ], + ]; +} + +/** + * GRID D: + * ┌─────┬───────────┬─────┐ + * │ 0,0 │ 0,1 │ 0,3 │ + * │ ├───────────┼─────┤ + * │ │ 1,1 │ 1,3 │ + * ├─────┤ │ │ + * │ 2,0 │ │ │ + * ├─────┼─────┬─────┴─────┤ + * │ 3,0 │ 3,1 │ 3,2 │ + * └─────┴─────┴───────────┘ + */ +export function createGridD(): TestBaseGridCell[][] { + return [ + [ + {rowSpan: signal(2), colSpan: signal(1), id: signal('cell-0-0')}, + {rowSpan: signal(1), colSpan: signal(2), id: signal('cell-0-1')}, + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-0-3')}, + ], + [ + {rowSpan: signal(2), colSpan: signal(2), id: signal('cell-1-1')}, + {rowSpan: signal(2), colSpan: signal(1), id: signal('cell-1-3')}, + ], + [{rowSpan: signal(1), colSpan: signal(1), id: signal('cell-2-0')}], + [ + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-3-0')}, + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-3-1')}, + {rowSpan: signal(1), colSpan: signal(2), id: signal('cell-3-2')}, + ], + ]; +} + +/** + * GRID E: Uneven rows (jagged) + * ┌─────┬─────┬─────┐ + * │ 0,0 │ 0,1 │ 0,2 │ + * ├─────┤ ├─────┘ + * │ 1,0 │ │ + * ├─────┼─────┤ + * │ 2,0 │ 2,1 │ + * └─────┴─────┴ + */ +export function createGridE(): TestBaseGridCell[][] { + return [ + [ + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-0-0')}, + {rowSpan: signal(2), colSpan: signal(1), id: signal('cell-0-1')}, + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-0-2')}, + ], + [{rowSpan: signal(1), colSpan: signal(1), id: signal('cell-1-0')}], + [ + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-2-0')}, + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-2-1')}, + ], + ]; +} + +/** + * GRID F: Grid with empty rows + * ┌─────┬─────┬─────┐ + * │ 0,0 │ 0,1 │ 0,2 │ + * ├─────┼─────┼─────┤ + * │ │ │ │ + * ├─────┼─────┼─────┤ + * │ 2,0 │ 2,1 │ 2,2 │ + * └─────┴─────┴─────┘ + */ +export function createGridF(): TestBaseGridCell[][] { + return [ + [ + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-0-0')}, + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-0-1')}, + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-0-2')}, + ], + [], + [ + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-2-0')}, + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-2-1')}, + {rowSpan: signal(1), colSpan: signal(1), id: signal('cell-2-2')}, + ], + ]; +} + +function createGridData(cells: TestBaseGridCell[][]): GridData { + return new GridData({cells: signal(cells)}); +} + +describe('GridData', () => { + describe('rowCount', () => { + it('should return the number of rows in the grid', () => {}); + }); + + describe('maxRowCount', () => { + it('should return the maximum number of rows, accounting for row spans', () => { + const gridA = createGridData(createGridA()); + expect(gridA.maxRowCount()).toBe(3); + + const gridB = createGridData(createGridB()); + expect(gridB.maxRowCount()).toBe(4); + + const gridC = createGridData(createGridC()); + expect(gridC.maxRowCount()).toBe(3); + + const gridD = createGridData(createGridD()); + expect(gridD.maxRowCount()).toBe(4); + + const gridF = createGridData(createGridE()); + expect(gridF.maxRowCount()).toBe(3); + + const gridG = createGridData(createGridF()); + expect(gridG.maxRowCount()).toBe(3); + }); + }); + + describe('maxColCount', () => { + it('should return the maximum number of columns, accounting for column spans', () => { + const gridA = createGridData(createGridA()); + expect(gridA.maxColCount()).toBe(3); + + const gridB = createGridData(createGridB()); + expect(gridB.maxColCount()).toBe(3); + + const gridC = createGridData(createGridC()); + expect(gridC.maxColCount()).toBe(4); + + const gridD = createGridData(createGridD()); + expect(gridD.maxColCount()).toBe(4); + + const gridE = createGridData(createGridE()); + expect(gridE.maxColCount()).toBe(3); + + const gridF = createGridData(createGridF()); + expect(gridF.maxColCount()).toBe(3); + }); + }); + + describe('getCell', () => { + it('should get the cell at the given coordinates', () => { + const cells = createGridD(); + const grid = createGridData(cells); + + expect(grid.getCell({row: 0, col: 0})).toBe(cells[0][0]); + expect(grid.getCell({row: 1, col: 0})).toBe(cells[0][0]); + expect(grid.getCell({row: 0, col: 1})).toBe(cells[0][1]); + expect(grid.getCell({row: 0, col: 2})).toBe(cells[0][1]); + expect(grid.getCell({row: 1, col: 1})).toBe(cells[1][0]); + expect(grid.getCell({row: 2, col: 2})).toBe(cells[1][0]); + }); + + it('should return undefined for out-of-bounds coordinates', () => { + const grid = createGridData(createGridA()); + expect(grid.getCell({row: 5, col: 5})).toBeUndefined(); + expect(grid.getCell({row: -1, col: 0})).toBeUndefined(); + }); + }); + + describe('getCoords', () => { + it('should get the primary coordinates of the given cell', () => { + const cells = createGridD(); + const grid = createGridData(cells); + + expect(grid.getCoords(cells[0][0])).toEqual({row: 0, col: 0}); + expect(grid.getCoords(cells[1][0])).toEqual({row: 1, col: 1}); + expect(grid.getCoords(cells[3][2])).toEqual({row: 3, col: 2}); + }); + }); + + describe('getAllCoords', () => { + it('should get all coordinates that the given cell spans', () => { + const cells = createGridD(); + const grid = createGridData(cells); + + expect(grid.getAllCoords(cells[0][0])).toEqual([ + {row: 0, col: 0}, + {row: 1, col: 0}, + ]); + expect(grid.getAllCoords(cells[1][0])).toEqual([ + {row: 1, col: 1}, + {row: 1, col: 2}, + {row: 2, col: 1}, + {row: 2, col: 2}, + ]); + expect(grid.getAllCoords(cells[3][2])).toEqual([ + {row: 3, col: 2}, + {row: 3, col: 3}, + ]); + }); + }); + + describe('getRowCount', () => { + it('should get the number of rows in the given column', () => { + const grid = createGridData(createGridD()); + expect(grid.getRowCount(0)).toBe(4); + expect(grid.getRowCount(1)).toBe(4); + expect(grid.getRowCount(2)).toBe(4); + expect(grid.getRowCount(3)).toBe(4); + }); + + it('should return undefined for an out-of-bounds column', () => { + const grid = createGridData(createGridA()); + expect(grid.getRowCount(5)).toBeUndefined(); + expect(grid.getRowCount(-1)).toBeUndefined(); + }); + }); + + describe('getColCount', () => { + it('should get the number of columns in the given row', () => { + const gridD = createGridData(createGridD()); + expect(gridD.getColCount(0)).toBe(4); + expect(gridD.getColCount(1)).toBe(4); + expect(gridD.getColCount(2)).toBe(4); + expect(gridD.getColCount(3)).toBe(4); + + const gridE = createGridData(createGridE()); + expect(gridE.getColCount(0)).toBe(3); + expect(gridE.getColCount(1)).toBe(2); + expect(gridE.getColCount(2)).toBe(2); + }); + + it('should return undefined for an out-of-bounds row', () => { + const grid = createGridData(createGridA()); + expect(grid.getColCount(5)).toBeUndefined(); + expect(grid.getColCount(-1)).toBeUndefined(); + }); + }); +}); diff --git a/src/aria/ui-patterns/behaviors/grid/grid-data.ts b/src/aria/ui-patterns/behaviors/grid/grid-data.ts index b3065e97ac13..719147bae197 100644 --- a/src/aria/ui-patterns/behaviors/grid/grid-data.ts +++ b/src/aria/ui-patterns/behaviors/grid/grid-data.ts @@ -41,9 +41,6 @@ export class GridData { /** The two-dimensional array of cells that represents the grid. */ readonly cells: SignalLike; - /** The number of rows in the grid. */ - readonly rowCount = computed(() => this.cells().length); - /** The maximum number of rows in the grid, accounting for row spans. */ readonly maxRowCount = computed(() => Math.max(...this._rowCountByCol().values(), 0)); diff --git a/src/aria/ui-patterns/behaviors/grid/grid-focus.spec.ts b/src/aria/ui-patterns/behaviors/grid/grid-focus.spec.ts new file mode 100644 index 000000000000..d9f582dae2be --- /dev/null +++ b/src/aria/ui-patterns/behaviors/grid/grid-focus.spec.ts @@ -0,0 +1,380 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * 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 {signal, Signal, WritableSignal} from '@angular/core'; +import {GridData} from './grid-data'; +import {createGridA, createGridB, createGridD, TestBaseGridCell} from './grid-data.spec'; +import {GridFocus, GridFocusInputs} from './grid-focus'; + +export interface TestGridFocusCell extends TestBaseGridCell { + element: WritableSignal; + disabled: WritableSignal; +} + +function createTestCell(): Omit { + return { + element: signal(document.createElement('div')), + disabled: signal(false), + }; +} + +function createTestGrid(createGridFn: () => TestBaseGridCell[][]): TestGridFocusCell[][] { + return createGridFn().map(row => + row.map(cell => { + return {...createTestCell(), ...cell}; + }), + ); +} + +function setupGridFocus( + cells: Signal, + gridFocusInputs: Partial = {}, +): GridFocus { + const gridData = new GridData({cells}); + return new GridFocus({ + grid: gridData, + focusMode: signal('roving'), + disabled: signal(false), + skipDisabled: signal(true), + ...gridFocusInputs, + }); +} + +describe('GridFocus', () => { + describe('stateEmpty', () => { + it('should be true initially', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells)); + expect(gridFocus.stateEmpty()).toBe(true); + }); + + it('should be false after focusing a cell', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells)); + + gridFocus.focusCell(cells[1][1]); + + expect(gridFocus.stateEmpty()).toBe(false); + }); + + it('should be true if activeCell is undefined', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells)); + + // Manually create a partially-empty state. + gridFocus.activeCell.set(undefined); + gridFocus.activeCoords.set({row: 1, col: 1}); + + expect(gridFocus.stateEmpty()).toBe(true); + }); + }); + + describe('stateStale', () => { + it('should be true if the active cell is no longer in the grid', () => { + const cells = createTestGrid(createGridA); + const cellsSignal = signal(cells); + const gridFocus = setupGridFocus(cellsSignal); + + gridFocus.focusCell(cells[1][1]); + + // Remove the active cell from the grid. + const newCells = createTestGrid(createGridA); + newCells[1].splice(1, 1); + cellsSignal.set(newCells); + + expect(gridFocus.stateStale()).toBe(true); + }); + + it('should be true if the active coordinates point to a different cell', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells)); + + gridFocus.focusCell(cells[1][1]); + + // Manually set the active coordinates to a different cell. + gridFocus.activeCoords.set({row: 0, col: 0}); + + expect(gridFocus.stateStale()).toBe(true); + }); + + it('should be false if the active cell and coordinates are valid and in sync', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells)); + + gridFocus.focusCell(cells[1][1]); + + expect(gridFocus.stateStale()).toBe(false); + }); + }); + + describe('activeDescendant', () => { + it('should return the ID of the active cell in activedescendant mode', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells), { + focusMode: signal('activedescendant'), + }); + + gridFocus.focusCell(cells[1][1]); + + expect(gridFocus.activeDescendant()).toBe('cell-1-1'); + }); + + it('should be undefined in roving focus mode', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells), {focusMode: signal('roving')}); + + gridFocus.focusCell(cells[1][1]); + + expect(gridFocus.activeDescendant()).toBeUndefined(); + }); + + it('should be undefined if the grid is disabled', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells), { + focusMode: signal('activedescendant'), + disabled: signal(true), + }); + + gridFocus.activeCell.set(cells[1][1]); + + expect(gridFocus.activeDescendant()).toBeUndefined(); + }); + }); + + describe('gridDisabled', () => { + it('should be true if the grid is disabled via inputs', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells), { + disabled: signal(true), + }); + expect(gridFocus.gridDisabled()).toBe(true); + }); + + it('should be true if all cells are disabled', () => { + const cells = createTestGrid(createGridA); + for (const row of cells) { + for (const cell of row) { + cell.disabled.set(true); + } + } + const gridFocus = setupGridFocus(signal(cells)); + expect(gridFocus.gridDisabled()).toBe(true); + }); + + it('should be true if there are no cells', () => { + const gridFocus = setupGridFocus(signal([])); + expect(gridFocus.gridDisabled()).toBe(true); + }); + + it('should be false if at least one cell is enabled', () => { + const cells = createTestGrid(createGridA); + for (const row of cells) { + for (const cell of row) { + cell.disabled.set(true); + } + } + // Enable one cell. + cells[1][1].disabled.set(false); + const gridFocus = setupGridFocus(signal(cells)); + expect(gridFocus.gridDisabled()).toBe(false); + }); + }); + + describe('gridTabIndex', () => { + it('should be 0 in activedescendant mode', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells), { + focusMode: signal('activedescendant'), + }); + expect(gridFocus.gridTabIndex()).toBe(0); + }); + + it('should be -1 in roving focus mode', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells), { + focusMode: signal('roving'), + }); + expect(gridFocus.gridTabIndex()).toBe(-1); + }); + + it('should be 0 if the grid is disabled', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells), { + disabled: signal(true), + }); + expect(gridFocus.gridTabIndex()).toBe(0); + }); + }); + + describe('getCellTabindex', () => { + it('should return 0 for the active cell in roving mode', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells), { + focusMode: signal('roving'), + }); + + gridFocus.focusCell(cells[1][1]); + + expect(gridFocus.getCellTabindex(cells[1][1])).toBe(0); + }); + + it('should return -1 for inactive cells in roving mode', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells), { + focusMode: signal('roving'), + }); + + gridFocus.focusCell(cells[1][1]); + + expect(gridFocus.getCellTabindex(cells[0][0])).toBe(-1); + expect(gridFocus.getCellTabindex(cells[2][2])).toBe(-1); + }); + + it('should return -1 for all cells in activedescendant mode', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells), { + focusMode: signal('activedescendant'), + }); + + gridFocus.focusCell(cells[1][1]); + + expect(gridFocus.getCellTabindex(cells[0][0])).toBe(-1); + expect(gridFocus.getCellTabindex(cells[1][1])).toBe(-1); + }); + + it('should return -1 for all cells when the grid is disabled', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells), {disabled: signal(true)}); + expect(gridFocus.getCellTabindex(cells[1][1])).toBe(-1); + }); + }); + + describe('isFocusable', () => { + it('should return true for an enabled cell', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells)); + expect(gridFocus.isFocusable(cells[1][1])).toBe(true); + }); + + it('should return false for a disabled cell when skipDisabled is true', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells), {skipDisabled: signal(true)}); + + cells[1][1].disabled.set(true); + + expect(gridFocus.isFocusable(cells[1][1])).toBe(false); + }); + + it('should return true for a disabled cell when skipDisabled is false', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells), {skipDisabled: signal(false)}); + + cells[1][1].disabled.set(true); + + expect(gridFocus.isFocusable(cells[1][1])).toBe(true); + }); + }); + + describe('focusCell', () => { + it('should set the active cell and coordinates', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells)); + + const result = gridFocus.focusCell(cells[1][1]); + + expect(result).toBe(true); + expect(gridFocus.activeCell()).toBe(cells[1][1]); + expect(gridFocus.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('should not focus a disabled cell if skipDisabled is true', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells), {skipDisabled: signal(true)}); + + gridFocus.focusCell(cells[0][0]); + cells[1][1].disabled.set(true); + + const result = gridFocus.focusCell(cells[1][1]); + + expect(result).toBe(false); + expect(gridFocus.activeCell()).toBe(cells[0][0]); + expect(gridFocus.activeCoords()).toEqual({row: 0, col: 0}); + }); + + it('should focus a disabled cell if skipDisabled is false', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells), {skipDisabled: signal(false)}); + + cells[1][1].disabled.set(true); + const result = gridFocus.focusCell(cells[1][1]); + + expect(result).toBe(true); + expect(gridFocus.activeCell()).toBe(cells[1][1]); + expect(gridFocus.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('should return false if the cell is not in the grid', () => { + const cells = createTestGrid(createGridA); + const gridFocus = setupGridFocus(signal(cells)); + const unrelatedCell = createTestGrid(createGridB)[0][0]; + + const result = gridFocus.focusCell(unrelatedCell); + + expect(result).toBe(false); + }); + }); + + describe('focusCoordinates', () => { + it('should set the active cell and coordinates', () => { + const cells = createTestGrid(createGridD); + const gridFocus = setupGridFocus(signal(cells)); + + const result = gridFocus.focusCoordinates({row: 1, col: 2}); + + expect(result).toBe(true); + // The cell at `[1][0]` spans `[1,1]`, `[1,2]`, `[2,1]`, and `[2,2]`. + expect(gridFocus.activeCell()).toBe(cells[1][0]); + expect(gridFocus.activeCoords()).toEqual({row: 1, col: 2}); + }); + + it('should not focus coordinates of a disabled cell if skipDisabled is true', () => { + const cells = createTestGrid(createGridD); + const gridFocus = setupGridFocus(signal(cells), {skipDisabled: signal(true)}); + + gridFocus.focusCoordinates({row: 0, col: 0}); + cells[1][0].disabled.set(true); // This cell spans {row: 1, col: 2} + + const result = gridFocus.focusCoordinates({row: 1, col: 2}); + + expect(result).toBe(false); + expect(gridFocus.activeCell()).toBe(cells[0][0]); + expect(gridFocus.activeCoords()).toEqual({row: 0, col: 0}); + }); + + it('should focus coordinates of a disabled cell if skipDisabled is false', () => { + const cells = createTestGrid(createGridD); + const gridFocus = setupGridFocus(signal(cells), {skipDisabled: signal(false)}); + + cells[1][0].disabled.set(true); // This cell spans {row: 1, col: 2} + const result = gridFocus.focusCoordinates({row: 1, col: 2}); + + expect(result).toBe(true); + expect(gridFocus.activeCell()).toBe(cells[1][0]); + expect(gridFocus.activeCoords()).toEqual({row: 1, col: 2}); + }); + + it('should return false for out-of-bounds coordinates', () => { + const cells = createTestGrid(createGridD); + const gridFocus = setupGridFocus(signal(cells)); + + const result = gridFocus.focusCoordinates({row: 10, col: 10}); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/aria/ui-patterns/behaviors/grid/grid-navigation.spec.ts b/src/aria/ui-patterns/behaviors/grid/grid-navigation.spec.ts new file mode 100644 index 000000000000..f93d2cb52a09 --- /dev/null +++ b/src/aria/ui-patterns/behaviors/grid/grid-navigation.spec.ts @@ -0,0 +1,1924 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * 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 {signal, Signal, WritableSignal} from '@angular/core'; +import {GridData} from './grid-data'; +import { + createGridA, + createGridB, + createGridC, + createGridD, + createGridE, + createGridF, + TestBaseGridCell, +} from './grid-data.spec'; +import {GridFocus, GridFocusInputs} from './grid-focus'; +import {direction, GridNavigation, GridNavigationInputs, WrapStrategy} from './grid-navigation'; + +export interface TestGridNavigationCell extends TestBaseGridCell { + element: WritableSignal; + disabled: WritableSignal; +} + +function createTestCell(): Omit { + return { + element: signal(document.createElement('div')), + disabled: signal(false), + }; +} + +function createTestGrid(createGridFn: () => TestBaseGridCell[][]): TestGridNavigationCell[][] { + return createGridFn().map((row, r) => + row.map((cell, c) => { + return {...createTestCell(), ...cell}; + }), + ); +} + +function setupGridNavigation( + cells: Signal, + inputs: Partial = {}, +): { + gridNav: GridNavigation; + gridFocus: GridFocus; +} { + const gridData = new GridData({cells}); + const gridFocusInputs: GridFocusInputs = { + focusMode: signal('roving'), + disabled: signal(false), + skipDisabled: signal(true), + }; + const gridFocus = new GridFocus({ + grid: gridData, + ...gridFocusInputs, + ...inputs, + }); + + const gridNav = new GridNavigation({ + grid: gridData, + gridFocus: gridFocus, + rowWrap: signal('loop'), + colWrap: signal('loop'), + ...gridFocusInputs, + ...inputs, + }); + + return { + gridNav, + gridFocus, + }; +} + +describe('GridNavigation', () => { + describe('gotoCell', () => { + it('should focus the given cell', () => { + const cells = createTestGrid(createGridA); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells)); + + const result = gridNav.gotoCell(cells[1][1]); + + expect(result).toBe(true); + expect(gridFocus.activeCell()).toBe(cells[1][1]); + expect(gridFocus.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('should return false if the cell cannot be focused', () => { + const cells = createTestGrid(createGridA); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + skipDisabled: signal(true), + }); + + cells[1][1].disabled.set(true); + const result = gridNav.gotoCell(cells[1][1]); + + expect(result).toBe(false); + expect(gridFocus.activeCell()).toBeUndefined(); + }); + }); + + describe('gotoCoords', () => { + it('should focus the cell at the given coordinates', () => { + const cells = createTestGrid(createGridD); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells)); + + const result = gridNav.gotoCoords({row: 1, col: 2}); + + expect(result).toBe(true); + // The cell at `[1][0]` spans `[1,1]`, `[1,2]`, `[2,1]`, and `[2,2]`. + expect(gridFocus.activeCell()).toBe(cells[1][0]); + expect(gridFocus.activeCoords()).toEqual({row: 1, col: 2}); + }); + + it('should return false if the coordinates cannot be focused', () => { + const cells = createTestGrid(createGridD); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells)); + + cells[1][0].disabled.set(true); // This cell spans {row: 1, col: 2} + const result = gridNav.gotoCoords({row: 1, col: 2}); + + expect(result).toBe(false); + expect(gridFocus.activeCell()).toBeUndefined(); + }); + }); + + describe('peek', () => { + let cells: TestGridNavigationCell[][]; + let gridNav: GridNavigation; + let gridFocus: GridFocus; + + beforeEach(() => { + cells = createTestGrid(createGridB); + const setup = setupGridNavigation(signal(cells)); + gridNav = setup.gridNav; + gridFocus = setup.gridFocus; + }); + + describe('up', () => { + it('should get the next coordinates without changing focus', () => { + gridNav.gotoCoords({row: 1, col: 0}); + + const nextCoords = gridNav.peek(direction.Up, gridFocus.activeCoords()); + + expect(nextCoords).toEqual({row: 0, col: 0}); + expect(gridFocus.activeCoords()).toEqual({row: 1, col: 0}); + }); + + it('should respect the wrap strategy', () => { + const from = {row: 0, col: 0}; + gridNav.gotoCoords(from); + expect(gridNav.peek(direction.Up, from, 'loop')).toEqual({row: 3, col: 0}); + expect(gridNav.peek(direction.Up, from, 'nowrap')).toBeUndefined(); + expect(gridNav.peek(direction.Up, from, 'continuous')).toEqual({row: 3, col: 2}); + }); + + it('should return undefined if no focusable cell is found', () => { + cells.flat().forEach(cell => cell.disabled.set(true)); + gridNav.gotoCoords({row: 1, col: 0}); + + const nextCoords = gridNav.peek(direction.Up, gridFocus.activeCoords()); + + expect(nextCoords).toBeUndefined(); + }); + }); + + describe('down', () => { + it('should get the next coordinates without changing focus', () => { + gridNav.gotoCoords({row: 1, col: 0}); + + const nextCoords = gridNav.peek(direction.Down, gridFocus.activeCoords()); + + expect(nextCoords).toEqual({row: 2, col: 0}); + expect(gridFocus.activeCoords()).toEqual({row: 1, col: 0}); + }); + + it('should respect the wrap strategy', () => { + const from = {row: 3, col: 1}; + gridNav.gotoCoords(from); + expect(gridNav.peek(direction.Down, from, 'loop')).toEqual({row: 0, col: 1}); + expect(gridNav.peek(direction.Down, from, 'nowrap')).toBeUndefined(); + expect(gridNav.peek(direction.Down, from, 'continuous')).toEqual({row: 0, col: 2}); + }); + + it('should return undefined if no focusable cell is found', () => { + cells.flat().forEach(cell => cell.disabled.set(true)); + gridNav.gotoCoords({row: 1, col: 0}); + + const nextCoords = gridNav.peek(direction.Down, gridFocus.activeCoords()); + + expect(nextCoords).toBeUndefined(); + }); + }); + + describe('left', () => { + it('should get the next coordinates without changing focus', () => { + gridNav.gotoCoords({row: 0, col: 1}); + + const nextCoords = gridNav.peek(direction.Left, gridFocus.activeCoords()); + + expect(nextCoords).toEqual({row: 0, col: 0}); + expect(gridFocus.activeCoords()).toEqual({row: 0, col: 1}); + }); + + it('should respect the wrap strategy', () => { + const from = {row: 0, col: 0}; + gridNav.gotoCoords(from); + expect(gridNav.peek(direction.Left, from, 'loop')).toEqual({row: 0, col: 2}); + expect(gridNav.peek(direction.Left, from, 'nowrap')).toBeUndefined(); + expect(gridNav.peek(direction.Left, from, 'continuous')).toEqual({row: 3, col: 2}); + }); + + it('should return undefined if no focusable cell is found', () => { + cells.flat().forEach(function (cell) { + cell.disabled.set(true); + }); + gridNav.gotoCoords({row: 1, col: 0}); + + const nextCoords = gridNav.peek(direction.Left, gridFocus.activeCoords()); + + expect(nextCoords).toBeUndefined(); + }); + }); + + describe('right', () => { + it('should get the next coordinates without changing focus', () => { + gridNav.gotoCoords({row: 0, col: 1}); + + const nextCoords = gridNav.peek(direction.Right, gridFocus.activeCoords()); + + expect(nextCoords).toEqual({row: 0, col: 2}); + expect(gridFocus.activeCoords()).toEqual({row: 0, col: 1}); + }); + + it('should respect the wrap strategy', () => { + const from = {row: 0, col: 2}; + gridNav.gotoCoords(from); + expect(gridNav.peek(direction.Right, from, 'loop')).toEqual({row: 0, col: 0}); + expect(gridNav.peek(direction.Right, from, 'nowrap')).toBeUndefined(); + expect(gridNav.peek(direction.Right, from, 'continuous')).toEqual({row: 1, col: 0}); + }); + + it('should return undefined if no focusable cell is found', () => { + cells.flat().forEach(function (cell) { + cell.disabled.set(true); + }); + gridNav.gotoCoords({row: 1, col: 0}); + + const nextCoords = gridNav.peek(direction.Right, gridFocus.activeCoords()); + + expect(nextCoords).toBeUndefined(); + }); + }); + }); + + describe('advance', () => { + describe('wrap=continuous', () => { + describe('up', () => { + it('case 1', () => { + const cells = createTestGrid(createGridA); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-2'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-2'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-1'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 2', () => { + const cells = createTestGrid(createGridB); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-2'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-2'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-1'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 3', () => { + const cells = createTestGrid(createGridC); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-2'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-3'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-3'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-2'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-1'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 4', () => { + const cells = createTestGrid(createGridD); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-2'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-3'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-3'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-2'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-1'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 5', () => { + const cells = createTestGrid(createGridE); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-1'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 6', () => { + const cells = createTestGrid(createGridF); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-2'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-1'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + }); + + describe('down', () => { + it('case 1', () => { + const cells = createTestGrid(createGridA); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-1'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-2'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-2'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 2', () => { + const cells = createTestGrid(createGridB); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-1'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-2'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-2'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 3', () => { + const cells = createTestGrid(createGridC); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-1'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-2'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-3'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-3'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-2'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 4', () => { + const cells = createTestGrid(createGridD); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-1'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-2'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-3'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-3'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-2'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 5', () => { + const cells = createTestGrid(createGridE); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-1'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 6', () => { + const cells = createTestGrid(createGridF); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-1'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-2'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + }); + + describe('left', () => { + it('case 1', () => { + const cells = createTestGrid(createGridA); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 2', () => { + const cells = createTestGrid(createGridB); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 3', () => { + const cells = createTestGrid(createGridC); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-3'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-3'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 4', () => { + const cells = createTestGrid(createGridD); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-3'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-3'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-3'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 5', () => { + const cells = createTestGrid(createGridE); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 6', () => { + const cells = createTestGrid(createGridF); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + }); + + describe('right', () => { + it('case 1', () => { + const cells = createTestGrid(createGridA); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 2', () => { + const cells = createTestGrid(createGridB); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 3', () => { + const cells = createTestGrid(createGridC); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-3'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-3'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 4', () => { + const cells = createTestGrid(createGridD); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-3'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-3'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-3'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 5', () => { + const cells = createTestGrid(createGridE); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 6', () => { + const cells = createTestGrid(createGridF); + const setup = setupGridNavigation(signal(cells), { + rowWrap: signal('continuous'), + colWrap: signal('continuous'), + }); + const gridNav = setup.gridNav; + const gridFocus = setup.gridFocus; + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + }); + }); + + describe('wrap=loop', () => { + describe('up', () => { + it('case 1', () => { + const cells = createTestGrid(createGridA); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 2', () => { + const cells = createTestGrid(createGridB); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 3', () => { + const cells = createTestGrid(createGridC); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 4', () => { + const cells = createTestGrid(createGridD); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 5', () => { + const cells = createTestGrid(createGridE); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 6', () => { + const cells = createTestGrid(createGridF); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + }); + + describe('down', () => { + it('case 1', () => { + const cells = createTestGrid(createGridA); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 2', () => { + const cells = createTestGrid(createGridB); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 3', () => { + const cells = createTestGrid(createGridC); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 4', () => { + const cells = createTestGrid(createGridD); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 5', () => { + const cells = createTestGrid(createGridE); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 6', () => { + const cells = createTestGrid(createGridF); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + }); + + describe('left', () => { + it('case 1', () => { + const cells = createTestGrid(createGridA); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 2', () => { + const cells = createTestGrid(createGridB); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 3', () => { + const cells = createTestGrid(createGridC); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-3'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 4', () => { + const cells = createTestGrid(createGridD); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-3'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 5', () => { + const cells = createTestGrid(createGridE); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 6', () => { + const cells = createTestGrid(createGridF); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + }); + + describe('right', () => { + it('case 1', () => { + const cells = createTestGrid(createGridA); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 2', () => { + const cells = createTestGrid(createGridB); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 3', () => { + const cells = createTestGrid(createGridC); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-3'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 4', () => { + const cells = createTestGrid(createGridD); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-3'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 5', () => { + const cells = createTestGrid(createGridE); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 6', () => { + const cells = createTestGrid(createGridF); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('loop'), + colWrap: signal('loop'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + }); + }); + + describe('wrap=nowrap', () => { + describe('up', () => { + it('case 1', () => { + const cells = createTestGrid(createGridA); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 2, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 2', () => { + const cells = createTestGrid(createGridB); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 3, col: 1}); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-1'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + }); + + it('case 3', () => { + const cells = createTestGrid(createGridC); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 2, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 4', () => { + const cells = createTestGrid(createGridD); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 3, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 5', () => { + const cells = createTestGrid(createGridE); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 2, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 6', () => { + const cells = createTestGrid(createGridF); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 2, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Up); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + }); + + describe('down', () => { + it('case 1', () => { + const cells = createTestGrid(createGridA); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + }); + + it('case 2', () => { + const cells = createTestGrid(createGridB); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 0, col: 1}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-1'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-1'); + }); + + it('case 3', () => { + const cells = createTestGrid(createGridC); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + }); + + it('case 4', () => { + const cells = createTestGrid(createGridD); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-0'); + }); + + it('case 5', () => { + const cells = createTestGrid(createGridE); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + }); + + it('case 6', () => { + const cells = createTestGrid(createGridF); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + gridNav.advance(direction.Down); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + }); + }); + + describe('left', () => { + it('case 1', () => { + const cells = createTestGrid(createGridA); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 0, col: 2}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 2', () => { + const cells = createTestGrid(createGridB); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 0, col: 2}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 3', () => { + const cells = createTestGrid(createGridC); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 0, col: 3}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-3'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 4', () => { + const cells = createTestGrid(createGridD); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 0, col: 3}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-3'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 5', () => { + const cells = createTestGrid(createGridE); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 0, col: 2}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + + it('case 6', () => { + const cells = createTestGrid(createGridF); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 0, col: 2}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Left); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + }); + }); + + describe('right', () => { + it('case 1', () => { + const cells = createTestGrid(createGridA); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + }); + + it('case 2', () => { + const cells = createTestGrid(createGridB); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + }); + + it('case 3', () => { + const cells = createTestGrid(createGridC); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-3'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-3'); + }); + + it('case 4', () => { + const cells = createTestGrid(createGridD); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-3'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-3'); + }); + + it('case 5', () => { + const cells = createTestGrid(createGridE); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + }); + + it('case 6', () => { + const cells = createTestGrid(createGridF); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + rowWrap: signal('nowrap'), + colWrap: signal('nowrap'), + }); + + gridNav.gotoCoords({row: 0, col: 0}); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-0'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-1'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + gridNav.advance(direction.Right); + expect(gridFocus.activeCell()!.id()).toBe('cell-0-2'); + }); + }); + }); + }); + + describe('first/peekFirst', () => { + it('should navigate to the first focusable cell in the grid', () => { + const cells = createTestGrid(createGridB); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells)); + + // Disable the first few cells to make it more interesting. + cells[0][0].disabled.set(true); + cells[0][1].disabled.set(true); + + const firstCoords = gridNav.peekFirst(); + expect(firstCoords).toEqual({row: 0, col: 2}); + + // The active cell should not have changed yet. + expect(gridFocus.activeCell()).toBeUndefined(); + + const result = gridNav.first(); + expect(result).toBe(true); + expect(gridFocus.activeCell()).toBe(cells[0][2]); + expect(gridFocus.activeCoords()).toEqual({row: 0, col: 2}); + }); + + it('should navigate to the first focusable cell in a specific row', () => { + const cells = createTestGrid(createGridC); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells)); + + // Disable the first cell in row 1. + cells[1][0].disabled.set(true); + + const firstInRowCoords = gridNav.peekFirst(1); + expect(firstInRowCoords).toEqual({row: 1, col: 1}); + + // The active cell should not have changed yet. + expect(gridFocus.activeCell()).toBeUndefined(); + + const result = gridNav.first(1); + expect(result).toBe(true); + expect(gridFocus.activeCell()).toBe(cells[1][1]); + expect(gridFocus.activeCoords()).toEqual({row: 1, col: 1}); + }); + }); + + describe('last/peekLast', () => { + it('should navigate to the last focusable cell in the grid', () => { + const cells = createTestGrid(createGridB); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells)); + + // Disable the last few cells to make it more interesting. + cells[3][1].disabled.set(true); // cell-3-2 + cells[3][0].disabled.set(true); // cell-3-1 + + const lastCoords = gridNav.peekLast(); + expect(lastCoords).toEqual({row: 3, col: 0}); + + // The active cell should not have changed yet. + expect(gridFocus.activeCell()).toBeUndefined(); + + const result = gridNav.last(); + expect(result).toBe(true); + expect(gridFocus.activeCell()!.id()).toBe('cell-2-0'); + expect(gridFocus.activeCoords()).toEqual({row: 3, col: 0}); + }); + + it('should navigate to the last focusable cell in a specific row', () => { + const cells = createTestGrid(createGridC); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells)); + + // Disable the last cell in row 1. + cells[1][2].disabled.set(true); + + const lastInRowCoords = gridNav.peekLast(1); + expect(lastInRowCoords).toEqual({row: 1, col: 2}); + + const result = gridNav.last(1); + expect(result).toBe(true); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); + expect(gridFocus.activeCoords()).toEqual({row: 1, col: 2}); + }); + }); +}); diff --git a/src/aria/ui-patterns/behaviors/grid/grid-navigation.ts b/src/aria/ui-patterns/behaviors/grid/grid-navigation.ts index 54426bf3ed7e..891d1d2d7dca 100644 --- a/src/aria/ui-patterns/behaviors/grid/grid-navigation.ts +++ b/src/aria/ui-patterns/behaviors/grid/grid-navigation.ts @@ -174,6 +174,13 @@ export class GridNavigation { }; } + if (wrap === 'nowrap') { + nextCoords = { + row: nextCoords.row + rowDelta, + col: nextCoords.col + colDelta, + }; + } + // Back to original coordinates. if (nextCoords.row === fromCoords.row && nextCoords.col === fromCoords.col) { return undefined; diff --git a/src/aria/ui-patterns/behaviors/grid/grid-selection.spec.ts b/src/aria/ui-patterns/behaviors/grid/grid-selection.spec.ts new file mode 100644 index 000000000000..75dd4b7c80c3 --- /dev/null +++ b/src/aria/ui-patterns/behaviors/grid/grid-selection.spec.ts @@ -0,0 +1,210 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * 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 {signal, Signal, WritableSignal} from '@angular/core'; +import {GridData} from './grid-data'; +import {createGridA, createGridB, createGridD, TestBaseGridCell} from './grid-data.spec'; +import {GridFocus, GridFocusInputs} from './grid-focus'; +import {GridSelection, GridSelectionInputs} from './grid-selection'; + +export interface TestGridSelectionCell extends TestBaseGridCell { + element: WritableSignal; + disabled: WritableSignal; + selected: WritableSignal; + selectable: WritableSignal; +} + +function createTestCell(): Omit { + return { + element: signal(document.createElement('div')), + disabled: signal(false), + selected: signal(false), + selectable: signal(true), + }; +} + +function createTestGrid(createGridFn: () => TestBaseGridCell[][]): TestGridSelectionCell[][] { + return createGridFn().map(row => + row.map(cell => { + return {...createTestCell(), ...cell}; + }), + ); +} + +function setupGridSelection( + cells: Signal, + inputs: Partial = {}, +): { + gridSelection: GridSelection; + gridFocus: GridFocus; +} { + const gridData = new GridData({cells}); + const gridFocusInputs: GridFocusInputs = { + focusMode: signal('roving'), + disabled: signal(false), + skipDisabled: signal(true), + }; + const gridFocus = new GridFocus({ + grid: gridData, + ...gridFocusInputs, + ...inputs, + }); + + const gridSelection = new GridSelection({ + grid: gridData, + gridFocus: gridFocus, + ...gridFocusInputs, + ...inputs, + }); + + return {gridSelection, gridFocus}; +} + +describe('GridSelection', () => { + describe('select', () => { + it('should select a single cell', () => { + const cells = createTestGrid(createGridA); + const {gridSelection} = setupGridSelection(signal(cells)); + + gridSelection.select({row: 1, col: 1}); + + expect(cells[1][1].selected()).toBe(true); + }); + + it('should select a range of cells', () => { + const cells = createTestGrid(createGridD); + const {gridSelection} = setupGridSelection(signal(cells)); + + gridSelection.select({row: 0, col: 0}, {row: 1, col: 1}); + + expect(cells[0][0].selected()).toBe(true); // Spans {0,0}, {1,0} + expect(cells[0][1].selected()).toBe(true); // Spans {0,1}, {0,2} + expect(cells[1][0].selected()).toBe(true); // Spans {1,1}, {1,2}, {2,1}, {2,2} + }); + + it('should not select disabled or unselectable cells', () => { + const cells = createTestGrid(createGridA); + const {gridSelection} = setupGridSelection(signal(cells)); + + cells[0][1].disabled.set(true); + cells[1][0].selectable.set(false); + + gridSelection.select({row: 0, col: 0}, {row: 1, col: 1}); + + expect(cells[0][0].selected()).toBe(true); + expect(cells[0][1].selected()).toBe(false); + expect(cells[1][0].selected()).toBe(false); + expect(cells[1][1].selected()).toBe(true); + }); + }); + + describe('deselect', () => { + it('should deselect a single cell', () => { + const cells = createTestGrid(createGridA); + const {gridSelection} = setupGridSelection(signal(cells)); + cells[1][1].selected.set(true); + + gridSelection.deselect({row: 1, col: 1}); + + expect(cells[1][1].selected()).toBe(false); + }); + + it('should deselect a range of cells', () => { + const cells = createTestGrid(createGridD); + const {gridSelection} = setupGridSelection(signal(cells)); + cells[0][0].selected.set(true); + cells[0][1].selected.set(true); + cells[1][0].selected.set(true); + + gridSelection.deselect({row: 0, col: 0}, {row: 1, col: 1}); + + expect(cells[0][0].selected()).toBe(false); + expect(cells[0][1].selected()).toBe(false); + expect(cells[1][0].selected()).toBe(false); + }); + }); + + describe('toggle', () => { + it('should toggle the selection of a single cell', () => { + const cells = createTestGrid(createGridA); + const {gridSelection} = setupGridSelection(signal(cells)); + + gridSelection.toggle({row: 1, col: 1}); + expect(cells[1][1].selected()).toBe(true); + + gridSelection.toggle({row: 1, col: 1}); + expect(cells[1][1].selected()).toBe(false); + }); + + it('should toggle a range of cells', () => { + const cells = createTestGrid(createGridA); + const {gridSelection} = setupGridSelection(signal(cells)); + cells[0][0].selected.set(true); + cells[1][1].selected.set(true); + + gridSelection.toggle({row: 0, col: 0}, {row: 0, col: 1}); + + expect(cells[0][0].selected()).toBe(false); + expect(cells[0][1].selected()).toBe(true); + expect(cells[1][1].selected()).toBe(true); // Unchanged + }); + }); + + describe('selectAll', () => { + it('should select all selectable and enabled cells', () => { + const cells = createTestGrid(createGridB); + const {gridSelection} = setupGridSelection(signal(cells)); + + cells[0][1].disabled.set(true); + cells[1][1].selectable.set(false); + + gridSelection.selectAll(); + + const flatCells = cells.flat(); + expect(flatCells.filter(c => c.selected()).length).toBe(flatCells.length - 2); + expect(cells[0][1].selected()).toBe(false); + expect(cells[1][1].selected()).toBe(false); + }); + }); + + describe('deselectAll', () => { + it('should deselect all cells', () => { + const cells = createTestGrid(createGridA); + const {gridSelection} = setupGridSelection(signal(cells)); + + // Select some cells + cells[0][0].selected.set(true); + cells[1][1].selected.set(true); + cells[2][2].selected.set(true); + + gridSelection.deselectAll(); + + const flatCells = cells.flat(); + expect(flatCells.every(c => !c.selected())).toBe(true); + }); + }); + + describe('_validCells', () => { + it('should yield all selectable and enabled cells in a range', () => { + const cells = createTestGrid(createGridD); + const {gridSelection} = setupGridSelection(signal(cells)); + + cells[0][1].disabled.set(true); // cell-0-1 + cells[1][0].selectable.set(false); // cell-1-1 + + const validCells = Array.from(gridSelection._validCells({row: 0, col: 0}, {row: 3, col: 3})); + + const validCellIds = validCells.map(c => c.id()); + const allCellIds = cells.flat().map(c => c.id()); + + expect(validCellIds).not.toContain('cell-0-1'); + expect(validCellIds).not.toContain('cell-1-1'); + expect(validCellIds.length).toBe(allCellIds.length - 2); + }); + }); +}); diff --git a/src/aria/ui-patterns/behaviors/grid/grid.spec.ts b/src/aria/ui-patterns/behaviors/grid/grid.spec.ts new file mode 100644 index 000000000000..d2ceb823bf8b --- /dev/null +++ b/src/aria/ui-patterns/behaviors/grid/grid.spec.ts @@ -0,0 +1,307 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * 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 {signal, Signal, WritableSignal} from '@angular/core'; +import {Grid, GridInputs} from './grid'; +import {createGridA, createGridD, TestBaseGridCell} from './grid-data.spec'; +import {WrapStrategy} from './grid-navigation'; + +interface TestGridCell extends TestBaseGridCell { + element: WritableSignal; + disabled: WritableSignal; + selected: WritableSignal; + selectable: WritableSignal; +} + +function createTestCell(): Omit { + return { + element: signal(document.createElement('div')), + disabled: signal(false), + selected: signal(false), + selectable: signal(true), + }; +} + +function createTestGrid(createGridFn: () => TestBaseGridCell[][]): TestGridCell[][] { + return createGridFn().map(row => + row.map(cell => { + return {...createTestCell(), ...cell}; + }), + ); +} + +function setupGrid( + cells: Signal, + inputs: Partial> = {}, +): Grid { + const gridInputs: GridInputs = { + cells, + focusMode: signal('roving'), + disabled: signal(false), + skipDisabled: signal(true), + rowWrap: signal('loop'), + colWrap: signal('loop'), + enableSelection: signal(true), + ...inputs, + }; + + return new Grid(gridInputs); +} + +describe('Grid', () => { + describe('indices', () => { + it('should return 1-based row and column indices', () => { + const cells = createTestGrid(createGridA); + const grid = setupGrid(signal(cells)); + + expect(grid.rowIndex(cells[1][2])).toBe(2); + expect(grid.colIndex(cells[1][2])).toBe(3); + }); + + it('should return undefined for a cell not in the grid', () => { + const cells = createTestGrid(createGridA); + const grid = setupGrid(signal(cells)); + const otherCell = createTestGrid(createGridA)[0][0]; + + expect(grid.rowIndex(otherCell)).toBeUndefined(); + expect(grid.colIndex(otherCell)).toBeUndefined(); + }); + }); + + describe('cellTabIndex', () => { + it('should return the tabindex for a cell', () => { + const cells = createTestGrid(createGridA); + const grid = setupGrid(signal(cells)); + + grid.gotoCell(cells[1][1]); + + expect(grid.cellTabIndex(cells[1][1])).toBe(0); + expect(grid.cellTabIndex(cells[0][0])).toBe(-1); + }); + }); + + describe('navigation', () => { + let cells: TestGridCell[][]; + let grid: Grid; + + beforeEach(() => { + cells = createTestGrid(createGridA); + grid = setupGrid(signal(cells)); + grid.gotoCell(cells[1][1]); + }); + + it('should navigate up/down/left/right', () => { + expect(grid.focusBehavior.activeCell()).toBe(cells[1][1]); + grid.up(); + expect(grid.focusBehavior.activeCell()).toBe(cells[0][1]); + grid.down(); + expect(grid.focusBehavior.activeCell()).toBe(cells[1][1]); + grid.left(); + expect(grid.focusBehavior.activeCell()).toBe(cells[1][0]); + grid.right(); + expect(grid.focusBehavior.activeCell()).toBe(cells[1][1]); + }); + + it('should navigate to first/last cell in grid', () => { + grid.last(); + expect(grid.focusBehavior.activeCell()).toBe(cells[2][2]); + grid.first(); + expect(grid.focusBehavior.activeCell()).toBe(cells[0][0]); + }); + + it('should navigate to first/last cell in row', () => { + grid.lastInRow(); + expect(grid.focusBehavior.activeCell()).toBe(cells[1][2]); + grid.firstInRow(); + expect(grid.focusBehavior.activeCell()).toBe(cells[1][0]); + }); + }); + + describe('selection', () => { + let cells: TestGridCell[][]; + let grid: Grid; + + beforeEach(() => { + cells = createTestGrid(createGridD); + grid = setupGrid(signal(cells)); + grid.gotoCell(cells[0][0]); // active cell at {0,0} + }); + + it('should toggle selection of a cell', () => { + grid.toggleSelect(cells[1][0]); // cell at {1,1} + expect(cells[1][0].selected()).toBe(true); + grid.toggleSelect(cells[1][0]); + expect(cells[1][0].selected()).toBe(false); + }); + + it('should select a row', () => { + grid.gotoCell(cells[1][0]); // active cell at {1,1} + grid.selectRow(); + // row 1 contains cells at {0,0}, {1,0}, {1,1} + expect(cells[0][0].selected()).toBe(true); // spans row 0 and 1 + expect(cells[1][0].selected()).toBe(true); // spans row 1 and 2 + expect(cells[1][1].selected()).toBe(true); // spans row 1 and 2 + expect(cells[0][1].selected()).toBe(false); // only in row 0 + }); + + it('should select a column', () => { + grid.gotoCell(cells[1][0]); // active cell at {1,1} + grid.selectCol(); + // col 1 contains cells at {0,1}, {1,0} + expect(cells[0][1].selected()).toBe(true); + expect(cells[1][0].selected()).toBe(true); + expect(cells[0][0].selected()).toBe(false); + }); + + it('should select all cells', () => { + grid.selectAll(); + cells.flat().forEach(cell => expect(cell.selected()).toBe(true)); + }); + }); + + describe('range selection', () => { + let cells: TestGridCell[][]; + let grid: Grid; + + beforeEach(() => { + cells = createTestGrid(createGridA); + grid = setupGrid(signal(cells)); + grid.gotoCell(cells[1][1]); // active cell and anchor at {1,1} + }); + + it('should range select with arrow keys', () => { + grid.rangeSelectRight(); + expect(cells[1][1].selected()).toBe(true); + expect(cells[1][2].selected()).toBe(true); + expect(grid.selectionAnchor()).toEqual({row: 1, col: 2}); + + grid.rangeSelectDown(); + expect(cells[1][1].selected()).toBe(true); + expect(cells[1][2].selected()).toBe(true); + expect(cells[2][1].selected()).toBe(true); + expect(cells[2][2].selected()).toBe(true); + expect(grid.selectionAnchor()).toEqual({row: 2, col: 2}); + + grid.rangeSelectUp(); + expect(cells[1][1].selected()).toBe(true); + expect(cells[1][2].selected()).toBe(true); + expect(cells[2][1].selected()).toBe(false); + expect(cells[2][2].selected()).toBe(false); + expect(grid.selectionAnchor()).toEqual({row: 1, col: 2}); + + grid.rangeSelectLeft(); + expect(cells[1][1].selected()).toBe(true); + expect(cells[1][2].selected()).toBe(false); + expect(grid.selectionAnchor()).toEqual({row: 1, col: 1}); + }); + + it('should range select to a specific cell', () => { + grid.rangeSelect(cells[2][2]); + + expect(cells[1][1].selected()).toBe(true); + expect(cells[1][2].selected()).toBe(true); + expect(cells[2][1].selected()).toBe(true); + expect(cells[2][2].selected()).toBe(true); + expect(grid.selectionAnchor()).toEqual({row: 2, col: 2}); + }); + + it('should handle range selection with spanning cells', () => { + const spanningCells = createTestGrid(createGridD); + grid = setupGrid(signal(spanningCells)); + grid.gotoCell(spanningCells[0][0]); // active cell at {0,0} + + grid.rangeSelect(spanningCells[3][2]); // cell at {3,2} + + // The range is from {0,0} to {3,3} because cell at {3,2} has colspan 2. + // All cells should be selected. + spanningCells.flat().forEach(cell => expect(cell.selected()).toBe(true)); + expect(grid.selectionAnchor()).toEqual({row: 3, col: 2}); + }); + }); + + describe('resetState', () => { + it('should focus the first focusable cell if state is empty', () => { + const cells = createTestGrid(createGridA); + const grid = setupGrid(signal(cells)); + + expect(grid.focusBehavior.stateEmpty()).toBe(true); + const result = grid.resetState(); + expect(result).toBe(true); + expect(grid.focusBehavior.activeCell()).toBe(cells[0][0]); + }); + + it('should return false if no focusable cell is found when state is empty', () => { + const cells = createTestGrid(createGridA); + cells.flat().forEach(c => c.disabled.set(true)); + const grid = setupGrid(signal(cells)); + + const result = grid.resetState(); + expect(result).toBe(false); + expect(grid.focusBehavior.activeCell()).toBeUndefined(); + }); + + it('should re-focus the active cell if it is stale but still exists', () => { + const cellsSignal = signal(createTestGrid(createGridA)); + const grid = setupGrid(cellsSignal); + const originalCell = cellsSignal()[1][1]; + grid.gotoCell(originalCell); + + // Simulate reordering by creating a new grid but keeping the original cell instance + const newCells = createTestGrid(createGridA); + newCells[2][2] = originalCell; + cellsSignal.set(newCells); + + expect(grid.focusBehavior.stateStale()).toBe(true); + const result = grid.resetState(); + expect(result).toBe(true); + expect(grid.focusBehavior.activeCell()).toBe(originalCell); + expect(grid.focusBehavior.activeCoords()).toEqual({row: 2, col: 2}); + }); + + it('should focus the original coordinates if the active cell is gone', () => { + const cellsSignal = signal(createTestGrid(createGridA)); + const grid = setupGrid(cellsSignal); + grid.gotoCell(cellsSignal()[1][1]); + + // Replace the cell at {1,1} + const newCells = createTestGrid(createGridA); + cellsSignal.set(newCells); + + expect(grid.focusBehavior.stateStale()).toBe(true); + const result = grid.resetState(); + expect(result).toBe(true); + expect(grid.focusBehavior.activeCell()).toBe(newCells[1][1]); + expect(grid.focusBehavior.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('should focus the first cell if active cell and coords are no longer valid', () => { + const cellsSignal = signal(createTestGrid(createGridA)); + const grid = setupGrid(cellsSignal); + grid.gotoCell(cellsSignal()[2][2]); + + // Make grid smaller + const newCells: TestGridCell[][] = [ + [ + {...createTestCell(), id: signal('cell-0-0'), rowSpan: signal(1), colSpan: signal(1)}, + {...createTestCell(), id: signal('cell-0-1'), rowSpan: signal(1), colSpan: signal(1)}, + ], + [ + {...createTestCell(), id: signal('cell-1-0'), rowSpan: signal(1), colSpan: signal(1)}, + {...createTestCell(), id: signal('cell-1-1'), rowSpan: signal(1), colSpan: signal(1)}, + ], + ]; + cellsSignal.set(newCells); + + expect(grid.focusBehavior.stateStale()).toBe(true); + const result = grid.resetState(); + expect(result).toBe(true); + expect(grid.focusBehavior.activeCell()).toBe(newCells[0][0]); + expect(grid.focusBehavior.activeCoords()).toEqual({row: 0, col: 0}); + }); + }); +}); diff --git a/src/aria/ui-patterns/grid/grid.ts b/src/aria/ui-patterns/grid/grid.ts index 00480ab38f9e..6b944014bd29 100644 --- a/src/aria/ui-patterns/grid/grid.ts +++ b/src/aria/ui-patterns/grid/grid.ts @@ -180,13 +180,8 @@ export class GridPattern { } /** Handles focusin events on the grid. */ - onFocusIn(event: FocusEvent) { + onFocusIn() { this.isFocused.set(true); - - const cell = this.inputs.getCell(event.target as Element); - if (!cell) return; - - this.gridBehavior.gotoCell(cell); } /** Indicates maybe the losing focus is caused by row/cell deletion. */