From a6df58a2407917e03d42a57666671b447cc846b8 Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Tue, 14 Oct 2025 21:33:03 +0000 Subject: [PATCH 1/4] feat(aria/grid): create the aria grid --- .ng-dev/commit-message.mts | 1 + src/aria/grid/BUILD.bazel | 37 +++ src/aria/grid/grid.ts | 272 ++++++++++++++++ src/aria/grid/index.ts | 9 + src/aria/ui-patterns/BUILD.bazel | 1 + .../event-manager/pointer-event-manager.ts | 2 +- .../ui-patterns/behaviors/grid/BUILD.bazel | 32 ++ .../ui-patterns/behaviors/grid/grid-data.ts | 164 ++++++++++ .../ui-patterns/behaviors/grid/grid-focus.ts | 165 ++++++++++ .../behaviors/grid/grid-navigation.ts | 295 ++++++++++++++++++ .../behaviors/grid/grid-selection.ts | 98 ++++++ src/aria/ui-patterns/behaviors/grid/grid.ts | 247 +++++++++++++++ src/aria/ui-patterns/behaviors/grid/index.ts | 13 + src/aria/ui-patterns/grid/BUILD.bazel | 38 +++ src/aria/ui-patterns/grid/cell.ts | 116 +++++++ src/aria/ui-patterns/grid/grid.ts | 181 +++++++++++ src/aria/ui-patterns/grid/row.ts | 33 ++ src/aria/ui-patterns/grid/widget.ts | 49 +++ src/aria/ui-patterns/public-api.ts | 4 + src/components-examples/aria/grid/BUILD.bazel | 31 ++ .../aria/grid/grid-common.css | 22 ++ .../grid-configurable-example.css | 21 ++ .../grid-configurable-example.html | 78 +++++ .../grid-configurable-example.ts | 101 ++++++ .../aria/grid/grid-data.ts | 0 .../grid-pill-list/grid-pill-list-example.css | 58 ++++ .../grid-pill-list-example.html | 38 +++ .../grid-pill-list/grid-pill-list-example.ts | 62 ++++ src/components-examples/aria/grid/index.ts | 2 + src/dev-app/BUILD.bazel | 1 + src/dev-app/aria-grid/BUILD.bazel | 16 + src/dev-app/aria-grid/grid-demo.css | 19 ++ src/dev-app/aria-grid/grid-demo.html | 12 + src/dev-app/aria-grid/grid-demo.ts | 19 ++ src/dev-app/common-classes.css | 2 +- src/dev-app/dev-app/dev-app-layout.ts | 1 + src/dev-app/routes.ts | 4 + 37 files changed, 2242 insertions(+), 2 deletions(-) create mode 100644 src/aria/grid/BUILD.bazel create mode 100644 src/aria/grid/grid.ts create mode 100644 src/aria/grid/index.ts create mode 100644 src/aria/ui-patterns/behaviors/grid/BUILD.bazel create mode 100644 src/aria/ui-patterns/behaviors/grid/grid-data.ts create mode 100644 src/aria/ui-patterns/behaviors/grid/grid-focus.ts create mode 100644 src/aria/ui-patterns/behaviors/grid/grid-navigation.ts create mode 100644 src/aria/ui-patterns/behaviors/grid/grid-selection.ts create mode 100644 src/aria/ui-patterns/behaviors/grid/grid.ts create mode 100644 src/aria/ui-patterns/behaviors/grid/index.ts create mode 100644 src/aria/ui-patterns/grid/BUILD.bazel create mode 100644 src/aria/ui-patterns/grid/cell.ts create mode 100644 src/aria/ui-patterns/grid/grid.ts create mode 100644 src/aria/ui-patterns/grid/row.ts create mode 100644 src/aria/ui-patterns/grid/widget.ts create mode 100644 src/components-examples/aria/grid/BUILD.bazel create mode 100644 src/components-examples/aria/grid/grid-common.css create mode 100644 src/components-examples/aria/grid/grid-configurable/grid-configurable-example.css create mode 100644 src/components-examples/aria/grid/grid-configurable/grid-configurable-example.html create mode 100644 src/components-examples/aria/grid/grid-configurable/grid-configurable-example.ts create mode 100644 src/components-examples/aria/grid/grid-data.ts create mode 100644 src/components-examples/aria/grid/grid-pill-list/grid-pill-list-example.css create mode 100644 src/components-examples/aria/grid/grid-pill-list/grid-pill-list-example.html create mode 100644 src/components-examples/aria/grid/grid-pill-list/grid-pill-list-example.ts create mode 100644 src/components-examples/aria/grid/index.ts create mode 100644 src/dev-app/aria-grid/BUILD.bazel create mode 100644 src/dev-app/aria-grid/grid-demo.css create mode 100644 src/dev-app/aria-grid/grid-demo.html create mode 100644 src/dev-app/aria-grid/grid-demo.ts diff --git a/.ng-dev/commit-message.mts b/.ng-dev/commit-message.mts index 45af3ed66c31..6fe1a9f509eb 100644 --- a/.ng-dev/commit-message.mts +++ b/.ng-dev/commit-message.mts @@ -11,6 +11,7 @@ export const commitMessage: CommitMessageConfig = { 'multiple', // For when a commit applies to multiple components. 'aria/accordion', 'aria/combobox', + 'aria/grid', 'aria/listbox', 'aria/menu', 'aria/radio-group', diff --git a/src/aria/grid/BUILD.bazel b/src/aria/grid/BUILD.bazel new file mode 100644 index 000000000000..25219a49971c --- /dev/null +++ b/src/aria/grid/BUILD.bazel @@ -0,0 +1,37 @@ +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "grid", + srcs = [ + "grid.ts", + "index.ts", + ], + deps = [ + "//:node_modules/@angular/core", + "//src/aria/deferred-content", + "//src/aria/ui-patterns", + "//src/cdk/a11y", + "//src/cdk/bidi", + ], +) + +ng_project( + name = "unit_test_sources", + testonly = True, + srcs = [ + "grid.spec.ts", + ], + deps = [ + ":tabs", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/aria/grid/grid.ts b/src/aria/grid/grid.ts new file mode 100644 index 000000000000..4f21ecd5d29a --- /dev/null +++ b/src/aria/grid/grid.ts @@ -0,0 +1,272 @@ +/** + * @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 {_IdGenerator} from '@angular/cdk/a11y'; +import { + afterRenderEffect, + booleanAttribute, + computed, + contentChild, + contentChildren, + Directive, + ElementRef, + inject, + input, + model, + signal, + Signal, + untracked, +} from '@angular/core'; +import {GridPattern, GridRowPattern, GridCellPattern, GridCellWidgetPattern} from '../ui-patterns'; + +/** A directive that provides grid-based navigation and selection behavior. */ +@Directive({ + selector: '[ngGrid]', + exportAs: 'ngGrid', + host: { + 'class': 'grid', + 'role': 'grid', + '[tabindex]': 'pattern.tabIndex()', + '[attr.aria-disabled]': 'pattern.disabled()', + '[attr.aria-activedescendant]': 'pattern.activeDescendant()', + '(keydown)': 'pattern.onKeydown($event)', + '(pointerdown)': 'pattern.onPointerdown($event)', + '(pointermove)': 'pattern.onPointermove($event)', + '(pointerup)': 'pattern.onPointerup($event)', + '(focusin)': 'onFocusIn()', + '(focusout)': 'onFocusOut()', + }, +}) +export class Grid { + /** The rows that make up the grid. */ + private readonly _rows = contentChildren(GridRow); + + /** The UI patterns for the rows in the grid. */ + private readonly _rowPatterns: Signal = computed(() => + this._rows().map(r => r.pattern), + ); + + /** Whether selection is enabled for the grid. */ + readonly enableSelection = input(false, {transform: booleanAttribute}); + + /** Whether the grid is disabled. */ + readonly disabled = input(false, {transform: booleanAttribute}); + + /** Whether to skip disabled items during navigation. */ + readonly skipDisabled = input(true, {transform: booleanAttribute}); + + /** The focus strategy used by the tree. */ + readonly focusMode = input<'roving' | 'activedescendant'>('roving'); + + /** The wrapping behavior for keyboard navigation along the row axis. */ + readonly rowWrap = input<'continuous' | 'loop' | 'nowrap'>('loop'); + + /** The wrapping behavior for keyboard navigation along the column axis. */ + readonly colWrap = input<'continuous' | 'loop' | 'nowrap'>('loop'); + + /** The UI pattern for the grid. */ + readonly pattern = new GridPattern({ + ...this, + rows: this._rowPatterns, + getCell: e => this._getCell(e), + }); + + /** Whether the focus is in the grid. */ + private readonly _hasFocus = signal(false); + + constructor() { + afterRenderEffect(() => { + this.pattern.resetState(); + }); + + afterRenderEffect(() => { + const activeCell = this.pattern.activeCell(); + const hasFocus = untracked(() => this._hasFocus()); + const isRoving = this.focusMode() === 'roving'; + if (activeCell !== undefined && isRoving && hasFocus) { + activeCell.element().focus(); + } + }); + } + + /** Handles focusin events on the grid. */ + onFocusIn() { + this._hasFocus.set(true); + } + + /** Handles focusout events on the grid. */ + onFocusOut() { + this._hasFocus.set(false); + } + + /** Gets the cell pattern for a given element. */ + private _getCell(element: Element): GridCellPattern | undefined { + const cellElement = element.closest('[ngGridCell]'); + if (cellElement === undefined) return; + + const widgetElement = element.closest('[ngGridCellWidget]'); + for (const row of this._rowPatterns()) { + for (const cell of row.inputs.cells()) { + if ( + cell.element() === cellElement || + (widgetElement !== undefined && cell.element() === widgetElement) + ) { + return cell; + } + } + } + return; + } +} + +/** A directive that represents a row in a grid. */ +@Directive({ + selector: '[ngGridRow]', + exportAs: 'ngGridRow', + host: { + 'class': 'grid-row', + '[attr.role]': 'role()', + }, +}) +export class GridRow { + /** A reference to the host element. */ + private readonly _elementRef = inject(ElementRef); + + /** The cells that make up this row. */ + private readonly _cells = contentChildren(GridCell); + + /** The UI patterns for the cells in this row. */ + private readonly _cellPatterns: Signal = computed(() => + this._cells().map(c => c.pattern), + ); + + /** The parent grid. */ + private readonly _grid = inject(Grid); + + /** The parent grid UI pattern. */ + readonly grid = computed(() => this._grid.pattern); + + /** The host native element. */ + readonly element = computed(() => this._elementRef.nativeElement); + + /** The ARIA role for the row. */ + readonly role = input<'row' | 'rowheader'>('row'); + + /** The index of this row within the grid. */ + readonly rowIndex = input(); + + /** The UI pattern for the grid row. */ + readonly pattern = new GridRowPattern({ + ...this, + cells: this._cellPatterns, + }); +} + +/** A directive that represents a cell in a grid. */ +@Directive({ + selector: '[ngGridCell]', + exportAs: 'ngGridCell', + host: { + 'class': 'grid-cell', + '[attr.role]': 'role()', + '[attr.rowspan]': 'pattern.rowSpan()', + '[attr.colspan]': 'pattern.colSpan()', + '[attr.data-active]': 'pattern.active()', + '[attr.aria-disabled]': 'pattern.disabled()', + '[attr.aria-rowspan]': 'pattern.rowSpan()', + '[attr.aria-colspan]': 'pattern.colSpan()', + '[attr.aria-rowindex]': 'pattern.ariaRowIndex()', + '[attr.aria-colindex]': 'pattern.ariaColIndex()', + '[attr.aria-selected]': 'pattern.ariaSelected()', + '[tabindex]': 'pattern.tabIndex()', + }, +}) +export class GridCell { + /** A reference to the host element. */ + private readonly _elementRef = inject(ElementRef); + + /** The widget contained within this cell, if any. */ + private readonly _widget = contentChild(GridCellWidget); + + /** The UI pattern for the widget in this cell. */ + private readonly _widgetPattern: Signal = computed( + () => this._widget()?.pattern, + ); + + /** The parent row. */ + private readonly _row = inject(GridRow); + + /** A unique identifier for the cell. */ + private readonly _id = inject(_IdGenerator).getId('ng-grid-cell-'); + + /** The host native element. */ + readonly element = computed(() => this._elementRef.nativeElement); + + /** The ARIA role for the cell. */ + readonly role = input<'gridcell' | 'columnheader'>('gridcell'); + + /** The number of rows the cell should span. */ + readonly rowSpan = input(1); + + /** The number of columns the cell should span. */ + readonly colSpan = input(1); + + /** The index of this cell's row within the grid. */ + readonly rowIndex = input(); + + /** The index of this cell's column within the grid. */ + readonly colIndex = input(); + + /** Whether the cell is disabled. */ + readonly disabled = input(false, {transform: booleanAttribute}); + + /** Whether the cell is selected. */ + readonly selected = model(false); + + /** Whether the cell is selectable. */ + readonly selectable = input(true); + + /** The UI pattern for the grid cell. */ + readonly pattern = new GridCellPattern({ + ...this, + id: () => this._id, + grid: this._row.grid, + row: () => this._row.pattern, + widget: this._widgetPattern, + }); +} + +/** A directive that represents a widget inside a grid cell. */ +@Directive({ + selector: '[ngGridCellWidget]', + exportAs: 'ngGridCellWidget', + host: { + 'class': 'grid-cell-widget', + '[attr.data-active]': 'pattern.active()', + '[tabindex]': 'pattern.tabIndex()', + }, +}) +export class GridCellWidget { + /** A reference to the host element. */ + private readonly _elementRef = inject(ElementRef); + + /** The parent cell. */ + private readonly _cell = inject(GridCell); + + /** The host native element. */ + readonly element = computed(() => this._elementRef.nativeElement); + + /** Whether grid navigation should be paused, usually because this widget has focus. */ + readonly pauseGridNavigation = model(false); + + /** The UI pattern for the grid cell widget. */ + readonly pattern = new GridCellWidgetPattern({ + ...this, + cell: () => this._cell.pattern, + }); +} diff --git a/src/aria/grid/index.ts b/src/aria/grid/index.ts new file mode 100644 index 000000000000..94bcd871b881 --- /dev/null +++ b/src/aria/grid/index.ts @@ -0,0 +1,9 @@ +/** + * @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 + */ + +export * from './grid'; diff --git a/src/aria/ui-patterns/BUILD.bazel b/src/aria/ui-patterns/BUILD.bazel index d8da5e05682d..c6ddcec88a3a 100644 --- a/src/aria/ui-patterns/BUILD.bazel +++ b/src/aria/ui-patterns/BUILD.bazel @@ -13,6 +13,7 @@ ts_project( "//src/aria/ui-patterns/accordion", "//src/aria/ui-patterns/behaviors/signal-like", "//src/aria/ui-patterns/combobox", + "//src/aria/ui-patterns/grid", "//src/aria/ui-patterns/listbox", "//src/aria/ui-patterns/radio-group", "//src/aria/ui-patterns/tabs", diff --git a/src/aria/ui-patterns/behaviors/event-manager/pointer-event-manager.ts b/src/aria/ui-patterns/behaviors/event-manager/pointer-event-manager.ts index d03464562c15..018cbcc2e92e 100644 --- a/src/aria/ui-patterns/behaviors/event-manager/pointer-event-manager.ts +++ b/src/aria/ui-patterns/behaviors/event-manager/pointer-event-manager.ts @@ -70,7 +70,7 @@ export class PointerEventManager extends EventManager }; } - if (typeof args[0] === 'number' && typeof args[1] === 'function') { + if (args.length === 2) { return { button: MouseButton.Main, modifiers: args[0] as ModifierInputs, diff --git a/src/aria/ui-patterns/behaviors/grid/BUILD.bazel b/src/aria/ui-patterns/behaviors/grid/BUILD.bazel new file mode 100644 index 000000000000..6343d4ee8d14 --- /dev/null +++ b/src/aria/ui-patterns/behaviors/grid/BUILD.bazel @@ -0,0 +1,32 @@ +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "grid", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//:node_modules/@angular/core", + "//src/aria/ui-patterns/behaviors/signal-like", + ], +) + +ng_project( + name = "unit_test_sources", + testonly = True, + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":grid", + "//:node_modules/@angular/core", + "//src/cdk/keycodes", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/aria/ui-patterns/behaviors/grid/grid-data.ts b/src/aria/ui-patterns/behaviors/grid/grid-data.ts new file mode 100644 index 000000000000..460389bcbf31 --- /dev/null +++ b/src/aria/ui-patterns/behaviors/grid/grid-data.ts @@ -0,0 +1,164 @@ +/** + * @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} from '@angular/core'; +import {SignalLike} from '../signal-like/signal-like'; + +/** Represents coordinates in a grid. */ +export interface RowCol { + /** The row index. */ + row: number; + + /** The column index. */ + col: number; +} + +/** A string representation of grid coordinates. */ +type CoordString = `${RowCol['row']}:${RowCol['col']}`; + +/** The base interface for a cell in a grid. */ +export interface BaseGridCell { + /** The number of rows the cell should span. */ + rowSpan: SignalLike; + + /** The number of columns the cell should span. */ + colSpan: SignalLike; +} + +/** Represents the required inputs for GridData. */ +export interface GridDataInputs { + /** The two-dimensional array of cells that represents the grid. */ + cells: SignalLike; +} + +/** Controls internal coordinates for a grid of items. */ +export class GridData { + /** The two-dimensional array of cells that represents the grid. */ + readonly cells: SignalLike; + + /** A flattened array of all cells in the grid. */ + readonly flattenCells = computed(() => this.cells().flat()); + + /** The total number of cells in the grid. */ + readonly size = computed(() => this.flattenCells().length); + + /** The number of rows in the grid. */ + readonly rowCount = computed(() => this.cells().length); + + /** The maximum number of rows in the grid, accounting for row spans. */ + readonly maxRowCount = computed(() => Math.max(...this._rowCountByCol().values(), 0)); + + /** The maximum number of columns in the grid, accounting for column spans. */ + readonly maxColCount = computed(() => Math.max(...this._colCountsByRow().values(), 0)); + + /** A map from a cell to its primary and spanned coordinates. */ + private readonly _coordsMap = computed>(() => { + const coordsMap = new Map(); + const visitedCoords = new Set(); + + for (let rowIndex = 0; rowIndex < this.cells().length; rowIndex++) { + let colIndex = 0; + const row = this.cells()[rowIndex]; + + for (const cell of row) { + // Skip past cells that are already taken. + while (visitedCoords.has(`${rowIndex}:${colIndex}`)) { + colIndex++; + } + + const rowspan = cell.rowSpan(); + const colspan = cell.colSpan(); + const spanCoords: RowCol[] = []; + + for (let rowOffset = 0; rowOffset < rowspan; rowOffset++) { + const row = rowIndex + rowOffset; + for (let colOffset = 0; colOffset < colspan; colOffset++) { + const col = colIndex + colOffset; + visitedCoords.add(`${row}:${col}`); + spanCoords.push({row, col}); + } + } + coordsMap.set(cell, {coords: spanCoords[0], spanCoords}); + + colIndex += colspan; + } + } + + return coordsMap; + }); + + /** A map from a coordinate string to the cell at that coordinate. */ + private readonly _cellMap = computed>(() => { + const cellMap = new Map(); + for (const [cell, {spanCoords}] of this._coordsMap().entries()) { + for (const {row, col} of spanCoords) { + cellMap.set(`${row}:${col}`, cell); + } + } + return cellMap; + }); + + /** A map from a row index to the number of columns in that row. */ + private readonly _colCountsByRow = computed>(() => { + const colCountByRow = new Map(); + for (const [_, {spanCoords}] of this._coordsMap().entries()) { + for (const {row, col} of spanCoords) { + const colCount = colCountByRow.get(row); + const newColCount = col + 1; + if (colCount === undefined || colCount < newColCount) { + colCountByRow.set(row, newColCount); + } + } + } + return colCountByRow; + }); + + /** A map from a column index to the number of rows in that column. */ + private readonly _rowCountByCol = computed>(() => { + const rowCountByCol = new Map(); + for (const [_, {spanCoords}] of this._coordsMap().entries()) { + for (const {row, col} of spanCoords) { + const rowCount = rowCountByCol.get(col); + const newRowCount = row + 1; + if (rowCount === undefined || rowCount < newRowCount) { + rowCountByCol.set(col, newRowCount); + } + } + } + return rowCountByCol; + }); + + constructor(readonly inputs: GridDataInputs) { + this.cells = this.inputs.cells; + } + + /** Gets the cell at the given coordinates. */ + getCell(rowCol: RowCol): T | undefined { + return this._cellMap().get(`${rowCol.row}:${rowCol.col}`); + } + + /** Gets the primary coordinates of the given cell. */ + getCoords(cell: T): RowCol | undefined { + return this._coordsMap().get(cell)?.coords; + } + + /** Gets all coordinates that the given cell spans. */ + getAllCoords(cell: T): RowCol[] | undefined { + return this._coordsMap().get(cell)?.spanCoords; + } + + /** Gets the number of rows in the given column. */ + getRowCount(col: number): number | undefined { + return this._rowCountByCol().get(col); + } + + /** Gets the number of columns in the given row. */ + getColCount(row: number): number | undefined { + return this._colCountsByRow().get(row); + } +} diff --git a/src/aria/ui-patterns/behaviors/grid/grid-focus.ts b/src/aria/ui-patterns/behaviors/grid/grid-focus.ts new file mode 100644 index 000000000000..435a5d6abfeb --- /dev/null +++ b/src/aria/ui-patterns/behaviors/grid/grid-focus.ts @@ -0,0 +1,165 @@ +/** + * @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} from '../signal-like/signal-like'; +import type {GridData, BaseGridCell, RowCol} from './grid-data'; + +/** Represents an cell in a grid, such as a grid cell, that may receive focus. */ +export interface GridFocusCell extends BaseGridCell { + /** A unique identifier for the cell. */ + id: SignalLike; + + /** The html element that should receive focus. */ + element: SignalLike; + + /** Whether a cell is disabled. */ + disabled: 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; + + /** Whether disabled cells in the grid should be skipped when navigating. */ + skipDisabled: SignalLike; +} + +/** Dependencies for the `GridFocus` class. */ +interface GridFocusDeps { + /** The `GridData` instance that this focus manager operates on. */ + grid: GridData; +} + +/** Controls focus for a 2D grid of cells. */ +export class GridFocus { + /** The current active cell. */ + readonly activeCell = signal(undefined); + + /** The current active cell coordinates. */ + readonly activeCoords = signal({row: -1, col: -1}); + + /** Whether the grid active state is empty (no active cell or coordinates). */ + readonly stateEmpty = computed( + () => + this.activeCell() === undefined || + (this.activeCoords().row === -1 && this.activeCoords().col === -1), + ); + + /** + * Whether the grid focus state is stale. + * + * A stale state means the active cell or coordinates are no longer valid based on the + * current grid data, for example if the underlying cells have changed. + * A stale state should be re-initialized. + */ + readonly stateStale = computed(() => { + if (this.stateEmpty()) { + return true; + } + + const activeCell = this.activeCell(); + const activeCellCoords = this.inputs.grid.getCoords(activeCell!); + const activeCoords = this.activeCoords(); + const activeCoordsCell = this.inputs.grid.getCell(activeCoords); + + const activeCellNotValid = activeCellCoords === undefined; + const activeCellMismatch = activeCell !== activeCoordsCell; + return activeCellNotValid || activeCellMismatch; + }); + + /** The id of the current active cell, for ARIA activedescendant. */ + readonly activeDescendant = computed(() => { + if (this.gridDisabled() || this.inputs.focusMode() === 'roving') { + return undefined; + } + const currentActiveCell = this.activeCell(); + return currentActiveCell ? currentActiveCell.id() : undefined; + }); + + /** Whether the grid is in a disabled state. */ + readonly gridDisabled = computed(() => { + if (this.inputs.disabled()) { + return true; + } + const gridCells = this.inputs.grid.cells(); + return gridCells.length === 0 || gridCells.every(row => row.every(cell => cell.disabled())); + }); + + /** The tabindex for the grid container. */ + readonly gridTabIndex = computed<-1 | 0>(() => { + if (this.gridDisabled()) { + return 0; + } + return this.inputs.focusMode() === 'activedescendant' ? 0 : -1; + }); + + constructor(readonly inputs: GridFocusInputs & GridFocusDeps) {} + + /** Returns the tabindex for the given grid cell cell. */ + getCellTabindex(cell: T): -1 | 0 { + if (this.gridDisabled()) { + return -1; + } + if (this.inputs.focusMode() === 'activedescendant') { + return -1; + } + return this.activeCell() === cell ? 0 : -1; + } + + /** Returns true if the given cell can be navigated to. */ + isFocusable(cell: T): boolean { + return !cell.disabled() || !this.inputs.skipDisabled(); + } + + /** Focuses the given cell. */ + focusCell(cell: T): boolean { + if (this.gridDisabled()) { + return false; + } + + if (!this.isFocusable(cell)) { + return false; + } + + if (this.inputs.grid.getCoords(cell) === undefined) { + return false; + } + + this.activeCoords.set(this.inputs.grid.getCoords(cell)!); + this.activeCell.set(cell); + + return true; + } + + /** Moves focus to the cell at the given coordinates if it's part of a focusable cell. */ + focusCoordinates(coords: RowCol): boolean { + if (this.gridDisabled()) { + return false; + } + + const cell = this.inputs.grid.getCell(coords); + + if (!cell || !this.isFocusable(cell)) { + return false; + } + + if (this.inputs.grid.getCell(coords) === undefined) { + return false; + } + + this.activeCoords.set(coords); + this.activeCell.set(this.inputs.grid.getCell(coords)); + + return true; + } +} diff --git a/src/aria/ui-patterns/behaviors/grid/grid-navigation.ts b/src/aria/ui-patterns/behaviors/grid/grid-navigation.ts new file mode 100644 index 000000000000..af62dfcd88d6 --- /dev/null +++ b/src/aria/ui-patterns/behaviors/grid/grid-navigation.ts @@ -0,0 +1,295 @@ +/** + * @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} from '@angular/core'; +import {SignalLike} from '../signal-like/signal-like'; +import {GridFocus, GridFocusCell, GridFocusInputs} from './grid-focus'; +import {GridData, RowCol} from './grid-data'; + +/** A utility type that ensures an object has exactly one key from a given set. */ +type ExactlyOneKey = { + [P in K]: Record & Partial, never>>; +}[K]; + +/** Represents a directional change in the grid, either by row or by column. */ +type Delta = ExactlyOneKey<{row: -1 | 1; col: -1 | 1}>; + +/** 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 { + /** The wrapping behavior for keyboard navigation along the row axis. */ + rowWrap: SignalLike<'continuous' | 'loop' | 'nowrap'>; + + /** The wrapping behavior for keyboard navigation along the column axis. */ + colWrap: SignalLike<'continuous' | 'loop' | 'nowrap'>; +} + +/** Dependencies for the `GridNavigation` class. */ +interface GridNavigationDeps { + /** The `GridData` instance that this navigation manager operates on. */ + grid: GridData; + + /** The `GridFocus` instance that this navigation manager uses to manage focus. */ + gridFocus: GridFocus; +} + +/** Controls navigation for a grid of items. */ +export class GridNavigation { + /** The maximum number of steps to take when searching for the next cell. */ + private _maxSteps = computed( + () => this.inputs.grid.maxRowCount() * this.inputs.grid.maxColCount(), + ); + + constructor(readonly inputs: GridNavigationInputs & GridNavigationDeps) {} + + /** Navigates to the given item. */ + gotoCell(cell: T): boolean { + return this.inputs.gridFocus.focusCell(cell); + } + + /** Navigates to the given coordinates. */ + gotoCoords(coords: RowCol): boolean { + return this.inputs.gridFocus.focusCoordinates(coords); + } + + /** Gets the coordinates of the cell above the given coordinates. */ + peekUp(fromCoords: RowCol): RowCol | undefined { + return this._peekDirectional({row: -1}, fromCoords, this.inputs.rowWrap()); + } + + /** Navigates to the item above the current item. */ + up(): boolean { + const nextCoords = this.peekUp(this.inputs.gridFocus.activeCoords()); + return !!nextCoords && this.gotoCoords(nextCoords); + } + + /** Gets the coordinates of the cell below the given coordinates. */ + peekDown(fromCoords: RowCol): RowCol | undefined { + return this._peekDirectional({row: 1}, fromCoords, this.inputs.rowWrap()); + } + + /** Navigates to the item below the current item. */ + down(): boolean { + const nextCoords = this.peekDown(this.inputs.gridFocus.activeCoords()); + return !!nextCoords && this.gotoCoords(nextCoords); + } + + /** Gets the coordinates of the cell to the left of the given coordinates. */ + peekLeft(fromCoords: RowCol): RowCol | undefined { + return this._peekDirectional({col: -1}, fromCoords, this.inputs.colWrap()); + } + + /** Navigates to the item to the left of the current item. */ + left(): boolean { + const nextCoords = this.peekLeft(this.inputs.gridFocus.activeCoords()); + return !!nextCoords && this.gotoCoords(nextCoords); + } + + /** Gets the coordinates of the cell to the right of the given coordinates. */ + peekRight(fromCoords: RowCol): RowCol | undefined { + return this._peekDirectional({col: 1}, fromCoords, this.inputs.colWrap()); + } + + /** Navigates to the item to the right of the current item. */ + right(): boolean { + const nextCoords = this.peekRight(this.inputs.gridFocus.activeCoords()); + return !!nextCoords && this.gotoCoords(nextCoords); + } + + /** + * Gets the coordinates of the first focusable cell. + * If a row is not provided, searches the entire grid. + */ + peekFirst(row?: number): RowCol | undefined { + const delta: Delta = {col: 1}; + const startCoords = { + row: row ?? 0, + col: -1, + }; + return row === undefined + ? this._peekContinuous(delta, startCoords) + : this._peek(delta, startCoords); + } + + /** + * Navigates to the first focusable cell. + * If a row is not provided, searches the entire grid. + */ + first(row?: number): boolean { + const nextCoords = this.peekFirst(row); + return !!nextCoords && this.gotoCoords(nextCoords); + } + + /** + * Gets the coordinates of the last focusable cell. + * If a row is not provided, searches the entire grid. + */ + peekLast(row?: number): RowCol | undefined { + const delta: Delta = {col: -1}; + const startCoords = { + row: row ?? this.inputs.grid.maxRowCount() - 1, + col: this.inputs.grid.maxColCount(), + }; + return row === undefined + ? this._peekContinuous(delta, startCoords) + : this._peek(delta, startCoords); + } + + /** + * Navigates to the last focusable cell. + * If a row is not provided, searches the entire grid. + */ + last(row?: number): boolean { + const nextCoords = this.peekLast(row); + return !!nextCoords && this.gotoCoords(nextCoords); + } + + /** + * Finds the next focusable cell in a given direction, with continuous wrapping. + * This means that when the end of a row/column is reached, it wraps to the + * beginning of the next/previous row/column. + */ + private _peekContinuous(delta: Delta, startCoords: RowCol): RowCol | undefined { + const startCell = this.inputs.grid.getCell(startCoords); + const maxRowCount = this.inputs.grid.maxRowCount(); + const maxColCount = this.inputs.grid.maxColCount(); + const rowDelta = delta.row ?? 0; + const colDelta = delta.col ?? 0; + const generalDelta = delta.row ?? delta.col; + let nextCoords = {...startCoords}; + + for (let step = 0; step < this._maxSteps(); step++) { + const isWrapping = + nextCoords.col + colDelta < 0 || + nextCoords.col + colDelta >= maxColCount || + nextCoords.row + rowDelta < 0 || + nextCoords.row + rowDelta >= maxRowCount; + const rowStep = isWrapping ? generalDelta : rowDelta; + const colStep = isWrapping ? generalDelta : colDelta; + + nextCoords = { + row: (nextCoords.row + rowStep + maxRowCount) % maxRowCount, + col: (nextCoords.col + colStep + maxColCount) % maxColCount, + }; + + // Back to original coordinates. + if (nextCoords.row === startCoords.row && nextCoords.col === startCoords.col) { + return undefined; + } + + const nextCell = this.inputs.grid.getCell(nextCoords); + if ( + nextCell !== undefined && + nextCell !== startCell && + this.inputs.gridFocus.isFocusable(nextCell) + ) { + return nextCoords; + } + } + + return undefined; + } + + /** + * Finds the next focusable cell in a given direction, with loop wrapping. + * This means that when the end of a row/column is reached, it wraps to the + * beginning of the same row/column. + */ + private _peekLoop(delta: Delta, startCoords: RowCol): RowCol | undefined { + const startCell = this.inputs.grid.getCell(startCoords); + const maxRowCount = this.inputs.grid.maxRowCount(); + const maxColCount = this.inputs.grid.maxColCount(); + const rowDelta = delta.row ?? 0; + const colDelta = delta.col ?? 0; + let nextCoords = {...startCoords}; + + for (let step = 0; step < this._maxSteps(); step++) { + nextCoords = { + row: (nextCoords.row + rowDelta + maxRowCount) % maxRowCount, + col: (nextCoords.col + colDelta + maxColCount) % maxColCount, + }; + + // Back to original coordinates. + if (nextCoords.row === startCoords.row && nextCoords.col === startCoords.col) { + return undefined; + } + + const nextCell = this.inputs.grid.getCell(nextCoords); + if ( + nextCell !== undefined && + nextCell !== startCell && + this.inputs.gridFocus.isFocusable(nextCell) + ) { + return nextCoords; + } + } + + return undefined; + } + + /** + * Finds the next focusable cell in a given direction, without wrapping. + * This means that when the end of a row/column is reached, it stops. + */ + private _peek(delta: Delta, startCoords: RowCol): RowCol | undefined { + const startCell = this.inputs.grid.getCell(startCoords); + const maxRowCount = this.inputs.grid.maxRowCount(); + const maxColCount = this.inputs.grid.maxColCount(); + const rowDelta = delta.row ?? 0; + const colDelta = delta.col ?? 0; + let nextCoords = {...startCoords}; + + for (let step = 0; step < this._maxSteps(); step++) { + nextCoords = { + row: nextCoords.row + rowDelta, + col: nextCoords.col + colDelta, + }; + + // Out of boundary. + if ( + nextCoords.row < 0 || + nextCoords.row >= maxRowCount || + nextCoords.col < 0 || + nextCoords.col >= maxColCount + ) { + return undefined; + } + + const nextCell = this.inputs.grid.getCell(nextCoords); + if ( + nextCell !== undefined && + nextCell !== startCell && + this.inputs.gridFocus.isFocusable(nextCell) + ) { + return nextCoords; + } + } + + return undefined; + } + + /** + * Finds the next focusable cell in a given direction based on the wrapping behavior. + */ + private _peekDirectional( + delta: Delta, + fromCoords: RowCol, + wrap: 'continuous' | 'loop' | 'nowrap', + ): RowCol | undefined { + switch (wrap) { + case 'nowrap': + return this._peek(delta, fromCoords); + case 'loop': + return this._peekLoop(delta, fromCoords); + case 'continuous': + return this._peekContinuous(delta, fromCoords); + } + } +} diff --git a/src/aria/ui-patterns/behaviors/grid/grid-selection.ts b/src/aria/ui-patterns/behaviors/grid/grid-selection.ts new file mode 100644 index 000000000000..b7f548f91ad7 --- /dev/null +++ b/src/aria/ui-patterns/behaviors/grid/grid-selection.ts @@ -0,0 +1,98 @@ +/** + * @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, WritableSignalLike} from '../signal-like/signal-like'; +import {GridFocus, GridFocusCell, GridFocusInputs} from './grid-focus'; +import {GridData, RowCol} from './grid-data'; + +/** Represents a cell in a grid that can be selected. */ +export interface GridSelectionCell extends GridFocusCell { + /** Whether the cell is selected. */ + selected: WritableSignalLike; + + /** Whether the cell is selectable. */ + selectable: SignalLike; +} + +/** Represents the required inputs for a grid that has selectable cells. */ +export interface GridSelectionInputs extends GridFocusInputs {} + +/** Dependencies for the `GridSelection` class. */ +interface GridSelectionDeps { + /** The `GridData` instance that this selection manager operates on. */ + grid: GridData; + + /** The `GridFocus` instance that this selection manager uses to manage focus. */ + gridFocus: GridFocus; +} + +/** Controls selection for a grid of items. */ +export class GridSelection { + constructor(readonly inputs: GridSelectionInputs & GridSelectionDeps) {} + + /** Selects one or more cells in a given range. */ + select(fromCoords: RowCol, toCoords?: RowCol): void { + for (const cell of this._validCells(fromCoords, toCoords ?? fromCoords)) { + cell.selected.set(true); + } + } + + /** Deselects one or more cells in a given range. */ + deselect(fromCoords: RowCol, toCoords?: RowCol): void { + for (const cell of this._validCells(fromCoords, toCoords ?? fromCoords)) { + cell.selected.set(false); + } + } + + /** Toggles the selection state of one or more cells in a given range. */ + toggle(fromCoords: RowCol, toCoords?: RowCol): void { + for (const cell of this._validCells(fromCoords, toCoords ?? fromCoords)) { + cell.selected.update(state => !state); + } + } + + /** Selects all valid cells in the grid. */ + selectAll(): void { + for (const cell of this._validCells( + {row: 0, col: 0}, + {row: this.inputs.grid.maxRowCount(), col: this.inputs.grid.maxColCount()}, + )) { + cell.selected.set(true); + } + } + + /** Deselects all valid cells in the grid. */ + deselectAll(): void { + for (const cell of this._validCells( + {row: 0, col: 0}, + {row: this.inputs.grid.maxRowCount(), col: this.inputs.grid.maxColCount()}, + )) { + cell.selected.set(false); + } + } + + /** A generator that yields all valid (selectable and not disabled) cells within a given range. */ + *_validCells(fromCoords: RowCol, toCoords: RowCol): Generator { + const startRow = Math.min(fromCoords.row, toCoords.row); + const startCol = Math.min(fromCoords.col, toCoords.col); + const endRow = Math.max(fromCoords.row, toCoords.row); + const endCol = Math.max(fromCoords.col, toCoords.col); + const visited = new Set(); + for (let row = startRow; row < endRow + 1; row++) { + for (let col = startCol; col < endCol + 1; col++) { + const cell = this.inputs.grid.getCell({row, col}); + if (cell === undefined) continue; + if (!cell.selectable()) continue; + if (cell.disabled()) continue; + if (visited.has(cell)) continue; + visited.add(cell); + yield cell; + } + } + } +} diff --git a/src/aria/ui-patterns/behaviors/grid/grid.ts b/src/aria/ui-patterns/behaviors/grid/grid.ts new file mode 100644 index 000000000000..2826f25561d9 --- /dev/null +++ b/src/aria/ui-patterns/behaviors/grid/grid.ts @@ -0,0 +1,247 @@ +/** + * @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, linkedSignal} from '@angular/core'; +import {SignalLike} from '../signal-like/signal-like'; +import {GridData, BaseGridCell, GridDataInputs, RowCol} from './grid-data'; +import {GridFocus, GridFocusCell, GridFocusInputs} from './grid-focus'; +import {GridNavigation, GridNavigationCell, GridNavigationInputs} from './grid-navigation'; +import {GridSelectionCell, GridSelectionInputs, GridSelection} from './grid-selection'; + +/** A type that represents a cell in a grid, combining all cell-related interfaces. */ +export type GridCell = BaseGridCell & GridFocusCell & GridNavigationCell & GridSelectionCell; + +/** Represents the required inputs for a grid. */ +export interface GridInputs + extends GridDataInputs, + GridFocusInputs, + GridNavigationInputs, + GridSelectionInputs { + /** Whether selection is enabled for the grid. */ + enableSelection: SignalLike; +} + +/** The main class that orchestrates the grid behaviors. */ +export class Grid { + /** The underlying data structure for the grid. */ + readonly data: GridData; + + /** Controls focus for the grid. */ + readonly focusBehavior: GridFocus; + + /** Controls navigation for the grid. */ + readonly navigationBehavior: GridNavigation; + + /** Controls selection for the grid. */ + readonly selectionBehavior: GridSelection; + + /** The anchor point for range selection, linked to the active coordinates. */ + readonly selectionAnchor = linkedSignal(() => this.focusBehavior.activeCoords()); + + /** The `tabindex` for the grid container. */ + readonly gridTabIndex = computed(() => this.focusBehavior.gridTabIndex()); + + /** Whether the grid is in a disabled state. */ + readonly gridDisabled = computed(() => this.focusBehavior.gridDisabled()); + + /** The ID of the active descendant for ARIA `activedescendant` focus management. */ + readonly activeDescendant = computed(() => this.focusBehavior.activeDescendant()); + + constructor(readonly inputs: GridInputs) { + this.data = new GridData(inputs); + this.focusBehavior = new GridFocus({...inputs, grid: this.data}); + this.navigationBehavior = new GridNavigation({ + ...inputs, + grid: this.data, + gridFocus: this.focusBehavior, + }); + this.selectionBehavior = new GridSelection({ + ...inputs, + grid: this.data, + gridFocus: this.focusBehavior, + }); + } + + /** Gets the 1-based row index of a cell. */ + rowIndex(cell: T): number | undefined { + const index = this.data.getCoords(cell)?.row; + return index !== undefined ? index + 1 : undefined; + } + + /** Gets the 1-based column index of a cell. */ + colIndex(cell: T): number | undefined { + const index = this.data.getCoords(cell)?.col; + return index !== undefined ? index + 1 : undefined; + } + + /** Gets the `tabindex` for a given cell. */ + cellTabIndex(cell: T): -1 | 0 { + return this.focusBehavior.getCellTabindex(cell); + } + + /** Navigates to the cell above the currently active cell. */ + up(): boolean { + return this.navigationBehavior.up(); + } + + /** Extends the selection to the cell above the selection anchor. */ + rangeSelectUp(): void { + const coords = this.navigationBehavior.peekUp(this.selectionAnchor()); + if (coords === undefined) return; + + this._rangeSelectCoords(coords); + } + + /** Navigates to the cell below the currently active cell. */ + down(): boolean { + return this.navigationBehavior.down(); + } + + /** Extends the selection to the cell below the selection anchor. */ + rangeSelectDown(): void { + const coords = this.navigationBehavior.peekDown(this.selectionAnchor()); + if (coords === undefined) return; + + this._rangeSelectCoords(coords); + } + + /** Navigates to the cell to the left of the currently active cell. */ + left(): boolean { + return this.navigationBehavior.left(); + } + + /** Extends the selection to the cell to the left of the selection anchor. */ + rangeSelectLeft(): void { + const coords = this.navigationBehavior.peekLeft(this.selectionAnchor()); + if (coords === undefined) return; + + this._rangeSelectCoords(coords); + } + + /** Navigates to the cell to the right of the currently active cell. */ + right(): boolean { + return this.navigationBehavior.right(); + } + + /** Extends the selection to the cell to the right of the selection anchor. */ + rangeSelectRight(): void { + const coords = this.navigationBehavior.peekRight(this.selectionAnchor()); + if (coords === undefined) return; + + this._rangeSelectCoords(coords); + } + + /** Navigates to the first focusable cell in the grid. */ + first(): boolean { + return this.navigationBehavior.first(); + } + + /** Navigates to the first focusable cell in the current row. */ + firstInRow(): boolean { + return this.navigationBehavior.first(this.focusBehavior.activeCoords().row); + } + + /** Navigates to the last focusable cell in the grid. */ + last(): boolean { + return this.navigationBehavior.last(); + } + + /** Navigates to the last focusable cell in the current row. */ + lastInRow(): boolean { + return this.navigationBehavior.last(this.focusBehavior.activeCoords().row); + } + + /** Selects all cells in the current row. */ + selectRow(): void { + const row = this.focusBehavior.activeCoords().row; + this.selectionBehavior.deselectAll(); + this.selectionBehavior.select({row, col: 0}, {row, col: this.data.maxColCount()}); + } + + /** Selects all cells in the current column. */ + selectCol(): void { + const col = this.focusBehavior.activeCoords().col; + this.selectionBehavior.deselectAll(); + this.selectionBehavior.select({row: 0, col}, {row: this.data.maxRowCount(), col}); + } + + /** Selects all selectable cells in the grid. */ + selectAll(): void { + this.selectionBehavior.selectAll(); + } + + /** Navigates to and focuses the given cell. */ + gotoCell(cell: T): boolean { + return this.navigationBehavior.gotoCell(cell); + } + + /** Toggles the selection state of the given cell. */ + toggleSelect(cell: T): void { + const coords = this.data.getCoords(cell); + if (coords === undefined) return; + + this.selectionBehavior.toggle(coords); + } + + /** Extends the selection from the anchor to the given cell. */ + rangeSelect(cell: T): void { + const coords = this.data.getCoords(cell); + if (coords === undefined) return; + + this._rangeSelectCoords(coords); + } + + /** Extends the selection to the given coordinates. */ + private _rangeSelectCoords(coords: RowCol): void { + const activeCell = this.focusBehavior.activeCell(); + const anchorCell = this.data.getCell(coords); + if (activeCell === undefined || anchorCell === undefined) { + return; + } + + const allCoords = [ + ...this.data.getAllCoords(activeCell)!, + ...this.data.getAllCoords(anchorCell)!, + ]; + const allRows = allCoords.map(c => c.row); + const allCols = allCoords.map(c => c.col); + const fromCoords = { + row: Math.min(...allRows), + col: Math.min(...allCols), + }; + const toCoords = { + row: Math.max(...allRows), + col: Math.max(...allCols), + }; + + this.selectionBehavior.deselectAll(); + this.selectionBehavior.select(fromCoords, toCoords); + this.selectionAnchor.set(coords); + } + + /** Resets the focus state of the grid if it is empty or stale. */ + resetState(): boolean { + if (this.focusBehavior.stateEmpty()) { + const firstFocusableCoords = this.navigationBehavior.peekFirst(); + if (firstFocusableCoords === undefined) { + return false; + } + + return this.focusBehavior.focusCoordinates(firstFocusableCoords); + } + + if (this.focusBehavior.stateStale()) { + return ( + this.focusBehavior.focusCell(this.focusBehavior.activeCell()!) || + this.focusBehavior.focusCoordinates(this.focusBehavior.activeCoords()) + ); + } + + return false; + } +} diff --git a/src/aria/ui-patterns/behaviors/grid/index.ts b/src/aria/ui-patterns/behaviors/grid/index.ts new file mode 100644 index 000000000000..bfed707b6171 --- /dev/null +++ b/src/aria/ui-patterns/behaviors/grid/index.ts @@ -0,0 +1,13 @@ +/** + * @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 + */ + +export * from './grid'; +export * from './grid-data'; +export * from './grid-focus'; +export * from './grid-navigation'; +export * from './grid-selection'; diff --git a/src/aria/ui-patterns/grid/BUILD.bazel b/src/aria/ui-patterns/grid/BUILD.bazel new file mode 100644 index 000000000000..11ebe2815c86 --- /dev/null +++ b/src/aria/ui-patterns/grid/BUILD.bazel @@ -0,0 +1,38 @@ +load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "grid", + srcs = [ + "cell.ts", + "grid.ts", + "row.ts", + "widget.ts", + ], + deps = [ + "//:node_modules/@angular/core", + "//src/aria/ui-patterns/behaviors/event-manager", + "//src/aria/ui-patterns/behaviors/grid", + "//src/aria/ui-patterns/behaviors/signal-like", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = [ + "grid.spec.ts", + ], + deps = [ + ":grid", + "//:node_modules/@angular/core", + "//src/cdk/keycodes", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/aria/ui-patterns/grid/cell.ts b/src/aria/ui-patterns/grid/cell.ts new file mode 100644 index 000000000000..48326f779b06 --- /dev/null +++ b/src/aria/ui-patterns/grid/cell.ts @@ -0,0 +1,116 @@ +/** + * @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} from '@angular/core'; +import {SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like'; +import {GridCell} from '../behaviors/grid'; +import type {GridPattern} from './grid'; +import type {GridRowPattern} from './row'; +import type {GridCellWidgetPattern} from './widget'; + +/** The inputs for the `GridCellPattern`. */ +export interface GridCellInputs extends GridCell { + /** The `GridPattern` that this cell belongs to. */ + grid: SignalLike; + + /** The `GridRowPattern` that this cell belongs to. */ + row: SignalLike; + + /** The widget pattern contained within this cell, if any. */ + widget: SignalLike; + + /** The index of this cell's row within the grid. */ + rowIndex: SignalLike; + + /** The index of this cell's column within the grid. */ + colIndex: SignalLike; +} + +/** The UI pattern for a grid cell. */ +export class GridCellPattern implements GridCell { + /** A unique identifier for the cell. */ + readonly id: SignalLike; + + /** Whether a cell is disabled. */ + readonly disabled: SignalLike; + + /** Whether the cell is selected. */ + readonly selected: WritableSignalLike; + + /** Whether the cell is selectable. */ + readonly selectable: SignalLike; + + /** The number of rows the cell should span. */ + readonly rowSpan: SignalLike; + + /** The number of columns the cell should span. */ + readonly colSpan: SignalLike; + + /** The `aria-selected` attribute for the cell. */ + readonly ariaSelected = computed(() => + this.inputs.grid().inputs.enableSelection() && this.selectable() ? this.selected() : undefined, + ); + + /** The `aria-rowindex` attribute for the cell. */ + readonly ariaRowIndex = computed( + () => + this.inputs.row().rowIndex() ?? + this.inputs.rowIndex() ?? + this.inputs.grid().gridBehavior.rowIndex(this), + ); + + /** The `aria-colindex` attribute for the cell. */ + readonly ariaColIndex = computed( + () => this.inputs.colIndex() ?? this.inputs.grid().gridBehavior.colIndex(this), + ); + + /** The html element that should receive focus. */ + readonly element: SignalLike = computed( + () => this.inputs.widget()?.element() ?? this.inputs.element(), + ); + + /** Whether the cell is active. */ + readonly active = computed(() => this.inputs.grid().activeCell() === this); + + /** + * Whether grid navigation should be paused. + * Usually because the widget in a cell supports different interactions. + */ + readonly pauseGridNavigation = computed( + () => this.inputs.widget()?.pauseGridNavigation() ?? false, + ); + + /** The internal tab index calculation for the cell. */ + private readonly _tabIndex: SignalLike<-1 | 0> = computed(() => + this.inputs.grid().gridBehavior.cellTabIndex(this), + ); + + /** The `tabindex` for the cell. If the cell contains a widget, the cell's tabindex is -1. */ + readonly tabIndex: SignalLike<-1 | 0> = computed(() => + this.inputs.widget() !== undefined ? -1 : this._tabIndex(), + ); + + constructor(readonly inputs: GridCellInputs) { + this.id = inputs.id; + this.disabled = inputs.disabled; + this.rowSpan = inputs.rowSpan; + this.colSpan = inputs.colSpan; + this.selected = inputs.selected; + this.selectable = inputs.selectable; + } + + /** Gets the `tabindex` for the widget within the cell. */ + widgetTabIndex(): -1 | 0 { + return this._tabIndex(); + } + + /** Resets the navigation state of the widget within the cell, allowing grid navigation to resume. */ + resetGridNavigation(): void { + this.inputs.widget()?.pauseGridNavigation.set(false); + } +} diff --git a/src/aria/ui-patterns/grid/grid.ts b/src/aria/ui-patterns/grid/grid.ts new file mode 100644 index 000000000000..b92794ac8650 --- /dev/null +++ b/src/aria/ui-patterns/grid/grid.ts @@ -0,0 +1,181 @@ +/** + * @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} from '../behaviors/signal-like/signal-like'; +import {KeyboardEventManager, PointerEventManager, Modifier} from '../behaviors/event-manager'; +import {Grid, GridInputs as GridBehaviorInputs} from '../behaviors/grid'; +import type {GridRowPattern} from './row'; +import type {GridCellPattern} from './cell'; + +/** */ +export interface GridInputs extends Omit, 'cells'> { + /** */ + rows: SignalLike; + + /** */ + getCell: (e: Element) => GridCellPattern | undefined; +} + +/** */ +export class GridPattern { + /** */ + readonly gridBehavior: Grid; + + /** */ + readonly cells = computed(() => this.gridBehavior.data.cells()); + + /** */ + readonly tabIndex = computed(() => this.gridBehavior.gridTabIndex()); + + /** */ + readonly disabled = computed(() => this.gridBehavior.gridDisabled()); + + /** */ + readonly activeDescendant = computed(() => this.gridBehavior.activeDescendant()); + + /** */ + readonly activeCell = computed(() => this.gridBehavior.focusBehavior.activeCell()); + + /** */ + readonly pauseGridNavigation = computed(() => + this.gridBehavior.data.flattenCells().some(c => c.pauseGridNavigation()), + ); + + /** */ + readonly dragging = signal(false); + + /** The keydown event manager for the grid. */ + readonly keydown = computed(() => { + const manager = new KeyboardEventManager(); + + if (this.pauseGridNavigation()) { + return manager; + } + + manager + .on('ArrowUp', () => this.gridBehavior.up()) + .on('ArrowDown', () => this.gridBehavior.down()) + .on('ArrowLeft', () => this.gridBehavior.left()) + .on('ArrowRight', () => this.gridBehavior.right()) + .on('Home', () => this.gridBehavior.firstInRow()) + .on('End', () => this.gridBehavior.lastInRow()) + .on([Modifier.Ctrl], 'Home', () => this.gridBehavior.first()) + .on([Modifier.Ctrl], 'End', () => this.gridBehavior.last()); + + if (this.inputs.enableSelection()) { + manager + .on(Modifier.Shift, 'ArrowUp', () => this.gridBehavior.rangeSelectUp()) + .on(Modifier.Shift, 'ArrowDown', () => this.gridBehavior.rangeSelectDown()) + .on(Modifier.Shift, 'ArrowLeft', () => this.gridBehavior.rangeSelectLeft()) + .on(Modifier.Shift, 'ArrowRight', () => this.gridBehavior.rangeSelectRight()) + .on([Modifier.Ctrl, Modifier.Meta], 'A', () => this.gridBehavior.selectAll()) + .on([Modifier.Shift], ' ', () => this.gridBehavior.selectRow()) + .on([Modifier.Ctrl, Modifier.Meta], ' ', () => this.gridBehavior.selectCol()); + } + + return manager; + }); + + /** The pointerdown event manager for the grid. */ + readonly pointerdown = computed(() => { + const manager = new PointerEventManager(); + + manager.on(e => { + const cell = this.inputs.getCell(e.target as Element); + if (!cell) return; + + this.gridBehavior.gotoCell(cell); + + if (this.inputs.enableSelection()) { + this.dragging.set(true); + } + }); + + if (this.inputs.enableSelection()) { + manager + .on([Modifier.Ctrl, Modifier.Meta], e => { + const cell = this.inputs.getCell(e.target as Element); + if (!cell) return; + + this.gridBehavior.toggleSelect(cell); + }) + .on(Modifier.Shift, e => { + const cell = this.inputs.getCell(e.target as Element); + if (!cell) return; + + this.gridBehavior.rangeSelect(cell); + this.dragging.set(true); + }); + } + + return manager; + }); + + /** */ + readonly pointerup = computed(() => { + const manager = new PointerEventManager(); + + if (this.inputs.enableSelection()) { + manager + .on(() => { + this.dragging.set(false); + }) + .on(Modifier.Shift, () => { + this.dragging.set(false); + }); + } + + return manager; + }); + + constructor(readonly inputs: GridInputs) { + this.gridBehavior = new Grid({ + ...inputs, + cells: computed(() => this.inputs.rows().map(row => row.inputs.cells())), + }); + } + + /** Resets the active state of the grid if it is empty or stale. */ + resetState(): boolean { + return this.gridBehavior.resetState(); + } + + /** Handles keydown events on the grid. */ + onKeydown(event: KeyboardEvent) { + if (!this.disabled()) { + this.keydown().handle(event); + } + } + + /** Handles pointerdown events on the grid. */ + onPointerdown(event: PointerEvent) { + if (!this.disabled()) { + this.pointerdown().handle(event); + } + } + + /** Handles pointermove events on the grid. */ + onPointermove(event: PointerEvent) { + if (this.disabled()) return; + if (!this.inputs.enableSelection()) return; + if (!this.dragging()) return; + + const cell = this.inputs.getCell(event.target as Element); + if (!cell) return; + + this.gridBehavior.rangeSelect(cell); + } + + /** Handles pointerup events on the grid. */ + onPointerup(event: PointerEvent) { + if (!this.disabled()) { + this.pointerup().handle(event); + } + } +} diff --git a/src/aria/ui-patterns/grid/row.ts b/src/aria/ui-patterns/grid/row.ts new file mode 100644 index 000000000000..cb6e08ecb656 --- /dev/null +++ b/src/aria/ui-patterns/grid/row.ts @@ -0,0 +1,33 @@ +/** + * @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 '../behaviors/signal-like/signal-like'; +import type {GridPattern} from './grid'; +import type {GridCellPattern} from './cell'; + +/** The inputs for the `GridRowPattern`. */ +export interface GridRowInputs { + /** The `GridPattern` that this row belongs to. */ + grid: SignalLike; + + /** The cells that make up this row. */ + cells: SignalLike; + + /** The index of this row within the grid. */ + rowIndex: SignalLike; +} + +/** The UI pattern for a grid row. */ +export class GridRowPattern { + /** The index of this row within the grid. */ + rowIndex: SignalLike; + + constructor(readonly inputs: GridRowInputs) { + this.rowIndex = inputs.rowIndex; + } +} diff --git a/src/aria/ui-patterns/grid/widget.ts b/src/aria/ui-patterns/grid/widget.ts new file mode 100644 index 000000000000..ca61e23e7f4e --- /dev/null +++ b/src/aria/ui-patterns/grid/widget.ts @@ -0,0 +1,49 @@ +/** + * @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} from '@angular/core'; +import {SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like'; +import type {GridCellPattern} from './cell'; + +/** The inputs for the `GridCellWidgetPattern`. */ +export interface GridCellWidgetInputs { + /** The `GridCellPattern` that this widget belongs to. */ + cell: SignalLike; + + /** The html element that should receive focus. */ + element: SignalLike; + + /** + * Whether grid navigation should be paused. + * Usually because the widget in a cell supports different interactions. + */ + pauseGridNavigation: WritableSignalLike; +} + +/** The UI pattern for a widget inside a grid cell. */ +export class GridCellWidgetPattern { + /** The html element that should receive focus. */ + readonly element: SignalLike; + + /** + * Whether grid navigation should be paused. + * Usually because the widget in a cell supports different interactions. + */ + readonly pauseGridNavigation: WritableSignalLike; + + /** The `tabindex` for the widget. */ + readonly tabIndex: SignalLike<-1 | 0> = computed(() => this.inputs.cell().widgetTabIndex()); + + /** Whether the widget is in an active state (i.e. its containing cell is active). */ + readonly active: SignalLike = computed(() => this.inputs.cell().active()); + + constructor(readonly inputs: GridCellWidgetInputs) { + this.element = inputs.element; + this.pauseGridNavigation = inputs.pauseGridNavigation; + } +} diff --git a/src/aria/ui-patterns/public-api.ts b/src/aria/ui-patterns/public-api.ts index 55ae55da51a1..2550aa2f43da 100644 --- a/src/aria/ui-patterns/public-api.ts +++ b/src/aria/ui-patterns/public-api.ts @@ -22,3 +22,7 @@ export * from './accordion/accordion'; export * from './toolbar/toolbar'; export * from './tree/tree'; export * from './tree/combobox-tree'; +export * from './grid/grid'; +export * from './grid/row'; +export * from './grid/cell'; +export * from './grid/widget'; diff --git a/src/components-examples/aria/grid/BUILD.bazel b/src/components-examples/aria/grid/BUILD.bazel new file mode 100644 index 000000000000..2f11f723254e --- /dev/null +++ b/src/components-examples/aria/grid/BUILD.bazel @@ -0,0 +1,31 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "grid", + srcs = glob(["**/*.ts"]), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + deps = [ + "//:node_modules/@angular/common", + "//:node_modules/@angular/core", + "//:node_modules/@angular/forms", + "//src/aria/grid", + "//src/material/checkbox", + "//src/material/form-field", + "//src/material/icon", + "//src/material/select", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.ts", + ]), +) diff --git a/src/components-examples/aria/grid/grid-common.css b/src/components-examples/aria/grid/grid-common.css new file mode 100644 index 000000000000..cd748c68f4ca --- /dev/null +++ b/src/components-examples/aria/grid/grid-common.css @@ -0,0 +1,22 @@ +.example-grid-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px; + margin-bottom: 16px; +} + +.example-grid-cell[data-active='true'] { + outline: 3px dashed var(--mat-sys-outline); + outline-offset: 4px; +} + +.example-grid-cell[data-active='true']:focus-within, +[aria-activedescendant]:focus-within .example-grid-cell[data-active='true'] { + outline: 3px dashed var(--mat-sys-tertiary); + outline-offset: 3px; +} + +.example-grid-cell[aria-disabled='true'] { + border: 1px dashed var(--mat-sys-outline-variant); +} diff --git a/src/components-examples/aria/grid/grid-configurable/grid-configurable-example.css b/src/components-examples/aria/grid/grid-configurable/grid-configurable-example.css new file mode 100644 index 000000000000..9d620e264ec7 --- /dev/null +++ b/src/components-examples/aria/grid/grid-configurable/grid-configurable-example.css @@ -0,0 +1,21 @@ +.example-grid-container { + display: flex; +} + +.example-grid { + border-spacing: 4px; +} + +.example-grid[aria-disabled='true'] { + background: color-mix( + in srgb, + var(--mat-sys-on-surface) calc(var(--mat-sys-focus-state-layer-opacity) * 100%), + transparent + ); +} + +.example-grid-cell { + height: 50px; + width: 50px; + border: 1px solid; +} diff --git a/src/components-examples/aria/grid/grid-configurable/grid-configurable-example.html b/src/components-examples/aria/grid/grid-configurable/grid-configurable-example.html new file mode 100644 index 000000000000..f114d01cf055 --- /dev/null +++ b/src/components-examples/aria/grid/grid-configurable/grid-configurable-example.html @@ -0,0 +1,78 @@ +
+ Disabled + Skip Disabled + Enable Selection + + + Row Wrap + + No Wrap + Loop + Continuous + + + + + Col Wrap + + No Wrap + Loop + Continuous + + + + + Focus Strategy + + Roving + Active Descendant + + +
+ +
+ + @for (row of gridData; track row) { + + @for (cell of row; track cell) { + + } + + } +
+ +
    +
  • + +
  • +
  • Home: first cell in the row
  • +
  • End: last cell in the row
  • +
  • Crtl + Home: very first cell
  • +
  • Ctrl + End: very last cell
  • +
  • Shift + Space: select a row
  • +
  • Ctrl + Space: select a col
  • +
  • Shift + Arrow: expand selection
  • +
  • Ctrl + A: select all
  • +
  • + Internal coords: + ({{grid.pattern.gridBehavior.focusBehavior.activeCoords().row + }}, {{grid.pattern.gridBehavior.focusBehavior.activeCoords().col}}) +
  • +
+
diff --git a/src/components-examples/aria/grid/grid-configurable/grid-configurable-example.ts b/src/components-examples/aria/grid/grid-configurable/grid-configurable-example.ts new file mode 100644 index 000000000000..bf344f7e95ce --- /dev/null +++ b/src/components-examples/aria/grid/grid-configurable/grid-configurable-example.ts @@ -0,0 +1,101 @@ +/** + * @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 {Component} from '@angular/core'; +import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatSelectModule} from '@angular/material/select'; +import {Grid, GridRow, GridCell} from '@angular/aria/grid'; + +interface Cell { + rowSpan?: number; + colSpan?: number; + disabled?: boolean; +} + +function randomSpan(): number { + const spanChanceTable = [...Array(20).fill(1), ...Array(2).fill(2), ...Array(1).fill(3)]; + const randomIndex = Math.floor(Math.random() * spanChanceTable.length); + return spanChanceTable[randomIndex]; +} + +function randomDisabled(): boolean { + const disabledChanceTable = [...Array(15).fill(false), ...Array(4).fill(true)]; + const randomIndex = Math.floor(Math.random() * disabledChanceTable.length); + return disabledChanceTable[randomIndex]; +} + +function generateValidGrid(rowCount: number, colCount: number): Cell[][] { + const grid: Cell[][] = []; + const visitedCoords = new Set(); + for (let r = 0; r < rowCount; r++) { + const row = []; + for (let c = 0; c < colCount; c++) { + if (visitedCoords.has(`${r},${c}`)) { + continue; + } + + const rowSpan = Math.min(randomSpan(), rowCount - r); + const maxColSpan = Math.min(randomSpan(), colCount - c); + let colSpan = 1; + while (colSpan < maxColSpan) { + if (visitedCoords.has(`${r},${c + colSpan}`)) break; + colSpan += 1; + } + const disabled = randomDisabled(); + + row.push({ + rowSpan, + colSpan, + disabled, + }); + + for (let rs = 0; rs < rowSpan; rs++) { + for (let cs = 0; cs < colSpan; cs++) { + visitedCoords.add(`${r + rs},${c + cs}`); + } + } + } + grid.push(row); + } + return grid; +} + +/** @title Configurable Grid. */ +@Component({ + selector: 'grid-configurable-example', + exportAs: 'GridConfigurableExample', + templateUrl: 'grid-configurable-example.html', + styleUrls: ['../grid-common.css', 'grid-configurable-example.css'], + standalone: true, + imports: [ + FormsModule, + ReactiveFormsModule, + MatCheckboxModule, + MatFormFieldModule, + MatSelectModule, + Grid, + GridRow, + GridCell, + ], +}) +export class GridConfigurableExample { + rowWrap: 'continuous' | 'loop' | 'nowrap' = 'loop'; + colWrap: 'continuous' | 'loop' | 'nowrap' = 'continuous'; + focusMode: 'roving' | 'activedescendant' = 'roving'; + + disabled = new FormControl(false, {nonNullable: true}); + skipDisabled = new FormControl(true, {nonNullable: true}); + enableSelection = new FormControl(false, {nonNullable: true}); + + gridData: Cell[][] = generateValidGrid(10, 10); + + regenerateGrid() { + this.gridData = generateValidGrid(10, 10); + } +} diff --git a/src/components-examples/aria/grid/grid-data.ts b/src/components-examples/aria/grid/grid-data.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/components-examples/aria/grid/grid-pill-list/grid-pill-list-example.css b/src/components-examples/aria/grid/grid-pill-list/grid-pill-list-example.css new file mode 100644 index 000000000000..869197877b1b --- /dev/null +++ b/src/components-examples/aria/grid/grid-pill-list/grid-pill-list-example.css @@ -0,0 +1,58 @@ +.example-pill-list-container { + padding: 10px; + border: 1px solid; + max-width: 800px; +} + +.example-pill-list { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.example-pill { + display: flex; + align-items: center; + padding: 4px; + height: 32px; +} + +.example-pill-label { + border: 1px solid; + border-right-width: 0; + border-radius: 1rem 0 0 1rem; + padding: 4px 4px 4px 12px; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + min-width: 4rem; +} + +.example-pill-action { + border: 1px solid; + border-radius: 0 1rem 1rem 0; + padding: 4px 12px 4px 8px; + display: flex; + align-items: center; + height: 100%; +} + +.example-pill-button { + border-width: 0; + border-radius: 50%; + height: 24px; + width: 24px; + padding: 0; + background-color: transparent; + cursor: pointer; +} + +.example-pill-input { + border: none; + outline: none; + border-bottom: 1px solid; + height: 32px; + padding: 4px; +} diff --git a/src/components-examples/aria/grid/grid-pill-list/grid-pill-list-example.html b/src/components-examples/aria/grid/grid-pill-list/grid-pill-list-example.html new file mode 100644 index 000000000000..64bb0bda0dc9 --- /dev/null +++ b/src/components-examples/aria/grid/grid-pill-list/grid-pill-list-example.html @@ -0,0 +1,38 @@ +
+ Sort Alphabetically +
+ +
+
+ @for (item of sortedItems(); track item; let i = $index) { +
+
+ {{item.label}} +
+
+ +
+
+ } +
+
+ +
+
+
+
+ +
    +
  • Grid navigation paused when input is not empty.
  • +
  • Navigation is currently: {{grid.pattern.pauseGridNavigation() ? 'Off' : 'On'}}
  • +
diff --git a/src/components-examples/aria/grid/grid-pill-list/grid-pill-list-example.ts b/src/components-examples/aria/grid/grid-pill-list/grid-pill-list-example.ts new file mode 100644 index 000000000000..b5f0c76eb74b --- /dev/null +++ b/src/components-examples/aria/grid/grid-pill-list/grid-pill-list-example.ts @@ -0,0 +1,62 @@ +/** + * @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 {Component, computed, signal} from '@angular/core'; +import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatIconModule} from '@angular/material/icon'; +import {toSignal} from '@angular/core/rxjs-interop'; + +/** @title Grid Pill List. */ +@Component({ + selector: 'grid-pill-list-example', + exportAs: 'GridPillListExample', + templateUrl: 'grid-pill-list-example.html', + styleUrls: ['../grid-common.css', 'grid-pill-list-example.css'], + standalone: true, + imports: [ + Grid, + GridRow, + GridCell, + GridCellWidget, + MatIconModule, + MatCheckboxModule, + FormsModule, + ReactiveFormsModule, + MatFormFieldModule, + ], +}) +export class GridPillListExample { + readonly sort = new FormControl(false, {nonNullable: true}); + readonly sortSignal = toSignal(this.sort.valueChanges); + + readonly items = signal([ + {label: 'Cat'}, + {label: 'Giraffe'}, + {label: 'Dog'}, + {label: 'Bird'}, + {label: 'Hamster'}, + ]); + readonly sortedItems = computed(() => { + if (!this.sortSignal()) return this.items(); + return this.items().sort((a, b) => a.label.localeCompare(b.label)); + }); + + addItem(event: Event) { + const target = event.target as HTMLInputElement; + const value = target.value; + if (value.length === 0) return; + this.items.set([...this.items(), {label: value}]); + target.value = ''; + } + + removeItem(index: number) { + this.items.update(items => [...items.slice(0, index), ...items.slice(index + 1)]); + } +} diff --git a/src/components-examples/aria/grid/index.ts b/src/components-examples/aria/grid/index.ts new file mode 100644 index 000000000000..713726afa907 --- /dev/null +++ b/src/components-examples/aria/grid/index.ts @@ -0,0 +1,2 @@ +export {GridConfigurableExample} from './grid-configurable/grid-configurable-example'; +export {GridPillListExample} from './grid-pill-list/grid-pill-list-example'; diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index 87030ecb8fdf..041ce095f9c9 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -27,6 +27,7 @@ ng_project( "//src/cdk/overlay", "//src/dev-app/aria-accordion", "//src/dev-app/aria-combobox", + "//src/dev-app/aria-grid", "//src/dev-app/aria-listbox", "//src/dev-app/aria-menu", "//src/dev-app/aria-radio-group", diff --git a/src/dev-app/aria-grid/BUILD.bazel b/src/dev-app/aria-grid/BUILD.bazel new file mode 100644 index 000000000000..e79f141a8b11 --- /dev/null +++ b/src/dev-app/aria-grid/BUILD.bazel @@ -0,0 +1,16 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "aria-grid", + srcs = glob(["**/*.ts"]), + assets = [ + "grid-demo.html", + "grid-demo.css", + ], + deps = [ + "//:node_modules/@angular/core", + "//src/components-examples/aria/grid", + ], +) diff --git a/src/dev-app/aria-grid/grid-demo.css b/src/dev-app/aria-grid/grid-demo.css new file mode 100644 index 000000000000..b1c7a60608d0 --- /dev/null +++ b/src/dev-app/aria-grid/grid-demo.css @@ -0,0 +1,19 @@ +.demo-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(24rem, 1fr)); + gap: 20px; +} + +.demo-grid-container { + display: flex; + flex-direction: column; + justify-content: flex-start; +} + +.demo-configurable-grid-container { + padding-top: 40px; +} + +h2 { + font-size: 1.1rem; +} diff --git a/src/dev-app/aria-grid/grid-demo.html b/src/dev-app/aria-grid/grid-demo.html new file mode 100644 index 000000000000..f528a4a50e45 --- /dev/null +++ b/src/dev-app/aria-grid/grid-demo.html @@ -0,0 +1,12 @@ +
+
+

