diff --git a/src/aria/grid/grid.ts b/src/aria/grid/grid.ts index e9c872b4d19e..fa79924c9178 100644 --- a/src/aria/grid/grid.ts +++ b/src/aria/grid/grid.ts @@ -46,7 +46,7 @@ export class Grid { private readonly _elementRef = inject(ElementRef); /** The rows that make up the grid. */ - private readonly _rows = contentChildren(GridRow); + private readonly _rows = contentChildren(GridRow, {descendants: true}); /** The UI patterns for the rows in the grid. */ private readonly _rowPatterns: Signal = computed(() => @@ -77,6 +77,15 @@ export class Grid { /** The wrapping behavior for keyboard navigation along the column axis. */ readonly colWrap = input<'continuous' | 'loop' | 'nowrap'>('loop'); + /** Whether multiple cells in the grid can be selected. */ + readonly multi = input(false, {transform: booleanAttribute}); + + /** The selection strategy used by the grid. */ + readonly selectionMode = input<'follow' | 'explicit'>('follow'); + + /** Whether enable range selections (with modifier keys or dragging). */ + readonly enableRangeSelection = input(false, {transform: booleanAttribute}); + /** The UI pattern for the grid. */ readonly _pattern = new GridPattern({ ...this, @@ -85,6 +94,7 @@ export class Grid { }); constructor() { + afterRenderEffect(() => this._pattern.setDefaultStateEffect()); afterRenderEffect(() => this._pattern.resetStateEffect()); afterRenderEffect(() => this._pattern.focusEffect()); } @@ -123,7 +133,7 @@ export class GridRow { private readonly _elementRef = inject(ElementRef); /** The cells that make up this row. */ - private readonly _cells = contentChildren(GridCell); + private readonly _cells = contentChildren(GridCell, {descendants: true}); /** The UI patterns for the cells in this row. */ private readonly _cellPatterns: Signal = computed(() => @@ -163,6 +173,7 @@ export class GridRow { '[attr.rowspan]': '_pattern.rowSpan()', '[attr.colspan]': '_pattern.colSpan()', '[attr.data-active]': '_pattern.active()', + '[attr.data-anchor]': '_pattern.anchor()', '[attr.aria-disabled]': '_pattern.disabled()', '[attr.aria-rowspan]': '_pattern.rowSpan()', '[attr.aria-colspan]': '_pattern.colSpan()', diff --git a/src/aria/private/behaviors/grid/grid-navigation.spec.ts b/src/aria/private/behaviors/grid/grid-navigation.spec.ts index c90fe681ed6d..25bf4ee64f02 100644 --- a/src/aria/private/behaviors/grid/grid-navigation.spec.ts +++ b/src/aria/private/behaviors/grid/grid-navigation.spec.ts @@ -177,6 +177,19 @@ describe('GridNavigation', () => { expect(nextCoords).toBeUndefined(); }); + + it('should get disabled cells when allowDisabled is true and softDisabled is false', () => { + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + softDisabled: signal(false), + }); + gridNav.gotoCoords({row: 1, col: 0}); + cells[0][0].disabled.set(true); + + const nextCoords = gridNav.peek(direction.Up, gridFocus.activeCoords(), 'nowrap', true); + + expect(nextCoords).toEqual({row: 0, col: 0}); + expect(gridNav.peek(direction.Up, gridFocus.activeCoords(), 'nowrap')).toBeUndefined(); + }); }); describe('down', () => { @@ -216,6 +229,19 @@ describe('GridNavigation', () => { expect(nextCoords).toBeUndefined(); }); + + it('should get disabled cells when allowDisabled is true and softDisabled is false', () => { + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + softDisabled: signal(false), + }); + gridNav.gotoCoords({row: 1, col: 0}); + cells[2][0].disabled.set(true); + + const nextCoords = gridNav.peek(direction.Down, gridFocus.activeCoords(), 'nowrap', true); + + expect(nextCoords).toEqual({row: 2, col: 0}); + expect(gridNav.peek(direction.Down, gridFocus.activeCoords(), 'nowrap')).toBeUndefined(); + }); }); describe('left', () => { @@ -237,9 +263,7 @@ describe('GridNavigation', () => { }); it('should return the next coordinates even if all cells are disabled', () => { - cells.flat().forEach(function (cell) { - cell.disabled.set(true); - }); + cells.flat().forEach(c => c.disabled.set(true)); gridNav.gotoCoords({row: 1, col: 0}); const nextCoords = gridNav.peek(direction.Left, gridFocus.activeCoords()); @@ -257,6 +281,19 @@ describe('GridNavigation', () => { expect(nextCoords).toBeUndefined(); }); + + it('should get disabled cells when allowDisabled is true when softDisabled is false', () => { + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + softDisabled: signal(false), + }); + gridNav.gotoCoords({row: 0, col: 1}); + cells[0][0].disabled.set(true); + + const nextCoords = gridNav.peek(direction.Left, gridFocus.activeCoords(), 'nowrap', true); + + expect(nextCoords).toEqual({row: 0, col: 0}); + expect(gridNav.peek(direction.Left, gridFocus.activeCoords(), 'nowrap')).toBeUndefined(); + }); }); describe('right', () => { @@ -278,9 +315,7 @@ describe('GridNavigation', () => { }); it('should return the next coordinates even if all cells are disabled', () => { - cells.flat().forEach(function (cell) { - cell.disabled.set(true); - }); + cells.flat().forEach(c => c.disabled.set(true)); gridNav.gotoCoords({row: 1, col: 0}); const nextCoords = gridNav.peek(direction.Right, gridFocus.activeCoords()); @@ -298,6 +333,19 @@ describe('GridNavigation', () => { expect(nextCoords).toBeUndefined(); }); + + it('should get disabled cells when allowDisabled is true and softDisabled is false', () => { + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + softDisabled: signal(false), + }); + gridNav.gotoCoords({row: 0, col: 1}); + cells[0][2].disabled.set(true); + + const nextCoords = gridNav.peek(direction.Right, gridFocus.activeCoords(), 'nowrap', true); + + expect(nextCoords).toEqual({row: 0, col: 2}); + expect(gridNav.peek(direction.Right, gridFocus.activeCoords(), 'nowrap')).toBeUndefined(); + }); }); }); @@ -1971,6 +2019,17 @@ describe('GridNavigation', () => { expect(gridFocus.activeCell()).toBe(cells[1][0]); expect(gridFocus.activeCoords()).toEqual({row: 1, col: 0}); }); + + it('should get disabled cells when allowDisabled is true and softDisabled is false', () => { + const cells = createTestGrid(createGridA); + const {gridNav} = setupGridNavigation(signal(cells), {softDisabled: signal(false)}); + cells[0][0].disabled.set(true); + + const firstCoords = gridNav.peekFirst(undefined, true); + + expect(firstCoords).toEqual({row: 0, col: 0}); + expect(gridNav.peekFirst()).toEqual({row: 0, col: 1}); + }); }); describe('last/peekLast', () => { @@ -2049,5 +2108,16 @@ describe('GridNavigation', () => { expect(gridFocus.activeCell()!.id()).toBe('cell-1-3'); expect(gridFocus.activeCoords()).toEqual({row: 1, col: 3}); }); + + it('should get disabled cells when allowDisabled is true and softDisabled is false', () => { + const cells = createTestGrid(createGridA); + const {gridNav} = setupGridNavigation(signal(cells), {softDisabled: signal(false)}); + cells[2][2].disabled.set(true); + + const lastCoords = gridNav.peekLast(undefined, true); + + expect(lastCoords).toEqual({row: 2, col: 2}); + expect(gridNav.peekLast()).toEqual({row: 2, col: 1}); + }); }); }); diff --git a/src/aria/private/behaviors/grid/grid-navigation.ts b/src/aria/private/behaviors/grid/grid-navigation.ts index 891d1d2d7dca..4efe0307f76a 100644 --- a/src/aria/private/behaviors/grid/grid-navigation.ts +++ b/src/aria/private/behaviors/grid/grid-navigation.ts @@ -73,9 +73,14 @@ export class GridNavigation { /** * Gets the coordinates of the next focusable cell in a given direction, without changing focus. */ - peek(direction: Delta, fromCoords: RowCol, wrap?: WrapStrategy): RowCol | undefined { + peek( + direction: Delta, + fromCoords: RowCol, + wrap?: WrapStrategy, + allowDisabled?: boolean, + ): RowCol | undefined { wrap = wrap ?? (direction.row !== undefined ? this.inputs.rowWrap() : this.inputs.colWrap()); - return this._peekDirectional(direction, fromCoords, wrap); + return this._peekDirectional(direction, fromCoords, wrap, allowDisabled); } /** @@ -90,14 +95,14 @@ export class GridNavigation { * Gets the coordinates of the first focusable cell. * If a row is not provided, searches the entire grid. */ - peekFirst(row?: number): RowCol | undefined { + peekFirst(row?: number, allowDisabled?: boolean): RowCol | undefined { const fromCoords = { row: row ?? 0, col: -1, }; return row === undefined - ? this._peekDirectional(direction.Right, fromCoords, 'continuous') - : this._peekDirectional(direction.Right, fromCoords, 'nowrap'); + ? this._peekDirectional(direction.Right, fromCoords, 'continuous', allowDisabled) + : this._peekDirectional(direction.Right, fromCoords, 'nowrap', allowDisabled); } /** @@ -113,14 +118,14 @@ export class GridNavigation { * Gets the coordinates of the last focusable cell. * If a row is not provided, searches the entire grid. */ - peekLast(row?: number): RowCol | undefined { + peekLast(row?: number, allowDisabled?: boolean): RowCol | undefined { const fromCoords = { row: row ?? this.inputs.grid.maxRowCount() - 1, 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', allowDisabled) + : this._peekDirectional(direction.Left, fromCoords, 'nowrap', allowDisabled); } /** @@ -139,6 +144,7 @@ export class GridNavigation { delta: Delta, fromCoords: RowCol, wrap: 'continuous' | 'loop' | 'nowrap', + allowDisabled: boolean = false, ): RowCol | undefined { const fromCell = this.inputs.grid.getCell(fromCoords); const maxRowCount = this.inputs.grid.maxRowCount(); @@ -190,7 +196,7 @@ export class GridNavigation { if ( nextCell !== undefined && nextCell !== fromCell && - this.inputs.gridFocus.isFocusable(nextCell) + (allowDisabled || this.inputs.gridFocus.isFocusable(nextCell)) ) { return nextCoords; } diff --git a/src/aria/private/behaviors/grid/grid-selection.spec.ts b/src/aria/private/behaviors/grid/grid-selection.spec.ts index 95a4a3dda522..557fbe4be4ed 100644 --- a/src/aria/private/behaviors/grid/grid-selection.spec.ts +++ b/src/aria/private/behaviors/grid/grid-selection.spec.ts @@ -207,4 +207,97 @@ describe('GridSelection', () => { expect(validCellIds.length).toBe(allCellIds.length - 2); }); }); + + describe('undo', () => { + it('should undo a select operation', () => { + const cells = createTestGrid(createGridA); + const {gridSelection} = setupGridSelection(signal(cells)); + + gridSelection.select({row: 1, col: 1}); + expect(cells[1][1].selected()).toBe(true); + + gridSelection.undo(); + expect(cells[1][1].selected()).toBe(false); + }); + + it('should undo a deselect operation', () => { + const cells = createTestGrid(createGridA); + const {gridSelection} = setupGridSelection(signal(cells)); + cells[1][1].selected.set(true); + + gridSelection.deselect({row: 1, col: 1}); + expect(cells[1][1].selected()).toBe(false); + + gridSelection.undo(); + expect(cells[1][1].selected()).toBe(true); + }); + + it('should undo a toggle operation', () => { + const cells = createTestGrid(createGridA); + const {gridSelection} = setupGridSelection(signal(cells)); + cells[0][0].selected.set(true); + + gridSelection.toggle({row: 0, col: 0}, {row: 0, col: 1}); + expect(cells[0][0].selected()).toBe(false); + expect(cells[0][1].selected()).toBe(true); + + gridSelection.undo(); + expect(cells[0][0].selected()).toBe(true); + expect(cells[0][1].selected()).toBe(false); + }); + + it('should undo a selectAll operation', () => { + const cells = createTestGrid(createGridA); + const {gridSelection} = setupGridSelection(signal(cells)); + + gridSelection.selectAll(); + expect(cells.flat().every(c => c.selected())).toBe(true); + + gridSelection.undo(); + expect(cells.flat().every(c => !c.selected())).toBe(true); + }); + + it('should undo a deselectAll operation', () => { + const cells = createTestGrid(createGridA); + const {gridSelection} = setupGridSelection(signal(cells)); + cells.flat().forEach(c => c.selected.set(true)); + + gridSelection.deselectAll(); + expect(cells.flat().every(c => !c.selected())).toBe(true); + + gridSelection.undo(); + expect(cells.flat().every(c => c.selected())).toBe(true); + }); + + it('should do nothing if there is nothing to undo', () => { + const cells = createTestGrid(createGridA); + const {gridSelection} = setupGridSelection(signal(cells)); + cells[1][1].selected.set(true); + + gridSelection.undo(); + expect(cells[1][1].selected()).toBe(true); + }); + + it('should only undo the last operation', () => { + const cells = createTestGrid(createGridA); + const {gridSelection} = setupGridSelection(signal(cells)); + + gridSelection.select({row: 0, col: 0}); + gridSelection.select({row: 1, col: 1}); + expect(cells[1][1].selected()).toBe(true); + + gridSelection.undo(); + expect(cells[0][0].selected()).toBe(true); + expect(cells[1][1].selected()).toBe(false); + }); + + it('should do nothing after undoing once', () => { + const cells = createTestGrid(createGridA); + const {gridSelection} = setupGridSelection(signal(cells)); + gridSelection.select({row: 1, col: 1}); + gridSelection.undo(); + gridSelection.undo(); + expect(cells[1][1].selected()).toBe(false); + }); + }); }); diff --git a/src/aria/private/behaviors/grid/grid-selection.ts b/src/aria/private/behaviors/grid/grid-selection.ts index b7f548f91ad7..196eb611ac3f 100644 --- a/src/aria/private/behaviors/grid/grid-selection.ts +++ b/src/aria/private/behaviors/grid/grid-selection.ts @@ -9,6 +9,7 @@ import {SignalLike, WritableSignalLike} from '../signal-like/signal-like'; import {GridFocus, GridFocusCell, GridFocusInputs} from './grid-focus'; import {GridData, RowCol} from './grid-data'; +import {signal} from '@angular/core'; /** Represents a cell in a grid that can be selected. */ export interface GridSelectionCell extends GridFocusCell { @@ -33,50 +34,58 @@ interface GridSelectionDeps { /** Controls selection for a grid of items. */ export class GridSelection { + /** The list of cells that were changed in the last selection operation. */ + private readonly _undoList: WritableSignalLike<[T, boolean][]> = signal([]); + constructor(readonly inputs: GridSelectionInputs & GridSelectionDeps) {} + /** Reverts the last selection change. */ + undo(): void { + for (const [cell, oldState] of this._undoList()) { + cell.selected.set(oldState); + } + this._undoList.set([]); + } + /** 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); - } + this._updateState(fromCoords, toCoords ?? fromCoords, () => 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); - } + this._updateState(fromCoords, toCoords ?? fromCoords, () => 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); - } + this._updateState(fromCoords, toCoords ?? fromCoords, oldState => !oldState); } /** Selects all valid cells in the grid. */ selectAll(): void { - for (const cell of this._validCells( + this._updateState( {row: 0, col: 0}, {row: this.inputs.grid.maxRowCount(), col: this.inputs.grid.maxColCount()}, - )) { - cell.selected.set(true); - } + () => true, + ); } /** Deselects all valid cells in the grid. */ deselectAll(): void { - for (const cell of this._validCells( + this._updateState( {row: 0, col: 0}, {row: this.inputs.grid.maxRowCount(), col: this.inputs.grid.maxColCount()}, - )) { - cell.selected.set(false); - } + () => false, + ); + } + + /** Whether a cell is selctable. */ + isSelectable(cell: T) { + return cell.selectable() && !cell.disabled(); } - /** A generator that yields all valid (selectable and not disabled) cells within a given range. */ + /** A generator that yields all 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); @@ -87,12 +96,29 @@ export class GridSelection { 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 (!this.isSelectable(cell)) continue; if (visited.has(cell)) continue; visited.add(cell); yield cell; } } } + + /** + * Updates the selection state of cells in a given range and preserves previous changes + * to a undo list. + */ + private _updateState( + fromCoords: RowCol, + toCoords: RowCol, + stateFn: (oldState: boolean) => boolean, + ): void { + const undoList: [T, boolean][] = []; + for (const cell of this._validCells(fromCoords, toCoords)) { + const oldState = cell.selected(); + undoList.push([cell, oldState]); + cell.selected.set(stateFn(oldState)); + } + this._undoList.set(undoList); + } } diff --git a/src/aria/private/behaviors/grid/grid.spec.ts b/src/aria/private/behaviors/grid/grid.spec.ts index d78ed7ad05c9..e5d81a3c2569 100644 --- a/src/aria/private/behaviors/grid/grid.spec.ts +++ b/src/aria/private/behaviors/grid/grid.spec.ts @@ -46,7 +46,6 @@ function setupGrid( softDisabled: signal(true), rowWrap: signal('loop'), colWrap: signal('loop'), - enableSelection: signal(true), ...inputs, }; @@ -120,107 +119,224 @@ describe('Grid', () => { grid.firstInRow(); expect(grid.focusBehavior.activeCell()).toBe(cells[1][0]); }); + + describe('with selection', () => { + it('should select one on navigate when `selectOne` is true', () => { + const cells = createTestGrid(createGridA); + const grid = setupGrid(signal(cells)); + grid.gotoCell(cells[0][0]); + grid.select(); // cell 0,0 is selected + + grid.down({selectOne: true}); + + expect(cells[0][0].selected()).toBe(false); + expect(cells[1][0].selected()).toBe(true); + expect(grid.focusBehavior.activeCell()).toBe(cells[1][0]); + }); + + it('should select on navigate when `select` is true', () => { + const cells = createTestGrid(createGridA); + const grid = setupGrid(signal(cells)); + grid.gotoCell(cells[0][0]); + grid.select(); // cell 0,0 is selected + + grid.down({select: true}); + + expect(cells[0][0].selected()).toBe(true); + expect(cells[1][0].selected()).toBe(true); + expect(grid.focusBehavior.activeCell()).toBe(cells[1][0]); + }); + + it('should toggle on navigate when `toggle` is true', () => { + const cells = createTestGrid(createGridA); + const grid = setupGrid(signal(cells)); + grid.gotoCell(cells[0][0]); + + grid.down({toggle: true}); // Move to 1,0 and select it + expect(cells[1][0].selected()).toBe(true); + + grid.up({toggle: true}); // Move to 0,0 and select it + expect(cells[0][0].selected()).toBe(true); + expect(cells[1][0].selected()).toBe(true); // 1,0 remains selected + + grid.down({toggle: true}); // Move to 1,0 and deselect it + expect(cells[1][0].selected()).toBe(false); + }); + + it('should toggle one on navigate when `toggleOne` is true', () => { + const cells = createTestGrid(createGridA); + const grid = setupGrid(signal(cells)); + grid.gotoCell(cells[0][0]); + grid.select(); // cell 0,0 is selected + + grid.down({toggleOne: true}); // Move to 1,0 + + expect(cells[0][0].selected()).toBe(false); + expect(cells[1][0].selected()).toBe(true); + + grid.down({toggleOne: true}); // Move to 2,0 + expect(cells[1][0].selected()).toBe(false); + expect(cells[2][0].selected()).toBe(true); + }); + + it('should range select on navigate when `anchor` is true', () => { + const cells = createTestGrid(createGridA); + const grid = setupGrid(signal(cells)); + grid.gotoCell(cells[1][1]); + grid.down({anchor: true}); + expect(cells.flat().filter(c => c.selected()).length).toBe(2); + expect(cells[1][1].selected()).toBe(true); + expect(cells[2][1].selected()).toBe(true); + }); + }); }); describe('selection', () => { - let cells: TestGridCell[][]; - let grid: Grid; + describe('selectRow', () => { + it('should select all cells in the current row', () => { + const cells = createTestGrid(createGridA); + const grid = setupGrid(signal(cells)); + grid.gotoCell(cells[0][0]); + grid.select(); + expect(cells[0][0].selected()).toBe(true); + + grid.gotoCell(cells[1][1]); + grid.selectRow(); + + expect(cells[0][0].selected()).toBe(false); + expect(cells[1][0].selected()).toBe(true); + expect(cells[1][1].selected()).toBe(true); + expect(cells[1][2].selected()).toBe(true); + expect(cells[2][0].selected()).toBe(false); + }); + }); - beforeEach(() => { - cells = createTestGrid(createGridD); - grid = setupGrid(signal(cells)); - grid.gotoCell(cells[0][0]); // active cell at {0,0} + describe('selectCol', () => { + it('should select all cells in the current column', () => { + const cells = createTestGrid(createGridD); + const grid = setupGrid(signal(cells)); + grid.gotoCell(cells[0][0]); + grid.selectCol(); + + expect(cells[0][0].selected()).toBe(true); // spans row 0 and 1 in col 0 + expect(cells[2][0].selected()).toBe(true); + expect(cells[3][0].selected()).toBe(true); + expect(cells[0][1].selected()).toBe(false); + }); }); - it('should toggle selection of a cell', () => { - grid.toggleSelect(cells[1][0]); // cell at {1,1} - expect(cells[1][0].selected()).toBe(true); - grid.toggleSelect(cells[1][0]); - expect(cells[1][0].selected()).toBe(false); + describe('select', () => { + it('should select the active cell', () => { + const cells = createTestGrid(createGridA); + const grid = setupGrid(signal(cells)); + grid.gotoCell(cells[1][1]); + grid.select(); + + expect(cells[1][1].selected()).toBe(true); + expect(cells[0][0].selected()).toBe(false); + }); }); - it('should select a row', () => { - grid.gotoCell(cells[1][0]); // active cell at {1,1} - grid.selectRow(); - // row 1 contains cells at {0,0}, {1,0}, {1,1} - expect(cells[0][0].selected()).toBe(true); // spans row 0 and 1 - expect(cells[1][0].selected()).toBe(true); // spans row 1 and 2 - expect(cells[1][1].selected()).toBe(true); // spans row 1 and 2 - expect(cells[0][1].selected()).toBe(false); // only in row 0 + describe('deselect', () => { + it('should deselect the active cell', () => { + const cells = createTestGrid(createGridA); + const grid = setupGrid(signal(cells)); + grid.gotoCell(cells[1][1]); + grid.select(); + expect(cells[1][1].selected()).toBe(true); + + grid.deselect(); + expect(cells[1][1].selected()).toBe(false); + }); }); - it('should select a column', () => { - grid.gotoCell(cells[1][0]); // active cell at {1,1} - grid.selectCol(); - // col 1 contains cells at {0,1}, {1,0} - expect(cells[0][1].selected()).toBe(true); - expect(cells[1][0].selected()).toBe(true); - expect(cells[0][0].selected()).toBe(false); + describe('toggle', () => { + it('should toggle the selection of the active cell', () => { + const cells = createTestGrid(createGridA); + const grid = setupGrid(signal(cells)); + grid.gotoCell(cells[1][1]); + + grid.toggle(); + expect(cells[1][1].selected()).toBe(true); + + grid.toggle(); + expect(cells[1][1].selected()).toBe(false); + }); }); - it('should select all cells', () => { - grid.selectAll(); - cells.flat().forEach(cell => expect(cell.selected()).toBe(true)); + describe('toggleOne', () => { + it('should toggle the selection of the active cell and deselect others', () => { + const cells = createTestGrid(createGridA); + const grid = setupGrid(signal(cells)); + grid.gotoCell(cells[0][0]); + grid.select(); + + grid.gotoCell(cells[1][1]); + grid.toggleOne(); + + expect(cells[0][0].selected()).toBe(false); + expect(cells[1][1].selected()).toBe(true); + + grid.toggleOne(); + expect(cells[1][1].selected()).toBe(false); + }); }); - }); - describe('range selection', () => { - let cells: TestGridCell[][]; - let grid: Grid; + describe('selectAll', () => { + it('should select all selectable cells', () => { + const cells = createTestGrid(createGridA); + cells[1][1].selectable.set(false); + const grid = setupGrid(signal(cells)); + grid.selectAll(); - beforeEach(() => { - cells = createTestGrid(createGridA); - grid = setupGrid(signal(cells)); - grid.gotoCell(cells[1][1]); // active cell and anchor at {1,1} + expect(cells.flat().filter(c => c.selected()).length).toBe(8); + expect(cells[1][1].selected()).toBe(false); + }); }); + }); - it('should range select with arrow keys', () => { - grid.rangeSelectRight(); - expect(cells[1][1].selected()).toBe(true); - expect(cells[1][2].selected()).toBe(true); - expect(grid.selectionAnchor()).toEqual({row: 1, col: 2}); - - grid.rangeSelectDown(); - expect(cells[1][1].selected()).toBe(true); - expect(cells[1][2].selected()).toBe(true); - expect(cells[2][1].selected()).toBe(true); - expect(cells[2][2].selected()).toBe(true); - expect(grid.selectionAnchor()).toEqual({row: 2, col: 2}); - - grid.rangeSelectUp(); - expect(cells[1][1].selected()).toBe(true); - expect(cells[1][2].selected()).toBe(true); - expect(cells[2][1].selected()).toBe(false); - expect(cells[2][2].selected()).toBe(false); - expect(grid.selectionAnchor()).toEqual({row: 1, col: 2}); - - grid.rangeSelectLeft(); - expect(cells[1][1].selected()).toBe(true); - expect(cells[1][2].selected()).toBe(false); - expect(grid.selectionAnchor()).toEqual({row: 1, col: 1}); + describe('setDefaultState', () => { + it('should focus the first focusable selected cell if one exists', () => { + const cells = createTestGrid(createGridA); + const grid = setupGrid(signal(cells), {softDisabled: signal(false)}); + + // This one is selected but not focusable. + cells[1][1].selected.set(true); + cells[1][1].disabled.set(true); + + // This is the first focusable selected cell. + cells[2][0].selected.set(true); + + // This one is also focusable and selected, but comes after. + cells[2][2].selected.set(true); + + const result = grid.setDefaultState(); + + expect(result).toBe(true); + expect(grid.focusBehavior.activeCell()).toBe(cells[2][0]); }); - it('should range select to a specific cell', () => { - grid.rangeSelect(cells[2][2]); + it('should focus the first focusable cell if no selected cell exists', () => { + const cells = createTestGrid(createGridA); + const grid = setupGrid(signal(cells), {softDisabled: signal(false)}); - expect(cells[1][1].selected()).toBe(true); - expect(cells[1][2].selected()).toBe(true); - expect(cells[2][1].selected()).toBe(true); - expect(cells[2][2].selected()).toBe(true); - expect(grid.selectionAnchor()).toEqual({row: 2, col: 2}); + cells[0][0].disabled.set(true); + + const result = grid.setDefaultState(); + + expect(result).toBe(true); + expect(grid.focusBehavior.activeCell()).toBe(cells[0][1]); }); - it('should handle range selection with spanning cells', () => { - const spanningCells = createTestGrid(createGridD); - grid = setupGrid(signal(spanningCells)); - grid.gotoCell(spanningCells[0][0]); // active cell at {0,0} + it('should return false if no focusable cell is found', () => { + const cells = createTestGrid(createGridA); + cells.flat().forEach(c => c.disabled.set(true)); + const grid = setupGrid(signal(cells), {softDisabled: signal(false)}); - grid.rangeSelect(spanningCells[3][2]); // cell at {3,2} + const result = grid.setDefaultState(); - // The range is from {0,0} to {3,3} because cell at {3,2} has colspan 2. - // All cells should be selected. - spanningCells.flat().forEach(cell => expect(cell.selected()).toBe(true)); - expect(grid.selectionAnchor()).toEqual({row: 3, col: 2}); + expect(result).toBe(false); + expect(grid.focusBehavior.activeCell()).toBeUndefined(); }); }); diff --git a/src/aria/private/behaviors/grid/grid.ts b/src/aria/private/behaviors/grid/grid.ts index 123171e61794..3c3b107507c5 100644 --- a/src/aria/private/behaviors/grid/grid.ts +++ b/src/aria/private/behaviors/grid/grid.ts @@ -6,8 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {computed, linkedSignal} from '@angular/core'; -import {SignalLike} from '../signal-like/signal-like'; +import {computed, linkedSignal, signal} from '@angular/core'; import {GridData, BaseGridCell, GridDataInputs, RowCol} from './grid-data'; import {GridFocus, GridFocusCell, GridFocusInputs} from './grid-focus'; import { @@ -17,19 +16,40 @@ import { GridNavigationInputs, } from './grid-navigation'; import {GridSelectionCell, GridSelectionInputs, GridSelection} from './grid-selection'; +import {SignalLike} from '../signal-like/signal-like'; + +/** The selection operations that can be performed after a navigation operation. */ +export interface NavOptions { + /** Toggles the selection state of the active cell. */ + toggle?: boolean; + + /** + * Toggles the selection state of the active cell, and deselects all other cells if the + * active cell is selected. If the active cell is the only selected cell, it will be deselected. + */ + toggleOne?: boolean; + + /** Selects the active cell, preserving the selection state of other cells. */ + select?: boolean; + + /** Deselects all other cells and selects the active cell. */ + selectOne?: boolean; + + /** + * Moves the selection anchor to the active cell and updates the selection to include all + * cells between the anchor and the active cell. + */ + anchor?: boolean; +} /** 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; -} +export type GridInputs = GridDataInputs & + GridFocusInputs & + GridNavigationInputs & + GridSelectionInputs; /** The main class that orchestrates the grid behaviors. */ export class Grid { @@ -48,14 +68,30 @@ export class Grid { /** The anchor point for range selection, linked to the active coordinates. */ readonly selectionAnchor = linkedSignal(() => this.focusBehavior.activeCoords()); - /** The `tab index` for the grid container. */ - readonly gridTabIndex = computed(() => this.focusBehavior.gridTabIndex()); + /** The cell at the selection anchor. */ + readonly selectionAnchorCell = computed(() => this.data.getCell(this.selectionAnchor())); + + /** Whether a range selection has settled. */ + readonly selectionStabled = signal(true); + + /** Whether all selectable cells are selected. */ + readonly allSelected: SignalLike = computed(() => + this.data + .cells() + .flat() + .filter(c => this.selectionBehavior.isSelectable(c)) + .every(c => c.selected()), + ); + + /** The tab index for the grid container. */ + readonly gridTabIndex: SignalLike<-1 | 0> = () => this.focusBehavior.gridTabIndex(); /** Whether the grid is in a disabled state. */ - readonly gridDisabled = computed(() => this.focusBehavior.gridDisabled()); + readonly gridDisabled: SignalLike = () => this.focusBehavior.gridDisabled(); /** The ID of the active descendant for ARIA `activedescendant` focus management. */ - readonly activeDescendant = computed(() => this.focusBehavior.activeDescendant()); + readonly activeDescendant: SignalLike = () => + this.focusBehavior.activeDescendant(); constructor(readonly inputs: GridInputs) { this.data = new GridData(inputs); @@ -84,81 +120,107 @@ export class Grid { return index !== undefined ? index + 1 : undefined; } - /** Gets the `tab index` for a given cell. */ + /** Gets the tab index 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.advance(direction.Up); - } - - /** Extends the selection to the cell above the selection anchor. */ - rangeSelectUp(): void { - const coords = this.navigationBehavior.peek(direction.Up, this.selectionAnchor()); - if (coords === undefined) return; - - this._rangeSelectCoords(coords); + up(opts: NavOptions = {}): boolean { + return this._navigateWithSelection( + () => + opts.anchor + ? this._updateSelectionAnchor(() => + this.navigationBehavior.peek(direction.Up, this.selectionAnchor(), 'nowrap', true), + ) + : this.navigationBehavior.advance(direction.Up), + opts, + ); } /** Navigates to the cell below the currently active cell. */ - down(): boolean { - 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()); - if (coords === undefined) return; - - this._rangeSelectCoords(coords); + down(opts: NavOptions = {}): boolean { + return this._navigateWithSelection( + () => + opts.anchor + ? this._updateSelectionAnchor(() => + this.navigationBehavior.peek(direction.Down, this.selectionAnchor(), 'nowrap', true), + ) + : this.navigationBehavior.advance(direction.Down), + opts, + ); } /** Navigates to the cell to the left of the currently active cell. */ - left(): boolean { - 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()); - if (coords === undefined) return; - - this._rangeSelectCoords(coords); + left(opts: NavOptions = {}): boolean { + return this._navigateWithSelection( + () => + opts.anchor + ? this._updateSelectionAnchor(() => + this.navigationBehavior.peek(direction.Left, this.selectionAnchor(), 'nowrap', true), + ) + : this.navigationBehavior.advance(direction.Left), + opts, + ); } /** Navigates to the cell to the right of the currently active cell. */ - right(): boolean { - 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()); - if (coords === undefined) return; - - this._rangeSelectCoords(coords); + right(opts: NavOptions = {}): boolean { + return this._navigateWithSelection( + () => + opts.anchor + ? this._updateSelectionAnchor(() => + this.navigationBehavior.peek(direction.Right, this.selectionAnchor(), 'nowrap', true), + ) + : this.navigationBehavior.advance(direction.Right), + opts, + ); } /** Navigates to the first focusable cell in the grid. */ - first(): boolean { - return this.navigationBehavior.first(); + first(opts: NavOptions = {}): boolean { + return this._navigateWithSelection( + () => + opts.anchor + ? this._updateSelectionAnchor(() => this.navigationBehavior.peekFirst(undefined, true)) + : this.navigationBehavior.first(), + opts, + ); } /** Navigates to the first focusable cell in the current row. */ - firstInRow(): boolean { - return this.navigationBehavior.first(this.focusBehavior.activeCoords().row); + firstInRow(opts: NavOptions = {}): boolean { + const row = this.focusBehavior.activeCoords().row; + return this._navigateWithSelection( + () => + opts.anchor + ? this._updateSelectionAnchor(() => this.navigationBehavior.peekFirst(row, true)) + : this.navigationBehavior.first(row), + opts, + ); } /** Navigates to the last focusable cell in the grid. */ - last(): boolean { - return this.navigationBehavior.last(); + last(opts: NavOptions = {}): boolean { + return this._navigateWithSelection( + () => + opts.anchor + ? this._updateSelectionAnchor(() => this.navigationBehavior.peekLast(undefined, true)) + : this.navigationBehavior.last(), + opts, + ); } /** Navigates to the last focusable cell in the current row. */ - lastInRow(): boolean { - return this.navigationBehavior.last(this.focusBehavior.activeCoords().row); + lastInRow(opts: NavOptions = {}): boolean { + const row = this.focusBehavior.activeCoords().row; + return this._navigateWithSelection( + () => + opts.anchor + ? this._updateSelectionAnchor(() => this.navigationBehavior.peekLast(row, true)) + : this.navigationBehavior.last(row), + opts, + ); } /** Selects all cells in the current row. */ @@ -175,69 +237,85 @@ export class Grid { this.selectionBehavior.select({row: 0, col}, {row: this.data.maxRowCount(), col}); } - /** Selects all selectable cells in the grid. */ - selectAll(): void { - this.selectionBehavior.selectAll(); + /** Selects the active cell. */ + select(): void { + this.selectionBehavior.select(this.focusBehavior.activeCoords()); } - /** Navigates to and focuses the given cell. */ - gotoCell(cell: T): boolean { - return this.navigationBehavior.gotoCell(cell); + /** Deselects the active cell. */ + deselect(): void { + this.selectionBehavior.deselect(this.focusBehavior.activeCoords()); } - /** Toggles the selection state of the given cell. */ - toggleSelect(cell: T): void { - const coords = this.data.getCoords(cell); - if (coords === undefined) return; + /** + * Toggles the selection state of the coordinates of the given cell + * or the current active coordinates. + */ + toggle(): void { + this.selectionBehavior.toggle(this.focusBehavior.activeCoords()); + } + + /** Toggles the selection state of the active cell, and deselects all other cells. */ + toggleOne(): void { + const selected = !!this.focusBehavior.activeCell()?.selected(); + if (selected) { + this.deselect(); + return; + } + + this.deselectAll(); + this.select(); + } - this.selectionBehavior.toggle(coords); + /** Selects all selectable cells in the grid. */ + selectAll(): void { + this.selectionBehavior.selectAll(); } - /** Extends the selection from the anchor to the given cell. */ - rangeSelect(cell: T): void { - const coords = this.data.getCoords(cell); - if (coords === undefined) return; + /** Deselects all cells in the grid. */ + deselectAll(): void { + this.selectionBehavior.deselectAll(); + } - this._rangeSelectCoords(coords); + /** Navigates to and focuses the given cell. */ + gotoCell(cell: T, opts: NavOptions = {}): boolean { + return this._navigateWithSelection( + () => + opts.anchor + ? this._updateSelectionAnchor(() => this.data.getCoords(cell)) + : this.navigationBehavior.gotoCell(cell), + opts, + ); } - /** 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; + /** Sets the default active state of the grid. */ + setDefaultState(): boolean { + // Try to find a selected cell that's focusable. + const focusableSelectedCell: T | undefined = this.data + .cells() + .flat() + .filter(c => this.focusBehavior.isFocusable(c)) + .find(c => c.selected()); + + if (focusableSelectedCell !== undefined) { + this.focusBehavior.focusCell(focusableSelectedCell); + return true; } - 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), - }; + // Otherwise find the first focusable cell. + const firstFocusableCoords = this.navigationBehavior.peekFirst(); - this.selectionBehavior.deselectAll(); - this.selectionBehavior.select(fromCoords, toCoords); - this.selectionAnchor.set(coords); + if (firstFocusableCoords !== undefined) { + return this.focusBehavior.focusCoordinates(firstFocusableCoords); + } + + return false; } /** Resets the active 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); + return this.setDefaultState(); } if (this.focusBehavior.stateStale()) { @@ -259,4 +337,84 @@ export class Grid { return false; } + + /** Updates the selection anchor to the given coordinates. */ + private _updateSelectionAnchor(peekFn: () => RowCol | undefined): boolean { + const coords = peekFn(); + const success = coords !== undefined; + if (!success) return false; + this.selectionAnchor.set(coords); + return success; + } + + /** Updates the selection to include all cells between the anchor and the active cell. */ + private _updateRangeSelection(): void { + if (!this.selectionStabled()) { + this.selectionBehavior.undo(); + } + this.selectionBehavior.select( + ...this._getSelectionCoords(this.focusBehavior.activeCoords(), this.selectionAnchor()), + ); + } + + /** Gets the start and end coordinates for a selection range. */ + private _getSelectionCoords(startCoords: RowCol, endCoords: RowCol): [RowCol, RowCol] { + const startCell = this.data.getCell(startCoords)!; + const endCell = this.data.getCell(endCoords)!; + const allCoords = [...this.data.getAllCoords(startCell)!, ...this.data.getAllCoords(endCell)!]; + 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), + }; + + return [fromCoords, toCoords]; + } + + /** Executes a navigation operation and applies selection options. */ + private _navigateWithSelection(op: () => boolean, opts: NavOptions = {}): boolean { + const success = op(); + if (!success) return false; + + if (opts.anchor) { + this._updateRangeSelection(); + this.selectionStabled.set(false); + return success; + } + + // Selection becomes stable after the active cell/coords moved. + this.selectionStabled.set(true); + + if (opts.select) { + this.select(); + return success; + } + + if (opts.selectOne) { + this.deselectAll(); + this.select(); + return success; + } + + if (opts.toggle) { + this.toggle(); + return success; + } + + if (opts.toggleOne) { + const selected = !!this.focusBehavior.activeCell()?.selected(); + this.deselectAll(); + if (!selected) { + this.select(); + } + return success; + } + + return success; + } } diff --git a/src/aria/private/grid/cell.ts b/src/aria/private/grid/cell.ts index 05338ec3f206..0d34bb6cfff4 100644 --- a/src/aria/private/grid/cell.ts +++ b/src/aria/private/grid/cell.ts @@ -77,6 +77,11 @@ export class GridCellPattern implements GridCell { /** Whether the cell is active. */ readonly active = computed(() => this.inputs.grid().activeCell() === this); + /** Whether the cell is a selection anchor. */ + readonly anchor: SignalLike = computed(() => + this.inputs.grid().anchorCell() === this ? true : undefined, + ); + /** The internal tab index calculation for the cell. */ private readonly _tabIndex: SignalLike<-1 | 0> = computed(() => this.inputs.grid().gridBehavior.cellTabIndex(this), diff --git a/src/aria/private/grid/grid.ts b/src/aria/private/grid/grid.ts index ff429ac37ae3..0e0fb3594e90 100644 --- a/src/aria/private/grid/grid.ts +++ b/src/aria/private/grid/grid.ts @@ -9,7 +9,7 @@ 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 {NavOptions, Grid, GridInputs as GridBehaviorInputs} from '../behaviors/grid'; import type {GridRowPattern} from './row'; import type {GridCellPattern} from './cell'; @@ -24,6 +24,18 @@ export interface GridInputs extends Omit, 'c /** The direction that text is read based on the users locale. */ textDirection: SignalLike<'rtl' | 'ltr'>; + /** Whether selection is enabled for the grid. */ + enableSelection: SignalLike; + + /** Whether multiple cell in the grid can be selected. */ + multi: SignalLike; + + /** The selection strategy used by the grid. */ + selectionMode: SignalLike<'follow' | 'explicit'>; + + /** Whether enable range selection. */ + enableRangeSelection: SignalLike; + /** A function that returns the grid cell associated with a given element. */ getCell: (e: Element) => GridCellPattern | undefined; } @@ -48,6 +60,13 @@ export class GridPattern { /** The currently active cell. */ readonly activeCell = computed(() => this.gridBehavior.focusBehavior.activeCell()); + /** The current selection anchor cell. */ + readonly anchorCell: SignalLike = computed(() => + this.inputs.enableSelection() && this.inputs.multi() + ? this.gridBehavior.selectionAnchorCell() + : undefined, + ); + /** Whether to pause grid navigation. */ readonly pauseNavigation = computed(() => this.gridBehavior.data @@ -59,6 +78,9 @@ export class GridPattern { /** Whether the focus is in the grid. */ readonly isFocused = signal(false); + /** Whether the grid has been focused once. */ + readonly hasBeenFocused = signal(false); + /** Whether the user is currently dragging to select a range of cells. */ readonly dragging = signal(false); @@ -80,23 +102,49 @@ export class GridPattern { return manager; } + // Navigation handlers. + const opts: NavOptions = { + selectOne: this.inputs.enableSelection() && this.inputs.selectionMode() === 'follow', + }; manager - .on('ArrowUp', () => this.gridBehavior.up()) - .on('ArrowDown', () => this.gridBehavior.down()) - .on(this.prevColKey(), () => this.gridBehavior.left()) - .on(this.nextColKey(), () => 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()); + .on('ArrowUp', () => this.gridBehavior.up(opts)) + .on('ArrowDown', () => this.gridBehavior.down(opts)) + .on(this.prevColKey(), () => this.gridBehavior.left(opts)) + .on(this.nextColKey(), () => this.gridBehavior.right(opts)) + .on('Home', () => this.gridBehavior.firstInRow(opts)) + .on('End', () => this.gridBehavior.lastInRow(opts)) + .on([Modifier.Ctrl], 'Home', () => this.gridBehavior.first(opts)) + .on([Modifier.Ctrl], 'End', () => this.gridBehavior.last(opts)); + + // Basic explicit selection handlers. + if (this.inputs.enableSelection() && this.inputs.selectionMode() === 'explicit') { + manager + .on('Enter', () => + this.inputs.multi() ? this.gridBehavior.toggle() : this.gridBehavior.toggleOne(), + ) + .on(' ', () => + this.inputs.multi() ? this.gridBehavior.toggle() : this.gridBehavior.toggleOne(), + ); + } - if (this.inputs.enableSelection()) { + // Range selection handlers. + if (this.inputs.enableSelection() && this.inputs.enableRangeSelection()) { 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, 'ArrowUp', () => this.gridBehavior.up({anchor: true})) + .on(Modifier.Shift, 'ArrowDown', () => this.gridBehavior.down({anchor: true})) + .on(Modifier.Shift, this.prevColKey(), () => this.gridBehavior.left({anchor: true})) + .on(Modifier.Shift, this.nextColKey(), () => this.gridBehavior.right({anchor: true})) + .on(Modifier.Shift, 'Home', () => this.gridBehavior.firstInRow({anchor: true})) + .on(Modifier.Shift, 'End', () => this.gridBehavior.lastInRow({anchor: true})) + .on([Modifier.Ctrl | Modifier.Shift], 'Home', () => this.gridBehavior.first({anchor: true})) + .on([Modifier.Ctrl | Modifier.Shift], 'End', () => this.gridBehavior.last({anchor: true})) + .on([Modifier.Ctrl, Modifier.Meta], 'A', () => { + if (this.gridBehavior.allSelected()) { + this.gridBehavior.deselectAll(); + } else { + this.gridBehavior.selectAll(); + } + }) .on([Modifier.Shift], ' ', () => this.gridBehavior.selectRow()) .on([Modifier.Ctrl, Modifier.Meta], ' ', () => this.gridBehavior.selectCol()); } @@ -108,32 +156,56 @@ export class GridPattern { readonly pointerdown = computed(() => { const manager = new PointerEventManager(); - manager.on(e => { - const cell = this.inputs.getCell(e.target as Element); - if (!cell) return; + // Navigation without selection. + if (!this.inputs.enableSelection()) { + manager.on(e => { + const cell = this.inputs.getCell(e.target as Element); + if (!cell || !this.gridBehavior.focusBehavior.isFocusable(cell)) return; - this.gridBehavior.gotoCell(cell); - - if (this.inputs.enableSelection()) { - this.dragging.set(true); - } - }); + this.gridBehavior.gotoCell(cell); + }); + } + // Navigation with selection. if (this.inputs.enableSelection()) { - manager - .on([Modifier.Ctrl, Modifier.Meta], e => { - const cell = this.inputs.getCell(e.target as Element); - if (!cell) return; + manager.on(e => { + const cell = this.inputs.getCell(e.target as Element); + if (!cell || !this.gridBehavior.focusBehavior.isFocusable(cell)) return; + + this.gridBehavior.gotoCell(cell, { + selectOne: this.inputs.selectionMode() === 'follow', + toggleOne: this.inputs.selectionMode() === 'explicit' && !this.inputs.multi(), + toggle: this.inputs.selectionMode() === 'explicit' && this.inputs.multi(), + }); - this.gridBehavior.toggleSelect(cell); - }) - .on(Modifier.Shift, e => { + if (this.inputs.multi() && this.inputs.enableRangeSelection()) { + this.dragging.set(true); + } + }); + + // Selection with modifier keys. + if (this.inputs.multi()) { + manager.on([Modifier.Ctrl, Modifier.Meta], e => { const cell = this.inputs.getCell(e.target as Element); - if (!cell) return; + if (!cell || !this.gridBehavior.focusBehavior.isFocusable(cell)) return; - this.gridBehavior.rangeSelect(cell); - this.dragging.set(true); + this.gridBehavior.gotoCell(cell, {toggle: true}); + + if (this.inputs.enableRangeSelection()) { + this.dragging.set(true); + } }); + + if (this.inputs.enableRangeSelection()) { + manager.on(Modifier.Shift, e => { + const cell = this.inputs.getCell(e.target as Element); + if (!cell) return; + + this.gridBehavior.gotoCell(cell, {anchor: true}); + this.dragging.set(true); + }); + } + } } return manager; @@ -143,8 +215,8 @@ export class GridPattern { readonly pointerup = computed(() => { const manager = new PointerEventManager(); - if (this.inputs.enableSelection()) { - manager.on([Modifier.Shift, Modifier.None], () => { + if (this.inputs.enableSelection() && this.inputs.enableRangeSelection()) { + manager.on([Modifier.Shift, Modifier.Ctrl, Modifier.Meta, Modifier.None], () => { this.dragging.set(false); }); } @@ -152,6 +224,12 @@ export class GridPattern { return manager; }); + /** Indicates maybe the losing focus is caused by row/cell deletion. */ + private readonly _maybeDeletion = signal(false); + + /** Indicates the losing focus is certainly caused by row/cell deletion. */ + private readonly _deletion = signal(false); + constructor(readonly inputs: GridInputs) { this.gridBehavior = new Grid({ ...inputs, @@ -177,12 +255,13 @@ export class GridPattern { onPointermove(event: PointerEvent) { if (this.disabled()) return; if (!this.inputs.enableSelection()) return; + if (!this.inputs.enableRangeSelection()) return; if (!this.dragging()) return; const cell = this.inputs.getCell(event.target as Element); if (!cell) return; - this.gridBehavior.rangeSelect(cell); + this.gridBehavior.gotoCell(cell, {anchor: true}); } /** Handles pointerup events on the grid. */ @@ -195,11 +274,9 @@ export class GridPattern { /** Handles focusin events on the grid. */ onFocusIn() { this.isFocused.set(true); + this.hasBeenFocused.set(true); } - /** Indicates maybe the losing focus is caused by row/cell deletion. */ - private readonly _maybeDeletion = signal(false); - /** Handles focusout events on the grid. */ onFocusOut(event: FocusEvent) { const parentEl = this.inputs.element(); @@ -216,8 +293,12 @@ export class GridPattern { this.isFocused.set(false); } - /** Indicates the losing focus is certainly caused by row/cell deletion. */ - private readonly _deletion = signal(false); + /** Sets the default active state of the grid before receiving focus the first time. */ + setDefaultStateEffect(): void { + if (this.hasBeenFocused()) return; + + this.gridBehavior.setDefaultState(); + } /** Resets the active state of the grid if it is empty or stale. */ resetStateEffect(): void { @@ -228,10 +309,8 @@ export class GridPattern { if (hasReset && this._maybeDeletion()) { this._deletion.set(true); } - - if (this._maybeDeletion()) { - this._maybeDeletion.set(false); - } + // Reset maybe deletion state. + this._maybeDeletion.set(false); } /** Focuses on the active cell element. */ diff --git a/src/components-examples/aria/grid/grid-calendar/grid-calendar-example.css b/src/components-examples/aria/grid/grid-calendar/grid-calendar-example.css new file mode 100644 index 000000000000..7e3326335e3b --- /dev/null +++ b/src/components-examples/aria/grid/grid-calendar/grid-calendar-example.css @@ -0,0 +1,73 @@ +.example-grid-container { + max-width: fit-content; +} + +.example-grid-calendar-debug { + padding-bottom: 12px; +} + +.example-grid-calendar-control { + display: flex; + justify-content: space-around; + align-items: center; +} + +.example-grid-calendar-control-button { + background-color: transparent; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + width: 80px; + border: 2px solid color-mix(in srgb, var(--mat-sys-outline) 40%, transparent); + cursor: pointer; +} + +.example-grid-calendar-control-button:hover, +.example-grid-calendar-control-button:focus { + background: color-mix( + in srgb, + var(--mat-sys-on-surface) calc(var(--mat-sys-hover-state-layer-opacity) * 100%), + transparent + ); +} + +.example-calendar-cell { + width: 50px; + height: 50px; + text-align: center; + vertical-align: middle; +} + +.example-calendar-cell[aria-disabled='true'] { + color: color-mix(in srgb, var(--mat-sys-on-surface) 38%, transparent); +} + +.example-calendar-day-button { + width: 100%; + height: 100%; + border-radius: 50%; + border: 0; + background-color: transparent; +} + +.example-calendar-day-button:hover { + background: color-mix( + in srgb, + var(--mat-sys-on-surface) calc(var(--mat-sys-hover-state-layer-opacity) * 100%), + transparent + ); +} + +[data-active='true'] .example-calendar-day-button { + outline: 3px dashed var(--mat-sys-outline); +} + +[data-active='true']:focus-within .example-calendar-day-button { + outline: 3px dashed var(--mat-sys-tertiary); +} + +[aria-selected='true'] .example-calendar-day-button { + color: var(--mat-sys-on-primary); + background: color-mix(in srgb, var(--mat-sys-primary) 80%, transparent); +} diff --git a/src/components-examples/aria/grid/grid-calendar/grid-calendar-example.html b/src/components-examples/aria/grid/grid-calendar/grid-calendar-example.html new file mode 100644 index 000000000000..3e6ecc11a627 --- /dev/null +++ b/src/components-examples/aria/grid/grid-calendar/grid-calendar-example.html @@ -0,0 +1,60 @@ +
+
Selected Date: {{displayActiveDate()}}
+
+ +
{{monthYearLabel()}}
+ +
+ + + + + @for (day of weekdays(); track day.long) { + + } + + + + @for (week of weeks(); track week) { + + @if ($first) { + @for (day of daysFromPrevMonth(); track day) { + + } + } + + @for (day of week; track day) { + + } + + @if ($last && week.length < 7) { + @for (day of [].constructor(7 - week.length); track $index) { + + } + } + + } +
+ {{day.long}} + +
{{day}} + + {{$index + 1}}
+
diff --git a/src/components-examples/aria/grid/grid-calendar/grid-calendar-example.ts b/src/components-examples/aria/grid/grid-calendar/grid-calendar-example.ts new file mode 100644 index 000000000000..08ae8672656d --- /dev/null +++ b/src/components-examples/aria/grid/grid-calendar/grid-calendar-example.ts @@ -0,0 +1,141 @@ +/** + * @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 { + inject, + Component, + WritableSignal, + signal, + Signal, + computed, + untracked, + afterRenderEffect, +} from '@angular/core'; +import {DateAdapter, MAT_DATE_FORMATS, MatDateFormats} from '@angular/material/core'; +import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid'; + +const DAYS_PER_WEEK = 7; + +interface CalendarCell { + displayName: string; + ariaLabel: string; + date: D; + selected: WritableSignal; +} + +/** @title Grid Calendar. */ +@Component({ + selector: 'grid-calendar-example', + exportAs: 'GridCalendarExample', + templateUrl: 'grid-calendar-example.html', + styleUrls: ['../grid-common.css', 'grid-calendar-example.css'], + imports: [Grid, GridRow, GridCell, GridCellWidget], +}) +export class GridCalendarExample { + private readonly _dateAdapter = inject>(DateAdapter, {optional: true})!; + private readonly _dateFormats = inject(MAT_DATE_FORMATS, {optional: true})!; + private readonly _firstWeekOffset: Signal = computed(() => { + const firstOfMonth = this._dateAdapter.createDate( + this._dateAdapter.getYear(this.viewMonth()), + this._dateAdapter.getMonth(this.viewMonth()), + 1, + ); + + return ( + (DAYS_PER_WEEK + + this._dateAdapter.getDayOfWeek(firstOfMonth) - + this._dateAdapter.getFirstDayOfWeek()) % + DAYS_PER_WEEK + ); + }); + + private readonly _activeDate: WritableSignal = signal(this._dateAdapter.today()); + readonly displayActiveDate: Signal = computed(() => + this._dateAdapter.format(this._activeDate(), this._dateFormats.display), + ); + + readonly monthYearLabel: Signal = computed(() => + this._dateAdapter + .format(this.viewMonth(), this._dateFormats.display.monthYearLabel) + .toLocaleUpperCase(), + ); + readonly prevMonthNumDays: Signal = computed(() => + this._dateAdapter.getNumDaysInMonth(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1)), + ); + readonly daysFromPrevMonth: Signal = computed(() => { + const days: number[] = []; + for (let i = this._firstWeekOffset() - 1; i >= 0; i--) { + days.push(this.prevMonthNumDays() - i); + } + return days; + }); + readonly viewMonth: WritableSignal = signal(this._dateAdapter.today()); + readonly weekdays: Signal<{long: string; narrow: string}[]> = computed(() => { + const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek(); + const narrowWeekdays = this._dateAdapter.getDayOfWeekNames('narrow'); + const longWeekdays = this._dateAdapter.getDayOfWeekNames('long'); + + // Rotate the labels for days of the week based on the configured first day of the week. + const weekdays = longWeekdays.map((long, i) => { + return {long, narrow: narrowWeekdays[i]}; + }); + return weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek)); + }); + readonly weeks: Signal[][]> = computed(() => + this._createWeekCells(this.viewMonth()), + ); + + constructor() { + afterRenderEffect(() => { + for (const day of this.weeks().flat()) { + if (day.selected()) { + this._activeDate.set(day.date); + return; + } + } + }); + } + + nextMonth(): void { + this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), 1)); + } + + prevMonth(): void { + this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1)); + } + + private _createWeekCells(viewMonth: D): CalendarCell[][] { + const daysInMonth = this._dateAdapter.getNumDaysInMonth(viewMonth); + const dateNames = this._dateAdapter.getDateNames(); + const weeks: CalendarCell[][] = [[]]; + for (let i = 0, cell = this._firstWeekOffset(); i < daysInMonth; i++, cell++) { + if (cell == DAYS_PER_WEEK) { + weeks.push([]); + cell = 0; + } + const date = this._dateAdapter.createDate( + this._dateAdapter.getYear(viewMonth), + this._dateAdapter.getMonth(viewMonth), + i + 1, + ); + const ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel); + + weeks[weeks.length - 1].push({ + displayName: dateNames[i], + ariaLabel, + date, + selected: signal( + this._dateAdapter.compareDate( + date, + untracked(() => this._activeDate()), + ) === 0, + ), + }); + } + return weeks; + } +} diff --git a/src/components-examples/aria/grid/grid-common.css b/src/components-examples/aria/grid/grid-common.css index 90b84b899be3..bad0351ee59d 100644 --- a/src/components-examples/aria/grid/grid-common.css +++ b/src/components-examples/aria/grid/grid-common.css @@ -1,5 +1,8 @@ .example-grid-controls { + display: flex; + flex-wrap: wrap; align-items: center; + gap: 16px; margin-bottom: 16px; } @@ -12,6 +15,11 @@ outline-offset: 4px; } +.example-grid:focus-within .example-grid-cell[data-anchor='true'][data-active='false'] { + outline: 3px dashed var(--mat-sys-outline); + outline-offset: 3px; +} + .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); 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 index 9d620e264ec7..227c822e0eb5 100644 --- a/src/components-examples/aria/grid/grid-configurable/grid-configurable-example.css +++ b/src/components-examples/aria/grid/grid-configurable/grid-configurable-example.css @@ -1,5 +1,6 @@ .example-grid-container { display: flex; + align-items: flex-start; } .example-grid { @@ -19,3 +20,7 @@ width: 50px; border: 1px solid; } + +.example-grid-shortcuts { + margin-left: 12px; +} 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 index f19a80b22bdd..d1027c8dfda8 100644 --- a/src/components-examples/aria/grid/grid-configurable/grid-configurable-example.html +++ b/src/components-examples/aria/grid/grid-configurable/grid-configurable-example.html @@ -1,93 +1,57 @@ - - - - - - +
+ Disabled + Soft Disabled + Enable Selection + Multi + Range Selection -
- - - - -
- Disabled - - Soft Disabled - - Enable Selection -
- - Row Wrap - - No Wrap - Loop - Continuous - - - - - Col Wrap - - No Wrap - Loop - Continuous - - - - - Focus Strategy - - Roving - Active Descendant - - -
+ + Row Wrap + + No Wrap + Loop + Continuous + + + + Col Wrap + + No Wrap + Loop + Continuous + + + + Focus Strategy + + Roving + Active Descendant + + + + Selection Strategy + + Explicit + Follow Focus + + +
@for (row of gridData; track row) { @@ -116,17 +83,74 @@ }
-
    -
  • - -
  • -
  • 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
  • -
+
+
+ + Example Options + + Row Span + Col Span + Disabled + + +
+
+ Navigation Keys +
    +
  • Arrow keys navigation
  • +
  • Home: first cell in the row
  • +
  • End: last cell in the row
  • +
  • Crtl + Home: very first cell
  • +
  • Ctrl + End: very last cell
  • +
+ + @if (enableSelection.value) { + Selection Keys +
    + @if (this.selectionMode === 'explicit') { +
  • Enter/Space: select/deselect cell (explicit)
  • + } + @if (this.selectionMode === 'follow') { +
  • Selection change on navigation (follow focus)
  • + } + @if (multi.value && enableRangeSelection.value) { +
  • Shift + Arrow: expand selection
  • +
  • Ctrl + A: select all or deselect all (if all selected)
  • +
  • Shift + Space: select a row
  • +
  • Ctrl + Space: select a col
  • +
  • Shift + Home: range select from first cell in the row
  • +
  • Shift + End: range select to last cell in the row
  • +
  • Shift + Ctrl + Home: range select from very first cell
  • +
  • Shift + Ctrl + End: range select to very last cell
  • + } +
+ + Mouse Selection +
    + @if (this.selectionMode === 'explicit') { +
  • Click: select/deselect cell (explicit)
  • + } + @if (this.selectionMode === 'follow') { +
  • Click: select cell (follow focus)
  • + @if (multi.value) { +
  • Ctrl + Click: add/remove a cell
  • + } + } + @if (multi.value && enableRangeSelection.value) { + @if (this.selectionMode === 'explicit') { +
  • Drag: add a new range selection (explicit)
  • + } + @if (this.selectionMode === 'follow') { +
  • Drag: start a new range selection (follow focus)
  • + } +
  • Shift + Drag: update the current range selection
  • +
  • Ctrl + Drag: add a new range selection
  • + } +
+ } +
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 index c44fc55d39a6..d0edbaa6c111 100644 --- a/src/components-examples/aria/grid/grid-configurable/grid-configurable-example.ts +++ b/src/components-examples/aria/grid/grid-configurable/grid-configurable-example.ts @@ -5,12 +5,12 @@ * 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 {Component, model, afterRenderEffect, computed} 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, GridCellWidget} from '@angular/aria/grid'; +import {Grid, GridRow, GridCell} from '@angular/aria/grid'; interface Cell { rowSpan?: number; @@ -30,7 +30,13 @@ function randomDisabled(): boolean { return disabledChanceTable[randomIndex]; } -function generateValidGrid(rowCount: number, colCount: number): Cell[][] { +function generateValidGrid( + rowCount: number, + colCount: number, + randomRowSpan: boolean = true, + randomColSpan: boolean = true, + randomDisable: boolean = true, +): Cell[][] { const grid: Cell[][] = []; const visitedCoords = new Set(); for (let r = 0; r < rowCount; r++) { @@ -40,14 +46,14 @@ function generateValidGrid(rowCount: number, colCount: number): Cell[][] { continue; } - const rowSpan = Math.min(randomSpan(), rowCount - r); - const maxColSpan = Math.min(randomSpan(), colCount - c); + const rowSpan = randomRowSpan ? Math.min(randomSpan(), rowCount - r) : 1; + const maxColSpan = randomColSpan ? Math.min(randomSpan(), colCount - c) : 1; let colSpan = 1; while (colSpan < maxColSpan) { if (visitedCoords.has(`${r},${c + colSpan}`)) break; colSpan += 1; } - const disabled = randomDisabled(); + const disabled = randomDisable ? randomDisabled() : false; row.push({ rowSpan, @@ -82,21 +88,42 @@ function generateValidGrid(rowCount: number, colCount: number): Cell[][] { Grid, GridRow, GridCell, - GridCellWidget, ], }) export class GridConfigurableExample { rowWrap: 'continuous' | 'loop' | 'nowrap' = 'loop'; colWrap: 'continuous' | 'loop' | 'nowrap' = 'continuous'; focusMode: 'roving' | 'activedescendant' = 'roving'; + selectionMode: 'explicit' | 'follow' = 'follow'; + exampleOptions = model(['rowSpan', 'colSpan', 'disable']); + + randomRowSpan = computed(() => this.exampleOptions().includes('rowSpan')); + randomColSpan = computed(() => this.exampleOptions().includes('colSpan')); + randomDisable = computed(() => this.exampleOptions().includes('disable')); disabled = new FormControl(false, {nonNullable: true}); softDisabled = new FormControl(false, {nonNullable: true}); enableSelection = new FormControl(false, {nonNullable: true}); + multi = new FormControl(false, {nonNullable: true}); + enableRangeSelection = new FormControl(false, {nonNullable: true}); - gridData: Cell[][] = generateValidGrid(10, 10); + gridData: Cell[][] = generateValidGrid( + 10, + 10, + this.randomRowSpan(), + this.randomColSpan(), + this.randomDisable(), + ); - regenerateGrid() { - this.gridData = generateValidGrid(10, 10); + constructor() { + afterRenderEffect(() => { + this.gridData = generateValidGrid( + 10, + 10, + this.randomRowSpan(), + this.randomColSpan(), + this.randomDisable(), + ); + }); } } diff --git a/src/components-examples/aria/grid/index.ts b/src/components-examples/aria/grid/index.ts index 713726afa907..8c9cf0eaac43 100644 --- a/src/components-examples/aria/grid/index.ts +++ b/src/components-examples/aria/grid/index.ts @@ -1,2 +1,3 @@ export {GridConfigurableExample} from './grid-configurable/grid-configurable-example'; export {GridPillListExample} from './grid-pill-list/grid-pill-list-example'; +export {GridCalendarExample} from './grid-calendar/grid-calendar-example'; diff --git a/src/dev-app/aria-grid/grid-demo.css b/src/dev-app/aria-grid/grid-demo.css index b1c7a60608d0..1e8e47023ec1 100644 --- a/src/dev-app/aria-grid/grid-demo.css +++ b/src/dev-app/aria-grid/grid-demo.css @@ -1,17 +1,7 @@ -.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; + gap: 40px; } h2 { diff --git a/src/dev-app/aria-grid/grid-demo.html b/src/dev-app/aria-grid/grid-demo.html index f528a4a50e45..bb366513b2fc 100644 --- a/src/dev-app/aria-grid/grid-demo.html +++ b/src/dev-app/aria-grid/grid-demo.html @@ -1,11 +1,15 @@ -
-
+
+

Grid Pill List

-
-
-
+ +
+

Grid Calendar

+ +
+ +

Configurable

diff --git a/src/dev-app/aria-grid/grid-demo.ts b/src/dev-app/aria-grid/grid-demo.ts index 1f415d8153aa..ccaad03b5593 100644 --- a/src/dev-app/aria-grid/grid-demo.ts +++ b/src/dev-app/aria-grid/grid-demo.ts @@ -7,11 +7,15 @@ */ import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core'; -import {GridConfigurableExample, GridPillListExample} from '@angular/components-examples/aria/grid'; +import { + GridConfigurableExample, + GridPillListExample, + GridCalendarExample, +} from '@angular/components-examples/aria/grid'; @Component({ templateUrl: 'grid-demo.html', - imports: [GridConfigurableExample, GridPillListExample], + imports: [GridConfigurableExample, GridPillListExample, GridCalendarExample], styleUrl: 'grid-demo.css', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush,