Skip to content

Commit fc60c67

Browse files
committed
refactor(aria/grid): support deferred focus target
1 parent c25e625 commit fc60c67

File tree

14 files changed

+190
-48
lines changed

14 files changed

+190
-48
lines changed

goldens/aria/grid/index.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export class GridCellWidget {
6464
readonly deactivated: _angular_core.OutputEmitterRef<FocusEvent | KeyboardEvent | undefined>;
6565
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
6666
readonly element: HTMLElement;
67-
readonly focusTarget: _angular_core.InputSignal<ElementRef<any> | HTMLElement | undefined>;
67+
readonly focusTarget: _angular_core.InputSignal<ElementResolver<HTMLElement>>;
6868
readonly id: _angular_core.InputSignal<string>;
6969
get isActivated(): Signal<boolean>;
7070
readonly _pattern: GridCellWidgetPattern;

goldens/aria/private/index.api.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
```ts
66

77
import * as _angular_core from '@angular/core';
8+
import { ElementRef } from '@angular/core';
89
import { OnDestroy } from '@angular/core';
910
import { untracked } from '@angular/core/primitives/signals';
1011

@@ -291,6 +292,9 @@ export class DeferredContentAware {
291292
static ɵfac: _angular_core.ɵɵFactoryDeclaration<DeferredContentAware, never>;
292293
}
293294

295+
// @public
296+
export type ElementResolver<T = HTMLElement> = ElementRef<T> | T | undefined | null | ((context: HTMLElement) => T | null | undefined);
297+
294298
// @public
295299
export interface GridCellInputs extends GridCell {
296300
colIndex: SignalLike<number | undefined>;
@@ -334,7 +338,7 @@ export interface GridCellWidgetInputs {
334338
cell: SignalLike<GridCellPattern>;
335339
disabled: SignalLike<boolean>;
336340
element: SignalLike<HTMLElement>;
337-
focusTarget: SignalLike<HTMLElement | undefined>;
341+
focusTarget: SignalLike<ElementResolver<HTMLElement>>;
338342
widgetType: SignalLike<'simple' | 'complex' | 'editable'>;
339343
}
340344

@@ -649,6 +653,9 @@ export class OptionPattern<V> {
649653
readonly value: SignalLike<V>;
650654
}
651655

656+
// @public
657+
export function resolveElement<T = HTMLElement>(resolver: ElementResolver<T>, context: HTMLElement): T | undefined;
658+
652659
// @public (undocumented)
653660
export function signal<T>(initialValue: T): WritableSignalLike<T>;
654661

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
@@ -14,6 +14,7 @@ import {
1414
WritableSignalLike,
1515
} from '../behaviors/signal-like/signal-like';
1616
import type {GridCellPattern} from './cell';
17+
import {ElementResolver, resolveElement} from '../element-resolver';
1718

1819
/** The inputs for the `GridCellWidgetPattern`. */
1920
export interface GridCellWidgetInputs {
@@ -30,7 +31,7 @@ export interface GridCellWidgetInputs {
3031
widgetType: SignalLike<'simple' | 'complex' | 'editable'>;
3132

3233
/** The element that will receive focus when the widget is activated. */
33-
focusTarget: SignalLike<HTMLElement | undefined>;
34+
focusTarget: SignalLike<ElementResolver<HTMLElement>>;
3435
}
3536

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

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

4646
/** Whether the widget is disabled. */
4747
readonly disabled: SignalLike<boolean> = 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';

0 commit comments

Comments
 (0)