Skip to content

Commit 626dce8

Browse files
committed
refactor(aria/grid): rework cell widget and wrap continuous behavior
1 parent 0d83d65 commit 626dce8

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
/** A directive that provides grid-based navigation and selection behavior. */
2735
@Directive({
@@ -37,7 +45,7 @@ import {GridPattern, GridRowPattern, GridCellPattern, GridCellWidgetPattern} fro
3745
'(pointerdown)': '_pattern.onPointerdown($event)',
3846
'(pointermove)': '_pattern.onPointermove($event)',
3947
'(pointerup)': '_pattern.onPointerup($event)',
40-
'(focusin)': '_pattern.onFocusIn()',
48+
'(focusin)': '_pattern.onFocusIn($event)',
4149
'(focusout)': '_pattern.onFocusOut($event)',
4250
},
4351
})
@@ -86,6 +94,9 @@ export class Grid {
8694
/** Whether enable range selections (with modifier keys or dragging). */
8795
readonly enableRangeSelection = input(false, {transform: booleanAttribute});
8896

97+
/** Emits when navigation occurs within the grid. */
98+
readonly onNavigate = output<NavigateEvent>();
99+
89100
/** The UI pattern for the grid. */
90101
readonly _pattern = new GridPattern({
91102
...this,
@@ -96,26 +107,34 @@ export class Grid {
96107
constructor() {
97108
afterRenderEffect(() => this._pattern.setDefaultStateEffect());
98109
afterRenderEffect(() => this._pattern.resetStateEffect());
110+
afterRenderEffect(() => this._pattern.resetFocusEffect());
111+
afterRenderEffect(() => this._pattern.restoreFocusEffect());
99112
afterRenderEffect(() => this._pattern.focusEffect());
113+
afterRenderEffect(() => {
114+
const navigateEvent = this._pattern.lastNavigateEvent();
115+
if (navigateEvent) {
116+
this.onNavigate.emit(navigateEvent);
117+
}
118+
});
100119
}
101120

102121
/** Gets the cell pattern for a given element. */
103-
private _getCell(element: Element): GridCellPattern | undefined {
104-
const cellElement = element.closest('[ngGridCell]');
105-
if (cellElement === undefined) return;
106-
107-
const widgetElement = element.closest('[ngGridCellWidget]');
108-
for (const row of this._rowPatterns()) {
109-
for (const cell of row.inputs.cells()) {
110-
if (
111-
cell.element() === cellElement ||
112-
(widgetElement !== undefined && cell.element() === widgetElement)
113-
) {
114-
return cell;
122+
private _getCell(element: Element | null | undefined): GridCellPattern | undefined {
123+
let target = element;
124+
125+
while (target) {
126+
for (const row of this._rowPatterns()) {
127+
for (const cell of row.inputs.cells()) {
128+
if (cell.element() === target) {
129+
return cell;
130+
}
115131
}
116132
}
133+
134+
target = target.parentElement?.closest('[ngGridCell]');
117135
}
118-
return;
136+
137+
return undefined;
119138
}
120139
}
121140

@@ -125,7 +144,8 @@ export class Grid {
125144
exportAs: 'ngGridRow',
126145
host: {
127146
'class': 'grid-row',
128-
'[attr.role]': 'role()',
147+
'role': 'row',
148+
'[attr.aria-rowindex]': '_pattern.rowIndex()',
129149
},
130150
})
131151
export class GridRow {
@@ -149,9 +169,6 @@ export class GridRow {
149169
/** The host native element. */
150170
readonly element = computed(() => this._elementRef.nativeElement);
151171

152-
/** The ARIA role for the row. */
153-
readonly role = input<'row' | 'rowheader'>('row');
154-
155172
/** The index of this row within the grid. */
156173
readonly rowIndex = input<number>();
157174

@@ -180,32 +197,35 @@ export class GridRow {
180197
'[attr.aria-rowindex]': '_pattern.ariaRowIndex()',
181198
'[attr.aria-colindex]': '_pattern.ariaColIndex()',
182199
'[attr.aria-selected]': '_pattern.ariaSelected()',
183-
'[tabindex]': '_pattern.tabIndex()',
200+
'[tabindex]': '_tabIndex()',
184201
},
185202
})
186203
export class GridCell {
187204
/** A reference to the host element. */
188205
private readonly _elementRef = inject(ElementRef);
189206

190-
/** The widget contained within this cell, if any. */
191-
private readonly _widgets = contentChild(GridCellWidget);
207+
/** The widgets contained within this cell, if any. */
208+
private readonly _widgets = contentChildren(GridCellWidget, {descendants: true});
192209

193210
/** The UI pattern for the widget in this cell. */
194-
private readonly _widgetPattern: Signal<GridCellWidgetPattern | undefined> = computed(
195-
() => this._widgets()?._pattern,
211+
private readonly _widgetPatterns: Signal<GridCellWidgetPattern[]> = computed(() =>
212+
this._widgets().map(w => w._pattern),
196213
);
197214

198215
/** The parent row. */
199216
private readonly _row = inject(GridRow);
200217

218+
/** Text direction. */
219+
readonly textDirection = inject(Directionality).valueSignal;
220+
201221
/** A unique identifier for the cell. */
202-
private readonly _id = inject(_IdGenerator).getId('ng-grid-cell-', true);
222+
readonly id = input(inject(_IdGenerator).getId('ng-grid-cell-', true));
203223

204224
/** The host native element. */
205225
readonly element = computed(() => this._elementRef.nativeElement);
206226

207227
/** The ARIA role for the cell. */
208-
readonly role = input<'gridcell' | 'columnheader'>('gridcell');
228+
readonly role = input<'gridcell' | 'columnheader' | 'rowheader'>('gridcell');
209229

210230
/** The number of rows the cell should span. */
211231
readonly rowSpan = input<number>(1);
@@ -228,14 +248,49 @@ export class GridCell {
228248
/** Whether the cell is selectable. */
229249
readonly selectable = input<boolean>(true);
230250

251+
/** Orientation of the widgets in the cell. */
252+
readonly orientation = input<'vertical' | 'horizontal'>('horizontal');
253+
254+
/** Whether widgets navigation wraps. */
255+
readonly wrap = input(true, {transform: booleanAttribute});
256+
257+
/** The tabindex override. */
258+
readonly tabindex = input<number | undefined>();
259+
260+
/**
261+
* The tabindex value set to the element.
262+
* If a focus target exists then return -1. Unless an override.
263+
*/
264+
protected readonly _tabIndex: Signal<number> = computed(
265+
() => this.tabindex() ?? this._pattern.tabIndex(),
266+
);
267+
231268
/** The UI pattern for the grid cell. */
232269
readonly _pattern = new GridCellPattern({
233270
...this,
234-
id: () => this._id,
235271
grid: this._row.grid,
236272
row: () => this._row._pattern,
237-
widget: this._widgetPattern,
273+
widgets: this._widgetPatterns,
274+
getWidget: e => this._getWidget(e),
238275
});
276+
277+
constructor() {}
278+
279+
/** Gets the cell widget pattern for a given element. */
280+
private _getWidget(element: Element | null | undefined): GridCellWidgetPattern | undefined {
281+
let target = element;
282+
283+
while (target) {
284+
const pattern = this._widgetPatterns().find(w => w.element() === target);
285+
if (pattern) {
286+
return pattern;
287+
}
288+
289+
target = target.parentElement?.closest('[ngGridCellWidget]');
290+
}
291+
292+
return undefined;
293+
}
239294
}
240295

241296
/** A directive that represents a widget inside a grid cell. */
@@ -245,7 +300,8 @@ export class GridCell {
245300
host: {
246301
'class': 'grid-cell-widget',
247302
'[attr.data-active]': '_pattern.active()',
248-
'[tabindex]': '_pattern.tabIndex()',
303+
'[attr.data-widget-activated]': 'isActivated()',
304+
'[tabindex]': '_tabIndex()',
249305
},
250306
})
251307
export class GridCellWidget {
@@ -258,17 +314,75 @@ export class GridCellWidget {
258314
/** The host native element. */
259315
readonly element = computed(() => this._elementRef.nativeElement);
260316

261-
/** Whether the widget is activated and the grid navigation should be paused. */
262-
readonly activate = model<boolean>(false);
317+
/** A unique identifier for the widget. */
318+
readonly id = input<string>(inject(_IdGenerator).getId('ng-grid-cell-', true));
319+
320+
/** The type of widget, which determines how it is activated. */
321+
readonly widgetType = input<'simple' | 'complex' | 'editable'>('simple');
322+
323+
/** Whether the widget is disabled. */
324+
readonly disabled = input(false, {transform: booleanAttribute});
325+
326+
/** The target that will receive focus instead of the widget. */
327+
readonly focusTarget = input<ElementRef | HTMLElement | undefined>();
328+
329+
/** Emits when the widget is activated. */
330+
readonly onActivate = output<KeyboardEvent | FocusEvent | undefined>();
331+
332+
/** Emits when the widget is deactivated. */
333+
readonly onDeactivate = output<KeyboardEvent | FocusEvent | undefined>();
334+
335+
/** The tabindex override. */
336+
readonly tabindex = input<number | undefined>();
337+
338+
/**
339+
* The tabindex value set to the element.
340+
* If a focus target exists then return -1. Unless an override.
341+
*/
342+
protected readonly _tabIndex: Signal<number> = computed(
343+
() => this.tabindex() ?? (this.focusTarget() ? -1 : this._pattern.tabIndex()),
344+
);
263345

264346
/** The UI pattern for the grid cell widget. */
265347
readonly _pattern = new GridCellWidgetPattern({
266348
...this,
267349
cell: () => this._cell._pattern,
350+
focusTarget: computed(() => {
351+
if (this.focusTarget() instanceof ElementRef) {
352+
return (this.focusTarget() as ElementRef).nativeElement;
353+
}
354+
return this.focusTarget();
355+
}),
268356
});
269357

270-
/** Focuses the widget. */
271-
focus(): void {
272-
this.element().focus();
358+
/** Whether the widget is activated. */
359+
get isActivated(): Signal<boolean> {
360+
return this._pattern.isActivated.asReadonly();
361+
}
362+
363+
constructor() {
364+
afterRenderEffect(() => {
365+
const activateEvent = this._pattern.lastActivateEvent();
366+
if (activateEvent) {
367+
this.onActivate.emit(activateEvent);
368+
}
369+
});
370+
371+
afterRenderEffect(() => {
372+
const deactivateEvent = this._pattern.lastDeactivateEvent();
373+
if (deactivateEvent) {
374+
this.onDeactivate.emit(deactivateEvent);
375+
}
376+
});
377+
}
378+
379+
/** Activates the widget. */
380+
activate(): void {
381+
this._pattern.activate();
382+
}
383+
384+
/** Deactivates the widget. */
385+
deactivate(): void {
386+
this._pattern.deactivate();
273387
}
274388
}

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)