diff --git a/goldens/aria/grid/index.api.md b/goldens/aria/grid/index.api.md index ccb6923cb765..d304ade5a0d0 100644 --- a/goldens/aria/grid/index.api.md +++ b/goldens/aria/grid/index.api.md @@ -8,18 +8,23 @@ import * as _angular_cdk_bidi from '@angular/cdk/bidi'; import * as _angular_core from '@angular/core'; import { ElementRef } from '@angular/core'; import { EventEmitter } from '@angular/core'; +import { OnDestroy } from '@angular/core'; +import { OnInit } from '@angular/core'; import { Signal } from '@angular/core'; // @public -export class Grid { +export class Grid implements OnDestroy { constructor(); readonly activeDescendant: Signal; + readonly _collection: SortedCollection; readonly colWrap: _angular_core.InputSignal<"continuous" | "loop" | "nowrap">; readonly disabled: _angular_core.InputSignalWithTransform; readonly element: HTMLElement; readonly enableSelection: _angular_core.InputSignalWithTransform; readonly focusMode: _angular_core.InputSignal<"roving" | "activedescendant">; readonly multi: _angular_core.InputSignalWithTransform; + // (undocumented) + ngOnDestroy(): void; readonly _pattern: GridPattern; readonly rowWrap: _angular_core.InputSignal<"continuous" | "loop" | "nowrap">; scrollActiveCellIntoView(options?: ScrollIntoViewOptions): void; @@ -28,13 +33,13 @@ export class Grid { readonly tabIndex: _angular_core.InputSignalWithTransform; readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } // @public -export class GridCell { +export class GridCell implements OnInit, OnDestroy { constructor(); readonly activated: EventEmitter; readonly active: Signal; @@ -43,6 +48,10 @@ export class GridCell { readonly disabled: _angular_core.InputSignalWithTransform; readonly element: HTMLElement; readonly id: _angular_core.InputSignal; + // (undocumented) + ngOnDestroy(): void; + // (undocumented) + ngOnInit(): void; readonly _pattern: GridCellPattern; readonly role: _angular_core.InputSignal<"gridcell" | "columnheader" | "rowheader">; readonly rowIndex: _angular_core.InputSignal; @@ -82,13 +91,19 @@ export class GridCellWidget { } // @public -export class GridRow { +export class GridRow implements OnInit, OnDestroy { + constructor(); + readonly _collection: SortedCollection; readonly element: HTMLElement; readonly _gridPattern: Signal; + // (undocumented) + ngOnDestroy(): void; + // (undocumented) + ngOnInit(): void; readonly _pattern: GridRowPattern; readonly rowIndex: _angular_core.InputSignal; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } diff --git a/src/aria/grid/BUILD.bazel b/src/aria/grid/BUILD.bazel index 085b3c9305c6..ec62fa5648f1 100644 --- a/src/aria/grid/BUILD.bazel +++ b/src/aria/grid/BUILD.bazel @@ -28,6 +28,7 @@ ng_project( "//:node_modules/@angular/core", "//:node_modules/@angular/platform-browser", "//:node_modules/axe-core", + "//src/aria/private/testing", "//src/cdk/testing/private", ], ) diff --git a/src/aria/grid/grid-cell.ts b/src/aria/grid/grid-cell.ts index 2f683baeda57..9cd3dc974592 100644 --- a/src/aria/grid/grid-cell.ts +++ b/src/aria/grid/grid-cell.ts @@ -18,6 +18,8 @@ import { inject, input, model, + OnDestroy, + OnInit, Output, Signal, Renderer2, @@ -47,7 +49,7 @@ import {GRID_CELL, GRID_ROW} from './grid-tokens'; exportAs: 'ngGridCell', providers: [{provide: GRID_CELL, useExisting: GridCell}], }) -export class GridCell { +export class GridCell implements OnInit, OnDestroy { private readonly _elementRef = inject(ElementRef); private readonly _renderer = inject(Renderer2); @@ -149,6 +151,14 @@ export class GridCell { }); } + ngOnInit() { + this._row._collection.register(this); + } + + ngOnDestroy() { + this._row._collection.unregister(this); + } + private _toggleAttribute = (name: string, value: unknown) => { if (value == null) { this._renderer.removeAttribute(this.element, name); diff --git a/src/aria/grid/grid-row.ts b/src/aria/grid/grid-row.ts index 7a24a79f6dea..b7aa86478bc2 100644 --- a/src/aria/grid/grid-row.ts +++ b/src/aria/grid/grid-row.ts @@ -7,17 +7,19 @@ */ import { + afterNextRender, computed, - contentChildren, Directive, ElementRef, inject, input, + OnDestroy, + OnInit, Signal, } from '@angular/core'; -import {GridPattern, GridRowPattern} from '../private'; -import {Grid} from './grid'; -import {GRID_CELL, GRID_ROW} from './grid-tokens'; +import {GridPattern, GridRowPattern, SortedCollection} from '../private'; +import {GRID_ROW, GRID} from './grid-tokens'; +import {GridCell} from './grid-cell'; /** * Represents a row within a grid. It is a container for `ngGridCell` directives. @@ -41,23 +43,23 @@ import {GRID_CELL, GRID_ROW} from './grid-tokens'; }, providers: [{provide: GRID_ROW, useExisting: GridRow}], }) -export class GridRow { +export class GridRow implements OnInit, OnDestroy { /** A reference to the host element. */ private readonly _elementRef = inject(ElementRef); /** A reference to the host element. */ readonly element = this._elementRef.nativeElement as HTMLElement; - /** The cells that make up this row. */ - private readonly _cells = contentChildren(GRID_CELL, {descendants: true}); + /** The collection of cells in this row. */ + readonly _collection = new SortedCollection(); /** The UI patterns for the cells in this row. */ private readonly _cellPatterns: Signal = computed(() => - this._cells().map(c => c._pattern), + this._collection.orderedItems().map(c => c._pattern), ); /** The parent grid. */ - private readonly _grid = inject(Grid); + private readonly _grid = inject(GRID); /** The parent grid UI pattern. */ readonly _gridPattern = computed(() => this._grid._pattern); @@ -71,4 +73,19 @@ export class GridRow { cells: this._cellPatterns, grid: this._gridPattern, }); + + constructor() { + afterNextRender(() => { + this._collection.startObserving(this.element); + }); + } + + ngOnInit() { + this._grid._collection.register(this); + } + + ngOnDestroy() { + this._grid._collection.unregister(this); + this._collection.stopObserving(); + } } diff --git a/src/aria/grid/grid-tokens.ts b/src/aria/grid/grid-tokens.ts index 3e70c18a6f79..2f314b114f55 100644 --- a/src/aria/grid/grid-tokens.ts +++ b/src/aria/grid/grid-tokens.ts @@ -9,9 +9,13 @@ import {InjectionToken} from '@angular/core'; import type {GridCell} from './grid-cell'; import type {GridRow} from './grid-row'; +import type {Grid} from './grid'; /** Token used to expose a `GridCell`. */ export const GRID_CELL = new InjectionToken('GRID_CELL'); /** Token used to expose a `GridRow`. */ export const GRID_ROW = new InjectionToken('GRID_ROW'); + +/** Token used to expose a `Grid`. */ +export const GRID = new InjectionToken('GRID'); diff --git a/src/aria/grid/grid.spec.ts b/src/aria/grid/grid.spec.ts index 2fb873abdd41..53d64450769b 100644 --- a/src/aria/grid/grid.spec.ts +++ b/src/aria/grid/grid.spec.ts @@ -5,6 +5,7 @@ import {Grid} from './grid'; import {GridRow} from './grid-row'; import {GridCell} from './grid-cell'; import {GridCellWidget} from './grid-cell-widget'; +import {waitForMicrotasks} from '../private/testing/test-helpers'; interface ModifierKeys { ctrlKey?: boolean; @@ -567,6 +568,29 @@ describe('Grid directives', () => { expect(getActiveCellId()).toBe('c1-2'); }); }); + + describe('dynamic updates', () => { + it('should update row order correctly after rows are shuffled', async () => { + setupGrid(); + gridInstance._pattern.setDefaultStateEffect(); + fixture.detectChanges(); + + const rowPatternsBefore = gridInstance._pattern.inputs.rows(); + expect(rowPatternsBefore.length).toBe(3); + expect(rowPatternsBefore[0].inputs.cells()[0].element()?.id).toBe('c0-0'); + + const gridData = fixture.componentInstance.gridData(); + const firstRow = gridData.shift()!; + gridData.push(firstRow); + fixture.componentInstance.gridData.set([...gridData]); + fixture.detectChanges(); + await waitForMicrotasks(); + + const rowPatternsAfter = gridInstance._pattern.inputs.rows(); + expect(rowPatternsAfter.length).toBe(3); + expect(rowPatternsAfter[0].inputs.cells()[0].element()?.id).toBe('c1-0'); + }); + }); }); describe('GridRow', () => { @@ -585,6 +609,31 @@ describe('Grid directives', () => { expect(row.getAttribute('aria-rowindex')).toBe('5'); }); }); + + describe('dynamic updates', () => { + it('should update cell order correctly after cells are shuffled', async () => { + setupGrid(); + gridInstance._pattern.setDefaultStateEffect(); + fixture.detectChanges(); + + const firstRow = gridDebugElement.query(By.directive(GridRow)).injector.get(GridRow); + const cellPatternsBefore = firstRow._pattern.inputs.cells(); + expect(cellPatternsBefore.length).toBe(3); + expect(cellPatternsBefore[0].element()?.id).toBe('c0-0'); + + const gridData = fixture.componentInstance.gridData(); + const firstRowCells = gridData[0].cells; + const firstCell = firstRowCells.shift()!; + firstRowCells.push(firstCell); + fixture.componentInstance.gridData.set([...gridData]); + fixture.detectChanges(); + await waitForMicrotasks(); + + const cellPatternsAfter = firstRow._pattern.inputs.cells(); + expect(cellPatternsAfter.length).toBe(3); + expect(cellPatternsAfter[0].element()?.id).toBe('c0-1'); + }); + }); }); describe('GridCell', () => { @@ -975,9 +1024,9 @@ describe('Grid directives', () => { [enableSelection]="enableSelection()" [selectionMode]="selectionMode()" [tabindex]="tabIndex()"> - @for (row of gridData(); track $index; let rIndex = $index) { + @for (row of gridData(); track row; let rIndex = $index) { - @for (cell of row.cells; track $index; let cIndex = $index) { + @for (cell of row.cells; track cell; let cIndex = $index) { (); /** The UI patterns for the rows in the grid. */ - private readonly _rowPatterns: Signal = computed(() => this._rows().map(r => r._pattern)); + private readonly _rowPatterns: Signal = computed(() => + this._collection.orderedItems().map(r => r._pattern), + ); /** Text direction. */ readonly textDirection = inject(Directionality).valueSignal; @@ -144,6 +155,14 @@ export class Grid { afterRenderEffect({write: () => this._pattern.resetFocusEffect()}); afterRenderEffect({write: () => this._pattern.restoreFocusEffect()}); afterRenderEffect({write: () => this._pattern.focusEffect()}); + + afterNextRender(() => { + this._collection.startObserving(this.element); + }); + } + + ngOnDestroy() { + this._collection.stopObserving(); } /** Scrolls the active cell into view. */