Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions goldens/aria/grid/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>;
readonly _collection: SortedCollection<GridRow>;
readonly colWrap: _angular_core.InputSignal<"continuous" | "loop" | "nowrap">;
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
readonly element: HTMLElement;
readonly enableSelection: _angular_core.InputSignalWithTransform<boolean, unknown>;
readonly focusMode: _angular_core.InputSignal<"roving" | "activedescendant">;
readonly multi: _angular_core.InputSignalWithTransform<boolean, unknown>;
// (undocumented)
ngOnDestroy(): void;
readonly _pattern: GridPattern;
readonly rowWrap: _angular_core.InputSignal<"continuous" | "loop" | "nowrap">;
scrollActiveCellIntoView(options?: ScrollIntoViewOptions): void;
Expand All @@ -28,13 +33,13 @@ export class Grid {
readonly tabIndex: _angular_core.InputSignalWithTransform<number | undefined, string | number | undefined>;
readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>;
// (undocumented)
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>;
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>;
// (undocumented)
static ɵfac: _angular_core.ɵɵFactoryDeclaration<Grid, never>;
}

// @public
export class GridCell {
export class GridCell implements OnInit, OnDestroy {
constructor();
readonly activated: EventEmitter<KeyboardEvent>;
readonly active: Signal<boolean>;
Expand All @@ -43,6 +48,10 @@ export class GridCell {
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
readonly element: HTMLElement;
readonly id: _angular_core.InputSignal<string>;
// (undocumented)
ngOnDestroy(): void;
// (undocumented)
ngOnInit(): void;
readonly _pattern: GridCellPattern;
readonly role: _angular_core.InputSignal<"gridcell" | "columnheader" | "rowheader">;
readonly rowIndex: _angular_core.InputSignal<number | undefined>;
Expand Down Expand Up @@ -82,13 +91,19 @@ export class GridCellWidget {
}

// @public
export class GridRow {
export class GridRow implements OnInit, OnDestroy {
constructor();
readonly _collection: SortedCollection<GridCell>;
readonly element: HTMLElement;
readonly _gridPattern: Signal<GridPattern>;
// (undocumented)
ngOnDestroy(): void;
// (undocumented)
ngOnInit(): void;
readonly _pattern: GridRowPattern;
readonly rowIndex: _angular_core.InputSignal<number | undefined>;
// (undocumented)
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<GridRow, "[ngGridRow]", ["ngGridRow"], { "rowIndex": { "alias": "rowIndex"; "required": false; "isSignal": true; }; }, {}, ["_cells"], never, true, never>;
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<GridRow, "[ngGridRow]", ["ngGridRow"], { "rowIndex": { "alias": "rowIndex"; "required": false; "isSignal": true; }; }, {}, never, never, true, never>;
// (undocumented)
static ɵfac: _angular_core.ɵɵFactoryDeclaration<GridRow, never>;
}
Expand Down
1 change: 1 addition & 0 deletions src/aria/grid/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
)
Expand Down
12 changes: 11 additions & 1 deletion src/aria/grid/grid-cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
inject,
input,
model,
OnDestroy,
OnInit,
Output,
Signal,
Renderer2,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down
35 changes: 26 additions & 9 deletions src/aria/grid/grid-row.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<GridCell>();

/** The UI patterns for the cells in this row. */
private readonly _cellPatterns: Signal<any[]> = 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<GridPattern>(() => this._grid._pattern);
Expand All @@ -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();
}
}
4 changes: 4 additions & 0 deletions src/aria/grid/grid-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GridCell>('GRID_CELL');

/** Token used to expose a `GridRow`. */
export const GRID_ROW = new InjectionToken<GridRow>('GRID_ROW');

/** Token used to expose a `Grid`. */
export const GRID = new InjectionToken<Grid>('GRID');
53 changes: 51 additions & 2 deletions src/aria/grid/grid.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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) {
<tr ngGridRow [rowIndex]="row.rowIndex">
@for (cell of row.cells; track $index; let cIndex = $index) {
@for (cell of row.cells; track cell; let cIndex = $index) {
<td ngGridCell
[id]="cell.id"
[disabled]="cell.disabled ?? false"
Expand Down
27 changes: 20 additions & 7 deletions src/aria/grid/grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,22 @@
*/

import {
afterNextRender,
afterRenderEffect,
booleanAttribute,
computed,
contentChildren,
Directive,
ElementRef,
inject,
input,
numberAttribute,
OnDestroy,
Signal,
} from '@angular/core';
import {Directionality} from '@angular/cdk/bidi';
import {GridPattern, GridCellPattern} from '../private';
import {GRID_ROW} from './grid-tokens';
import {GridPattern, GridCellPattern, GridRowPattern, SortedCollection} from '../private';
import {GridRow} from './grid-row';
import {GRID} from './grid-tokens';

/**
* The container for a grid. It provides keyboard navigation and focus management for the grid's
Expand Down Expand Up @@ -59,19 +61,22 @@ import {GRID_ROW} from './grid-tokens';
'(focusin)': '_pattern.onFocusIn($event)',
'(focusout)': '_pattern.onFocusOut($event)',
},
providers: [{provide: GRID, useExisting: Grid}],
})
export class Grid {
export class Grid implements 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 rows that make up the grid. */
private readonly _rows = contentChildren(GRID_ROW, {descendants: true});
/** The collection of rows in the grid. */
readonly _collection = new SortedCollection<GridRow>();

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

/** Text direction. */
readonly textDirection = inject(Directionality).valueSignal;
Expand Down Expand Up @@ -145,6 +150,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. */
Expand Down
Loading