Skip to content

Commit 99b4dc9

Browse files
committed
refactor(aria/grid): support deferred focus target
1 parent a2392bf commit 99b4dc9

15 files changed

Lines changed: 186 additions & 49 deletions

File tree

goldens/aria/grid/index.api.md

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

@@ -650,6 +654,9 @@ export class OptionPattern<V> {
650654
readonly value: SignalLike<V>;
651655
}
652656

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

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
@@ -922,6 +922,7 @@ describe('Grid directives', () => {
922922
fixture.detectChanges();
923923

924924
expect(widgetDirective.isActivated()).toBeTrue();
925+
expect(fixture.componentInstance.onActivated).toHaveBeenCalled();
925926
});
926927

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

src/aria/private/BUILD.bazel

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
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+
ng_web_test_suite(
6+
name = "unit_tests",
7+
deps = ["//src/aria/private/utils:unit_test_sources"],
8+
)
9+
510
ts_project(
611
name = "private",
712
srcs = glob(
813
["**/*.ts"],
9-
exclude = ["**/*.spec.ts"],
14+
exclude = [
15+
"**/*.spec.ts",
16+
],
1017
),
1118
deps = [
1219
"//:node_modules/@angular/core",

src/aria/private/grid/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ ts_project(
1717
"//src/aria/private/behaviors/list-focus",
1818
"//src/aria/private/behaviors/list-navigation",
1919
"//src/aria/private/behaviors/signal-like",
20+
"//src/aria/private/utils",
2021
],
2122
)
2223

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 '../utils/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
@@ -26,3 +26,4 @@ export * from './grid/cell';
2626
export * from './grid/widget';
2727
export * from './deferred-content';
2828
export * from './utils/element';
29+
export * from './utils/element-resolver';

src/aria/private/utils/BUILD.bazel

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,21 @@ package(default_visibility = ["//visibility:public"])
44

55
ts_project(
66
name = "utils",
7-
srcs = ["element.ts"],
7+
srcs = [
8+
"element.ts",
9+
"element-resolver.ts",
10+
],
11+
deps = [
12+
"//:node_modules/@angular/core",
13+
],
14+
)
15+
16+
ts_project(
17+
name = "unit_test_sources",
18+
testonly = True,
19+
srcs = ["element-resolver.spec.ts"],
20+
deps = [
21+
":utils",
22+
"//:node_modules/@angular/core",
23+
],
824
)
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+
});

0 commit comments

Comments
 (0)