diff --git a/src/cdk-experimental/ui-patterns/behaviors/grid-focus/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/grid-focus/BUILD.bazel new file mode 100644 index 000000000000..763e2d09c806 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/grid-focus/BUILD.bazel @@ -0,0 +1,27 @@ +load("//tools:defaults.bzl", "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/cdk-experimental/ui-patterns/behaviors/signal-like", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = ["grid-focus.spec.ts"], + deps = [ + ":grid-focus", + "//: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-focus/grid-focus.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/grid-focus/grid-focus.spec.ts new file mode 100644 index 000000000000..958de708bc38 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/grid-focus/grid-focus.spec.ts @@ -0,0 +1,347 @@ +/** + * @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, signal, WritableSignal} from '@angular/core'; +import {GridFocus, GridFocusInputs, GridFocusCell} from './grid-focus'; + +// Helper type for test cells, extending GridFocusCell +interface TestGridCell extends GridFocusCell { + id: WritableSignal; + element: WritableSignal; + disabled: WritableSignal; +} + +// Helper type for configuring GridFocus inputs in tests +type TestSetupInputs = Partial> & { + numRows?: number; + numCols?: number; + gridFocus?: WritableSignal | undefined>; +}; + +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}); + 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, column: -1}); + cell.rowindex = computed(() => coordinates().row); + cell.colindex = computed(() => coordinates().column); + return cell; +} + +function createTestCells( + gridFocus: Signal | undefined>, + numRows: number, + numCols: number, +): WritableSignal { + 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 +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, column: 0}); + const focusMode = signal<'roving' | 'activedescendant'>( + inputs.focusMode ? inputs.focusMode() : 'roving', + ); + const disabled = signal(inputs.disabled ? inputs.disabled() : false); + const skipDisabled = signal(inputs.skipDisabled ? inputs.skipDisabled() : true); + + gridFocus.set( + new GridFocus({ + cells: cells, + activeCoords: activeCoords, + focusMode: focusMode, + disabled: disabled, + skipDisabled: skipDisabled, + }), + ); + + return { + cells: cells(), + gridFocus: gridFocus()!, + }; +} + +describe('GridFocus', () => { + describe('Initialization', () => { + it('should initialize with activeCell at {row: 0, column: 0} by default', () => { + const {gridFocus} = setupGridFocus(); + expect(gridFocus.inputs.activeCoords()).toEqual({row: 0, column: 0}); + }); + + it('should compute activeCell based on activeCell', () => { + const {gridFocus, cells} = setupGridFocus({ + activeCoords: signal({row: 1, column: 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 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, column: 0}); + expect(gridFocus.activeCell()).toBe(cell_0_0); + activeCoords.set({row: 0, column: 1}); + expect(gridFocus.activeCell()).toBe(cell_0_0); + activeCoords.set({row: 1, column: 0}); + expect(gridFocus.activeCell()).toBe(cell_0_0); + activeCoords.set({row: 1, column: 1}); + expect(gridFocus.activeCell()).toBe(cell_0_0); + + activeCoords.set({row: 0, column: 2}); + expect(gridFocus.activeCell()).toBe(cell_0_2); + + activeCoords.set({row: 1, column: 2}); + expect(gridFocus.activeCell()).toBe(cell_1_2); + }); + }); + + 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, column: 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(cell)', () => { + 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(cell)', () => { + 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 skipDisabled is true', () => { + const {gridFocus, cells} = setupGridFocus({ + numRows: 1, + numCols: 3, + skipDisabled: signal(true), + }); + 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 skipDisabled is false', () => { + const {gridFocus, cells} = setupGridFocus({ + numRows: 1, + numCols: 3, + skipDisabled: signal(false), + }); + 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('focus(cell)', () => { + it('should return false and not change state if grid is disabled', () => { + const activeCoords = signal({row: 0, column: 0}); + const {gridFocus, cells} = setupGridFocus({ + activeCoords, + disabled: signal(true), + }); + + const success = gridFocus.focus({row: 1, column: 0}); + + expect(success).toBeFalse(); + expect(activeCoords()).toEqual({row: 0, column: 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 {gridFocus, cells} = setupGridFocus({activeCoords}); + cells[1][0].disabled.set(true); + + const success = gridFocus.focus({row: 1, column: 0}); + + expect(success).toBeFalse(); + expect(activeCoords()).toEqual({row: 0, column: 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 {gridFocus, cells} = setupGridFocus({ + activeCoords, + focusMode: signal('roving'), + }); + + const success = gridFocus.focus({row: 1, column: 0}); + + expect(success).toBeTrue(); + expect(activeCoords()).toEqual({row: 1, column: 0}); + expect(cells[1][0].element().focus).toHaveBeenCalled(); + + expect(gridFocus.activeCell()).toBe(cells[1][0]); + expect(gridFocus.prevActiveCoords()).toEqual({row: 0, column: 0}); + }); + + it('should update activeCell and prevActiveCell but not call element.focus in "activedescendant" mode', () => { + const activeCoords = signal({row: 0, column: 0}); + const {gridFocus, cells} = setupGridFocus({ + activeCoords, + focusMode: signal('activedescendant'), + }); + + const success = gridFocus.focus({row: 1, column: 0}); + + expect(success).toBeTrue(); + expect(activeCoords()).toEqual({row: 1, column: 0}); + expect(cells[1][0].element().focus).not.toHaveBeenCalled(); + + expect(gridFocus.activeCell()).toBe(cells[1][0]); + expect(gridFocus.prevActiveCoords()).toEqual({row: 0, column: 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 new file mode 100644 index 000000000000..1dc145a3786a --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/grid-focus/grid-focus.ts @@ -0,0 +1,184 @@ +/** + * @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<{row: number; column: number}>; + + /** Whether disabled cells in the grid should be skipped when navigating. */ + skipDisabled: SignalLike; +} + +/** 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}); + + /** The current active cell based on `activeCoords` coordinates. */ + activeCell = computed(() => this.getCell(this.inputs.activeCoords())); + + 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 tabindex for the grid container. */ + getGridTabindex(): -1 | 0 { + if (this.isGridDisabled()) { + return 0; + } + return this.inputs.focusMode() === 'activedescendant' ? 0 : -1; + } + + /** Returns the tabindex 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; + } + + /** Moves focus to the cell at the given coordinates if it's part of a focusable cell. */ + focus(coordinates: {row: number; column: number}): boolean { + if (this.isGridDisabled()) { + return false; + } + + const cell = this.getCell(coordinates); + + if (!cell || !this.isFocusable(cell)) { + return false; // No cell at coordinates, or cell is not focusable. + } + + this.prevActiveCoords.set(this.inputs.activeCoords()); + this.inputs.activeCoords.set(coordinates); // Set activeCoords to the exact coordinates + + if (this.inputs.focusMode() === 'roving') { + const element = cell.element(); // Element of the cell that *covers* these coordinates + if (element && typeof element.focus === 'function') { + element.focus(); + } + } + return true; + } + + /** Returns true if the given cell can be navigated to. */ + isFocusable(cell: T): boolean { + return !cell.disabled() || !this.inputs.skipDisabled(); + } + + /** Finds the top-left anchor coordinates of a given cell instance in the grid. */ + getCoordinates(cellToFind: T): {row: number; column: number} | void { + const grid = this.inputs.cells(); + const occupiedCells = new Set(); // Stores "row,column" string keys for occupied cells. + + 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, column: 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: {row: number; column: number}): 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 + ) { + return cell; + } + } + } + } +}