Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion goldens/aria/grid/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export class GridCellWidget {
readonly deactivated: _angular_core.OutputEmitterRef<FocusEvent | KeyboardEvent | undefined>;
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
readonly element: HTMLElement;
readonly focusTarget: _angular_core.InputSignal<ElementRef<any> | HTMLElement | undefined>;
readonly focusTarget: _angular_core.InputSignal<ElementResolver<HTMLElement>>;
readonly id: _angular_core.InputSignal<string>;
get isActivated(): Signal<boolean>;
readonly _pattern: GridCellWidgetPattern;
Expand Down
9 changes: 8 additions & 1 deletion goldens/aria/private/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';

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

// @public
export type ElementResolver<T = HTMLElement> = ElementRef<T> | T | undefined | null | ((context: HTMLElement) => T | null | undefined);

// @public
export interface GridCellInputs extends GridCell {
colIndex: SignalLike<number | undefined>;
Expand Down Expand Up @@ -334,7 +338,7 @@ export interface GridCellWidgetInputs {
cell: SignalLike<GridCellPattern>;
disabled: SignalLike<boolean>;
element: SignalLike<HTMLElement>;
focusTarget: SignalLike<HTMLElement | undefined>;
focusTarget: SignalLike<ElementResolver<HTMLElement>>;
widgetType: SignalLike<'simple' | 'complex' | 'editable'>;
}

Expand Down Expand Up @@ -649,6 +653,9 @@ export class OptionPattern<V> {
readonly value: SignalLike<V>;
}

// @public
export function resolveElement<T = HTMLElement>(resolver: ElementResolver<T>, context: HTMLElement): T | undefined;

// @public (undocumented)
export function signal<T>(initialValue: T): WritableSignalLike<T>;

Expand Down
13 changes: 5 additions & 8 deletions src/aria/grid/grid-cell-widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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<ElementRef | HTMLElement | undefined>();
readonly focusTarget = input<ElementResolver<HTMLElement>>();

/** Emits when the widget is activated. */
readonly activated = output<KeyboardEvent | FocusEvent | undefined>();
Expand All @@ -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. */
Expand All @@ -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();
}
});

Expand Down
1 change: 1 addition & 0 deletions src/aria/grid/grid.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
31 changes: 29 additions & 2 deletions src/aria/private/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
51 changes: 51 additions & 0 deletions src/aria/private/element-resolver.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
28 changes: 28 additions & 0 deletions src/aria/private/element-resolver.ts
Original file line number Diff line number Diff line change
@@ -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<T = HTMLElement> =
| ElementRef<T>
| T
| undefined
| null
| ((context: HTMLElement) => T | null | undefined);

/** Evaluates an ElementResolver to return the underlying DOM element, or undefined. */
export function resolveElement<T = HTMLElement>(
resolver: ElementResolver<T>,
context: HTMLElement,
): T | undefined {
if (typeof resolver === 'function') {
return (resolver as Function)(context) ?? undefined;
}
return (resolver instanceof ElementRef ? resolver.nativeElement : resolver) ?? undefined;
}
1 change: 1 addition & 0 deletions src/aria/private/grid/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions src/aria/private/grid/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<HTMLElement | undefined>;
focusTarget: SignalLike<ElementResolver<HTMLElement>>;
}

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

/** The element that should receive focus. */
readonly widgetHost: SignalLike<HTMLElement> = computed(
() => this.inputs.focusTarget() ?? this.element(),
);
readonly widgetHost: SignalLike<HTMLElement> = () =>
resolveElement(this.inputs.focusTarget(), this.element()) ?? this.element();

/** Whether the widget is disabled. */
readonly disabled: SignalLike<boolean> = computed(
Expand Down
1 change: 1 addition & 0 deletions src/aria/private/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ export * from './grid/row';
export * from './grid/cell';
export * from './grid/widget';
export * from './deferred-content';
export * from './element-resolver';
2 changes: 2 additions & 0 deletions src/components-examples/aria/grid/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,4 @@
.example-hidden {
display: none;
}

Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<table ngGrid class="example-table">
<thead>
<tr ngGridRow class="example-header-row">
<th ngGridCell class="example-cell example-cell-checkbox">
<th ngGridCell role="columnheader" class="example-cell example-cell-checkbox">
<mat-checkbox
ngGridCellWidget
[focusTarget]="cb._inputElement"
Expand All @@ -12,9 +12,11 @@
#cb="matCheckbox"
></mat-checkbox>
</th>
<th ngGridCell class="example-cell">Summary</th>
<th ngGridCell class="example-cell">Assignee</th>
<th ngGridCell class="example-cell">Tags</th>
<th ngGridCell role="columnheader" class="example-cell">Summary</th>
<th ngGridCell role="columnheader" class="example-cell">Assignee</th>
<th ngGridCell role="columnheader" class="example-cell">Tags</th>
<th ngGridCell role="columnheader" class="example-cell">Actions</th>
<th ngGridCell role="columnheader" class="example-cell">More</th>
</tr>
</thead>
<tbody>
Expand All @@ -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"
>
<span [class.example-hidden]="widget.isActivated()">{{task.summary()}}</span>
<span
aria-hidden="true"
class="material-symbols-outlined example-edit-icon"
[class.example-hidden]="widget.isActivated()"
>edit</span
>
<mat-form-field
[class.example-hidden]="!widget.isActivated()"
subscriptSizing="dynamic"
>
<input matInput [(ngModel)]="tempInput" #summaryInput />
</mat-form-field>
@if (!widget.isActivated()) {
<span>{{task.summary()}}</span>
<span
aria-hidden="true"
class="material-symbols-outlined example-edit-icon"
(click)="widget.activate()"
>edit</span
>
} @else {
<mat-form-field
subscriptSizing="dynamic"
>
<input matInput class="summary-input" [(ngModel)]="tempInput" />
</mat-form-field>
}
</div>
</td>
<td ngGridCell class="example-cell example-cell-assignee">
Expand Down Expand Up @@ -82,6 +86,24 @@
<grid-chips [(values)]="task.tags" [tabindex]="-1" #chips />
</div>
</td>
<td ngGridCell class="example-cell">
<button mat-button ngGridCellWidget (click)="viewDetails(task)">View</button>
</td>
<td ngGridCell class="example-cell">
<button
mat-icon-button
ngGridCellWidget
widgetType="complex"
[matMenuTriggerFor]="menu"
(activated)="menuTrigger.openMenu()"
#menuTrigger="matMenuTrigger"
>
<span class="material-symbols-outlined">more_vert</span>
</button>
<mat-menu #menu="matMenu">
<button mat-menu-item (click)="deleteTask(task)">Delete</button>
</mat-menu>
</td>
</tr>
}
</tbody>
Expand Down
Loading
Loading