Skip to content

Commit fe50566

Browse files
committed
refactor(aria/grid): rework cell widget and wrap continuous behavior (#32290)
* refactor(aria/grid): rework cell widget and wrap continuous behavior * refactor: data-active-control attribute * refactor: remove onNavigate output (cherry picked from commit 3d74ca6)
1 parent 551ce3d commit fe50566

28 files changed

+2193
-904
lines changed

src/aria/grid/grid.ts

Lines changed: 131 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ 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';
@@ -57,7 +57,7 @@ import {GridPattern, GridRowPattern, GridCellPattern, GridCellWidgetPattern} fro
5757
'(pointerdown)': '_pattern.onPointerdown($event)',
5858
'(pointermove)': '_pattern.onPointermove($event)',
5959
'(pointerup)': '_pattern.onPointerup($event)',
60-
'(focusin)': '_pattern.onFocusIn()',
60+
'(focusin)': '_pattern.onFocusIn($event)',
6161
'(focusout)': '_pattern.onFocusOut($event)',
6262
},
6363
})
@@ -137,26 +137,28 @@ export class Grid {
137137
constructor() {
138138
afterRenderEffect(() => this._pattern.setDefaultStateEffect());
139139
afterRenderEffect(() => this._pattern.resetStateEffect());
140+
afterRenderEffect(() => this._pattern.resetFocusEffect());
141+
afterRenderEffect(() => this._pattern.restoreFocusEffect());
140142
afterRenderEffect(() => this._pattern.focusEffect());
141143
}
142144

143145
/** 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;
146+
private _getCell(element: Element | null | undefined): GridCellPattern | undefined {
147+
let target = element;
148+
149+
while (target) {
150+
for (const row of this._rowPatterns()) {
151+
for (const cell of row.inputs.cells()) {
152+
if (cell.element() === target) {
153+
return cell;
154+
}
156155
}
157156
}
157+
158+
target = target.parentElement?.closest('[ngGridCell]');
158159
}
159-
return;
160+
161+
return undefined;
160162
}
161163
}
162164

@@ -176,7 +178,8 @@ export class Grid {
176178
exportAs: 'ngGridRow',
177179
host: {
178180
'class': 'grid-row',
179-
'[attr.role]': 'role()',
181+
'role': 'row',
182+
'[attr.aria-rowindex]': '_pattern.rowIndex()',
180183
},
181184
})
182185
export class GridRow {
@@ -200,9 +203,6 @@ export class GridRow {
200203
/** The host native element. */
201204
readonly element = computed(() => this._elementRef.nativeElement);
202205

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

@@ -243,32 +243,35 @@ export class GridRow {
243243
'[attr.aria-rowindex]': '_pattern.ariaRowIndex()',
244244
'[attr.aria-colindex]': '_pattern.ariaColIndex()',
245245
'[attr.aria-selected]': '_pattern.ariaSelected()',
246-
'[tabindex]': '_pattern.tabIndex()',
246+
'[tabindex]': '_tabIndex()',
247247
},
248248
})
249249
export class GridCell {
250250
/** A reference to the host element. */
251251
private readonly _elementRef = inject(ElementRef);
252252

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

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

261261
/** The parent row. */
262262
private readonly _row = inject(GridRow);
263263

264+
/** Text direction. */
265+
readonly textDirection = inject(Directionality).valueSignal;
266+
264267
/** A unique identifier for the cell. */
265-
private readonly _id = inject(_IdGenerator).getId('ng-grid-cell-', true);
268+
readonly id = input(inject(_IdGenerator).getId('ng-grid-cell-', true));
266269

267270
/** The host native element. */
268271
readonly element = computed(() => this._elementRef.nativeElement);
269272

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

273276
/** The number of rows the cell should span. */
274277
readonly rowSpan = input<number>(1);
@@ -291,14 +294,49 @@ export class GridCell {
291294
/** Whether the cell is selectable. */
292295
readonly selectable = input<boolean>(true);
293296

297+
/** Orientation of the widgets in the cell. */
298+
readonly orientation = input<'vertical' | 'horizontal'>('horizontal');
299+
300+
/** Whether widgets navigation wraps. */
301+
readonly wrap = input(true, {transform: booleanAttribute});
302+
303+
/** The tabindex override. */
304+
readonly tabindex = input<number | undefined>();
305+
306+
/**
307+
* The tabindex value set to the element.
308+
* If a focus target exists then return -1. Unless an override.
309+
*/
310+
protected readonly _tabIndex: Signal<number> = computed(
311+
() => this.tabindex() ?? this._pattern.tabIndex(),
312+
);
313+
294314
/** The UI pattern for the grid cell. */
295315
readonly _pattern = new GridCellPattern({
296316
...this,
297-
id: () => this._id,
298317
grid: this._row.grid,
299318
row: () => this._row._pattern,
300-
widget: this._widgetPattern,
319+
widgets: this._widgetPatterns,
320+
getWidget: e => this._getWidget(e),
301321
});
322+
323+
constructor() {}
324+
325+
/** Gets the cell widget pattern for a given element. */
326+
private _getWidget(element: Element | null | undefined): GridCellWidgetPattern | undefined {
327+
let target = element;
328+
329+
while (target) {
330+
const pattern = this._widgetPatterns().find(w => w.element() === target);
331+
if (pattern) {
332+
return pattern;
333+
}
334+
335+
target = target.parentElement?.closest('[ngGridCellWidget]');
336+
}
337+
338+
return undefined;
339+
}
302340
}
303341

304342
/**
@@ -323,7 +361,8 @@ export class GridCell {
323361
host: {
324362
'class': 'grid-cell-widget',
325363
'[attr.data-active]': '_pattern.active()',
326-
'[tabindex]': '_pattern.tabIndex()',
364+
'[attr.data-active-control]': 'isActivated() ? "widget" : "cell"',
365+
'[tabindex]': '_tabIndex()',
327366
},
328367
})
329368
export class GridCellWidget {
@@ -336,17 +375,75 @@ export class GridCellWidget {
336375
/** The host native element. */
337376
readonly element = computed(() => this._elementRef.nativeElement);
338377

339-
/** Whether the widget is activated and the grid navigation should be paused. */
340-
readonly activate = model<boolean>(false);
378+
/** A unique identifier for the widget. */
379+
readonly id = input<string>(inject(_IdGenerator).getId('ng-grid-cell-', true));
380+
381+
/** The type of widget, which determines how it is activated. */
382+
readonly widgetType = input<'simple' | 'complex' | 'editable'>('simple');
383+
384+
/** Whether the widget is disabled. */
385+
readonly disabled = input(false, {transform: booleanAttribute});
386+
387+
/** The target that will receive focus instead of the widget. */
388+
readonly focusTarget = input<ElementRef | HTMLElement | undefined>();
389+
390+
/** Emits when the widget is activated. */
391+
readonly onActivate = output<KeyboardEvent | FocusEvent | undefined>();
392+
393+
/** Emits when the widget is deactivated. */
394+
readonly onDeactivate = output<KeyboardEvent | FocusEvent | undefined>();
395+
396+
/** The tabindex override. */
397+
readonly tabindex = input<number | undefined>();
398+
399+
/**
400+
* The tabindex value set to the element.
401+
* If a focus target exists then return -1. Unless an override.
402+
*/
403+
protected readonly _tabIndex: Signal<number> = computed(
404+
() => this.tabindex() ?? (this.focusTarget() ? -1 : this._pattern.tabIndex()),
405+
);
341406

342407
/** The UI pattern for the grid cell widget. */
343408
readonly _pattern = new GridCellWidgetPattern({
344409
...this,
345410
cell: () => this._cell._pattern,
411+
focusTarget: computed(() => {
412+
if (this.focusTarget() instanceof ElementRef) {
413+
return (this.focusTarget() as ElementRef).nativeElement;
414+
}
415+
return this.focusTarget();
416+
}),
346417
});
347418

348-
/** Focuses the widget. */
349-
focus(): void {
350-
this.element().focus();
419+
/** Whether the widget is activated. */
420+
get isActivated(): Signal<boolean> {
421+
return this._pattern.isActivated.asReadonly();
422+
}
423+
424+
constructor() {
425+
afterRenderEffect(() => {
426+
const activateEvent = this._pattern.lastActivateEvent();
427+
if (activateEvent) {
428+
this.onActivate.emit(activateEvent);
429+
}
430+
});
431+
432+
afterRenderEffect(() => {
433+
const deactivateEvent = this._pattern.lastDeactivateEvent();
434+
if (deactivateEvent) {
435+
this.onDeactivate.emit(deactivateEvent);
436+
}
437+
});
438+
}
439+
440+
/** Activates the widget. */
441+
activate(): void {
442+
this._pattern.activate();
443+
}
444+
445+
/** Deactivates the widget. */
446+
deactivate(): void {
447+
this._pattern.deactivate();
351448
}
352449
}

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)