Grid Pill List

+ +
+
+
+
+

Configurable

+ +
+
diff --git a/src/dev-app/aria-grid/grid-demo.ts b/src/dev-app/aria-grid/grid-demo.ts new file mode 100644 index 000000000000..1f415d8153aa --- /dev/null +++ b/src/dev-app/aria-grid/grid-demo.ts @@ -0,0 +1,19 @@ +/** + * @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 {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core'; +import {GridConfigurableExample, GridPillListExample} from '@angular/components-examples/aria/grid'; + +@Component({ + templateUrl: 'grid-demo.html', + imports: [GridConfigurableExample, GridPillListExample], + styleUrl: 'grid-demo.css', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class GridDemo {} diff --git a/src/dev-app/common-classes.css b/src/dev-app/common-classes.css index a4a7de05e811..75bd63bdac2e 100644 --- a/src/dev-app/common-classes.css +++ b/src/dev-app/common-classes.css @@ -13,7 +13,7 @@ } .example-stateful:focus, -[aria-activedescendant]:focus-within .example-stateful.cdk-active { +[aria-activedescendant]:focus-within .example-stateful[data-active='true'] { background: color-mix( in srgb, var(--mat-sys-on-surface) calc(var(--mat-sys-focus-state-layer-opacity) * 100%), diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index f535155bf322..a459ac767f14 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -64,6 +64,7 @@ export class DevAppLayout { {name: 'CDK Dialog', route: '/cdk-dialog'}, {name: 'Aria Accordion', route: '/aria-accordion'}, {name: 'Aria Combobox', route: '/aria-combobox'}, + {name: 'Aria Grid', route: '/aria-grid'}, {name: 'Aria Listbox', route: '/aria-listbox'}, {name: 'Aria Menu', route: '/aria-menu'}, {name: 'Aria Radio Group', route: '/aria-radio-group'}, diff --git a/src/dev-app/routes.ts b/src/dev-app/routes.ts index 7206def61fa4..f24d0e51258b 100644 --- a/src/dev-app/routes.ts +++ b/src/dev-app/routes.ts @@ -44,6 +44,10 @@ export const DEV_APP_ROUTES: Routes = [ path: 'aria-combobox', loadComponent: () => import('./aria-combobox/combobox-demo').then(m => m.ComboboxDemo), }, + { + path: 'aria-grid', + loadComponent: () => import('./aria-grid/grid-demo').then(m => m.GridDemo), + }, { path: 'aria-listbox', loadComponent: () => import('./aria-listbox/listbox-demo').then(m => m.ListboxDemo), From 5b31ca9cb3b2af038891a849b3fedaff454c3857 Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Thu, 16 Oct 2025 06:53:52 +0000 Subject: [PATCH 2/4] fixup! feat(aria/grid): create the aria grid --- .../behaviors/grid/grid-navigation.ts | 198 ++++-------------- src/aria/ui-patterns/behaviors/grid/grid.ts | 23 +- 2 files changed, 59 insertions(+), 162 deletions(-) diff --git a/src/aria/ui-patterns/behaviors/grid/grid-navigation.ts b/src/aria/ui-patterns/behaviors/grid/grid-navigation.ts index af62dfcd88d6..fb0a8c962014 100644 --- a/src/aria/ui-patterns/behaviors/grid/grid-navigation.ts +++ b/src/aria/ui-patterns/behaviors/grid/grid-navigation.ts @@ -19,6 +19,14 @@ type ExactlyOneKey = { /** Represents a directional change in the grid, either by row or by column. */ type Delta = ExactlyOneKey<{row: -1 | 1; col: -1 | 1}>; +/** */ +export const Direction: Record<'Up' | 'Down' | 'Left' | 'Right', Delta> = { + Up: {row: -1}, + Down: {row: 1}, + Left: {col: -1}, + Right: {col: 1}, +} as const; + /** Represents an item in a collection, such as a listbox option, than can be navigated to. */ export interface GridNavigationCell extends GridFocusCell {} @@ -59,47 +67,15 @@ export class GridNavigation { return this.inputs.gridFocus.focusCoordinates(coords); } - /** Gets the coordinates of the cell above the given coordinates. */ - peekUp(fromCoords: RowCol): RowCol | undefined { - return this._peekDirectional({row: -1}, fromCoords, this.inputs.rowWrap()); - } - - /** Navigates to the item above the current item. */ - up(): boolean { - const nextCoords = this.peekUp(this.inputs.gridFocus.activeCoords()); - return !!nextCoords && this.gotoCoords(nextCoords); - } - - /** Gets the coordinates of the cell below the given coordinates. */ - peekDown(fromCoords: RowCol): RowCol | undefined { - return this._peekDirectional({row: 1}, fromCoords, this.inputs.rowWrap()); - } - - /** Navigates to the item below the current item. */ - down(): boolean { - const nextCoords = this.peekDown(this.inputs.gridFocus.activeCoords()); - return !!nextCoords && this.gotoCoords(nextCoords); - } - - /** Gets the coordinates of the cell to the left of the given coordinates. */ - peekLeft(fromCoords: RowCol): RowCol | undefined { - return this._peekDirectional({col: -1}, fromCoords, this.inputs.colWrap()); + /** */ + peek(direction: Delta, fromCoords: RowCol): RowCol | undefined { + const wrap = direction.row !== undefined ? this.inputs.rowWrap() : this.inputs.colWrap(); + return this._peekDirectional(direction, fromCoords, wrap); } - /** Navigates to the item to the left of the current item. */ - left(): boolean { - const nextCoords = this.peekLeft(this.inputs.gridFocus.activeCoords()); - return !!nextCoords && this.gotoCoords(nextCoords); - } - - /** Gets the coordinates of the cell to the right of the given coordinates. */ - peekRight(fromCoords: RowCol): RowCol | undefined { - return this._peekDirectional({col: 1}, fromCoords, this.inputs.colWrap()); - } - - /** Navigates to the item to the right of the current item. */ - right(): boolean { - const nextCoords = this.peekRight(this.inputs.gridFocus.activeCoords()); + /** */ + advance(direction: Delta): boolean { + const nextCoords = this.peek(direction, this.inputs.gridFocus.activeCoords()); return !!nextCoords && this.gotoCoords(nextCoords); } @@ -108,14 +84,13 @@ export class GridNavigation { * If a row is not provided, searches the entire grid. */ peekFirst(row?: number): RowCol | undefined { - const delta: Delta = {col: 1}; - const startCoords = { + const fromCoords = { row: row ?? 0, col: -1, }; return row === undefined - ? this._peekContinuous(delta, startCoords) - : this._peek(delta, startCoords); + ? this._peekDirectional(Direction.Right, fromCoords, 'continuous') + : this._peekDirectional(Direction.Right, fromCoords, 'nowrap'); } /** @@ -132,14 +107,13 @@ export class GridNavigation { * If a row is not provided, searches the entire grid. */ peekLast(row?: number): RowCol | undefined { - const delta: Delta = {col: -1}; - const startCoords = { + const fromCoords = { row: row ?? this.inputs.grid.maxRowCount() - 1, col: this.inputs.grid.maxColCount(), }; return row === undefined - ? this._peekContinuous(delta, startCoords) - : this._peek(delta, startCoords); + ? this._peekDirectional(Direction.Left, fromCoords, 'continuous') + : this._peekDirectional(Direction.Left, fromCoords, 'nowrap'); } /** @@ -152,18 +126,20 @@ export class GridNavigation { } /** - * Finds the next focusable cell in a given direction, with continuous wrapping. - * This means that when the end of a row/column is reached, it wraps to the - * beginning of the next/previous row/column. + * Finds the next focusable cell in a given direction based on the wrapping behavior. */ - private _peekContinuous(delta: Delta, startCoords: RowCol): RowCol | undefined { - const startCell = this.inputs.grid.getCell(startCoords); + private _peekDirectional( + delta: Delta, + fromCoords: RowCol, + wrap: 'continuous' | 'loop' | 'nowrap', + ): RowCol | undefined { + const fromCell = this.inputs.grid.getCell(fromCoords); const maxRowCount = this.inputs.grid.maxRowCount(); const maxColCount = this.inputs.grid.maxColCount(); const rowDelta = delta.row ?? 0; const colDelta = delta.col ?? 0; const generalDelta = delta.row ?? delta.col; - let nextCoords = {...startCoords}; + let nextCoords = {...fromCoords}; for (let step = 0; step < this._maxSteps(); step++) { const isWrapping = @@ -171,101 +147,35 @@ export class GridNavigation { nextCoords.col + colDelta >= maxColCount || nextCoords.row + rowDelta < 0 || nextCoords.row + rowDelta >= maxRowCount; - const rowStep = isWrapping ? generalDelta : rowDelta; - const colStep = isWrapping ? generalDelta : colDelta; - nextCoords = { - row: (nextCoords.row + rowStep + maxRowCount) % maxRowCount, - col: (nextCoords.col + colStep + maxColCount) % maxColCount, - }; + if (wrap === 'nowrap' && isWrapping) return; - // Back to original coordinates. - if (nextCoords.row === startCoords.row && nextCoords.col === startCoords.col) { - return undefined; - } + if (wrap === 'continuous') { + const rowStep = isWrapping ? generalDelta : rowDelta; + const colStep = isWrapping ? generalDelta : colDelta; - const nextCell = this.inputs.grid.getCell(nextCoords); - if ( - nextCell !== undefined && - nextCell !== startCell && - this.inputs.gridFocus.isFocusable(nextCell) - ) { - return nextCoords; + nextCoords = { + row: (nextCoords.row + rowStep + maxRowCount) % maxRowCount, + col: (nextCoords.col + colStep + maxColCount) % maxColCount, + }; } - } - return undefined; - } - - /** - * Finds the next focusable cell in a given direction, with loop wrapping. - * This means that when the end of a row/column is reached, it wraps to the - * beginning of the same row/column. - */ - private _peekLoop(delta: Delta, startCoords: RowCol): RowCol | undefined { - const startCell = this.inputs.grid.getCell(startCoords); - const maxRowCount = this.inputs.grid.maxRowCount(); - const maxColCount = this.inputs.grid.maxColCount(); - const rowDelta = delta.row ?? 0; - const colDelta = delta.col ?? 0; - let nextCoords = {...startCoords}; - - for (let step = 0; step < this._maxSteps(); step++) { - nextCoords = { - row: (nextCoords.row + rowDelta + maxRowCount) % maxRowCount, - col: (nextCoords.col + colDelta + maxColCount) % maxColCount, - }; - - // Back to original coordinates. - if (nextCoords.row === startCoords.row && nextCoords.col === startCoords.col) { - return undefined; - } - - const nextCell = this.inputs.grid.getCell(nextCoords); - if ( - nextCell !== undefined && - nextCell !== startCell && - this.inputs.gridFocus.isFocusable(nextCell) - ) { - return nextCoords; + if (wrap === 'loop') { + nextCoords = { + row: (nextCoords.row + rowDelta + maxRowCount) % maxRowCount, + col: (nextCoords.col + colDelta + maxColCount) % maxColCount, + }; } - } - - return undefined; - } - - /** - * Finds the next focusable cell in a given direction, without wrapping. - * This means that when the end of a row/column is reached, it stops. - */ - private _peek(delta: Delta, startCoords: RowCol): RowCol | undefined { - const startCell = this.inputs.grid.getCell(startCoords); - const maxRowCount = this.inputs.grid.maxRowCount(); - const maxColCount = this.inputs.grid.maxColCount(); - const rowDelta = delta.row ?? 0; - const colDelta = delta.col ?? 0; - let nextCoords = {...startCoords}; - - for (let step = 0; step < this._maxSteps(); step++) { - nextCoords = { - row: nextCoords.row + rowDelta, - col: nextCoords.col + colDelta, - }; - // Out of boundary. - if ( - nextCoords.row < 0 || - nextCoords.row >= maxRowCount || - nextCoords.col < 0 || - nextCoords.col >= maxColCount - ) { + // Back to original coordinates. + if (nextCoords.row === fromCoords.row && nextCoords.col === fromCoords.col) { return undefined; } const nextCell = this.inputs.grid.getCell(nextCoords); if ( nextCell !== undefined && - nextCell !== startCell && + nextCell !== fromCell && this.inputs.gridFocus.isFocusable(nextCell) ) { return nextCoords; @@ -274,22 +184,4 @@ export class GridNavigation { return undefined; } - - /** - * Finds the next focusable cell in a given direction based on the wrapping behavior. - */ - private _peekDirectional( - delta: Delta, - fromCoords: RowCol, - wrap: 'continuous' | 'loop' | 'nowrap', - ): RowCol | undefined { - switch (wrap) { - case 'nowrap': - return this._peek(delta, fromCoords); - case 'loop': - return this._peekLoop(delta, fromCoords); - case 'continuous': - return this._peekContinuous(delta, fromCoords); - } - } } diff --git a/src/aria/ui-patterns/behaviors/grid/grid.ts b/src/aria/ui-patterns/behaviors/grid/grid.ts index 2826f25561d9..1082f42ecab0 100644 --- a/src/aria/ui-patterns/behaviors/grid/grid.ts +++ b/src/aria/ui-patterns/behaviors/grid/grid.ts @@ -10,7 +10,12 @@ import {computed, linkedSignal} from '@angular/core'; import {SignalLike} from '../signal-like/signal-like'; import {GridData, BaseGridCell, GridDataInputs, RowCol} from './grid-data'; import {GridFocus, GridFocusCell, GridFocusInputs} from './grid-focus'; -import {GridNavigation, GridNavigationCell, GridNavigationInputs} from './grid-navigation'; +import { + Direction, + GridNavigation, + GridNavigationCell, + GridNavigationInputs, +} from './grid-navigation'; import {GridSelectionCell, GridSelectionInputs, GridSelection} from './grid-selection'; /** A type that represents a cell in a grid, combining all cell-related interfaces. */ @@ -86,12 +91,12 @@ export class Grid { /** Navigates to the cell above the currently active cell. */ up(): boolean { - return this.navigationBehavior.up(); + return this.navigationBehavior.advance(Direction.Up); } /** Extends the selection to the cell above the selection anchor. */ rangeSelectUp(): void { - const coords = this.navigationBehavior.peekUp(this.selectionAnchor()); + const coords = this.navigationBehavior.peek(Direction.Up, this.selectionAnchor()); if (coords === undefined) return; this._rangeSelectCoords(coords); @@ -99,12 +104,12 @@ export class Grid { /** Navigates to the cell below the currently active cell. */ down(): boolean { - return this.navigationBehavior.down(); + return this.navigationBehavior.advance(Direction.Down); } /** Extends the selection to the cell below the selection anchor. */ rangeSelectDown(): void { - const coords = this.navigationBehavior.peekDown(this.selectionAnchor()); + const coords = this.navigationBehavior.peek(Direction.Down, this.selectionAnchor()); if (coords === undefined) return; this._rangeSelectCoords(coords); @@ -112,12 +117,12 @@ export class Grid { /** Navigates to the cell to the left of the currently active cell. */ left(): boolean { - return this.navigationBehavior.left(); + return this.navigationBehavior.advance(Direction.Left); } /** Extends the selection to the cell to the left of the selection anchor. */ rangeSelectLeft(): void { - const coords = this.navigationBehavior.peekLeft(this.selectionAnchor()); + const coords = this.navigationBehavior.peek(Direction.Left, this.selectionAnchor()); if (coords === undefined) return; this._rangeSelectCoords(coords); @@ -125,12 +130,12 @@ export class Grid { /** Navigates to the cell to the right of the currently active cell. */ right(): boolean { - return this.navigationBehavior.right(); + return this.navigationBehavior.advance(Direction.Right); } /** Extends the selection to the cell to the right of the selection anchor. */ rangeSelectRight(): void { - const coords = this.navigationBehavior.peekRight(this.selectionAnchor()); + const coords = this.navigationBehavior.peek(Direction.Right, this.selectionAnchor()); if (coords === undefined) return; this._rangeSelectCoords(coords); From ae872567bae5846d80c14ad0f4a3ee6ad66ff5f3 Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Thu, 16 Oct 2025 16:51:02 +0000 Subject: [PATCH 3/4] fixup! feat(aria/grid): create the aria grid --- .../behaviors/grid/grid-navigation.ts | 22 +++++++++------- src/aria/ui-patterns/behaviors/grid/grid.ts | 18 ++++++------- src/aria/ui-patterns/grid/grid.ts | 26 +++++++++---------- 3 files changed, 35 insertions(+), 31 deletions(-) diff --git a/src/aria/ui-patterns/behaviors/grid/grid-navigation.ts b/src/aria/ui-patterns/behaviors/grid/grid-navigation.ts index fb0a8c962014..6a7babcac3a5 100644 --- a/src/aria/ui-patterns/behaviors/grid/grid-navigation.ts +++ b/src/aria/ui-patterns/behaviors/grid/grid-navigation.ts @@ -19,8 +19,8 @@ type ExactlyOneKey = { /** Represents a directional change in the grid, either by row or by column. */ type Delta = ExactlyOneKey<{row: -1 | 1; col: -1 | 1}>; -/** */ -export const Direction: Record<'Up' | 'Down' | 'Left' | 'Right', Delta> = { +/** Constants for the four cardinal directions. */ +export const direction: Record<'Up' | 'Down' | 'Left' | 'Right', Delta> = { Up: {row: -1}, Down: {row: 1}, Left: {col: -1}, @@ -67,13 +67,17 @@ export class GridNavigation { return this.inputs.gridFocus.focusCoordinates(coords); } - /** */ + /** + * Gets the coordinates of the next focusable cell in a given direction, without changing focus. + */ peek(direction: Delta, fromCoords: RowCol): RowCol | undefined { const wrap = direction.row !== undefined ? this.inputs.rowWrap() : this.inputs.colWrap(); return this._peekDirectional(direction, fromCoords, wrap); } - /** */ + /** + * Navigates to the next focusable cell in a given direction. + */ advance(direction: Delta): boolean { const nextCoords = this.peek(direction, this.inputs.gridFocus.activeCoords()); return !!nextCoords && this.gotoCoords(nextCoords); @@ -89,8 +93,8 @@ export class GridNavigation { col: -1, }; return row === undefined - ? this._peekDirectional(Direction.Right, fromCoords, 'continuous') - : this._peekDirectional(Direction.Right, fromCoords, 'nowrap'); + ? this._peekDirectional(direction.Right, fromCoords, 'continuous') + : this._peekDirectional(direction.Right, fromCoords, 'nowrap'); } /** @@ -112,8 +116,8 @@ export class GridNavigation { col: this.inputs.grid.maxColCount(), }; return row === undefined - ? this._peekDirectional(Direction.Left, fromCoords, 'continuous') - : this._peekDirectional(Direction.Left, fromCoords, 'nowrap'); + ? this._peekDirectional(direction.Left, fromCoords, 'continuous') + : this._peekDirectional(direction.Left, fromCoords, 'nowrap'); } /** @@ -138,7 +142,6 @@ export class GridNavigation { const maxColCount = this.inputs.grid.maxColCount(); const rowDelta = delta.row ?? 0; const colDelta = delta.col ?? 0; - const generalDelta = delta.row ?? delta.col; let nextCoords = {...fromCoords}; for (let step = 0; step < this._maxSteps(); step++) { @@ -151,6 +154,7 @@ export class GridNavigation { if (wrap === 'nowrap' && isWrapping) return; if (wrap === 'continuous') { + const generalDelta = delta.row ?? delta.col; const rowStep = isWrapping ? generalDelta : rowDelta; const colStep = isWrapping ? generalDelta : colDelta; diff --git a/src/aria/ui-patterns/behaviors/grid/grid.ts b/src/aria/ui-patterns/behaviors/grid/grid.ts index 1082f42ecab0..f6ec87e88788 100644 --- a/src/aria/ui-patterns/behaviors/grid/grid.ts +++ b/src/aria/ui-patterns/behaviors/grid/grid.ts @@ -11,7 +11,7 @@ import {SignalLike} from '../signal-like/signal-like'; import {GridData, BaseGridCell, GridDataInputs, RowCol} from './grid-data'; import {GridFocus, GridFocusCell, GridFocusInputs} from './grid-focus'; import { - Direction, + direction, GridNavigation, GridNavigationCell, GridNavigationInputs, @@ -91,12 +91,12 @@ export class Grid { /** Navigates to the cell above the currently active cell. */ up(): boolean { - return this.navigationBehavior.advance(Direction.Up); + return this.navigationBehavior.advance(direction.Up); } /** Extends the selection to the cell above the selection anchor. */ rangeSelectUp(): void { - const coords = this.navigationBehavior.peek(Direction.Up, this.selectionAnchor()); + const coords = this.navigationBehavior.peek(direction.Up, this.selectionAnchor()); if (coords === undefined) return; this._rangeSelectCoords(coords); @@ -104,12 +104,12 @@ export class Grid { /** Navigates to the cell below the currently active cell. */ down(): boolean { - return this.navigationBehavior.advance(Direction.Down); + return this.navigationBehavior.advance(direction.Down); } /** Extends the selection to the cell below the selection anchor. */ rangeSelectDown(): void { - const coords = this.navigationBehavior.peek(Direction.Down, this.selectionAnchor()); + const coords = this.navigationBehavior.peek(direction.Down, this.selectionAnchor()); if (coords === undefined) return; this._rangeSelectCoords(coords); @@ -117,12 +117,12 @@ export class Grid { /** Navigates to the cell to the left of the currently active cell. */ left(): boolean { - return this.navigationBehavior.advance(Direction.Left); + return this.navigationBehavior.advance(direction.Left); } /** Extends the selection to the cell to the left of the selection anchor. */ rangeSelectLeft(): void { - const coords = this.navigationBehavior.peek(Direction.Left, this.selectionAnchor()); + const coords = this.navigationBehavior.peek(direction.Left, this.selectionAnchor()); if (coords === undefined) return; this._rangeSelectCoords(coords); @@ -130,12 +130,12 @@ export class Grid { /** Navigates to the cell to the right of the currently active cell. */ right(): boolean { - return this.navigationBehavior.advance(Direction.Right); + return this.navigationBehavior.advance(direction.Right); } /** Extends the selection to the cell to the right of the selection anchor. */ rangeSelectRight(): void { - const coords = this.navigationBehavior.peek(Direction.Right, this.selectionAnchor()); + const coords = this.navigationBehavior.peek(direction.Right, this.selectionAnchor()); if (coords === undefined) return; this._rangeSelectCoords(coords); diff --git a/src/aria/ui-patterns/grid/grid.ts b/src/aria/ui-patterns/grid/grid.ts index b92794ac8650..3c1932e79599 100644 --- a/src/aria/ui-patterns/grid/grid.ts +++ b/src/aria/ui-patterns/grid/grid.ts @@ -13,41 +13,41 @@ import {Grid, GridInputs as GridBehaviorInputs} from '../behaviors/grid'; import type {GridRowPattern} from './row'; import type {GridCellPattern} from './cell'; -/** */ +/** Represents the required inputs for the grid pattern. */ export interface GridInputs extends Omit, 'cells'> { - /** */ + /** The rows that make up the grid. */ rows: SignalLike; - /** */ + /** A function that returns the grid cell associated with a given element. */ getCell: (e: Element) => GridCellPattern | undefined; } -/** */ +/** The UI pattern for a grid, handling keyboard navigation, focus, and selection. */ export class GridPattern { - /** */ + /** The underlying grid behavior that this pattern is built on. */ readonly gridBehavior: Grid; - /** */ + /** The cells in the grid. */ readonly cells = computed(() => this.gridBehavior.data.cells()); - /** */ + /** The tab index for the grid. */ readonly tabIndex = computed(() => this.gridBehavior.gridTabIndex()); - /** */ + /** Whether the grid is disabled. */ readonly disabled = computed(() => this.gridBehavior.gridDisabled()); - /** */ + /** The ID of the currently active descendant cell. */ readonly activeDescendant = computed(() => this.gridBehavior.activeDescendant()); - /** */ + /** The currently active cell. */ readonly activeCell = computed(() => this.gridBehavior.focusBehavior.activeCell()); - /** */ + /** Whether grid navigation is currently paused by a cell (e.g. an input field). */ readonly pauseGridNavigation = computed(() => this.gridBehavior.data.flattenCells().some(c => c.pauseGridNavigation()), ); - /** */ + /** Whether the user is currently dragging to select a range of cells. */ readonly dragging = signal(false); /** The keydown event manager for the grid. */ @@ -117,7 +117,7 @@ export class GridPattern { return manager; }); - /** */ + /** The pointerup event manager for the grid. */ readonly pointerup = computed(() => { const manager = new PointerEventManager(); From 497fa531ca5591aa48718e7fee6f08f041795274 Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Thu, 16 Oct 2025 17:02:21 +0000 Subject: [PATCH 4/4] fixup! feat(aria/grid): create the aria grid --- src/aria/grid/grid.ts | 8 ++++---- src/aria/ui-patterns/grid/grid.ts | 10 +++------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/aria/grid/grid.ts b/src/aria/grid/grid.ts index 4f21ecd5d29a..204f7af4416a 100644 --- a/src/aria/grid/grid.ts +++ b/src/aria/grid/grid.ts @@ -77,7 +77,7 @@ export class Grid { }); /** Whether the focus is in the grid. */ - private readonly _hasFocus = signal(false); + private readonly _isFocused = signal(false); constructor() { afterRenderEffect(() => { @@ -86,7 +86,7 @@ export class Grid { afterRenderEffect(() => { const activeCell = this.pattern.activeCell(); - const hasFocus = untracked(() => this._hasFocus()); + const hasFocus = untracked(() => this._isFocused()); const isRoving = this.focusMode() === 'roving'; if (activeCell !== undefined && isRoving && hasFocus) { activeCell.element().focus(); @@ -96,12 +96,12 @@ export class Grid { /** Handles focusin events on the grid. */ onFocusIn() { - this._hasFocus.set(true); + this._isFocused.set(true); } /** Handles focusout events on the grid. */ onFocusOut() { - this._hasFocus.set(false); + this._isFocused.set(false); } /** Gets the cell pattern for a given element. */ diff --git a/src/aria/ui-patterns/grid/grid.ts b/src/aria/ui-patterns/grid/grid.ts index 3c1932e79599..c26ec5d2bb29 100644 --- a/src/aria/ui-patterns/grid/grid.ts +++ b/src/aria/ui-patterns/grid/grid.ts @@ -122,13 +122,9 @@ export class GridPattern { const manager = new PointerEventManager(); if (this.inputs.enableSelection()) { - manager - .on(() => { - this.dragging.set(false); - }) - .on(Modifier.Shift, () => { - this.dragging.set(false); - }); + manager.on([Modifier.Shift, Modifier.None], () => { + this.dragging.set(false); + }); } return manager;