diff --git a/src/cdk-experimental/ui-patterns/behaviors/grid-focus/grid-focus.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/grid-focus/grid-focus.spec.ts index 958de708bc38..70f661556a21 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/grid-focus/grid-focus.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/grid-focus/grid-focus.spec.ts @@ -7,7 +7,7 @@ */ import {computed, Signal, signal, WritableSignal} from '@angular/core'; -import {GridFocus, GridFocusInputs, GridFocusCell} from './grid-focus'; +import {GridFocus, GridFocusInputs, GridFocusCell, RowCol} from './grid-focus'; // Helper type for test cells, extending GridFocusCell interface TestGridCell extends GridFocusCell { @@ -23,13 +23,13 @@ type TestSetupInputs = Partial> & { gridFocus?: WritableSignal | undefined>; }; -function createTestCell( +export function createTestCell( gridFocus: Signal | undefined>, opts: {id: string; rowspan?: number; colspan?: number}, ): TestGridCell { const el = document.createElement('div'); spyOn(el, 'focus').and.callThrough(); - let coordinates: Signal<{row: number; column: number}> = signal({row: -1, column: -1}); + let coordinates: Signal = signal({row: -1, col: -1}); const cell: TestGridCell = { id: signal(opts.id), element: signal(el as HTMLElement), @@ -39,13 +39,13 @@ function createTestCell( rowindex: signal(-1), colindex: signal(-1), }; - coordinates = computed(() => gridFocus()?.getCoordinates(cell) ?? {row: -1, column: -1}); + coordinates = computed(() => gridFocus()?.getCoordinates(cell) ?? {row: -1, col: -1}); cell.rowindex = computed(() => coordinates().row); - cell.colindex = computed(() => coordinates().column); + cell.colindex = computed(() => coordinates().col); return cell; } -function createTestCells( +export function createTestCells( gridFocus: Signal | undefined>, numRows: number, numCols: number, @@ -60,7 +60,7 @@ function createTestCells( } // Main helper function to instantiate GridFocus and its dependencies for testing -function setupGridFocus(inputs: TestSetupInputs = {}): { +export function setupGridFocus(inputs: TestSetupInputs = {}): { cells: TestGridCell[][]; gridFocus: GridFocus; } { @@ -70,7 +70,7 @@ function setupGridFocus(inputs: TestSetupInputs = {}): { const gridFocus = inputs.gridFocus ?? signal | undefined>(undefined); const cells = inputs.cells ?? createTestCells(gridFocus, numRows, numCols); - const activeCoords = inputs.activeCoords ?? signal({row: 0, column: 0}); + const activeCoords = inputs.activeCoords ?? signal({row: 0, col: 0}); const focusMode = signal<'roving' | 'activedescendant'>( inputs.focusMode ? inputs.focusMode() : 'roving', ); @@ -95,20 +95,20 @@ function setupGridFocus(inputs: TestSetupInputs = {}): { describe('GridFocus', () => { describe('Initialization', () => { - it('should initialize with activeCell at {row: 0, column: 0} by default', () => { + it('should initialize with activeCell at {row: 0, col: 0} by default', () => { const {gridFocus} = setupGridFocus(); - expect(gridFocus.inputs.activeCoords()).toEqual({row: 0, column: 0}); + expect(gridFocus.inputs.activeCoords()).toEqual({row: 0, col: 0}); }); it('should compute activeCell based on activeCell', () => { const {gridFocus, cells} = setupGridFocus({ - activeCoords: signal({row: 1, column: 1}), + activeCoords: signal({row: 1, col: 1}), }); expect(gridFocus.activeCell()).toBe(cells[1][1]); }); it('should compute activeCell correctly when rowspan and colspan are set', () => { - const activeCoords = signal({row: 0, column: 0}); + const activeCoords = signal({row: 0, col: 0}); const gridFocusSignal = signal | undefined>(undefined); // Visualization of this irregular grid. @@ -130,24 +130,58 @@ describe('GridFocus', () => { gridFocus: gridFocusSignal, }); - activeCoords.set({row: 0, column: 0}); + activeCoords.set({row: 0, col: 0}); expect(gridFocus.activeCell()).toBe(cell_0_0); - activeCoords.set({row: 0, column: 1}); + activeCoords.set({row: 0, col: 1}); expect(gridFocus.activeCell()).toBe(cell_0_0); - activeCoords.set({row: 1, column: 0}); + activeCoords.set({row: 1, col: 0}); expect(gridFocus.activeCell()).toBe(cell_0_0); - activeCoords.set({row: 1, column: 1}); + activeCoords.set({row: 1, col: 1}); expect(gridFocus.activeCell()).toBe(cell_0_0); - activeCoords.set({row: 0, column: 2}); + activeCoords.set({row: 0, col: 2}); expect(gridFocus.activeCell()).toBe(cell_0_2); - activeCoords.set({row: 1, column: 2}); + activeCoords.set({row: 1, col: 2}); expect(gridFocus.activeCell()).toBe(cell_1_2); }); + + it('should compute rowCount and colCount correctly', () => { + const {gridFocus} = setupGridFocus({ + numRows: 2, + numCols: 3, + }); + expect(gridFocus.rowCount()).toBe(2); + expect(gridFocus.colCount()).toBe(3); + }); + + it('should compute rowCount and colCount correctly when rowspan and colspan are set', () => { + const gridFocusSignal = signal | undefined>(undefined); + + // Visualization of this irregular grid. + // + // +---+---+---+ + // | |0,2| + // + 0,0 +---+ + // | |1,2| + // +---+---+---+ + // + const cell_0_0 = createTestCell(gridFocusSignal, {id: `cell-0-0`, rowspan: 2, colspan: 2}); + const cell_0_2 = createTestCell(gridFocusSignal, {id: `cell-0-2`}); + const cell_1_2 = createTestCell(gridFocusSignal, {id: `cell-1-2`}); + const cells = signal([[cell_0_0, cell_0_2], [cell_1_2]]); + + const {gridFocus} = setupGridFocus({ + cells, + gridFocus: gridFocusSignal, + }); + + expect(gridFocus.rowCount()).toBe(2); + expect(gridFocus.colCount()).toBe(3); + }); }); - describe('isGridDisabled()', () => { + describe('isGridDisabled', () => { it('should return true if inputs.disabled is true', () => { const {gridFocus} = setupGridFocus({disabled: signal(true)}); expect(gridFocus.isGridDisabled()).toBeTrue(); @@ -171,7 +205,7 @@ describe('GridFocus', () => { }); }); - describe('getActiveDescendant()', () => { + describe('getActiveDescendant', () => { it('should return undefined if focusMode is "roving"', () => { const {gridFocus} = setupGridFocus({focusMode: signal('roving')}); expect(gridFocus.getActiveDescendant()).toBeUndefined(); @@ -188,13 +222,13 @@ describe('GridFocus', () => { it('should return the activeCell id if focusMode is "activedescendant"', () => { const {gridFocus, cells} = setupGridFocus({ focusMode: signal('activedescendant'), - activeCoords: signal({row: 2, column: 2}), + activeCoords: signal({row: 2, col: 2}), }); expect(gridFocus.getActiveDescendant()).toBe(cells[2][2].id()); }); }); - describe('getGridTabindex()', () => { + describe('getGridTabindex', () => { it('should return 0 if grid is disabled', () => { const {gridFocus} = setupGridFocus({disabled: signal(true)}); expect(gridFocus.getGridTabindex()).toBe(0); @@ -211,7 +245,7 @@ describe('GridFocus', () => { }); }); - describe('getCellTabindex(cell)', () => { + describe('getCellTabindex', () => { it('should return -1 if grid is disabled', () => { const {gridFocus, cells} = setupGridFocus({ numRows: 1, @@ -247,7 +281,7 @@ describe('GridFocus', () => { }); }); - describe('isFocusable(cell)', () => { + describe('isFocusable', () => { it('should return true if cell is not disabled', () => { const {gridFocus, cells} = setupGridFocus({ numRows: 1, @@ -283,65 +317,127 @@ describe('GridFocus', () => { }); }); - describe('focus(cell)', () => { + describe('focusCoordinates', () => { + it('should return false and not change state if grid is disabled', () => { + const activeCoords = signal({row: 0, col: 0}); + const {gridFocus, cells} = setupGridFocus({ + activeCoords, + disabled: signal(true), + }); + + const success = gridFocus.focusCoordinates({row: 1, col: 0}); + + expect(success).toBeFalse(); + expect(activeCoords()).toEqual({row: 0, col: 0}); + expect(cells[1][0].element().focus).not.toHaveBeenCalled(); + }); + + it('should return false and not change state if cell is not focusable', () => { + const activeCoords = signal({row: 0, col: 0}); + const {gridFocus, cells} = setupGridFocus({activeCoords}); + cells[1][0].disabled.set(true); + + const success = gridFocus.focusCoordinates({row: 1, col: 0}); + + expect(success).toBeFalse(); + expect(activeCoords()).toEqual({row: 0, col: 0}); + expect(cells[1][0].element().focus).not.toHaveBeenCalled(); + }); + + it('should focus cell, update activeCell and prevActiveCell in "roving" mode', () => { + const activeCoords = signal({row: 0, col: 0}); + const {gridFocus, cells} = setupGridFocus({ + activeCoords, + focusMode: signal('roving'), + }); + + const success = gridFocus.focusCoordinates({row: 1, col: 0}); + + expect(success).toBeTrue(); + expect(activeCoords()).toEqual({row: 1, col: 0}); + expect(cells[1][0].element().focus).toHaveBeenCalled(); + + expect(gridFocus.activeCell()).toBe(cells[1][0]); + expect(gridFocus.prevActiveCoords()).toEqual({row: 0, col: 0}); + }); + + it('should update activeCell and prevActiveCell but not call element.focus in "activedescendant" mode', () => { + const activeCoords = signal({row: 0, col: 0}); + const {gridFocus, cells} = setupGridFocus({ + activeCoords, + focusMode: signal('activedescendant'), + }); + + const success = gridFocus.focusCoordinates({row: 1, col: 0}); + + expect(success).toBeTrue(); + expect(activeCoords()).toEqual({row: 1, col: 0}); + expect(cells[1][0].element().focus).not.toHaveBeenCalled(); + + expect(gridFocus.activeCell()).toBe(cells[1][0]); + expect(gridFocus.prevActiveCoords()).toEqual({row: 0, col: 0}); + }); + }); + + describe('focusCell', () => { it('should return false and not change state if grid is disabled', () => { - const activeCoords = signal({row: 0, column: 0}); + const activeCoords = signal({row: 0, col: 0}); const {gridFocus, cells} = setupGridFocus({ activeCoords, disabled: signal(true), }); - const success = gridFocus.focus({row: 1, column: 0}); + const success = gridFocus.focusCell(cells[1][0]); expect(success).toBeFalse(); - expect(activeCoords()).toEqual({row: 0, column: 0}); + expect(activeCoords()).toEqual({row: 0, col: 0}); expect(cells[1][0].element().focus).not.toHaveBeenCalled(); }); it('should return false and not change state if cell is not focusable', () => { - const activeCoords = signal({row: 0, column: 0}); + const activeCoords = signal({row: 0, col: 0}); const {gridFocus, cells} = setupGridFocus({activeCoords}); cells[1][0].disabled.set(true); - const success = gridFocus.focus({row: 1, column: 0}); + const success = gridFocus.focusCell(cells[1][0]); expect(success).toBeFalse(); - expect(activeCoords()).toEqual({row: 0, column: 0}); + expect(activeCoords()).toEqual({row: 0, col: 0}); expect(cells[1][0].element().focus).not.toHaveBeenCalled(); }); it('should focus cell, update activeCell and prevActiveCell in "roving" mode', () => { - const activeCoords = signal({row: 0, column: 0}); + const activeCoords = signal({row: 0, col: 0}); const {gridFocus, cells} = setupGridFocus({ activeCoords, focusMode: signal('roving'), }); - const success = gridFocus.focus({row: 1, column: 0}); + const success = gridFocus.focusCell(cells[1][0]); expect(success).toBeTrue(); - expect(activeCoords()).toEqual({row: 1, column: 0}); + expect(activeCoords()).toEqual({row: 1, col: 0}); expect(cells[1][0].element().focus).toHaveBeenCalled(); expect(gridFocus.activeCell()).toBe(cells[1][0]); - expect(gridFocus.prevActiveCoords()).toEqual({row: 0, column: 0}); + expect(gridFocus.prevActiveCoords()).toEqual({row: 0, col: 0}); }); it('should update activeCell and prevActiveCell but not call element.focus in "activedescendant" mode', () => { - const activeCoords = signal({row: 0, column: 0}); + const activeCoords = signal({row: 0, col: 0}); const {gridFocus, cells} = setupGridFocus({ activeCoords, focusMode: signal('activedescendant'), }); - const success = gridFocus.focus({row: 1, column: 0}); + const success = gridFocus.focusCell(cells[1][0]); expect(success).toBeTrue(); - expect(activeCoords()).toEqual({row: 1, column: 0}); + expect(activeCoords()).toEqual({row: 1, col: 0}); expect(cells[1][0].element().focus).not.toHaveBeenCalled(); expect(gridFocus.activeCell()).toBe(cells[1][0]); - expect(gridFocus.prevActiveCoords()).toEqual({row: 0, column: 0}); + expect(gridFocus.prevActiveCoords()).toEqual({row: 0, col: 0}); }); }); }); diff --git a/src/cdk-experimental/ui-patterns/behaviors/grid-focus/grid-focus.ts b/src/cdk-experimental/ui-patterns/behaviors/grid-focus/grid-focus.ts index 1dc145a3786a..bacb2b5d9daa 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/grid-focus/grid-focus.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/grid-focus/grid-focus.ts @@ -45,20 +45,37 @@ export interface GridFocusInputs { cells: SignalLike; /** The coordinates (row and column) of the current active cell. */ - activeCoords: WritableSignalLike<{row: number; column: number}>; + activeCoords: WritableSignalLike; /** Whether disabled cells in the grid should be skipped when navigating. */ skipDisabled: SignalLike; } +/** Represents coordinates in a grid. */ +export interface RowCol { + /** The row index. */ + row: number; + + /** The column index. */ + col: number; +} + /** Controls focus for a 2D grid of cells. */ export class GridFocus { /** The last active cell coordinates. */ - prevActiveCoords = signal<{row: number; column: number}>({row: 0, column: 0}); + prevActiveCoords = signal({row: 0, col: 0}); /** The current active cell based on `activeCoords` coordinates. */ activeCell = computed(() => this.getCell(this.inputs.activeCoords())); + /** The number of rows in the grid. */ + rowCount = computed(() => this.inputs.cells().length); + + /** The number of columns in the grid. */ + colCount = computed(() => { + return this.inputs.cells()[0].reduce((count, curr) => count + curr.colspan(), 0); + }); + constructor(readonly inputs: GridFocusInputs) {} /** The id of the current active cell, for ARIA activedescendant. */ @@ -98,8 +115,25 @@ export class GridFocus { return this.activeCell() === cell ? 0 : -1; } + /** Focuses the given cell. */ + focusCell(cell: T): boolean { + if (this.isGridDisabled()) { + return false; + } + + if (!this.isFocusable(cell)) { + return false; + } + + this.prevActiveCoords.set(this.inputs.activeCoords()); + this.inputs.activeCoords.set({row: cell.rowindex(), col: cell.colindex()}); + this._focus(cell); + + return true; + } + /** Moves focus to the cell at the given coordinates if it's part of a focusable cell. */ - focus(coordinates: {row: number; column: number}): boolean { + focusCoordinates(coordinates: RowCol): boolean { if (this.isGridDisabled()) { return false; } @@ -107,19 +141,24 @@ export class GridFocus { const cell = this.getCell(coordinates); if (!cell || !this.isFocusable(cell)) { - return false; // No cell at coordinates, or cell is not focusable. + return false; } this.prevActiveCoords.set(this.inputs.activeCoords()); - this.inputs.activeCoords.set(coordinates); // Set activeCoords to the exact coordinates + this.inputs.activeCoords.set(coordinates); + this._focus(cell); + return true; + } + + /** Handles conditionally calling `focus` on the HTML element of the cell. */ + private _focus(cell: T) { if (this.inputs.focusMode() === 'roving') { - const element = cell.element(); // Element of the cell that *covers* these coordinates + const element = cell.element(); if (element && typeof element.focus === 'function') { element.focus(); } } - return true; } /** Returns true if the given cell can be navigated to. */ @@ -128,9 +167,9 @@ export class GridFocus { } /** Finds the top-left anchor coordinates of a given cell instance in the grid. */ - getCoordinates(cellToFind: T): {row: number; column: number} | void { + getCoordinates(cellToFind: T): RowCol | void { const grid = this.inputs.cells(); - const occupiedCells = new Set(); // Stores "row,column" string keys for occupied cells. + const occupiedCells = new Set(); for (let rowindex = 0; rowindex < grid.length; rowindex++) { let colindex = 0; @@ -144,7 +183,7 @@ export class GridFocus { // Check if this is the cell we're looking for. if (gridCell === cellToFind) { - return {row: rowindex, column: colindex}; + return {row: rowindex, col: colindex}; } const rowspan = gridCell.rowspan(); @@ -167,14 +206,14 @@ export class GridFocus { } /** Gets the cell that covers the given coordinates, considering rowspan and colspan. */ - getCell(coords: {row: number; column: number}): T | void { + getCell(coords: RowCol): T | void { for (const row of this.inputs.cells()) { for (const cell of row) { if ( coords.row >= cell.rowindex() && coords.row <= cell.rowindex() + cell.rowspan() - 1 && - coords.column >= cell.colindex() && - coords.column <= cell.colindex() + cell.colspan() - 1 + coords.col >= cell.colindex() && + coords.col <= cell.colindex() + cell.colspan() - 1 ) { return cell; } diff --git a/src/cdk-experimental/ui-patterns/behaviors/grid-navigation/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/grid-navigation/BUILD.bazel new file mode 100644 index 000000000000..a9b146b2e295 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/grid-navigation/BUILD.bazel @@ -0,0 +1,28 @@ +load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "grid-navigation", + srcs = ["grid-navigation.ts"], + deps = [ + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/behaviors/grid-focus", + "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = ["grid-navigation.spec.ts"], + deps = [ + ":grid-navigation", + "//:node_modules/@angular/core", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/ui-patterns/behaviors/grid-navigation/grid-navigation.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/grid-navigation/grid-navigation.spec.ts new file mode 100644 index 000000000000..6d48f217d17f --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/grid-navigation/grid-navigation.spec.ts @@ -0,0 +1,1253 @@ +/** + * @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 {computed, signal, WritableSignal} from '@angular/core'; +import {GridFocus} from '../grid-focus/grid-focus'; +import {GridNavigation, GridNavigationCell, GridNavigationInputs} from './grid-navigation'; + +type TestGridNav = GridNavigation; + +interface TestCell extends GridNavigationCell { + disabled: WritableSignal; +} + +interface TestCellInputs { + rowspan?: number; + colspan?: number; +} + +function createCell(config?: TestCellInputs): TestCell { + const element = document.createElement('div'); + spyOn(element, 'focus').and.callThrough(); + + return { + id: signal(''), + element: signal(element), + disabled: signal(false), + rowindex: signal(0), + colindex: signal(0), + rowspan: signal(config?.rowspan ?? 1), + colspan: signal(config?.colspan ?? 1), + }; +} + +type TestGridNavInputs = Partial> & + Pick, 'cells'>; + +function createGridNav(config: TestGridNavInputs): {gridNav: TestGridNav; cells: TestCell[][]} { + const wrap = signal(true); + const disabled = signal(false); + const skipDisabled = signal(false); + const focusMode = signal('roving' as const); + const activeCoords = signal({row: 0, col: 0}); + const wrapBehavior = signal('continuous' as const); + + const gridFocus = new GridFocus({ + disabled, + focusMode, + activeCoords, + skipDisabled, + ...config, + }); + + const gridNav = new GridNavigation({ + wrap, + disabled, + focusMode, + activeCoords, + skipDisabled, + wrapBehavior, + gridFocus, + ...config, + }); + + for (const row of config.cells()) { + for (const cell of row) { + const coordinates = computed(() => gridFocus.getCoordinates(cell) ?? {row: -1, col: -1}); + cell.rowindex = computed(() => coordinates().row); + cell.colindex = computed(() => coordinates().col); + } + } + + return {gridNav, cells: config.cells()}; +} + +describe('GridNavigation', () => { + /** + * GRID A: + * ┌─────┬─────┬─────┐ + * │ 0,0 │ 0,1 │ 0,2 │ + * ├─────┼─────┼─────┤ + * │ 1,0 │ 1,1 │ 1,2 │ + * ├─────┼─────┼─────┤ + * │ 2,0 │ 2,1 │ 2,2 │ + * └─────┴─────┴─────┘ + */ + let gridA = signal([]); + + /** + * GRID B: + * ┌─────┬─────┬─────┐ + * │ 0,0 │ 0,1 │ 0,2 │ + * ├─────┼─────┤ │ + * │ 1,0 │ 1,1 │ │ + * ├─────┤ ├─────┤ + * │ 2,0 │ │ 2,2 │ + * │ ├─────┼─────┤ + * │ │ 3,1 │ 3,2 │ + * └─────┴─────┴─────┘ + */ + let gridB = signal([]); + + /** + * GRID C: + * ┌───────────┬─────┬─────┐ + * │ 0,0 │ 0,2 │ 0,3 │ + * ├─────┬─────┴─────┼─────┤ + * │ 1,0 │ 1,1 │ 1,3 │ + * ├─────┼─────┬─────┴─────┤ + * │ 2,0 │ 2,1 │ 2,2 │ + * └─────┴─────┴───────────┘ + */ + let gridC = signal([]); + + /** + * GRID D: + * ┌─────┬───────────┬─────┐ + * │ 0,0 │ 0,1 │ 0,3 │ + * │ ├───────────┼─────┤ + * │ │ 1,1 │ 1,3 │ + * ├─────┤ │ │ + * │ 2,0 │ │ │ + * ├─────┼─────┬─────┴─────┤ + * │ 3,0 │ 3,1 │ 3,2 │ + * └─────┴─────┴───────────┘ + */ + let gridD = signal([]); + + beforeEach(() => { + gridA.set([ + [createCell(), createCell(), createCell()], + [createCell(), createCell(), createCell()], + [createCell(), createCell(), createCell()], + ]); + + gridB.set([ + [createCell(), createCell(), createCell({rowspan: 2})], + [createCell(), createCell({rowspan: 2})], + [createCell({rowspan: 2}), createCell()], + [createCell(), createCell()], + ]); + + gridC.set([ + [createCell({colspan: 2}), createCell(), createCell()], + [createCell(), createCell({colspan: 2}), createCell()], + [createCell(), createCell(), createCell({colspan: 2})], + ]); + + gridD.set([ + [createCell({rowspan: 2}), createCell({colspan: 2}), createCell()], + [createCell({rowspan: 2, colspan: 2}), createCell({rowspan: 2})], + [createCell()], + [createCell(), createCell(), createCell({colspan: 2})], + ]); + }); + + describe('up()', () => { + it('should navigate up', () => { + const {gridNav} = createGridNav({ + cells: gridA, + activeCoords: signal({row: 1, col: 1}), + }); + const result = gridNav.up(); + expect(result).toBeTrue(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + }); + + it('(wrap: false) should not wrap', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(false), + activeCoords: signal({row: 0, col: 1}), + }); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + }); + + it('(skip disabled: false) should be able to navigate through disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + skipDisabled: signal(false), + activeCoords: signal({row: 1, col: 1}), + }); + cells[0][1].disabled.set(true); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + }); + + it('(skip disabled: true) should skip disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + skipDisabled: signal(true), + activeCoords: signal({row: 2, col: 1}), + }); + cells[1][1].disabled.set(true); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + }); + + it('(wrap: false) (skip disabled: true) should not navigate through disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(false), + skipDisabled: signal(true), + activeCoords: signal({row: 1, col: 1}), + }); + cells[0][1].disabled.set(true); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('(disabled: true) should not navigate', () => { + const {gridNav} = createGridNav({ + cells: gridA, + disabled: signal(true), + activeCoords: signal({row: 1, col: 1}), + }); + const result = gridNav.up(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + describe('(wrap: true)', () => { + describe('(wrap behavior: loop)', () => { + it('should loop to the last cell of the current column', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 0, col: 1}), + }); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 1}); + }); + + it('should wrap until it finds a cell that is focusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 0, col: 1}), + }); + cells[2][1].disabled.set(true); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('should not navigate if all cells that would be navigated to are unfocusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 0, col: 1}), + }); + cells[1][1].disabled.set(true); + cells[2][1].disabled.set(true); + const result = gridNav.up(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + }); + }); + + describe('(wrap behavior: continuous)', () => { + it('should wrap to the last cell of the previous column', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 0, col: 1}), + }); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 0}); + }); + + it('should wrap to the last cell of the last column', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 0, col: 0}), + }); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 2}); + }); + + it('should wrap until it finds a cell that is focusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 0, col: 1}), + }); + + cells[0][0].disabled.set(true); + cells[1][0].disabled.set(true); + cells[2][0].disabled.set(true); + + cells[1][1].disabled.set(true); + cells[2][1].disabled.set(true); + + cells[0][2].disabled.set(true); + cells[2][2].disabled.set(true); + + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 2}); + }); + + it('should wrap until it finds a cell that is focusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 0, col: 1}), + }); + + cells[0][0].disabled.set(true); + cells[1][0].disabled.set(true); + cells[2][0].disabled.set(true); + + cells[1][1].disabled.set(true); + cells[2][1].disabled.set(true); + + cells[0][2].disabled.set(true); + cells[0][1].disabled.set(true); + cells[2][2].disabled.set(true); + + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 2}); + }); + + it('should not navigate if all cells that would be navigated to are unfocusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 1, col: 1}), + }); + cells[0][0].disabled.set(true); + cells[1][0].disabled.set(true); + cells[2][0].disabled.set(true); + cells[0][1].disabled.set(true); + cells[2][1].disabled.set(true); + cells[0][2].disabled.set(true); + cells[1][2].disabled.set(true); + cells[2][2].disabled.set(true); + + const result = gridNav.up(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + }); + }); + + describe('with rowspan set', () => { + it('should navigate correctly', () => { + const {gridNav} = createGridNav({ + cells: gridB, + activeCoords: signal({row: 3, col: 2}), + }); + + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 2}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 3, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 3, col: 2}); + }); + + it('should navigate correctly when in a subcoordinate of a cell', () => { + const {gridNav} = createGridNav({ + cells: gridB, + activeCoords: signal({row: 3, col: 0}), + }); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + }); + + it('(skip disabled: true) should skip disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridB, + skipDisabled: signal(true), + activeCoords: signal({row: 2, col: 2}), + }); + cells[0][2].disabled.set(true); + cells[2][0].disabled.set(true); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 3, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + }); + + it('(wrap: false) should navigate correctly when in a subcoordinate of a cell', () => { + const {gridNav} = createGridNav({ + cells: gridB, + wrap: signal(false), + activeCoords: signal({row: 1, col: 2}), + }); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + }); + }); + + describe('with colspan set', () => { + it('should navigate correctly', () => { + const {gridNav} = createGridNav({ + cells: gridC, + activeCoords: signal({row: 2, col: 3}), + }); + + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 3}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 3}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 2}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 2}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 3}); + }); + + it('(skip disabled: true) should skip disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridC, + skipDisabled: signal(true), + activeCoords: signal({row: 1, col: 2}), + }); + cells[0][0].disabled.set(true); + cells[0][1].disabled.set(true); + cells[2][1].disabled.set(true); + + const result = gridNav.up(); + expect(result).toBeTrue(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 3}); + }); + }); + + describe('with rowspan and colspan set', () => { + it('should navigate correctly', () => { + const {gridNav} = createGridNav({ + cells: gridD, + activeCoords: signal({row: 3, col: 3}), + }); + + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 3}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 3}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 3, col: 2}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 2}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 3, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 3, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 3, col: 3}); + }); + + it('(skip disabled: true) should skip disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridD, + skipDisabled: signal(true), + activeCoords: signal({row: 3, col: 3}), + }); + + cells[1][0].disabled.set(true); + cells[1][1].disabled.set(true); + + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 3}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 3, col: 2}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 3, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + }); + }); + }); + + describe('down()', () => { + it('should navigate down', () => { + const {gridNav} = createGridNav({ + cells: gridA, + activeCoords: signal({row: 1, col: 1}), + }); + const result = gridNav.down(); + expect(result).toBeTrue(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 1}); + }); + + it('(wrap: false) should not wrap', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(false), + activeCoords: signal({row: 2, col: 1}), + }); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 1}); + }); + + it('(skip disabled: false) should be able to navigate through disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + skipDisabled: signal(false), + activeCoords: signal({row: 1, col: 1}), + }); + cells[2][1].disabled.set(true); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 1}); + }); + + it('(skip disabled: true) should skip disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + skipDisabled: signal(true), + activeCoords: signal({row: 0, col: 1}), + }); + cells[1][1].disabled.set(true); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 1}); + }); + + it('(wrap: false) (skip disabled: true) should not navigate through disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(false), + skipDisabled: signal(true), + activeCoords: signal({row: 1, col: 1}), + }); + cells[2][1].disabled.set(true); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('(disabled: true) should not navigate', () => { + const {gridNav} = createGridNav({ + cells: gridA, + disabled: signal(true), + activeCoords: signal({row: 1, col: 1}), + }); + const result = gridNav.down(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + describe('(wrap: true)', () => { + describe('(wrap behavior: loop)', () => { + it('should loop to the first cell of the current column', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 2, col: 1}), + }); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + }); + + it('should wrap until it finds a cell that is focusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 2, col: 1}), + }); + cells[0][1].disabled.set(true); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('should not navigate if all cells that would be navigated to are unfocusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 2, col: 1}), + }); + cells[0][1].disabled.set(true); + cells[1][1].disabled.set(true); + const result = gridNav.down(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 1}); + }); + }); + + describe('(wrap behavior: continuous)', () => { + it('should wrap to the first cell of the next column', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 2, col: 1}), + }); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + }); + + it('should wrap until it finds a cell that is focusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 2, col: 1}), + }); + + cells[0][2].disabled.set(true); + cells[1][2].disabled.set(true); + + gridNav.down(); + // Should land on (2,2) + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 2}); + }); + + it('should not navigate if all cells that would be navigated to are unfocusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 1, col: 1}), + }); + cells[0][0].disabled.set(true); + cells[1][0].disabled.set(true); + cells[2][0].disabled.set(true); + cells[0][1].disabled.set(true); + cells[2][1].disabled.set(true); + cells[0][2].disabled.set(true); + cells[1][2].disabled.set(true); + cells[2][2].disabled.set(true); + + const result = gridNav.down(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('should wrap to the first cell of the first column', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 2, col: 2}), + }); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + }); + }); + }); + + describe('with rowspan set', () => { + it('should navigate correctly', () => { + const {gridNav} = createGridNav({ + cells: gridB, + activeCoords: signal({row: 0, col: 0}), + }); + + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 0}); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + }); + + it('should navigate correctly when in a subcoordinate of a cell', () => { + const {gridNav} = createGridNav({ + cells: gridB, + activeCoords: signal({row: 1, col: 2}), + }); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 2}); + }); + + it('(skip disabled: true) should skip disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridB, + skipDisabled: signal(true), + activeCoords: signal({row: 0, col: 0}), + }); + cells[1][0].disabled.set(true); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 0}); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + }); + + it('(wrap: false) should navigate correctly when in a subcoordinate of a cell', () => { + const {gridNav} = createGridNav({ + cells: gridB, + wrap: signal(false), + activeCoords: signal({row: 0, col: 2}), + }); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 2}); + }); + }); + + describe('with colspan set', () => { + // For `down()`, colspan doesn't affect vertical navigation as much as rowspan. + // Basic navigation should still work. + it('should navigate correctly', () => { + const {gridNav} = createGridNav({cells: gridC, activeCoords: signal({row: 0, col: 0})}); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + }); + }); + }); + + describe('left()', () => { + it('should navigate left', () => { + const {gridNav} = createGridNav({ + cells: gridA, + activeCoords: signal({row: 1, col: 1}), + }); + const result = gridNav.left(); + expect(result).toBeTrue(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + }); + + it('(wrap: false) should not wrap', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(false), + activeCoords: signal({row: 1, col: 0}), + }); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + }); + + it('(skip disabled: false) should be able to navigate through disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + skipDisabled: signal(false), + activeCoords: signal({row: 1, col: 1}), + }); + cells[1][0].disabled.set(true); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + }); + + it('(skip disabled: true) should skip disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + skipDisabled: signal(true), + activeCoords: signal({row: 1, col: 2}), + }); + cells[1][1].disabled.set(true); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + }); + + it('(wrap: false) (skip disabled: true) should not navigate through disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(false), + skipDisabled: signal(true), + activeCoords: signal({row: 1, col: 1}), + }); + cells[1][0].disabled.set(true); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('(disabled: true) should not navigate', () => { + const {gridNav} = createGridNav({ + cells: gridA, + disabled: signal(true), + activeCoords: signal({row: 1, col: 1}), + }); + const result = gridNav.left(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + describe('(wrap: true)', () => { + describe('(wrap behavior: loop)', () => { + it('should loop to the last cell of the current row', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 1, col: 0}), + }); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 2}); + }); + + it('should wrap until it finds a cell that is focusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 1, col: 0}), + }); + cells[1][2].disabled.set(true); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('should not navigate if all cells that would be navigated to are unfocusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 1, col: 0}), + }); + cells[1][2].disabled.set(true); + cells[1][1].disabled.set(true); + const result = gridNav.left(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + }); + }); + + describe('(wrap behavior: continuous)', () => { + it('should wrap to the last cell of the previous row', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 1, col: 0}), + }); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + }); + + it('should wrap until it finds a cell that is focusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 1, col: 0}), + }); + + cells[0][2].disabled.set(true); + cells[0][1].disabled.set(true); + + gridNav.left(); + // Should land on (0,0) + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + }); + + it('should not navigate if all cells that would be navigated to are unfocusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 1, col: 1}), + }); + cells[0][0].disabled.set(true); + cells[1][0].disabled.set(true); + cells[2][0].disabled.set(true); + cells[0][1].disabled.set(true); + cells[2][1].disabled.set(true); + cells[0][2].disabled.set(true); + cells[1][2].disabled.set(true); + cells[2][2].disabled.set(true); + const result = gridNav.left(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('should wrap to the last cell of the last row', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 0, col: 0}), + }); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 2}); + }); + }); + }); + + describe('with rowspan set', () => { + // For `left()`, rowspan doesn't affect horizontal navigation as much as colspan. + // Basic navigation should still work. + it('should navigate correctly', () => { + const {gridNav} = createGridNav({cells: gridB, activeCoords: signal({row: 0, col: 1})}); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + }); + }); + + describe('with colspan set', () => { + it('should navigate correctly', () => { + const {gridNav} = createGridNav({ + cells: gridC, + activeCoords: signal({row: 0, col: 3}), + }); + + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 2}); + }); + + it('should navigate correctly when in a subcoordinate of a cell', () => { + const {gridNav} = createGridNav({ + cells: gridC, + activeCoords: signal({row: 0, col: 1}), + }); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 2}); + }); + + it('(skip disabled: true) should skip disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridC, + skipDisabled: signal(true), + activeCoords: signal({row: 0, col: 3}), + }); + + cells[0][1].disabled.set(true); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + }); + + it('(wrap: false) should navigate correctly when in a subcoordinate of a cell', () => { + const {gridNav} = createGridNav({ + cells: gridC, + wrap: signal(false), + activeCoords: signal({row: 0, col: 1}), + }); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + }); + }); + + describe('with rowspan and colspan set', () => { + it('should navigate correctly', () => { + const {gridNav} = createGridNav({ + cells: gridD, + activeCoords: signal({row: 0, col: 3}), + }); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + }); + }); + }); + + describe('right()', () => { + it('should navigate right', () => { + const {gridNav} = createGridNav({ + cells: gridA, + activeCoords: signal({row: 1, col: 1}), + }); + const result = gridNav.right(); + expect(result).toBeTrue(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 2}); + }); + + it('(wrap: false) should not wrap', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(false), + activeCoords: signal({row: 1, col: 2}), + }); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 2}); + }); + + it('(skip disabled: false) should be able to navigate through disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + skipDisabled: signal(false), + activeCoords: signal({row: 1, col: 1}), + }); + cells[1][2].disabled.set(true); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 2}); + }); + + it('(skip disabled: true) should skip disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + skipDisabled: signal(true), + activeCoords: signal({row: 1, col: 0}), + }); + cells[1][1].disabled.set(true); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 2}); + }); + + it('(wrap: false) (skip disabled: true) should not navigate through disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(false), + skipDisabled: signal(true), + activeCoords: signal({row: 1, col: 1}), + }); + cells[1][2].disabled.set(true); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('(disabled: true) should not navigate', () => { + const {gridNav} = createGridNav({ + cells: gridA, + disabled: signal(true), + activeCoords: signal({row: 1, col: 1}), + }); + const result = gridNav.right(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + describe('(wrap: true)', () => { + describe('(wrap behavior: loop)', () => { + it('should loop to the first cell of the current row', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 1, col: 2}), + }); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + }); + + it('should wrap until it finds a cell that is focusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 1, col: 2}), + }); + cells[1][0].disabled.set(true); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('should not navigate if all cells that would be navigated to are unfocusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 1, col: 2}), + }); + cells[1][0].disabled.set(true); + cells[1][1].disabled.set(true); + const result = gridNav.right(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 2}); + }); + }); + + describe('(wrap behavior: continuous)', () => { + it('should wrap to the first cell of the next row', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 1, col: 2}), + }); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 0}); + }); + + it('should wrap until it finds a cell that is focusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 1, col: 2}), + }); + + cells[2][0].disabled.set(true); + cells[2][1].disabled.set(true); + + gridNav.right(); + // Should land on (2,2) + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 2}); + }); + + it('should not navigate if all cells that would be navigated to are unfocusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 1, col: 1}), + }); + cells[0][0].disabled.set(true); + cells[1][0].disabled.set(true); + cells[2][0].disabled.set(true); + cells[0][1].disabled.set(true); + cells[2][1].disabled.set(true); + cells[0][2].disabled.set(true); + cells[1][2].disabled.set(true); + cells[2][2].disabled.set(true); + const result = gridNav.right(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('should wrap to the first cell of the first row', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 2, col: 2}), + }); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + }); + }); + }); + + describe('with rowspan set', () => { + // For `right()`, rowspan doesn't affect horizontal navigation as much as colspan. + // Basic navigation should still work. + it('should navigate correctly', () => { + const {gridNav} = createGridNav({cells: gridB, activeCoords: signal({row: 0, col: 0})}); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + }); + }); + + describe('with colspan set', () => { + it('should navigate correctly', () => { + const {gridNav} = createGridNav({ + cells: gridC, + activeCoords: signal({row: 0, col: 0}), + }); + + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 3}); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + }); + + it('should navigate correctly when in a subcoordinate of a cell', () => { + const {gridNav} = createGridNav({ + cells: gridC, + activeCoords: signal({row: 0, col: 1}), + }); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + }); + + it('(skip disabled: true) should skip disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridC, + skipDisabled: signal(true), + activeCoords: signal({row: 0, col: 0}), + }); + cells[0][1].disabled.set(true); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 3}); + }); + + it('(wrap: false) should navigate correctly when in a subcoordinate of a cell', () => { + const {gridNav} = createGridNav({ + cells: gridC, + wrap: signal(false), + activeCoords: signal({row: 0, col: 1}), + }); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + }); + }); + + describe('with rowspan and colspan set', () => { + it('should navigate correctly', () => { + const {gridNav} = createGridNav({ + cells: gridD, + activeCoords: signal({row: 0, col: 0}), + }); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 3}); + }); + }); + }); +}); diff --git a/src/cdk-experimental/ui-patterns/behaviors/grid-navigation/grid-navigation.ts b/src/cdk-experimental/ui-patterns/behaviors/grid-navigation/grid-navigation.ts new file mode 100644 index 000000000000..62cce7975bcf --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/grid-navigation/grid-navigation.ts @@ -0,0 +1,158 @@ +/** + * @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 {SignalLike} from '../signal-like/signal-like'; +import {GridFocus, GridFocusCell, GridFocusInputs, RowCol} from '../grid-focus/grid-focus'; +import {computed} from '@angular/core'; + +/** Represents an item in a collection, such as a listbox option, than can be navigated to. */ +export interface GridNavigationCell extends GridFocusCell {} + +/** Represents the required inputs for a collection that has navigable items. */ +export interface GridNavigationInputs extends GridFocusInputs { + gridFocus: GridFocus; + wrap: SignalLike; + wrapBehavior: SignalLike<'continuous' | 'loop'>; +} + +/** Controls navigation for a grid of items. */ +export class GridNavigation { + rowcount = computed(() => this.inputs.gridFocus.rowCount()); + colcount = computed(() => this.inputs.gridFocus.colCount()); + + constructor(readonly inputs: GridNavigationInputs) {} + + /** Navigates to the given item. */ + gotoCell(cell?: T): boolean { + return cell ? this.inputs.gridFocus.focusCell(cell) : false; + } + + /** Navigates to the given coordinates. */ + gotoCoords(coords: RowCol): boolean { + return this.inputs.gridFocus.focusCoordinates(coords); + } + + /** Navigates to the item above the current item. */ + up(): boolean { + return this._advance((cell: T, {col}: RowCol) => { + const rowindex = cell.rowindex(); + const isRowWrapping = this.inputs.wrap() && rowindex - 1 < 0; + const isColumnWrapping = isRowWrapping && this.inputs.wrapBehavior() === 'continuous'; + + const nextCoords = { + row: isRowWrapping + ? (rowindex - 1 + this.rowcount()) % this.rowcount() + : Math.max(rowindex - 1, 0), + col: isColumnWrapping ? (col - 1 + this.colcount()) % this.colcount() : col, + }; + + const nextCell = this.inputs.gridFocus.getCell(nextCoords)!; + + return { + row: nextCell.rowindex(), + col: nextCoords.col, + }; + }); + } + + /** Navigates to the item below the current item. */ + down(): boolean { + return this._advance((cell: T, {col}: RowCol) => { + const rowspan = cell.rowspan(); + const rowindex = cell.rowindex(); + const isRowWrapping = this.inputs.wrap() && rowindex + rowspan >= this.rowcount(); + const isColumnWrapping = isRowWrapping && this.inputs.wrapBehavior() === 'continuous'; + + return { + row: isRowWrapping + ? (rowindex + rowspan) % this.rowcount() + : Math.min(rowindex + rowspan, this.rowcount() - 1), + col: isColumnWrapping ? (col + 1 + this.colcount()) % this.colcount() : col, + }; + }); + } + + /** Navigates to the item to the left of the current item. */ + left(): boolean { + return this._advance((cell: T, {row, col}: RowCol) => { + const colindex = cell.colindex(); + const isColumnWrapping = this.inputs.wrap() && colindex - 1 < 0; + const isRowWrapping = isColumnWrapping && this.inputs.wrapBehavior() === 'continuous'; + + const nextCoords = { + row: isRowWrapping ? (row - 1 + this.rowcount()) % this.rowcount() : row, + col: isColumnWrapping + ? (colindex - 1 + this.colcount()) % this.colcount() + : Math.max(colindex - 1, 0), + }; + + const nextCell = this.inputs.gridFocus.getCell(nextCoords)!; + + return { + row: nextCoords.row, + col: nextCell.colindex(), + }; + }); + } + + /** Navigates to the item to the right of the current item. */ + right(): boolean { + return this._advance((cell: T, {row}: RowCol) => { + const colspan = cell.colspan(); + const colindex = cell.colindex(); + const isColumnWrapping = this.inputs.wrap() && colindex + colspan >= this.colcount(); + const isRowWrapping = isColumnWrapping && this.inputs.wrapBehavior() === 'continuous'; + + return { + row: isRowWrapping ? (row + 1 + this.rowcount()) % this.rowcount() : row, + col: isColumnWrapping + ? (colindex + colspan + this.colcount()) % this.colcount() + : Math.min(colindex + colspan, this.colcount() - 1), + }; + }); + } + + /** + * Continuously calls the given stepFn starting at the given coordinates + * until either a new focusable cell is reached or the grid fully loops. + */ + private _advance(stepFn: (cell: T, coords: RowCol) => RowCol) { + const startCoords = this.inputs.activeCoords(); + let prevCoords = {row: startCoords.row, col: startCoords.col}; + let nextCoords = {row: startCoords.row, col: startCoords.col}; + let nextCell = this.inputs.gridFocus.activeCell()!; + + while (true) { + prevCoords = {row: nextCoords.row, col: nextCoords.col}; + nextCoords = stepFn(nextCell, nextCoords); + + // The step did not result in any change in coordinates. + // + // This will happen if the user is at a boundary (start/end row or col) + // and tries to advance past it while `wrap` is false. + if (nextCoords.row === prevCoords.row && nextCoords.col === prevCoords.col) { + return false; + } + + // The step has resulted in arriving back to the original coordinates. + // + // This will happen if the other cells in the grid are unfocusable and `wrap` + // is true. The `stepFn` will eventually loop all the way back to the original cells. + if (nextCoords.row === startCoords.row && nextCoords.col === startCoords.col) { + return false; + } + + nextCell = this.inputs.gridFocus.getCell(nextCoords)!; + + // The `stepFn` has successfully reached a cell that is focusable. + if (this.inputs.gridFocus.isFocusable(nextCell)) { + return this.gotoCoords(nextCoords); + } + } + } +}