Skip to content

Commit f4e9a87

Browse files
authored
refactor(aria/grid): consolidate widget focus logic and activation handling (#33203)
1 parent 4d045b8 commit f4e9a87

10 files changed

Lines changed: 125 additions & 54 deletions

File tree

goldens/aria/grid/index.api.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import * as _angular_cdk_bidi from '@angular/cdk/bidi';
88
import * as _angular_core from '@angular/core';
99
import { ElementRef } from '@angular/core';
10-
import { EventEmitter } from '@angular/core';
1110
import { OnDestroy } from '@angular/core';
1211
import { OnInit } from '@angular/core';
1312
import { Signal } from '@angular/core';
@@ -41,7 +40,6 @@ export class Grid implements OnDestroy {
4140
// @public
4241
export class GridCell implements OnInit, OnDestroy {
4342
constructor();
44-
readonly activated: EventEmitter<KeyboardEvent>;
4543
readonly active: Signal<boolean>;
4644
readonly colIndex: _angular_core.InputSignal<number | undefined>;
4745
readonly colSpan: _angular_core.InputSignal<number>;
@@ -62,7 +60,7 @@ export class GridCell implements OnInit, OnDestroy {
6260
readonly tabindex: _angular_core.InputSignal<number | undefined>;
6361
readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>;
6462
// (undocumented)
65-
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<GridCell, "[ngGridCell]", ["ngGridCell"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "role": { "alias": "role"; "required": false; "isSignal": true; }; "rowSpan": { "alias": "rowSpan"; "required": false; "isSignal": true; }; "colSpan": { "alias": "colSpan"; "required": false; "isSignal": true; }; "rowIndex": { "alias": "rowIndex"; "required": false; "isSignal": true; }; "colIndex": { "alias": "colIndex"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "selected": { "alias": "selected"; "required": false; "isSignal": true; }; "selectable": { "alias": "selectable"; "required": false; "isSignal": true; }; "tabindex": { "alias": "tabindex"; "required": false; "isSignal": true; }; }, { "activated": "activated"; "selected": "selectedChange"; }, ["_widget"], never, true, never>;
63+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<GridCell, "[ngGridCell]", ["ngGridCell"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "role": { "alias": "role"; "required": false; "isSignal": true; }; "rowSpan": { "alias": "rowSpan"; "required": false; "isSignal": true; }; "colSpan": { "alias": "colSpan"; "required": false; "isSignal": true; }; "rowIndex": { "alias": "rowIndex"; "required": false; "isSignal": true; }; "colIndex": { "alias": "colIndex"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "selected": { "alias": "selected"; "required": false; "isSignal": true; }; "selectable": { "alias": "selectable"; "required": false; "isSignal": true; }; "tabindex": { "alias": "tabindex"; "required": false; "isSignal": true; }; }, { "selected": "selectedChange"; }, ["_widget"], never, true, never>;
6664
// (undocumented)
6765
static ɵfac: _angular_core.ɵɵFactoryDeclaration<GridCell, never>;
6866
}

goldens/aria/private/index.api.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,6 @@ export interface GridCellInputs extends GridCell {
301301
colIndex: SignalLike<number | undefined>;
302302
getWidget: (e: Element | null) => GridCellWidgetPattern | undefined;
303303
grid: SignalLike<GridPattern>;
304-
onActivate?: (event: KeyboardEvent) => void;
305304
row: SignalLike<GridRowPattern>;
306305
rowIndex: SignalLike<number | undefined>;
307306
widget: SignalLike<GridCellWidgetPattern | undefined>;
@@ -341,15 +340,19 @@ export interface GridCellWidgetInputs {
341340
disabled: SignalLike<boolean>;
342341
element: SignalLike<HTMLElement>;
343342
focusTarget: SignalLike<ElementResolver<HTMLElement>>;
343+
onActivate?: (event: KeyboardEvent | FocusEvent | undefined) => void;
344+
onDeactivate?: (event: KeyboardEvent | FocusEvent | undefined) => void;
344345
widgetType: SignalLike<'simple' | 'complex' | 'editable'>;
345346
}
346347

347348
// @public
348349
export class GridCellWidgetPattern {
349350
constructor(inputs: GridCellWidgetInputs);
350351
activate(event?: KeyboardEvent | FocusEvent): void;
352+
activationEffect(): void;
351353
readonly active: SignalLike<boolean>;
352354
deactivate(event?: KeyboardEvent | FocusEvent): void;
355+
deactivationEffect(): void;
353356
readonly disabled: SignalLike<boolean>;
354357
readonly element: SignalLike<HTMLElement>;
355358
focus(): void;

src/aria/grid/grid-cell-widget.ts

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,16 @@ export class GridCellWidget {
8888
* If a focus target exists then return -1. Unless an override.
8989
*/
9090
protected readonly _tabIndex: Signal<number> = computed(
91-
() => this.tabindex() ?? (this.focusTarget() ? -1 : this._pattern.tabIndex()),
91+
() => this.tabindex() ?? this._pattern.tabIndex(),
9292
);
9393

9494
/** The UI pattern for the grid cell widget. */
9595
readonly _pattern = new GridCellWidgetPattern({
9696
...this,
9797
element: () => this.element,
9898
cell: () => this._cell._pattern,
99+
onActivate: e => this.activated.emit(e),
100+
onDeactivate: e => this.deactivated.emit(e),
99101
});
100102

101103
/** Whether the widget is activated. */
@@ -105,22 +107,11 @@ export class GridCellWidget {
105107

106108
constructor() {
107109
afterRenderEffect({
108-
read: () => {
109-
if (this._pattern.isActivated()) {
110-
const activateEvent = this._pattern.lastActivateEvent();
111-
this.activated.emit(activateEvent);
112-
this._pattern.focus();
113-
}
114-
},
110+
write: () => this._pattern.activationEffect(),
115111
});
116112

117113
afterRenderEffect({
118-
read: () => {
119-
const deactivateEvent = this._pattern.lastDeactivateEvent();
120-
if (deactivateEvent) {
121-
this.deactivated.emit(deactivateEvent);
122-
}
123-
},
114+
write: () => this._pattern.deactivationEffect(),
124115
});
125116
}
126117

src/aria/grid/grid-cell.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,11 @@ import {
1414
contentChild,
1515
Directive,
1616
ElementRef,
17-
EventEmitter,
1817
inject,
1918
input,
2019
model,
2120
OnDestroy,
2221
OnInit,
23-
Output,
2422
Signal,
2523
Renderer2,
2624
} from '@angular/core';
@@ -56,9 +54,6 @@ export class GridCell implements OnInit, OnDestroy {
5654
/** A reference to the host element. */
5755
readonly element = this._elementRef.nativeElement as HTMLElement;
5856

59-
/** Emits when the cell is activated via Enter/Space (simple widgets only). */
60-
@Output() readonly activated = new EventEmitter<KeyboardEvent>();
61-
6257
/** Whether the cell is currently active (focused). */
6358
readonly active = computed(() => this._pattern.active());
6459

@@ -122,7 +117,6 @@ export class GridCell implements OnInit, OnDestroy {
122117
widget: this._widgetPattern,
123118
getWidget: e => this._getWidget(e),
124119
element: () => this.element,
125-
onActivate: e => this.activated.emit(e),
126120
});
127121

128122
constructor() {

src/aria/grid/grid.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -938,6 +938,56 @@ describe('Grid directives', () => {
938938
expect(widgetElement.getAttribute('tabindex')).toBe('-1');
939939
});
940940

941+
it('should emit the activated output on Enter for simple widget', () => {
942+
const gridData = createGridData();
943+
gridData[0].cells[0].widgets = [{id: 'w1', type: 'simple'}];
944+
setupGrid({gridData});
945+
gridInstance._pattern.setDefaultStateEffect();
946+
fixture.detectChanges();
947+
948+
tabIntoGrid();
949+
950+
expect(fixture.componentInstance.onActivated).not.toHaveBeenCalled();
951+
952+
keydown('Enter');
953+
expect(fixture.componentInstance.onActivated).toHaveBeenCalled();
954+
});
955+
956+
it('should emit the activated output on Space for simple widget', () => {
957+
const gridData = createGridData();
958+
gridData[0].cells[0].widgets = [{id: 'w1', type: 'simple'}];
959+
setupGrid({gridData});
960+
gridInstance._pattern.setDefaultStateEffect();
961+
fixture.detectChanges();
962+
963+
tabIntoGrid();
964+
965+
expect(fixture.componentInstance.onActivated).not.toHaveBeenCalled();
966+
967+
keydown(' ');
968+
expect(fixture.componentInstance.onActivated).toHaveBeenCalled();
969+
});
970+
971+
it('should emit the activated output in activedescendant mode when event is dispatched directly to grid', () => {
972+
const gridData = createGridData();
973+
gridData[0].cells[0].widgets = [{id: 'w1', type: 'simple'}];
974+
setupGrid({gridData, focusMode: 'activedescendant'});
975+
gridInstance._pattern.setDefaultStateEffect();
976+
fixture.detectChanges();
977+
978+
expect(fixture.componentInstance.onActivated).not.toHaveBeenCalled();
979+
980+
// Verify standard activedescendant behavior by targeting the CONTAINER directly
981+
const event = new KeyboardEvent('keydown', {
982+
key: 'Enter',
983+
bubbles: true,
984+
});
985+
gridElement.dispatchEvent(event);
986+
fixture.detectChanges();
987+
988+
expect(fixture.componentInstance.onActivated).toHaveBeenCalled();
989+
});
990+
941991
it('should emit the activated output when the widget becomes active', () => {
942992
const gridData = createGridData();
943993
gridData[0].cells[0].widgets = [{id: 'w1', type: 'complex'}];

src/aria/private/grid/cell.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,6 @@ export interface GridCellInputs extends GridCell {
3636

3737
/** A function that returns the cell widget associated with a given element. */
3838
getWidget: (e: Element | null) => GridCellWidgetPattern | undefined;
39-
40-
/** Callback when the cell is activated via Enter/Space. */
41-
onActivate?: (event: KeyboardEvent) => void;
4239
}
4340

4441
/** The UI pattern for a grid cell. */
@@ -120,12 +117,6 @@ export class GridCellPattern implements GridCell {
120117
onKeydown(event: KeyboardEvent): void {
121118
if (this.disabled()) return;
122119
this.widget()?.onKeydown(event);
123-
124-
if (this.widget()?.inputs.widgetType() === 'simple') {
125-
if (event.key === 'Enter' || event.key === ' ') {
126-
this.inputs.onActivate?.(event);
127-
}
128-
}
129120
}
130121

131122
/** Handles focusin events for the cell. */

src/aria/private/grid/grid.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ describe('Grid', () => {
370370
const onActivateSpy = jasmine.createSpy('onActivate');
371371
const {grid} = createGrid([{cells: [{widget: {widgetType: 'simple'}}]}], gridInputs);
372372
const cell = grid.cells()[0][0];
373-
(cell.inputs as any).onActivate = onActivateSpy;
373+
cell.inputs.widget()!.inputs.onActivate = onActivateSpy;
374374

375375
const event = enter();
376376
cell.onKeydown(event);
@@ -381,7 +381,7 @@ describe('Grid', () => {
381381
const onActivateSpy = jasmine.createSpy('onActivate');
382382
const {grid} = createGrid([{cells: [{widget: {widgetType: 'simple'}}]}], gridInputs);
383383
const cell = grid.cells()[0][0];
384-
(cell.inputs as any).onActivate = onActivateSpy;
384+
cell.inputs.widget()!.inputs.onActivate = onActivateSpy;
385385

386386
const event = space();
387387
cell.onKeydown(event);
@@ -392,7 +392,7 @@ describe('Grid', () => {
392392
const onActivateSpy = jasmine.createSpy('onActivate');
393393
const {grid} = createGrid([{cells: [{widget: {widgetType: 'complex'}}]}], gridInputs);
394394
const cell = grid.cells()[0][0];
395-
(cell.inputs as any).onActivate = onActivateSpy;
395+
cell.inputs.widget()!.inputs.onActivate = onActivateSpy;
396396

397397
const event = enter();
398398
cell.onKeydown(event);
@@ -403,7 +403,7 @@ describe('Grid', () => {
403403
const onActivateSpy = jasmine.createSpy('onActivate');
404404
const {grid} = createGrid([{cells: [{widget: {widgetType: 'simple'}}]}], gridInputs);
405405
const cell = grid.cells()[0][0];
406-
(cell.inputs as any).onActivate = onActivateSpy;
406+
cell.inputs.widget()!.inputs.onActivate = onActivateSpy;
407407

408408
const event = up();
409409
cell.onKeydown(event);

src/aria/private/grid/widget.ts

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ export interface GridCellWidgetInputs {
3232

3333
/** The element that will receive focus when the widget is activated. */
3434
focusTarget: SignalLike<ElementResolver<HTMLElement>>;
35+
36+
/** Callback hook used to notify parents or directives upon interaction. */
37+
onActivate?: (event: KeyboardEvent | FocusEvent | undefined) => void;
38+
39+
/** Callback hook used to notify parents or directives upon exit. */
40+
onDeactivate?: (event: KeyboardEvent | FocusEvent | undefined) => void;
3541
}
3642

3743
/** The UI pattern for a widget inside a grid cell. */
@@ -49,7 +55,12 @@ export class GridCellWidgetPattern {
4955
);
5056

5157
/** The tab index for the widget. */
52-
readonly tabIndex: SignalLike<-1 | 0> = computed(() => this.inputs.cell().widgetTabIndex());
58+
readonly tabIndex: SignalLike<-1 | 0> = computed(() => {
59+
if (this.inputs.focusTarget()) {
60+
return -1;
61+
}
62+
return this.inputs.cell().widgetTabIndex();
63+
});
5364

5465
/** Whether the widget is the active widget in the cell. */
5566
readonly active: SignalLike<boolean> = computed(
@@ -71,18 +82,25 @@ export class GridCellWidgetPattern {
7182
readonly keydown = computed(() => {
7283
const manager = new KeyboardEventManager();
7384

85+
// Simple widgets emit notification on interaction without capturing event flow
86+
if (this.inputs.widgetType() === 'simple') {
87+
return manager
88+
.on('Enter', e => this.inputs.onActivate?.(e), {
89+
preventDefault: false,
90+
stopPropagation: false,
91+
})
92+
.on(' ', e => this.inputs.onActivate?.(e), {
93+
preventDefault: false,
94+
stopPropagation: false,
95+
});
96+
}
97+
7498
// If a widget is activated, only listen to events that exits activate state.
7599
if (this.isActivated()) {
76-
manager.on('Escape', e => {
77-
this.deactivate(e);
78-
this.focus();
79-
});
100+
manager.on('Escape', e => this.deactivate(e));
80101

81102
if (this.inputs.widgetType() === 'editable') {
82-
manager.on('Enter', e => {
83-
this.deactivate(e);
84-
this.focus();
85-
});
103+
manager.on('Enter', e => this.deactivate(e));
86104
}
87105

88106
return manager;
@@ -135,6 +153,32 @@ export class GridCellWidgetPattern {
135153
this.widgetHost().focus();
136154
}
137155

156+
/** Side-effect executed whenever the widget activates. Runs in the write phase. */
157+
activationEffect(): void {
158+
if (this.isActivated()) {
159+
const event = this.lastActivateEvent();
160+
this.inputs.onActivate?.(event);
161+
162+
// Only automatically redirect focus if explicit configuration was supplied.
163+
if (this.inputs.focusTarget()) {
164+
this.focus();
165+
}
166+
}
167+
}
168+
169+
/** Side-effect executed whenever the widget deactivates. Runs in the write phase. */
170+
deactivationEffect(): void {
171+
const event = this.lastDeactivateEvent();
172+
if (event) {
173+
this.inputs.onDeactivate?.(event);
174+
175+
// Only automatically restore focus if the deactivation was triggered by user keyboard interaction.
176+
if (event instanceof KeyboardEvent) {
177+
this.focus();
178+
}
179+
}
180+
}
181+
138182
/** Activates the widget. */
139183
activate(event?: KeyboardEvent | FocusEvent): void {
140184
if (this.isActivated()) return;

src/aria/simple-combobox/simple-combobox.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1541,13 +1541,13 @@ const states = [
15411541
<div ngComboboxWidget #grid="ngGrid" ngGrid id="grid" focusMode="activedescendant" [tabIndex]="-1" colWrap="continuous" [activeDescendant]="grid.activeDescendant()">
15421542
@for (item of filteredItems(); track item; let i = $index) {
15431543
<div ngGridRow>
1544-
<div ngGridCell [id]="item + '-label'" [rowIndex]="i" [colIndex]="0" (activated)="selectItem(item)">
1545-
<button ngGridCellWidget (click)="selectItem(item)">
1544+
<div ngGridCell [id]="item + '-label'" [rowIndex]="i" [colIndex]="0">
1545+
<button ngGridCellWidget (activated)="selectItem(item)" (click)="selectItem(item)">
15461546
{{item}}
15471547
</button>
15481548
</div>
1549-
<div ngGridCell [id]="item + '-delete'" [rowIndex]="i" [colIndex]="1" (activated)="removeItem(item)">
1550-
<button ngGridCellWidget (click)="removeItem(item)" (pointerdown)="$event.preventDefault()">
1549+
<div ngGridCell [id]="item + '-delete'" [rowIndex]="i" [colIndex]="1">
1550+
<button ngGridCellWidget (activated)="removeItem(item)" (click)="removeItem(item)" (pointerdown)="$event.preventDefault()">
15511551
Delete
15521552
</button>
15531553
</div>

src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@
2121
@for (item of filteredItems(); track item.label; let i = $index) {
2222
<div ngGridRow class="example-grid-row" [attr.aria-selected]="item === selectedItem()">
2323
<div ngGridCell [id]="item.label + '-label'" [rowIndex]="i" [colIndex]="0"
24-
class="example-cell-label example-cell" (activated)="selectItem(item)">
25-
<button ngGridCellWidget class="example-label-button example-no-active-outline" (click)="selectItem(item)">
24+
class="example-cell-label example-cell">
25+
<button ngGridCellWidget class="example-label-button example-no-active-outline" (click)="selectItem(item)" (activated)="selectItem(item)">
2626
{{item.label}}
2727
</button>
2828
<mat-icon class="example-selected-icon">check</mat-icon>
2929
</div>
3030
<div ngGridCell [id]="item.label + '-delete'" [rowIndex]="i" [colIndex]="1"
31-
class="example-cell-button example-cell " (activated)="removeItem(item)">
32-
<button ngGridCellWidget class="example-button example-no-active-outline" (click)="removeItem(item)"
31+
class="example-cell-button example-cell ">
32+
<button ngGridCellWidget class="example-button example-no-active-outline" (click)="removeItem(item)" (activated)="removeItem(item)"
3333
(pointerdown)="$event.preventDefault()">
3434
<mat-icon>close</mat-icon>
3535
</button>

0 commit comments

Comments
 (0)