From fc60c674d82f2b3c1b26c19ea8faaf5ec1f1bca6 Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Wed, 1 Apr 2026 07:36:33 +0000 Subject: [PATCH] refactor(aria/grid): support deferred focus target --- goldens/aria/grid/index.api.md | 2 +- goldens/aria/private/index.api.md | 9 ++- src/aria/grid/grid-cell-widget.ts | 13 ++-- src/aria/grid/grid.spec.ts | 1 + src/aria/private/BUILD.bazel | 31 +++++++++- src/aria/private/element-resolver.spec.ts | 51 ++++++++++++++++ src/aria/private/element-resolver.ts | 28 +++++++++ src/aria/private/grid/BUILD.bazel | 1 + src/aria/private/grid/widget.ts | 8 +-- src/aria/private/public-api.ts | 1 + src/components-examples/aria/grid/BUILD.bazel | 2 + .../grid/grid-table/grid-table-example.css | 1 + .../grid/grid-table/grid-table-example.html | 60 +++++++++++++------ .../grid/grid-table/grid-table-example.ts | 30 ++++++---- 14 files changed, 190 insertions(+), 48 deletions(-) create mode 100644 src/aria/private/element-resolver.spec.ts create mode 100644 src/aria/private/element-resolver.ts diff --git a/goldens/aria/grid/index.api.md b/goldens/aria/grid/index.api.md index 7cee477daecf..2eeee7147025 100644 --- a/goldens/aria/grid/index.api.md +++ b/goldens/aria/grid/index.api.md @@ -64,7 +64,7 @@ export class GridCellWidget { readonly deactivated: _angular_core.OutputEmitterRef; readonly disabled: _angular_core.InputSignalWithTransform; readonly element: HTMLElement; - readonly focusTarget: _angular_core.InputSignal | HTMLElement | undefined>; + readonly focusTarget: _angular_core.InputSignal>; readonly id: _angular_core.InputSignal; get isActivated(): Signal; readonly _pattern: GridCellWidgetPattern; diff --git a/goldens/aria/private/index.api.md b/goldens/aria/private/index.api.md index 8f2523717613..4243608840ac 100644 --- a/goldens/aria/private/index.api.md +++ b/goldens/aria/private/index.api.md @@ -5,6 +5,7 @@ ```ts import * as _angular_core from '@angular/core'; +import { ElementRef } from '@angular/core'; import { OnDestroy } from '@angular/core'; import { untracked } from '@angular/core/primitives/signals'; @@ -291,6 +292,9 @@ export class DeferredContentAware { static ɵfac: _angular_core.ɵɵFactoryDeclaration; } +// @public +export type ElementResolver = ElementRef | T | undefined | null | ((context: HTMLElement) => T | null | undefined); + // @public export interface GridCellInputs extends GridCell { colIndex: SignalLike; @@ -334,7 +338,7 @@ export interface GridCellWidgetInputs { cell: SignalLike; disabled: SignalLike; element: SignalLike; - focusTarget: SignalLike; + focusTarget: SignalLike>; widgetType: SignalLike<'simple' | 'complex' | 'editable'>; } @@ -649,6 +653,9 @@ export class OptionPattern { readonly value: SignalLike; } +// @public +export function resolveElement(resolver: ElementResolver, context: HTMLElement): T | undefined; + // @public (undocumented) export function signal(initialValue: T): WritableSignalLike; diff --git a/src/aria/grid/grid-cell-widget.ts b/src/aria/grid/grid-cell-widget.ts index 4cf9312e84eb..22bf2080e104 100644 --- a/src/aria/grid/grid-cell-widget.ts +++ b/src/aria/grid/grid-cell-widget.ts @@ -18,7 +18,7 @@ import { output, Signal, } from '@angular/core'; -import {GridCellWidgetPattern} from '../private'; +import {GridCellWidgetPattern, ElementResolver} from '../private'; import {GRID_CELL} from './grid-tokens'; /** @@ -72,7 +72,7 @@ export class GridCellWidget { readonly disabled = input(false, {transform: booleanAttribute}); /** The target that will receive focus instead of the widget. */ - readonly focusTarget = input(); + readonly focusTarget = input>(); /** Emits when the widget is activated. */ readonly activated = output(); @@ -96,10 +96,6 @@ export class GridCellWidget { ...this, element: () => this.element, cell: () => this._cell._pattern, - focusTarget: computed(() => { - const target = this.focusTarget(); - return target instanceof ElementRef ? target.nativeElement : target; - }), }); /** Whether the widget is activated. */ @@ -109,9 +105,10 @@ export class GridCellWidget { constructor() { afterRenderEffect(() => { - const activateEvent = this._pattern.lastActivateEvent(); - if (activateEvent) { + if (this._pattern.isActivated()) { + const activateEvent = this._pattern.lastActivateEvent(); this.activated.emit(activateEvent); + this._pattern.focus(); } }); diff --git a/src/aria/grid/grid.spec.ts b/src/aria/grid/grid.spec.ts index 7c2980d69c96..f88956b859c7 100644 --- a/src/aria/grid/grid.spec.ts +++ b/src/aria/grid/grid.spec.ts @@ -972,6 +972,7 @@ describe('Grid directives', () => { fixture.detectChanges(); expect(widgetDirective.isActivated()).toBeTrue(); + expect(fixture.componentInstance.onActivated).toHaveBeenCalled(); }); it('should lose active state when deactivate() is called programmatically', () => { diff --git a/src/aria/private/BUILD.bazel b/src/aria/private/BUILD.bazel index f688ab1b20e1..17f8f3e9a7bd 100644 --- a/src/aria/private/BUILD.bazel +++ b/src/aria/private/BUILD.bazel @@ -1,14 +1,41 @@ -load("//tools:defaults.bzl", "ts_project") +load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project") package(default_visibility = ["//visibility:public"]) +ts_project( + name = "element-resolver", + srcs = ["element-resolver.ts"], + deps = [ + "//:node_modules/@angular/core", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = ["element-resolver.spec.ts"], + deps = [ + ":element-resolver", + "//:node_modules/@angular/core", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) + ts_project( name = "private", srcs = glob( ["**/*.ts"], - exclude = ["**/*.spec.ts"], + exclude = [ + "**/*.spec.ts", + "element-resolver.ts", + ], ), deps = [ + ":element-resolver", "//:node_modules/@angular/core", "//src/aria/private/accordion", "//src/aria/private/behaviors/signal-like", diff --git a/src/aria/private/element-resolver.spec.ts b/src/aria/private/element-resolver.spec.ts new file mode 100644 index 000000000000..65da551dae3a --- /dev/null +++ b/src/aria/private/element-resolver.spec.ts @@ -0,0 +1,51 @@ +import {ElementRef} from '@angular/core'; +import {resolveElement} from './element-resolver'; + +describe('ElementResolver', () => { + let context: HTMLElement; + + beforeEach(() => { + context = document.createElement('div'); + context.id = 'context-host'; + }); + + describe('resolveElement', () => { + it('should resolve a direct DOM element', () => { + const target = document.createElement('span'); + expect(resolveElement(target, context)).toBe(target); + }); + + it('should resolve an ElementRef transparently', () => { + const target = document.createElement('span'); + const elementRef = new ElementRef(target); + expect(resolveElement(elementRef, context)).toBe(target); + }); + + it('should resolve null as undefined', () => { + expect(resolveElement(null, context)).toBeUndefined(); + }); + + it('should resolve undefined as undefined', () => { + expect(resolveElement(undefined, context)).toBeUndefined(); + }); + + it('should evaluate a resolution function', () => { + const target = document.createElement('span'); + const resolver = (ctx: HTMLElement) => { + expect(ctx).toBe(context); + return target; + }; + expect(resolveElement(resolver, context)).toBe(target); + }); + + it('should evaluate a resolution function returning null', () => { + const resolver = () => null; + expect(resolveElement(resolver, context)).toBeUndefined(); + }); + + it('should evaluate a resolution function returning undefined', () => { + const resolver = () => undefined; + expect(resolveElement(resolver, context)).toBeUndefined(); + }); + }); +}); diff --git a/src/aria/private/element-resolver.ts b/src/aria/private/element-resolver.ts new file mode 100644 index 000000000000..f6acff92dbed --- /dev/null +++ b/src/aria/private/element-resolver.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {ElementRef} from '@angular/core'; + +/** A type that allows lazy resolution of a DOM Element. */ +export type ElementResolver = + | ElementRef + | T + | undefined + | null + | ((context: HTMLElement) => T | null | undefined); + +/** Evaluates an ElementResolver to return the underlying DOM element, or undefined. */ +export function resolveElement( + resolver: ElementResolver, + context: HTMLElement, +): T | undefined { + if (typeof resolver === 'function') { + return (resolver as Function)(context) ?? undefined; + } + return (resolver instanceof ElementRef ? resolver.nativeElement : resolver) ?? undefined; +} diff --git a/src/aria/private/grid/BUILD.bazel b/src/aria/private/grid/BUILD.bazel index 2dd61d5d75e5..9543ad5ef1ea 100644 --- a/src/aria/private/grid/BUILD.bazel +++ b/src/aria/private/grid/BUILD.bazel @@ -12,6 +12,7 @@ ts_project( ], deps = [ "//:node_modules/@angular/core", + "//src/aria/private:element-resolver", "//src/aria/private/behaviors/event-manager", "//src/aria/private/behaviors/grid", "//src/aria/private/behaviors/list-focus", diff --git a/src/aria/private/grid/widget.ts b/src/aria/private/grid/widget.ts index 779e03640b73..1068154d7231 100644 --- a/src/aria/private/grid/widget.ts +++ b/src/aria/private/grid/widget.ts @@ -14,6 +14,7 @@ import { WritableSignalLike, } from '../behaviors/signal-like/signal-like'; import type {GridCellPattern} from './cell'; +import {ElementResolver, resolveElement} from '../element-resolver'; /** The inputs for the `GridCellWidgetPattern`. */ export interface GridCellWidgetInputs { @@ -30,7 +31,7 @@ export interface GridCellWidgetInputs { widgetType: SignalLike<'simple' | 'complex' | 'editable'>; /** The element that will receive focus when the widget is activated. */ - focusTarget: SignalLike; + focusTarget: SignalLike>; } /** The UI pattern for a widget inside a grid cell. */ @@ -39,9 +40,8 @@ export class GridCellWidgetPattern { readonly element: SignalLike = () => this.inputs.element(); /** The element that should receive focus. */ - readonly widgetHost: SignalLike = computed( - () => this.inputs.focusTarget() ?? this.element(), - ); + readonly widgetHost: SignalLike = () => + resolveElement(this.inputs.focusTarget(), this.element()) ?? this.element(); /** Whether the widget is disabled. */ readonly disabled: SignalLike = computed( diff --git a/src/aria/private/public-api.ts b/src/aria/private/public-api.ts index ed8716c7b67b..1cea546688e5 100644 --- a/src/aria/private/public-api.ts +++ b/src/aria/private/public-api.ts @@ -25,3 +25,4 @@ export * from './grid/row'; export * from './grid/cell'; export * from './grid/widget'; export * from './deferred-content'; +export * from './element-resolver'; diff --git a/src/components-examples/aria/grid/BUILD.bazel b/src/components-examples/aria/grid/BUILD.bazel index b16742afd8b4..e88d2229c657 100644 --- a/src/components-examples/aria/grid/BUILD.bazel +++ b/src/components-examples/aria/grid/BUILD.bazel @@ -14,10 +14,12 @@ ng_project( "//:node_modules/@angular/core", "//:node_modules/@angular/forms", "//src/aria/grid", + "//src/material/button", "//src/material/checkbox", "//src/material/form-field", "//src/material/icon", "//src/material/input", + "//src/material/menu", "//src/material/select", ], ) diff --git a/src/components-examples/aria/grid/grid-table/grid-table-example.css b/src/components-examples/aria/grid/grid-table/grid-table-example.css index 6ab620cd0b4a..09c3534e646b 100644 --- a/src/components-examples/aria/grid/grid-table/grid-table-example.css +++ b/src/components-examples/aria/grid/grid-table/grid-table-example.css @@ -71,3 +71,4 @@ .example-hidden { display: none; } + diff --git a/src/components-examples/aria/grid/grid-table/grid-table-example.html b/src/components-examples/aria/grid/grid-table/grid-table-example.html index 47df385faaee..ca014fe81d37 100644 --- a/src/components-examples/aria/grid/grid-table/grid-table-example.html +++ b/src/components-examples/aria/grid/grid-table/grid-table-example.html @@ -1,7 +1,7 @@ - - - - + + + + + @@ -34,24 +36,26 @@ role="button" ngGridCellWidget widgetType="editable" - (activated)="startEdit($event, task, summaryInput)" + [focusTarget]="findSummaryInput" + (activated)="startEdit($event, task)" (deactivated)="completeEdit($event, task)" - (click)="onClickEdit(widget, task, summaryInput)" #widget="ngGridCellWidget" > - {{task.summary()}} - - - - + @if (!widget.isActivated()) { + {{task.summary()}} + + } @else { + + + + } + + } diff --git a/src/components-examples/aria/grid/grid-table/grid-table-example.ts b/src/components-examples/aria/grid/grid-table/grid-table-example.ts index 55de8f359bec..778f43449814 100644 --- a/src/components-examples/aria/grid/grid-table/grid-table-example.ts +++ b/src/components-examples/aria/grid/grid-table/grid-table-example.ts @@ -11,6 +11,8 @@ import {MatCheckboxModule} from '@angular/material/checkbox'; import {MatFormFieldModule} from '@angular/material/form-field'; import {MatInputModule} from '@angular/material/input'; import {MatSelectModule} from '@angular/material/select'; +import {MatButtonModule} from '@angular/material/button'; +import {MatMenuModule} from '@angular/material/menu'; import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid'; import {GridChips} from './grid-chips'; @@ -33,6 +35,8 @@ interface TaskRow { MatFormFieldModule, MatSelectModule, MatInputModule, + MatButtonModule, + MatMenuModule, Grid, GridRow, GridCell, @@ -59,13 +63,11 @@ export class GridTableExample { readonly tasks: WritableSignal = signal(this._createRows()); - startEdit( - event: KeyboardEvent | FocusEvent | undefined, - task: TaskRow, - inputEl: HTMLInputElement, - ): void { + findSummaryInput = (host: HTMLElement) => + host.querySelector('input.summary-input'); + + startEdit(event: KeyboardEvent | FocusEvent | undefined, task: TaskRow): void { this.tempInput.set(task.summary()); - inputEl.focus(); if (!(event instanceof KeyboardEvent)) return; @@ -75,13 +77,6 @@ export class GridTableExample { } } - onClickEdit(widget: GridCellWidget, task: TaskRow, inputEl: HTMLInputElement) { - if (widget.isActivated()) return; - - widget.activate(); - setTimeout(() => this.startEdit(undefined, task, inputEl)); - } - completeEdit(event: KeyboardEvent | FocusEvent | undefined, task: TaskRow): void { if (!(event instanceof KeyboardEvent)) { return; @@ -91,6 +86,15 @@ export class GridTableExample { } } + viewDetails(task: TaskRow) { + console.log('Viewing details for task:', task.summary()); + } + + deleteTask(task: TaskRow) { + console.log('Deleting task:', task.summary()); + this.tasks.update(tasks => tasks.filter(t => t !== task)); + } + updateSelection(checked: boolean): void { this.tasks().forEach(t => t.selected.set(checked)); }
+ SummaryAssigneeTagsSummaryAssigneeTagsActionsMore
@@ -82,6 +86,24 @@ + + + + + + +