Skip to content

Commit 76774f8

Browse files
committed
refactor(aria/grid): support deferred focus target
1 parent 6cb6b5e commit 76774f8

File tree

10 files changed

+145
-46
lines changed

10 files changed

+145
-46
lines changed

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

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
output,
1919
Signal,
2020
} from '@angular/core';
21-
import {GridCellWidgetPattern} from '../private';
21+
import {GridCellWidgetPattern, ElementResolver} from '../private';
2222
import {GRID_CELL} from './grid-tokens';
2323

2424
/**
@@ -72,7 +72,7 @@ export class GridCellWidget {
7272
readonly disabled = input(false, {transform: booleanAttribute});
7373

7474
/** The target that will receive focus instead of the widget. */
75-
readonly focusTarget = input<ElementRef | HTMLElement | undefined>();
75+
readonly focusTarget = input<ElementResolver<HTMLElement>>();
7676

7777
/** Emits when the widget is activated. */
7878
readonly activated = output<KeyboardEvent | FocusEvent | undefined>();
@@ -96,10 +96,6 @@ export class GridCellWidget {
9696
...this,
9797
element: () => this.element,
9898
cell: () => this._cell._pattern,
99-
focusTarget: computed(() => {
100-
const target = this.focusTarget();
101-
return target instanceof ElementRef ? target.nativeElement : target;
102-
}),
10399
});
104100

