Skip to content

Commit 165e601

Browse files
committed
refactor(aria/grid): rework cell widget and wrap continuous behavior
1 parent 3b39d89 commit 165e601

28 files changed

+2292
-919
lines changed

src/aria/grid/grid.ts

Lines changed: 149 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,25 @@ import {
1111
afterRenderEffect,
1212
booleanAttribute,
1313
computed,
14-
contentChild,
1514
contentChildren,
1615
Directive,
1716
ElementRef,
1817
inject,
1918
input,
19+
output,
2020
model,
2121
Signal,
2222
} from '@angular/core';
2323
import {Directionality} from '@angular/cdk/bidi';
24-
import {GridPattern, GridRowPattern, GridCellPattern, GridCellWidgetPattern} from '../private';
24+
import {
25+
GridPattern,
26+
GridRowPattern,
27+
GridCellPattern,
28+
GridCellWidgetPattern,
29+
NavigateEvent,
30+
} from '../private';
31+
32+
export {NavigateEvent} from '../private';
2533

2634
/**
2735
* The container for a grid. It provides keyboard navigation and focus management for the grid's
@@ -57,7 +65,7 @@ import {GridPattern, GridRowPattern, GridCellPattern, GridCellWidgetPattern} fro
5765
'(pointerdown)': '_pattern.onPointerdown($event)',
5866
'(pointermove)': '_pattern.onPointermove($event)',
5967
'(pointerup)': '_pattern.onPointerup($event)',
60-
'(focusin)': '_pattern.onFocusIn()',
68+
'(focusin)': '_pattern.onFocusIn($event)',
6169
'(focusout)': '_pattern.onFocusOut($event)',
6270
},
6371
})
@@ -127,6 +135,9 @@ export class Grid {
127135
/** Whether enable range selections (with modifier keys or dragging). */
128136
readonly enableRangeSelection = input(false, {transform: booleanAttribute});
129137

