Skip to content

Commit 19e30bf

Browse files
committed
refactor(aria/grid): replace contentChildren with SortedCollection
1 parent bb4f8ec commit 19e30bf

7 files changed

Lines changed: 133 additions & 24 deletions

File tree

goldens/aria/grid/index.api.md

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,23 @@ import * as _angular_cdk_bidi from '@angular/cdk/bidi';
88
import * as _angular_core from '@angular/core';
99
import { ElementRef } from '@angular/core';
1010
import { EventEmitter } from '@angular/core';
11+
import { OnDestroy } from '@angular/core';
12+
import { OnInit } from '@angular/core';
1113
import { Signal } from '@angular/core';
1214

1315
// @public
14-
export class Grid {
16+
export class Grid implements OnDestroy {
1517
constructor();
1618
readonly activeDescendant: Signal<string | undefined>;
19+
readonly _collection: SortedCollection<GridRow>;
1720
readonly colWrap: _angular_core.InputSignal<"continuous" | "loop" | "nowrap">;
1821
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
1922
readonly element: HTMLElement;
2023
readonly enableSelection: _angular_core.InputSignalWithTransform<boolean, unknown>;
2124
readonly focusMode: _angular_core.InputSignal<"roving" | "activedescendant">;
2225
readonly multi: _angular_core.InputSignalWithTransform<boolean, unknown>;
26+
// (undocumented)
27+
ngOnDestroy(): void;
2328
readonly _pattern: GridPattern;
2429
readonly rowWrap: _angular_core.InputSignal<"continuous" | "loop" | "nowrap">;
2530
scrollActiveCellIntoView(options?: ScrollIntoViewOptions): void;
@@ -28,13 +33,13 @@ export class Grid {
2833
readonly tabIndex: _angular_core.InputSignalWithTransform<number | undefined, string | number | undefined>;
2934
readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>;
3035
// (undocumented)
31-
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<Grid, "[ngGrid]", ["ngGrid"], { "enableSelection": { "alias": "enableSelection"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "focusMode": { "alias": "focusMode"; "required": false; "isSignal": true; }; "rowWrap": { "alias": "rowWrap"; "required": false; "isSignal": true; }; "colWrap": { "alias": "colWrap"; "required": false; "isSignal": true; }; "multi": { "alias": "multi"; "required": false; "isSignal": true; }; "selectionMode": { "alias": "selectionMode"; "required": false; "isSignal": true; }; "tabIndex": { "alias": "tabIndex"; "required": false; "isSignal": true; }; }, {}, ["_rows"], never, true, never>;
36+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<Grid, "[ngGrid]", ["ngGrid"], { "enableSelection": { "alias": "enableSelection"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "focusMode": { "alias": "focusMode"; "required": false; "isSignal": true; }; "rowWrap": { "alias": "rowWrap"; "required": false; "isSignal": true; }; "colWrap": { "alias": "colWrap"; "required": false; "isSignal": true; }; "multi": { "alias": "multi"; "required": false; "isSignal": true; }; "selectionMode": { "alias": "selectionMode"; "required": false; "isSignal": true; }; "tabIndex": { "alias": "tabIndex"; "required": false; "isSignal": true; }; }, {}, never, never, true, never>;
3237
// (undocumented)
3338
static ɵfac: _angular_core.ɵɵFactoryDeclaration<Grid, never>;
3439
}
3540

3641
// @public
37-
export class GridCell {
42+
export class GridCell implements OnInit, OnDestroy {
3843
constructor();
3944
readonly activated: EventEmitter<KeyboardEvent>;
4045
readonly active: Signal<boolean>;
@@ -43,6 +48,10 @@ export class GridCell {
4348
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
4449
readonly element: HTMLElement;
4550
readonly id: _angular_core.InputSignal<string>;
51+
// (undocumented)
52+
ngOnDestroy(): void;
53+
// (undocumented)
54+
ngOnInit(): void;
4655
readonly _pattern: GridCellPattern;
4756
readonly role: _angular_core.InputSignal<"gridcell" | "columnheader" | "rowheader">;
4857
readonly rowIndex: _angular_core.InputSignal<number | undefined>;
@@ -82,13 +91,19 @@ export class GridCellWidget {
8291
}
8392

8493
// @public
85-
export class GridRow {
94+
export class GridRow implements OnInit, OnDestroy {
95+
constructor();
96+
readonly _collection: SortedCollection<GridCell>;
8697
readonly element: HTMLElement;
8798
readonly _gridPattern: Signal<GridPattern>;
99+
// (undocumented)
100+
ngOnDestroy(): void;
101+
// (undocumented)
102+
ngOnInit(): void;
88103
readonly _pattern: GridRowPattern;
89104
readonly rowIndex: _angular_core.InputSignal<number | undefined>;
90105
// (undocumented)
91-
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<GridRow, "[ngGridRow]", ["ngGridRow"], { "rowIndex": { "alias": "rowIndex"; "required": false; "isSignal": true; }; }, {}, ["_cells"], never, true, never>;
106+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<GridRow, "[ngGridRow]", ["ngGridRow"], { "rowIndex": { "alias": "rowIndex"; "required": false; "isSignal": true; }; }, {}, never, never, true, never>;
92107
// (undocumented)
93108
static ɵfac: _angular_core.ɵɵFactoryDeclaration<GridRow, never>;
94109
}

src/aria/grid/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ ng_project(
2828
"//:node_modules/@angular/core",
2929
"//:node_modules/@angular/platform-browser",
3030
"//:node_modules/axe-core",
31+
"//src/aria/private/testing",
3132
"//src/cdk/testing/private",
3233
],
3334
)

src/aria/grid/grid-cell.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
inject,
1919
input,
2020
model,
21+
OnDestroy,
22+
OnInit,
2123
Output,
2224
Signal,
2325
Renderer2,
@@ -47,7 +49,7 @@ import {GRID_CELL, GRID_ROW} from './grid-tokens';
4749
exportAs: 'ngGridCell',
4850
providers: [{provide: GRID_CELL, useExisting: GridCell}],
4951
})
50-
export class GridCell {
52+
export class GridCell implements OnInit, OnDestroy {
5153
private readonly _elementRef = inject(ElementRef);
5254
private readonly _renderer = inject(Renderer2);
5355

@@ -149,6 +151,14 @@ export class GridCell {
149151
});
150152
}
151153

154+
ngOnInit() {
155+
this._row._collection.register(this);
156+
}
157+
158+
ngOnDestroy() {
159+
this._row._collection.unregister(this);
160+
}
161+
152162
private _toggleAttribute = (name: string, value: unknown) => {
153163
if (value == null) {
154164
this._renderer.removeAttribute(this.element, name);

src/aria/grid/grid-row.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,19 @@
77
*/
88

99
import {
10+
afterNextRender,
1011
computed,
11-
contentChildren,
1212
Directive,
1313
ElementRef,
1414
inject,
1515
input,
16+
OnDestroy,
17+
OnInit,
1618
Signal,
1719
} from '@angular/core';
18-
import {GridPattern, GridRowPattern} from '../private';
19-
import {Grid} from './grid';
20-
import {GRID_CELL, GRID_ROW} from './grid-tokens';
20+
import {GridPattern, GridRowPattern, SortedCollection} from '../private';
21+
import {GRID_ROW, GRID} from './grid-tokens';
22+
import {GridCell} from './grid-cell';
2123

2224
/**
2325
* 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';
4143
},
4244
providers: [{provide: GRID_ROW, useExisting: GridRow}],
4345
})
44-
export class GridRow {
46+
export class GridRow implements OnInit, OnDestroy {
4547
/** A reference to the host element. */
4648
private readonly _elementRef = inject(ElementRef);
4749

4850
/** A reference to the host element. */
4951
readonly element = this._elementRef.nativeElement as HTMLElement;
5052

51-
/** The cells that make up this row. */
52-
private readonly _cells = contentChildren(GRID_CELL, {descendants: true});
53+
/** The collection of cells in this row. */
54+
readonly _collection = new SortedCollection<GridCell>();
5355

5456
/** The UI patterns for the cells in this row. */
5557
private readonly _cellPatterns: Signal<any[]> = computed(() =>
56-
this._cells().map(c => c._pattern),
58+
this._collection.orderedItems().map(c => c._pattern),
5759
);
5860

5961
/** The parent grid. */
60-
private readonly _grid = inject(Grid);
62+
private readonly _grid = inject(GRID);
6163

6264
/** The parent grid UI pattern. */
6365
readonly _gridPattern = computed<GridPattern>(() => this._grid._pattern);
@@ -71,4 +73,19 @@ export class GridRow {
7173
cells: this._cellPatterns,
7274
grid: this._gridPattern,
7375
});
76+
77+
constructor() {
78+
afterNextRender(() => {
79+
this._collection.startObserving(this.element);
80+
});
81+
}
82+
83+
ngOnInit() {
84+
this._grid._collection.register(this);
85+
}
86+
87+
ngOnDestroy() {
88+
this._grid._collection.unregister(this);
89+
this._collection.stopObserving();
90+
}
7491
}

src/aria/grid/grid-tokens.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@
99
import {InjectionToken} from '@angular/core';
1010
import type {GridCell} from './grid-cell';
1111
import type {GridRow} from './grid-row';
12+
import type {Grid} from './grid';
1213

1314
/** Token used to expose a `GridCell`. */
1415
export const GRID_CELL = new InjectionToken<GridCell>('GRID_CELL');
1516

1617
/** Token used to expose a `GridRow`. */
1718
export const GRID_ROW = new InjectionToken<GridRow>('GRID_ROW');
19+
20+
/** Token used to expose a `Grid`. */
21+
export const GRID = new InjectionToken<Grid>('GRID');

src/aria/grid/grid.spec.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {Grid} from './grid';
55
import {GridRow} from './grid-row';
66
import {GridCell} from './grid-cell';
77
import {GridCellWidget} from './grid-cell-widget';
8+
import {waitForMicrotasks} from '../private/testing/test-helpers';
89

910
interface ModifierKeys {
1011
ctrlKey?: boolean;
@@ -567,6 +568,29 @@ describe('Grid directives', () => {
567568
expect(getActiveCellId()).toBe('c1-2');
568569
});
569570
});
571+
572+
describe('dynamic updates', () => {
573+
it('should update row order correctly after rows are shuffled', async () => {
574+
setupGrid();
575+
gridInstance._pattern.setDefaultStateEffect();
576+
fixture.detectChanges();
577+
578+
const rowPatternsBefore = gridInstance._pattern.inputs.rows();
579+
expect(rowPatternsBefore.length).toBe(3);
580+
expect(rowPatternsBefore[0].inputs.cells()[0].element()?.id).toBe('c0-0');
581+
582+
const gridData = fixture.componentInstance.gridData();
583+
const firstRow = gridData.shift()!;
584+
gridData.push(firstRow);
585+
fixture.componentInstance.gridData.set([...gridData]);
586+
fixture.detectChanges();
587+
await waitForMicrotasks();
588+
589+
const rowPatternsAfter = gridInstance._pattern.inputs.rows();
590+
expect(rowPatternsAfter.length).toBe(3);
591+
expect(rowPatternsAfter[0].inputs.cells()[0].element()?.id).toBe('c1-0');
592+
});
593+
});
570594
});
571595

572596
describe('GridRow', () => {
@@ -585,6 +609,31 @@ describe('Grid directives', () => {
585609
expect(row.getAttribute('aria-rowindex')).toBe('5');
586610
});
587611
});
612+
613+
describe('dynamic updates', () => {
614+
it('should update cell order correctly after cells are shuffled', async () => {
615+
setupGrid();
616+
gridInstance._pattern.setDefaultStateEffect();
617+
fixture.detectChanges();
618+
619+
const firstRow = gridDebugElement.query(By.directive(GridRow)).injector.get(GridRow);
620+
const cellPatternsBefore = firstRow._pattern.inputs.cells();
621+
expect(cellPatternsBefore.length).toBe(3);
622+
expect(cellPatternsBefore[0].element()?.id).toBe('c0-0');
623+
624+
const gridData = fixture.componentInstance.gridData();
625+
const firstRowCells = gridData[0].cells;
626+
const firstCell = firstRowCells.shift()!;
627+
firstRowCells.push(firstCell);
628+
fixture.componentInstance.gridData.set([...gridData]);
629+
fixture.detectChanges();
630+
await waitForMicrotasks();
631+
632+
const cellPatternsAfter = firstRow._pattern.inputs.cells();
633+
expect(cellPatternsAfter.length).toBe(3);
634+
expect(cellPatternsAfter[0].element()?.id).toBe('c0-1');
635+
});
636+
});
588637
});
589638