105101
/** Whether the widget is activated. */
@@ -109,9 +105,10 @@ export class GridCellWidget {
109105

110106
constructor() {
111107
afterRenderEffect(() => {
112-
const activateEvent = this._pattern.lastActivateEvent();
113-
if (activateEvent) {
108+
if (this._pattern.isActivated()) {
109+
const activateEvent = this._pattern.lastActivateEvent();
114110
this.activated.emit(activateEvent);
111+
this._pattern.focus();
115112
}
116113
});
117114

src/aria/grid/grid.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,7 @@ describe('Grid directives', () => {
972972
fixture.detectChanges();
973973

974974
expect(widgetDirective.isActivated()).toBeTrue();
975+
expect(fixture.componentInstance.onActivated).toHaveBeenCalled();
975976
});
976977

977978
it('should lose active state when deactivate() is called programmatically', () => {

src/aria/private/BUILD.bazel

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,41 @@
1-
load("//tools:defaults.bzl", "ts_project")
1+
load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project")
22

33
package(default_visibility = ["//visibility:public"])
44

5+
ts_project(
6+
name = "element-resolver",
7+
srcs = ["element-resolver.ts"],
8+
deps = [
9+
"//:node_modules/@angular/core",
10+
],
11+
)
12+
13+
ts_project(
14+
name = "unit_test_sources",
15+
testonly = True,
16+
srcs = ["element-resolver.spec.ts"],
17+
deps = [
18+
":element-resolver",
19+
"//:node_modules/@angular/core",
20+
],
21+
)
22+
23+
ng_web_test_suite(
24+
name = "unit_tests",
25+
deps = [":unit_test_sources"],
26+
)
27+
528
ts_project(
629
name = "private",
730
srcs = glob(
831
["**/*.ts"],
9-
exclude = ["**/*.spec.ts"],
32+
exclude = [
33+
"**/*.spec.ts",
34+
"element-resolver.ts",
35+
],
1036
),
1137
deps = [
38+
":element-resolver",
1239
"//:node_modules/@angular/core",
1340
"//src/aria/private/accordion",
1441
"//src/aria/private/behaviors/signal-like",
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {ElementRef} from '@angular/core';
2+
import {resolveElement} from './element-resolver';
3+
4+
describe('ElementResolver', () => {
5+
let context: HTMLElement;
6+
7+
beforeEach(() => {
8+
context = document.createElement('div');
9+
context.id = 'context-host';
10+
});
11+
12+
describe('resolveElement', () => {
13+
it('should resolve a direct DOM element', () => {
14+
const target = document.createElement('span');
15+
expect(resolveElement(target, context)).toBe(target);
16+
});
17+
18+
it('should resolve an ElementRef transparently', () => {
19+
const target = document.createElement('span');
20+
const elementRef = new ElementRef(target);
21+
expect(resolveElement(elementRef, context)).toBe(target);
22+
});
23+
24+
it('should resolve null as undefined', () => {
25+
expect(resolveElement(null, context)).toBeUndefined();
26+
});
27+
28+
it('should resolve undefined as undefined', () => {
29+
expect(resolveElement(undefined, context)).toBeUndefined();
30+
});
31+
32+
it('should evaluate a resolution function', () => {
33+
const target = document.createElement('span');
34+
const resolver = (ctx: HTMLElement) => {
35+
expect(ctx).toBe(context);
36+
return target;
37+
};
38+
expect(resolveElement(resolver, context)).toBe(target);
39+
});
40+
41+
it('should evaluate a resolution function returning null', () => {
42+
const resolver = () => null;
43+
expect(resolveElement(resolver, context)).toBeUndefined();
44+
});
45+
46+
it('should evaluate a resolution function returning undefined', () => {
47+
const resolver = () => undefined;
48+
expect(resolveElement(resolver, context)).toBeUndefined();
49+
});
50+
});
51+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {ElementRef} from '@angular/core';
10+
11+
/** A type that allows lazy resolution of a DOM Element. */
12+
export type ElementResolver<T = HTMLElement> =
13+
| ElementRef<T>
14+
| T
15+
| undefined
16+
| null
17+
| ((context: HTMLElement) => T | null | undefined);
18+
19+
/** Evaluates an ElementResolver to return the underlying DOM element, or undefined. */
20+
export function resolveElement<T = HTMLElement>(
21+
resolver: ElementResolver<T>,
22+
context: HTMLElement,
23+
): T | undefined {
24+
if (typeof resolver === 'function') {
25+
return (resolver as Function)(context) ?? undefined;
26+
}
27+
return (resolver instanceof ElementRef ? resolver.nativeElement : resolver) ?? undefined;
28+
}

src/aria/private/grid/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ ts_project(
1212
],
1313
deps = [
1414
"//:node_modules/@angular/core",
15+
"//src/aria/private:element-resolver",
1516
"//src/aria/private/behaviors/event-manager",
1617
"//src/aria/private/behaviors/grid",
1718
"//src/aria/private/behaviors/list-focus",

src/aria/private/grid/widget.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
WritableSignalLike,
1616
} from '../behaviors/signal-like/signal-like';
1717
import type {GridCellPattern} from './cell';
18+
import {ElementResolver, resolveElement} from '../element-resolver';
1819

1920
/** The inputs for the `GridCellWidgetPattern`. */
2021
export interface GridCellWidgetInputs extends Omit<ListNavigationItem, 'index'> {
@@ -28,7 +29,7 @@ export interface GridCellWidgetInputs extends Omit<ListNavigationItem, 'index'>
2829
widgetType: SignalLike<'simple' | 'complex' | 'editable'>;
2930

3031
/** The element that will receive focus when the widget is activated. */
31-
focusTarget: SignalLike<HTMLElement | undefined>;
32+
focusTarget: SignalLike<ElementResolver<HTMLElement>>;
3233
}
3334

3435
/** The UI pattern for a widget inside a grid cell. */
@@ -40,9 +41,8 @@ export class GridCellWidgetPattern implements ListNavigationItem {
4041
readonly element: SignalLike<HTMLElement> = () => this.inputs.element();
4142

4243
/** The element that should receive focus. */
43-
readonly widgetHost: SignalLike<HTMLElement> = computed(
44-
() => this.inputs.focusTarget() ?? this.element(),
45-
);
44+
readonly widgetHost: SignalLike<HTMLElement> = () =>
45+
resolveElement(this.inputs.focusTarget(), this.element()) ?? this.element();
4646

4747
/** The index of the widget within the cell. */
4848
readonly index: SignalLike<number> = computed(() =>

src/aria/private/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ export * from './grid/row';
2525
export * from './grid/cell';
2626
export * from './grid/widget';
2727
export * from './deferred-content';
28+
export * from './element-resolver';

src/components-examples/aria/grid/grid-table/grid-table-example.html

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<table ngGrid class="example-table">
22
<thead>
33
<tr ngGridRow class="example-header-row">
4-
<th ngGridCell class="example-cell example-cell-checkbox">
4+
<th ngGridCell role="columnheader" class="example-cell example-cell-checkbox">
55
<mat-checkbox
66
ngGridCellWidget
77
[focusTarget]="cb._inputElement"
@@ -12,9 +12,9 @@
1212
#cb="matCheckbox"
1313
></mat-checkbox>
1414
</th>
15-
<th ngGridCell class="example-cell">Summary</th>
16-
<th ngGridCell class="example-cell">Assignee</th>
17-
<th ngGridCell class="example-cell">Tags</th>
15+
<th ngGridCell role="columnheader" class="example-cell">Summary</th>
16+
<th ngGridCell role="columnheader" class="example-cell">Assignee</th>
17+
<th ngGridCell role="columnheader" class="example-cell">Tags</th>
1818
</tr>
1919
</thead>
2020
<tbody>
@@ -34,24 +34,26 @@
3434
role="button"
3535
ngGridCellWidget
3636
widgetType="editable"
37-
(activated)="startEdit($event, task, summaryInput)"
37+
[focusTarget]="findSummaryInput"
38+
(activated)="startEdit($event, task)"
3839
(deactivated)="completeEdit($event, task)"
39-
(click)="onClickEdit(widget, task, summaryInput)"
4040
#widget="ngGridCellWidget"
4141
>
42-
<span [class.example-hidden]="widget.isActivated()">{{task.summary()}}</span>
43-
<span
44-
aria-hidden="true"
45-
class="material-symbols-outlined example-edit-icon"
46-
[class.example-hidden]="widget.isActivated()"
47-
>edit</span
48-
>
49-
<mat-form-field
50-
[class.example-hidden]="!widget.isActivated()"
51-
subscriptSizing="dynamic"
52-
>
53-
<input matInput [(ngModel)]="tempInput" #summaryInput />
54-
</mat-form-field>
42+
@if (!widget.isActivated()) {
43+
<span>{{task.summary()}}</span>
44+
<span
45+
aria-hidden="true"
46+
class="material-symbols-outlined example-edit-icon"
47+
(click)="widget.activate()"
48+
>edit</span
49+
>
50+
} @else {
51+
<mat-form-field
52+
subscriptSizing="dynamic"
53+
>
54+
<input matInput class="summary-input" [(ngModel)]="tempInput" />
55+
</mat-form-field>
56+
}
5557
</div>
5658
</td>
5759
<td ngGridCell class="example-cell example-cell-assignee">

src/components-examples/aria/grid/grid-table/grid-table-example.ts

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,11 @@ export class GridTableExample {
5959

6060
readonly tasks: WritableSignal<TaskRow[]> = signal(this._createRows());
6161

62-
startEdit(
63-
event: KeyboardEvent | FocusEvent | undefined,
64-
task: TaskRow,
65-
inputEl: HTMLInputElement,
66-
): void {
62+
findSummaryInput = (host: HTMLElement) =>
63+
host.querySelector<HTMLInputElement>('input.summary-input');
64+
65+
startEdit(event: KeyboardEvent | FocusEvent | undefined, task: TaskRow): void {
6766
this.tempInput.set(task.summary());
68-
inputEl.focus();
6967

7068
if (!(event instanceof KeyboardEvent)) return;
7169

@@ -75,13 +73,6 @@ export class GridTableExample {
7573
}
7674
}
7775

78-
onClickEdit(widget: GridCellWidget, task: TaskRow, inputEl: HTMLInputElement) {
79-
if (widget.isActivated()) return;
80-
81-
widget.activate();
82-
setTimeout(() => this.startEdit(undefined, task, inputEl));
83-
}
84-
8576
completeEdit(event: KeyboardEvent | FocusEvent | undefined, task: TaskRow): void {
8677
if (!(event instanceof KeyboardEvent)) {
8778
return;

0 commit comments

Comments
 (0)