138+
/** Emits when navigation occurs within the grid. */
139+
readonly onNavigate = output<NavigateEvent>();
140+
130141
/** The UI pattern for the grid. */
131142
readonly _pattern = new GridPattern({
132143
...this,
@@ -137,26 +148,34 @@ export class Grid {
137148
constructor() {
138149
afterRenderEffect(() => this._pattern.setDefaultStateEffect());
139150
afterRenderEffect(() => this._pattern.resetStateEffect());
151+
afterRenderEffect(() => this._pattern.resetFocusEffect());
152+
afterRenderEffect(() => this._pattern.restoreFocusEffect());
140153
afterRenderEffect(() => this._pattern.focusEffect());
154+
afterRenderEffect(() => {
155+
const navigateEvent = this._pattern.lastNavigateEvent();
156+
if (navigateEvent) {
157+
this.onNavigate.emit(navigateEvent);
158+
}
159+
});
141160
}
142161

143162
/** Gets the cell pattern for a given element. */
144-
private _getCell(element: Element): GridCellPattern | undefined {
145-
const cellElement = element.closest('[ngGridCell]');
146-
if (cellElement === undefined) return;
147-
148-
const widgetElement = element.closest('[ngGridCellWidget]');
149-
for (const row of this._rowPatterns()) {
150-
for (const cell of row.inputs.cells()) {
151-
if (
152-
cell.element() === cellElement ||
153-
(widgetElement !== undefined && cell.element() === widgetElement)
154-
) {
155-
return cell;
163+
private _getCell(element: Element | null | undefined): GridCellPattern | undefined {
164+
let target = element;
165+
166+
while (target) {
167+
for (const row of this._rowPatterns()) {
168+
for (const cell of row.inputs.cells()) {
169+
if (cell.element() === target) {
170+
return cell;
171+
}
156172
}
157173
}
174+
175+
target = target.parentElement?.closest('[ngGridCell]');
158176
}
159-
return;
177+
178+
return undefined;
160179
}
161180
}
162181

@@ -176,7 +195,8 @@ export class Grid {
176195
exportAs: 'ngGridRow',
177196
host: {
178197
'class': 'grid-row',
179-
'[attr.role]': 'role()',
198+
'role': 'row',
199+
'[attr.aria-rowindex]': '_pattern.rowIndex()',
180200
},
181201
})
182202
export class GridRow {
@@ -200,9 +220,6 @@ export class GridRow {
200220
/** The host native element. */
201221
readonly element = computed(() => this._elementRef.nativeElement);
202222

203-
/** The ARIA role for the row. */
204-
readonly role = input<'row' | 'rowheader'>('row');
205-
206223
/** The index of this row within the grid. */
207224
readonly rowIndex = input<number>();
208225

@@ -243,32 +260,35 @@ export class GridRow {
243260
'[attr.aria-rowindex]': '_pattern.ariaRowIndex()',
244261
'[attr.aria-colindex]': '_pattern.ariaColIndex()',
245262
'[attr.aria-selected]': '_pattern.ariaSelected()',
246-
'[tabindex]': '_pattern.tabIndex()',
263+
'[tabindex]': '_tabIndex()',
247264
},
248265
})
249266
export class GridCell {
250267
/** A reference to the host element. */
251268
private readonly _elementRef = inject(ElementRef);
252269

253-
/** The widget contained within this cell, if any. */
254-
private readonly _widgets = contentChild(GridCellWidget);
270+
/** The widgets contained within this cell, if any. */
271+
private readonly _widgets = contentChildren(GridCellWidget, {descendants: true});
255272

256273
/** The UI pattern for the widget in this cell. */
257-
private readonly _widgetPattern: Signal<GridCellWidgetPattern | undefined> = computed(
258-
() => this._widgets()?._pattern,
274+
private readonly _widgetPatterns: Signal<GridCellWidgetPattern[]> = computed(() =>
275+
this._widgets().map(w => w._pattern),
259276
);
260277

261278
/** The parent row. */
262279
private readonly _row = inject(GridRow);
263280

281+
/** Text direction. */
282+
readonly textDirection = inject(Directionality).valueSignal;
283+
264284
/** A unique identifier for the cell. */
265-
private readonly _id = inject(_IdGenerator).getId('ng-grid-cell-', true);
285+
readonly id = input(inject(_IdGenerator).getId('ng-grid-cell-', true));
266286

267287
/** The host native element. */
268288
readonly element = computed(() => this._elementRef.nativeElement);
269289

270290
/** The ARIA role for the cell. */
271-
readonly role = input<'gridcell' | 'columnheader'>('gridcell');
291+
readonly role = input<'gridcell' | 'columnheader' | 'rowheader'>('gridcell');
272292

273293
/** The number of rows the cell should span. */
274294
readonly rowSpan = input<number>(1);
@@ -291,14 +311,49 @@ export class GridCell {
291311
/** Whether the cell is selectable. */
292312
readonly selectable = input<boolean>(true);
293313

314+
/** Orientation of the widgets in the cell. */
315+
readonly orientation = input<'vertical' | 'horizontal'>('horizontal');
316+
317+
/** Whether widgets navigation wraps. */
318+
readonly wrap = input(true, {transform: booleanAttribute});
319+
320+
/** The tabindex override. */
321+
readonly tabindex = input<number | undefined>();
322+
323+
/**
324+
* The tabindex value set to the element.
325+
* If a focus target exists then return -1. Unless an override.
326+
*/
327+
protected readonly _tabIndex: Signal<number> = computed(
328+
() => this.tabindex() ?? this._pattern.tabIndex(),
329+
);
330+
294331
/** The UI pattern for the grid cell. */
295332
readonly _pattern = new GridCellPattern({
296333
...this,
297-
id: () => this._id,
298334
grid: this._row.grid,
299335
row: () => this._row._pattern,
300-
widget: this._widgetPattern,
336+
widgets: this._widgetPatterns,
337+
getWidget: e => this._getWidget(e),
301338
});
339+
340+
constructor() {}
341+
342+
/** Gets the cell widget pattern for a given element. */
343+
private _getWidget(element: Element | null | undefined): GridCellWidgetPattern | undefined {
344+
let target = element;
345+
346+
while (target) {
347+
const pattern = this._widgetPatterns().find(w => w.element() === target);
348+
if (pattern) {
349+
return pattern;
350+
}
351+
352+
target = target.parentElement?.closest('[ngGridCellWidget]');
353+
}
354+
355+
return undefined;
356+
}
302357
}
303358

304359
/**
@@ -323,7 +378,8 @@ export class GridCell {
323378
host: {
324379
'class': 'grid-cell-widget',
325380
'[attr.data-active]': '_pattern.active()',
326-
'[tabindex]': '_pattern.tabIndex()',
381+
'[attr.data-widget-activated]': 'isActivated()',
382+
'[tabindex]': '_tabIndex()',
327383
},
328384
})
329385
export class GridCellWidget {
@@ -336,17 +392,75 @@ export class GridCellWidget {
336392
/** The host native element. */
337393
readonly element = computed(() => this._elementRef.nativeElement);
338394

339-
/** Whether the widget is activated and the grid navigation should be paused. */
340-
readonly activate = model<boolean>(false);
395+
/** A unique identifier for the widget. */
396+
readonly id = input<string>(inject(_IdGenerator).getId('ng-grid-cell-', true));
397+
398+
/** The type of widget, which determines how it is activated. */
399+
readonly widgetType = input<'simple' | 'complex' | 'editable'>('simple');
400+
401+
/** Whether the widget is disabled. */
402+
readonly disabled = input(false, {transform: booleanAttribute});
403+
404+
/** The target that will receive focus instead of the widget. */
405+
readonly focusTarget = input<ElementRef | HTMLElement | undefined>();
406+
407+
/** Emits when the widget is activated. */
408+
readonly onActivate = output<KeyboardEvent | FocusEvent | undefined>();
409+
410+
/** Emits when the widget is deactivated. */
411+
readonly onDeactivate = output<KeyboardEvent | FocusEvent | undefined>();
412+
413+
/** The tabindex override. */
414+
readonly tabindex = input<number | undefined>();
415+
416+
/**
417+
* The tabindex value set to the element.
418+
* If a focus target exists then return -1. Unless an override.
419+
*/
420+
protected readonly _tabIndex: Signal<number> = computed(
421+
() => this.tabindex() ?? (this.focusTarget() ? -1 : this._pattern.tabIndex()),
422+
);
341423

342424
/** The UI pattern for the grid cell widget. */
343425
readonly _pattern = new GridCellWidgetPattern({
344426
...this,
345427
cell: () => this._cell._pattern,
428+
focusTarget: computed(() => {
429+
if (this.focusTarget() instanceof ElementRef) {
430+
return (this.focusTarget() as ElementRef).nativeElement;
431+
}
432+
return this.focusTarget();
433+
}),
346434
});
347435

348-
/** Focuses the widget. */
349-
focus(): void {
350-
this.element().focus();
436+
/** Whether the widget is activated. */
437+
get isActivated(): Signal<boolean> {
438+
return this._pattern.isActivated.asReadonly();
439+
}
440+
441+
constructor() {
442+
afterRenderEffect(() => {
443+
const activateEvent = this._pattern.lastActivateEvent();
444+
if (activateEvent) {
445+
this.onActivate.emit(activateEvent);
446+
}
447+
});
448+
449+
afterRenderEffect(() => {
450+
const deactivateEvent = this._pattern.lastDeactivateEvent();
451+
if (deactivateEvent) {
452+
this.onDeactivate.emit(deactivateEvent);
453+
}
454+
});
455+
}
456+
457+
/** Activates the widget. */
458+
activate(): void {
459+
this._pattern.activate();
460+
}
461+
462+
/** Deactivates the widget. */
463+
deactivate(): void {
464+
this._pattern.deactivate();
351465
}
352466
}

src/aria/private/behaviors/grid/grid-data.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ export class GridData<T extends BaseGridCell> {
128128
this.cells = this.inputs.cells;
129129
}
130130

131+
/** Whether the cell exists. */
132+
hasCell(cell: T): boolean {
133+
return this._coordsMap().has(cell);
134+
}
135+
131136
/** Gets the cell at the given coordinates. */
132137
getCell(rowCol: RowCol): T | undefined {
133138
return this._cellMap().get(`${rowCol.row}:${rowCol.col}`);

src/aria/private/behaviors/grid/grid-focus.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {computed, signal} from '@angular/core';
9+
import {computed, signal, WritableSignal} from '@angular/core';
1010
import {SignalLike} from '../signal-like/signal-like';
1111
import type {GridData, BaseGridCell, RowCol} from './grid-data';
1212

@@ -43,7 +43,7 @@ interface GridFocusDeps<T extends GridFocusCell> {
4343
/** Controls focus for a 2D grid of cells. */
4444
export class GridFocus<T extends GridFocusCell> {
4545
/** The current active cell. */
46-
readonly activeCell = signal<T | undefined>(undefined);
46+
readonly activeCell: WritableSignal<T | undefined> = signal(undefined);
4747

4848
/** The current active cell coordinates. */
4949
readonly activeCoords = signal<RowCol>({row: -1, col: -1});
@@ -118,7 +118,7 @@ export class GridFocus<T extends GridFocusCell> {
118118

119119
/** Returns true if the given cell can be navigated to. */
120120
isFocusable(cell: T): boolean {
121-
return !cell.disabled() || this.inputs.softDisabled();
121+
return this.inputs.grid.hasCell(cell) && (!cell.disabled() || this.inputs.softDisabled());
122122
}
123123

124124
/** Focuses the given cell. */

0 commit comments

Comments
 (0)