590639
describe('GridCell', () => {
@@ -975,9 +1024,9 @@ describe('Grid directives', () => {
9751024
[enableSelection]="enableSelection()"
9761025
[selectionMode]="selectionMode()"
9771026
[tabIndex]="tabIndex()">
978-
@for (row of gridData(); track $index; let rIndex = $index) {
1027+
@for (row of gridData(); track row; let rIndex = $index) {
9791028
<tr ngGridRow [rowIndex]="row.rowIndex">
980-
@for (cell of row.cells; track $index; let cIndex = $index) {
1029+
@for (cell of row.cells; track cell; let cIndex = $index) {
9811030
<td ngGridCell
9821031
[id]="cell.id"
9831032
[disabled]="cell.disabled ?? false"

src/aria/grid/grid.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,22 @@
77
*/
88

99
import {
10+
afterNextRender,
1011
afterRenderEffect,
1112
booleanAttribute,
1213
computed,
13-
contentChildren,
1414
Directive,
1515
ElementRef,
1616
inject,
1717
input,
1818
numberAttribute,
19+
OnDestroy,
1920
Signal,
2021
} from '@angular/core';
2122
import {Directionality} from '@angular/cdk/bidi';
22-
import {GridPattern, GridCellPattern} from '../private';
23-
import {GRID_ROW} from './grid-tokens';
23+
import {GridPattern, GridCellPattern, GridRowPattern, SortedCollection} from '../private';
24+
import {GridRow} from './grid-row';
25+
import {GRID} from './grid-tokens';
2426

2527
/**
2628
* The container for a grid. It provides keyboard navigation and focus management for the grid's
@@ -59,19 +61,22 @@ import {GRID_ROW} from './grid-tokens';
5961
'(focusin)': '_pattern.onFocusIn($event)',
6062
'(focusout)': '_pattern.onFocusOut($event)',
6163
},
64+
providers: [{provide: GRID, useExisting: Grid}],
6265
})
63-
export class Grid {
66+
export class Grid implements OnDestroy {
6467
/** A reference to the host element. */
6568
private readonly _elementRef = inject(ElementRef);
6669

6770
/** A reference to the host element. */
6871
readonly element = this._elementRef.nativeElement as HTMLElement;
6972

70-
/** The rows that make up the grid. */
71-
private readonly _rows = contentChildren(GRID_ROW, {descendants: true});
73+
/** The collection of rows in the grid. */
74+
readonly _collection = new SortedCollection<GridRow>();
7275

7376
/** The UI patterns for the rows in the grid. */
74-
private readonly _rowPatterns: Signal<any[]> = computed(() => this._rows().map(r => r._pattern));
77+
private readonly _rowPatterns: Signal<GridRowPattern[]> = computed(() =>
78+
this._collection.orderedItems().map(r => r._pattern),
79+
);
7580

7681
/** Text direction. */
7782
readonly textDirection = inject(Directionality).valueSignal;
@@ -145,6 +150,14 @@ export class Grid {
145150
afterRenderEffect({write: () => this._pattern.resetFocusEffect()});
146151
afterRenderEffect({write: () => this._pattern.restoreFocusEffect()});
147152
afterRenderEffect({write: () => this._pattern.focusEffect()});
153+
154+
afterNextRender(() => {
155+
this._collection.startObserving(this.element);
156+
});
157+
}
158+
159+
ngOnDestroy() {
160+
this._collection.stopObserving();
148161
}
149162

150163
/** Scrolls the active cell into view. */

0 commit comments

Comments
 (0)