diff --git a/src/aria/private/behaviors/grid-focus/BUILD.bazel b/src/aria/private/behaviors/grid-focus/BUILD.bazel deleted file mode 100644 index eb4f60a59a92..000000000000 --- a/src/aria/private/behaviors/grid-focus/BUILD.bazel +++ /dev/null @@ -1,28 +0,0 @@ -load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") - -package(default_visibility = ["//visibility:public"]) - -ts_project( - name = "grid-focus", - srcs = ["grid-focus.ts"], - deps = [ - "//:node_modules/@angular/core", - "//src/aria/private/behaviors/signal-like", - ], -) - -ng_project( - name = "unit_test_sources", - testonly = True, - srcs = ["grid-focus.spec.ts"], - deps = [ - ":grid-focus", - "//:node_modules/@angular/core", - "//src/aria/private/behaviors/signal-like", - ], -) - -ng_web_test_suite( - name = "unit_tests", - deps = [":unit_test_sources"], -) diff --git a/src/aria/private/behaviors/grid-focus/grid-focus.spec.ts b/src/aria/private/behaviors/grid-focus/grid-focus.spec.ts deleted file mode 100644 index 3fc7c3141b88..000000000000 --- a/src/aria/private/behaviors/grid-focus/grid-focus.spec.ts +++ /dev/null @@ -1,443 +0,0 @@ -/** - * @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, SignalLike, signal, WritableSignalLike} from '../signal-like/signal-like'; -import {GridFocus, GridFocusInputs, GridFocusCell, RowCol} from './grid-focus'; - -// Helper type for test cells, extending GridFocusCell -interface TestGridCell extends GridFocusCell { - id: WritableSignalLike; - element: WritableSignalLike; - disabled: WritableSignalLike; -} - -// Helper type for configuring GridFocus inputs in tests -type TestSetupInputs = Partial> & { - numRows?: number; - numCols?: number; - gridFocus?: WritableSignalLike | undefined>; -}; - -export function createTestCell( - gridFocus: SignalLike | undefined>, - opts: {id: string; rowspan?: number; colspan?: number}, -): TestGridCell { - const el = document.createElement('div'); - spyOn(el, 'focus').and.callThrough(); - let coordinates: SignalLike = signal({row: -1, col: -1}); - const cell: TestGridCell = { - id: signal(opts.id), - element: signal(el as HTMLElement), - disabled: signal(false), - rowspan: signal(opts.rowspan ?? 1), - colspan: signal(opts.rowspan ?? 1), - rowindex: signal(-1), - colindex: signal(-1), - }; - coordinates = computed(() => gridFocus()?.getCoordinates(cell) ?? {row: -1, col: -1}); - cell.rowindex = computed(() => coordinates().row); - cell.colindex = computed(() => coordinates().col); - return cell; -} - -export function createTestCells( - gridFocus: SignalLike | undefined>, - numRows: number, - numCols: number, -): WritableSignalLike { - return signal( - Array.from({length: numRows}).map((_, r) => - Array.from({length: numCols}).map((_, c) => { - return createTestCell(gridFocus, {id: `cell-${r}-${c}`}); - }), - ), - ); -} - -// Main helper function to instantiate GridFocus and its dependencies for testing -export function setupGridFocus(inputs: TestSetupInputs = {}): { - cells: TestGridCell[][]; - gridFocus: GridFocus; -} { - const numRows = inputs.numRows ?? 3; - const numCols = inputs.numCols ?? 3; - - const gridFocus = inputs.gridFocus ?? signal | undefined>(undefined); - const cells = inputs.cells ?? createTestCells(gridFocus, numRows, numCols); - - const activeCoords = inputs.activeCoords ?? signal({row: 0, col: 0}); - const focusMode = signal<'roving' | 'activedescendant'>( - inputs.focusMode ? inputs.focusMode() : 'roving', - ); - const disabled = signal(inputs.disabled ? inputs.disabled() : false); - const softDisabled = signal(inputs.softDisabled ? inputs.softDisabled() : false); - - gridFocus.set( - new GridFocus({ - cells: cells, - activeCoords: activeCoords, - focusMode: focusMode, - disabled: disabled, - softDisabled: softDisabled, - }), - ); - - return { - cells: cells(), - gridFocus: gridFocus()!, - }; -} - -describe('GridFocus', () => { - describe('Initialization', () => { - it('should initialize with activeCell at {row: 0, col: 0} by default', () => { - const {gridFocus} = setupGridFocus(); - expect(gridFocus.inputs.activeCoords()).toEqual({row: 0, col: 0}); - }); - - it('should compute activeCell based on activeCell', () => { - const {gridFocus, cells} = setupGridFocus({ - 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, col: 0}); - 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, - activeCoords, - gridFocus: gridFocusSignal, - }); - - activeCoords.set({row: 0, col: 0}); - expect(gridFocus.activeCell()).toBe(cell_0_0); - activeCoords.set({row: 0, col: 1}); - expect(gridFocus.activeCell()).toBe(cell_0_0); - activeCoords.set({row: 1, col: 0}); - expect(gridFocus.activeCell()).toBe(cell_0_0); - activeCoords.set({row: 1, col: 1}); - expect(gridFocus.activeCell()).toBe(cell_0_0); - - activeCoords.set({row: 0, col: 2}); - expect(gridFocus.activeCell()).toBe(cell_0_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', () => { - it('should return true if inputs.disabled is true', () => { - const {gridFocus} = setupGridFocus({disabled: signal(true)}); - expect(gridFocus.isGridDisabled()).toBeTrue(); - }); - - it('should return true if all cells are disabled', () => { - const {gridFocus, cells} = setupGridFocus({numRows: 2, numCols: 1}); - cells.forEach(row => row.forEach(cell => cell.disabled.set(true))); - expect(gridFocus.isGridDisabled()).toBeTrue(); - }); - - it('should return true if inputs.cells is empty', () => { - const {gridFocus} = setupGridFocus({numRows: 0, numCols: 0}); - expect(gridFocus.isGridDisabled()).toBeTrue(); - }); - - it('should return true if the grid contains only empty rows', () => { - const cells = signal([[], []]); - const {gridFocus} = setupGridFocus({cells: cells}); - expect(gridFocus.isGridDisabled()).toBeTrue(); - }); - }); - - describe('getActiveDescendant', () => { - it('should return undefined if focusMode is "roving"', () => { - const {gridFocus} = setupGridFocus({focusMode: signal('roving')}); - expect(gridFocus.getActiveDescendant()).toBeUndefined(); - }); - - it('should return undefined if the grid is disabled', () => { - const {gridFocus} = setupGridFocus({ - disabled: signal(true), - focusMode: signal('activedescendant'), - }); - expect(gridFocus.getActiveDescendant()).toBeUndefined(); - }); - - it('should return the activeCell id if focusMode is "activedescendant"', () => { - const {gridFocus, cells} = setupGridFocus({ - focusMode: signal('activedescendant'), - activeCoords: signal({row: 2, col: 2}), - }); - expect(gridFocus.getActiveDescendant()).toBe(cells[2][2].id()); - }); - }); - - describe('getGridTabindex', () => { - it('should return 0 if grid is disabled', () => { - const {gridFocus} = setupGridFocus({disabled: signal(true)}); - expect(gridFocus.getGridTabIndex()).toBe(0); - }); - - it('should return -1 if focusMode is "roving" and grid is not disabled', () => { - const {gridFocus} = setupGridFocus({focusMode: signal('roving')}); - expect(gridFocus.getGridTabIndex()).toBe(-1); - }); - - it('should return 0 if focusMode is "activedescendant" and grid is not disabled', () => { - const {gridFocus} = setupGridFocus({focusMode: signal('activedescendant')}); - expect(gridFocus.getGridTabIndex()).toBe(0); - }); - }); - - describe('getCellTabindex', () => { - it('should return -1 if grid is disabled', () => { - const {gridFocus, cells} = setupGridFocus({ - numRows: 1, - numCols: 3, - disabled: signal(true), - }); - expect(gridFocus.getCellTabIndex(cells[0][0])).toBe(-1); - expect(gridFocus.getCellTabIndex(cells[0][1])).toBe(-1); - expect(gridFocus.getCellTabIndex(cells[0][2])).toBe(-1); - }); - - it('should return -1 if focusMode is "activedescendant"', () => { - const {gridFocus, cells} = setupGridFocus({ - numRows: 1, - numCols: 3, - focusMode: signal('activedescendant'), - }); - expect(gridFocus.getCellTabIndex(cells[0][0])).toBe(-1); - expect(gridFocus.getCellTabIndex(cells[0][1])).toBe(-1); - expect(gridFocus.getCellTabIndex(cells[0][2])).toBe(-1); - }); - - it('should return 0 if focusMode is "roving" and cell is the activeCell', () => { - const {gridFocus, cells} = setupGridFocus({ - numRows: 1, - numCols: 3, - focusMode: signal('roving'), - }); - - expect(gridFocus.getCellTabIndex(cells[0][0])).toBe(0); - expect(gridFocus.getCellTabIndex(cells[0][1])).toBe(-1); - expect(gridFocus.getCellTabIndex(cells[0][2])).toBe(-1); - }); - }); - - describe('isFocusable', () => { - it('should return true if cell is not disabled', () => { - const {gridFocus, cells} = setupGridFocus({ - numRows: 1, - numCols: 3, - }); - expect(gridFocus.isFocusable(cells[0][0])).toBeTrue(); - expect(gridFocus.isFocusable(cells[0][1])).toBeTrue(); - expect(gridFocus.isFocusable(cells[0][2])).toBeTrue(); - }); - - it('should return false if cell is disabled and softDisabled is false', () => { - const {gridFocus, cells} = setupGridFocus({ - numRows: 1, - numCols: 3, - softDisabled: signal(false), - }); - cells[0][1].disabled.set(true); - expect(gridFocus.isFocusable(cells[0][0])).toBeTrue(); - expect(gridFocus.isFocusable(cells[0][1])).toBeFalse(); - expect(gridFocus.isFocusable(cells[0][2])).toBeTrue(); - }); - - it('should return true if cell is disabled but softDisabled is true', () => { - const {gridFocus, cells} = setupGridFocus({ - numRows: 1, - numCols: 3, - softDisabled: signal(true), - }); - cells[0][1].disabled.set(true); - expect(gridFocus.isFocusable(cells[0][0])).toBeTrue(); - expect(gridFocus.isFocusable(cells[0][1])).toBeTrue(); - expect(gridFocus.isFocusable(cells[0][2])).toBeTrue(); - }); - }); - - 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, col: 0}); - const {gridFocus, cells} = setupGridFocus({ - activeCoords, - disabled: signal(true), - }); - - const success = gridFocus.focusCell(cells[1][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.focusCell(cells[1][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.focusCell(cells[1][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.focusCell(cells[1][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}); - }); - }); -}); diff --git a/src/aria/private/behaviors/grid-focus/grid-focus.ts b/src/aria/private/behaviors/grid-focus/grid-focus.ts deleted file mode 100644 index 508026eb9726..000000000000 --- a/src/aria/private/behaviors/grid-focus/grid-focus.ts +++ /dev/null @@ -1,223 +0,0 @@ -/** - * @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} from '@angular/core'; -import {SignalLike, WritableSignalLike} from '../signal-like/signal-like'; - -/** Represents an cell in a grid, such as a grid cell, that may receive focus. */ -export interface GridFocusCell { - /** A unique identifier for the cell. */ - id: SignalLike; - - /** The html element that should receive focus. */ - element: SignalLike; - - /** Whether an cell is disabled. */ - disabled: SignalLike; - - /** The number of rows the cell should span. Defaults to 1. */ - rowspan: SignalLike; - - /** The number of columns the cell should span. Defaults to 1. */ - colspan: SignalLike; - - /** The row index of the cell within the grid. */ - rowindex: SignalLike; - - /** The column index of the cell within the grid. */ - colindex: SignalLike; -} - -/** Represents the required inputs for a grid that contains focusable cells. */ -export interface GridFocusInputs { - /** The focus strategy used by the grid. */ - focusMode: SignalLike<'roving' | 'activedescendant'>; - - /** Whether the grid is disabled. */ - disabled: SignalLike; - - /** The cells in the grid, represented as a 2D array (rows and columns). */ - cells: SignalLike; - - /** The coordinates (row and column) of the current active cell. */ - activeCoords: WritableSignalLike; - - /** Whether disabled cells in the grid should be focusable. */ - softDisabled: 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: 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. */ - getActiveDescendant(): string | undefined { - if (this.isGridDisabled() || this.inputs.focusMode() === 'roving') { - return undefined; - } - const currentActiveCell = this.activeCell(); - return currentActiveCell ? currentActiveCell.id() : undefined; - } - - /** Whether the grid is in a disabled state. */ - isGridDisabled(): boolean { - if (this.inputs.disabled()) { - return true; - } - const gridCells = this.inputs.cells(); - return gridCells.length === 0 || gridCells.every(row => row.every(cell => cell.disabled())); - } - - /** The tab index for the grid container. */ - getGridTabIndex(): -1 | 0 { - if (this.isGridDisabled()) { - return 0; - } - return this.inputs.focusMode() === 'activedescendant' ? 0 : -1; - } - - /** Returns the tab index for the given grid cell cell. */ - getCellTabIndex(cell: T): -1 | 0 { - if (this.isGridDisabled()) { - return -1; - } - if (this.inputs.focusMode() === 'activedescendant') { - return -1; - } - 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. */ - focusCoordinates(coordinates: RowCol): boolean { - if (this.isGridDisabled()) { - return false; - } - - const cell = this.getCell(coordinates); - - if (!cell || !this.isFocusable(cell)) { - return false; - } - - this.prevActiveCoords.set(this.inputs.activeCoords()); - 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(); - if (element && typeof element.focus === 'function') { - element.focus(); - } - } - } - - /** Returns true if the given cell can be navigated to. */ - isFocusable(cell: T): boolean { - return !cell.disabled() || this.inputs.softDisabled(); - } - - /** Finds the top-left anchor coordinates of a given cell instance in the grid. */ - getCoordinates(cellToFind: T): RowCol | void { - const grid = this.inputs.cells(); - const occupiedCells = new Set(); - - for (let rowindex = 0; rowindex < grid.length; rowindex++) { - let colindex = 0; - const gridRow = grid[rowindex]; - - for (const gridCell of gridRow) { - // Skip past cells that are already taken. - while (occupiedCells.has(`${rowindex},${colindex}`)) { - colindex++; - } - - // Check if this is the cell we're looking for. - if (gridCell === cellToFind) { - return {row: rowindex, col: colindex}; - } - - const rowspan = gridCell.rowspan(); - const colspan = gridCell.colspan(); - - // If this cell spans multiple rows, mark those cells as occupied. - if (rowspan > 1) { - for (let rOffset = 1; rOffset < rowspan; rOffset++) { - const spannedRow = rowindex + rOffset; - for (let cOffset = 0; cOffset < colspan; cOffset++) { - const spannedCol = colindex + cOffset; - occupiedCells.add(`${spannedRow},${spannedCol}`); - } - } - } - - colindex += colspan; - } - } - } - - /** Gets the cell that covers the given coordinates, considering rowspan and colspan. */ - 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.col >= cell.colindex() && - coords.col <= cell.colindex() + cell.colspan() - 1 - ) { - return cell; - } - } - } - } -} diff --git a/src/aria/private/behaviors/grid-navigation/BUILD.bazel b/src/aria/private/behaviors/grid-navigation/BUILD.bazel deleted file mode 100644 index eb67f76f4e6d..000000000000 --- a/src/aria/private/behaviors/grid-navigation/BUILD.bazel +++ /dev/null @@ -1,30 +0,0 @@ -load("//tools:defaults.bzl", "ng_project", "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/aria/private/behaviors/grid-focus", - "//src/aria/private/behaviors/signal-like", - ], -) - -ng_project( - name = "unit_test_sources", - testonly = True, - srcs = ["grid-navigation.spec.ts"], - deps = [ - ":grid-navigation", - "//:node_modules/@angular/core", - "//src/aria/private/behaviors/grid-focus", - "//src/aria/private/behaviors/signal-like", - ], -) - -ng_web_test_suite( - name = "unit_tests", - deps = [":unit_test_sources"], -) diff --git a/src/aria/private/behaviors/grid-navigation/grid-navigation.spec.ts b/src/aria/private/behaviors/grid-navigation/grid-navigation.spec.ts deleted file mode 100644 index ddbd297fca6c..000000000000 --- a/src/aria/private/behaviors/grid-navigation/grid-navigation.spec.ts +++ /dev/null @@ -1,1253 +0,0 @@ -/** - * @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, WritableSignalLike} from '../../behaviors/signal-like/signal-like'; -import {GridFocus} from '../grid-focus/grid-focus'; -import {GridNavigation, GridNavigationCell, GridNavigationInputs} from './grid-navigation'; - -type TestGridNav = GridNavigation; - -interface TestCell extends GridNavigationCell { - disabled: WritableSignalLike; -} - -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 softDisabled = signal(true); - 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, - softDisabled, - ...config, - }); - - const gridNav = new GridNavigation({ - wrap, - disabled, - focusMode, - activeCoords, - softDisabled, - 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('(soft disabled: true) should be able to navigate through disabled cells', () => { - const {gridNav, cells} = createGridNav({ - cells: gridA, - softDisabled: signal(true), - activeCoords: signal({row: 1, col: 1}), - }); - cells[0][1].disabled.set(true); - gridNav.up(); - expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); - }); - - it('(soft disabled: false) should skip disabled cells', () => { - const {gridNav, cells} = createGridNav({ - cells: gridA, - softDisabled: signal(false), - 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) (soft disabled: false) should not navigate through disabled cells', () => { - const {gridNav, cells} = createGridNav({ - cells: gridA, - wrap: signal(false), - softDisabled: signal(false), - 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), - softDisabled: signal(false), - 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), - softDisabled: signal(false), - 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), - softDisabled: signal(false), - 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), - softDisabled: signal(false), - 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), - softDisabled: signal(false), - 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('(soft disabled: false) should skip disabled cells', () => { - const {gridNav, cells} = createGridNav({ - cells: gridB, - softDisabled: signal(false), - 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('(soft disabled: false) should skip disabled cells', () => { - const {gridNav, cells} = createGridNav({ - cells: gridC, - softDisabled: signal(false), - 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('(soft disabled: false) should skip disabled cells', () => { - const {gridNav, cells} = createGridNav({ - cells: gridD, - softDisabled: signal(false), - 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('(soft disabled: true) should be able to navigate through disabled cells', () => { - const {gridNav, cells} = createGridNav({ - cells: gridA, - softDisabled: signal(true), - activeCoords: signal({row: 1, col: 1}), - }); - cells[2][1].disabled.set(true); - gridNav.down(); - expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 1}); - }); - - it('(soft disabled: false) should skip disabled cells', () => { - const {gridNav, cells} = createGridNav({ - cells: gridA, - softDisabled: signal(false), - 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) (soft disabled: false) should not navigate through disabled cells', () => { - const {gridNav, cells} = createGridNav({ - cells: gridA, - wrap: signal(false), - softDisabled: signal(false), - 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), - softDisabled: signal(false), - 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), - softDisabled: signal(false), - 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), - softDisabled: signal(false), - 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), - softDisabled: signal(false), - 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('(soft disabled: false) should skip disabled cells', () => { - const {gridNav, cells} = createGridNav({ - cells: gridB, - softDisabled: signal(false), - 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('(soft disabled: true) should be able to navigate through disabled cells', () => { - const {gridNav, cells} = createGridNav({ - cells: gridA, - softDisabled: signal(true), - activeCoords: signal({row: 1, col: 1}), - }); - cells[1][0].disabled.set(true); - gridNav.left(); - expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); - }); - - it('(soft disabled: false) should skip disabled cells', () => { - const {gridNav, cells} = createGridNav({ - cells: gridA, - softDisabled: signal(false), - 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) (soft disabled: false) should not navigate through disabled cells', () => { - const {gridNav, cells} = createGridNav({ - cells: gridA, - wrap: signal(false), - softDisabled: signal(false), - 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), - softDisabled: signal(false), - 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), - softDisabled: signal(false), - 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), - softDisabled: signal(false), - 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), - softDisabled: signal(false), - 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('(soft disabled: false) should skip disabled cells', () => { - const {gridNav, cells} = createGridNav({ - cells: gridC, - softDisabled: signal(false), - 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('(soft disabled: true) should be able to navigate through disabled cells', () => { - const {gridNav, cells} = createGridNav({ - cells: gridA, - softDisabled: signal(true), - activeCoords: signal({row: 1, col: 1}), - }); - cells[1][2].disabled.set(true); - gridNav.right(); - expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 2}); - }); - - it('(soft disabled: false) should skip disabled cells', () => { - const {gridNav, cells} = createGridNav({ - cells: gridA, - softDisabled: signal(false), - 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) (soft disabled: false) should not navigate through disabled cells', () => { - const {gridNav, cells} = createGridNav({ - cells: gridA, - wrap: signal(false), - softDisabled: signal(false), - 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), - softDisabled: signal(false), - 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), - softDisabled: signal(false), - 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), - softDisabled: signal(false), - 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), - softDisabled: signal(false), - 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('(soft disabled: false) should skip disabled cells', () => { - const {gridNav, cells} = createGridNav({ - cells: gridC, - softDisabled: signal(false), - 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/aria/private/behaviors/grid-navigation/grid-navigation.ts b/src/aria/private/behaviors/grid-navigation/grid-navigation.ts deleted file mode 100644 index 62cce7975bcf..000000000000 --- a/src/aria/private/behaviors/grid-navigation/grid-navigation.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * @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); - } - } - } -}