Skip to content
Merged
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
4 changes: 1 addition & 3 deletions goldens/aria/grid/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import * as _angular_cdk_bidi from '@angular/cdk/bidi';
import * as _angular_core from '@angular/core';
import { ElementRef } from '@angular/core';
import { EventEmitter } from '@angular/core';
import { OnDestroy } from '@angular/core';
import { OnInit } from '@angular/core';
import { Signal } from '@angular/core';
Expand Down Expand Up @@ -41,7 +40,6 @@ export class Grid implements OnDestroy {
// @public
export class GridCell implements OnInit, OnDestroy {
constructor();
readonly activated: EventEmitter<KeyboardEvent>;
readonly active: Signal<boolean>;
readonly colIndex: _angular_core.InputSignal<number | undefined>;
readonly colSpan: _angular_core.InputSignal<number>;
Expand All @@ -62,7 +60,7 @@ export class GridCell implements OnInit, OnDestroy {
readonly tabindex: _angular_core.InputSignal<number | undefined>;
readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>;
// (undocumented)
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<GridCell, "[ngGridCell]", ["ngGridCell"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "role": { "alias": "role"; "required": false; "isSignal": true; }; "rowSpan": { "alias": "rowSpan"; "required": false; "isSignal": true; }; "colSpan": { "alias": "colSpan"; "required": false; "isSignal": true; }; "rowIndex": { "alias": "rowIndex"; "required": false; "isSignal": true; }; "colIndex": { "alias": "colIndex"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "selected": { "alias": "selected"; "required": false; "isSignal": true; }; "selectable": { "alias": "selectable"; "required": false; "isSignal": true; }; "tabindex": { "alias": "tabindex"; "required": false; "isSignal": true; }; }, { "activated": "activated"; "selected": "selectedChange"; }, ["_widget"], never, true, never>;
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<GridCell, "[ngGridCell]", ["ngGridCell"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "role": { "alias": "role"; "required": false; "isSignal": true; }; "rowSpan": { "alias": "rowSpan"; "required": false; "isSignal": true; }; "colSpan": { "alias": "colSpan"; "required": false; "isSignal": true; }; "rowIndex": { "alias": "rowIndex"; "required": false; "isSignal": true; }; "colIndex": { "alias": "colIndex"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "selected": { "alias": "selected"; "required": false; "isSignal": true; }; "selectable": { "alias": "selectable"; "required": false; "isSignal": true; }; "tabindex": { "alias": "tabindex"; "required": false; "isSignal": true; }; }, { "selected": "selectedChange"; }, ["_widget"], never, true, never>;
// (undocumented)
static ɵfac: _angular_core.ɵɵFactoryDeclaration<GridCell, never>;
}
Expand Down
5 changes: 4 additions & 1 deletion goldens/aria/private/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,6 @@ export interface GridCellInputs extends GridCell {
colIndex: SignalLike<number | undefined>;
getWidget: (e: Element | null) => GridCellWidgetPattern | undefined;
grid: SignalLike<GridPattern>;
onActivate?: (event: KeyboardEvent) => void;
row: SignalLike<GridRowPattern>;
rowIndex: SignalLike<number | undefined>;
widget: SignalLike<GridCellWidgetPattern | undefined>;
Expand Down Expand Up @@ -341,15 +340,19 @@ export interface GridCellWidgetInputs {
disabled: SignalLike<boolean>;
element: SignalLike<HTMLElement>;
focusTarget: SignalLike<ElementResolver<HTMLElement>>;
onActivate?: (event: KeyboardEvent | FocusEvent | undefined) => void;
onDeactivate?: (event: KeyboardEvent | FocusEvent | undefined) => void;
widgetType: SignalLike<'simple' | 'complex' | 'editable'>;
}

// @public
export class GridCellWidgetPattern {
constructor(inputs: GridCellWidgetInputs);
activate(event?: KeyboardEvent | FocusEvent): void;
activationEffect(): void;
readonly active: SignalLike<boolean>;
deactivate(event?: KeyboardEvent | FocusEvent): void;
deactivationEffect(): void;
readonly disabled: SignalLike<boolean>;
readonly element: SignalLike<HTMLElement>;
focus(): void;
Expand Down
19 changes: 5 additions & 14 deletions src/aria/grid/grid-cell-widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,16 @@ export class GridCellWidget {
* If a focus target exists then return -1. Unless an override.
*/
protected readonly _tabIndex: Signal<number> = computed(
() => this.tabindex() ?? (this.focusTarget() ? -1 : this._pattern.tabIndex()),
() => this.tabindex() ?? this._pattern.tabIndex(),
);

/** The UI pattern for the grid cell widget. */
readonly _pattern = new GridCellWidgetPattern({
...this,
element: () => this.element,
cell: () => this._cell._pattern,
onActivate: e => this.activated.emit(e),
onDeactivate: e => this.deactivated.emit(e),
});

/** Whether the widget is activated. */
Expand All @@ -105,22 +107,11 @@ export class GridCellWidget {

constructor() {
afterRenderEffect({
read: () => {
if (this._pattern.isActivated()) {
const activateEvent = this._pattern.lastActivateEvent();
this.activated.emit(activateEvent);
this._pattern.focus();
}
},
write: () => this._pattern.activationEffect(),
});

afterRenderEffect({
read: () => {
const deactivateEvent = this._pattern.lastDeactivateEvent();
if (deactivateEvent) {
this.deactivated.emit(deactivateEvent);
}
},
write: () => this._pattern.deactivationEffect(),
});
}

Expand Down
6 changes: 0 additions & 6 deletions src/aria/grid/grid-cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,11 @@ import {
contentChild,
Directive,
ElementRef,
EventEmitter,
inject,
input,
model,
OnDestroy,
OnInit,
Output,
Signal,
Renderer2,
} from '@angular/core';
Expand Down Expand Up @@ -56,9 +54,6 @@ export class GridCell implements OnInit, OnDestroy {
/** A reference to the host element. */
readonly element = this._elementRef.nativeElement as HTMLElement;

/** Emits when the cell is activated via Enter/Space (simple widgets only). */
@Output() readonly activated = new EventEmitter<KeyboardEvent>();

/** Whether the cell is currently active (focused). */
readonly active = computed(() => this._pattern.active());

Expand Down Expand Up @@ -122,7 +117,6 @@ export class GridCell implements OnInit, OnDestroy {
widget: this._widgetPattern,
getWidget: e => this._getWidget(e),
element: () => this.element,
onActivate: e => this.activated.emit(e),
});

constructor() {
Expand Down
50 changes: 50 additions & 0 deletions src/aria/grid/grid.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,56 @@ describe('Grid directives', () => {
expect(widgetElement.getAttribute('tabindex')).toBe('-1');
});

it('should emit the activated output on Enter for simple widget', () => {
const gridData = createGridData();
gridData[0].cells[0].widgets = [{id: 'w1', type: 'simple'}];
setupGrid({gridData});
gridInstance._pattern.setDefaultStateEffect();
fixture.detectChanges();

tabIntoGrid();

expect(fixture.componentInstance.onActivated).not.toHaveBeenCalled();

keydown('Enter');
expect(fixture.componentInstance.onActivated).toHaveBeenCalled();
});

it('should emit the activated output on Space for simple widget', () => {
const gridData = createGridData();
gridData[0].cells[0].widgets = [{id: 'w1', type: 'simple'}];
setupGrid({gridData});
gridInstance._pattern.setDefaultStateEffect();
fixture.detectChanges();

tabIntoGrid();

expect(fixture.componentInstance.onActivated).not.toHaveBeenCalled();

keydown(' ');
expect(fixture.componentInstance.onActivated).toHaveBeenCalled();
});

it('should emit the activated output in activedescendant mode when event is dispatched directly to grid', () => {
const gridData = createGridData();
gridData[0].cells[0].widgets = [{id: 'w1', type: 'simple'}];
setupGrid({gridData, focusMode: 'activedescendant'});
gridInstance._pattern.setDefaultStateEffect();
fixture.detectChanges();

expect(fixture.componentInstance.onActivated).not.toHaveBeenCalled();

// Verify standard activedescendant behavior by targeting the CONTAINER directly
const event = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
});
gridElement.dispatchEvent(event);
fixture.detectChanges();

expect(fixture.componentInstance.onActivated).toHaveBeenCalled();
});

it('should emit the activated output when the widget becomes active', () => {
const gridData = createGridData();
gridData[0].cells[0].widgets = [{id: 'w1', type: 'complex'}];
Expand Down
9 changes: 0 additions & 9 deletions src/aria/private/grid/cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,6 @@ export interface GridCellInputs extends GridCell {

/** A function that returns the cell widget associated with a given element. */
getWidget: (e: Element | null) => GridCellWidgetPattern | undefined;

/** Callback when the cell is activated via Enter/Space. */
onActivate?: (event: KeyboardEvent) => void;
}

/** The UI pattern for a grid cell. */
Expand Down Expand Up @@ -120,12 +117,6 @@ export class GridCellPattern implements GridCell {
onKeydown(event: KeyboardEvent): void {
if (this.disabled()) return;
this.widget()?.onKeydown(event);

if (this.widget()?.inputs.widgetType() === 'simple') {
if (event.key === 'Enter' || event.key === ' ') {
this.inputs.onActivate?.(event);
}
}
}

/** Handles focusin events for the cell. */
Expand Down
8 changes: 4 additions & 4 deletions src/aria/private/grid/grid.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ describe('Grid', () => {
const onActivateSpy = jasmine.createSpy('onActivate');
const {grid} = createGrid([{cells: [{widget: {widgetType: 'simple'}}]}], gridInputs);
const cell = grid.cells()[0][0];
(cell.inputs as any).onActivate = onActivateSpy;
cell.inputs.widget()!.inputs.onActivate = onActivateSpy;

const event = enter();
cell.onKeydown(event);
Expand All @@ -381,7 +381,7 @@ describe('Grid', () => {
const onActivateSpy = jasmine.createSpy('onActivate');
const {grid} = createGrid([{cells: [{widget: {widgetType: 'simple'}}]}], gridInputs);
const cell = grid.cells()[0][0];
(cell.inputs as any).onActivate = onActivateSpy;
cell.inputs.widget()!.inputs.onActivate = onActivateSpy;

const event = space();
cell.onKeydown(event);
Expand All @@ -392,7 +392,7 @@ describe('Grid', () => {
const onActivateSpy = jasmine.createSpy('onActivate');
const {grid} = createGrid([{cells: [{widget: {widgetType: 'complex'}}]}], gridInputs);
const cell = grid.cells()[0][0];
(cell.inputs as any).onActivate = onActivateSpy;
cell.inputs.widget()!.inputs.onActivate = onActivateSpy;

const event = enter();
cell.onKeydown(event);
Expand All @@ -403,7 +403,7 @@ describe('Grid', () => {
const onActivateSpy = jasmine.createSpy('onActivate');
const {grid} = createGrid([{cells: [{widget: {widgetType: 'simple'}}]}], gridInputs);
const cell = grid.cells()[0][0];
(cell.inputs as any).onActivate = onActivateSpy;
cell.inputs.widget()!.inputs.onActivate = onActivateSpy;

const event = up();
cell.onKeydown(event);
Expand Down
62 changes: 53 additions & 9 deletions src/aria/private/grid/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ export interface GridCellWidgetInputs {

/** The element that will receive focus when the widget is activated. */
focusTarget: SignalLike<ElementResolver<HTMLElement>>;

/** Callback hook used to notify parents or directives upon interaction. */
onActivate?: (event: KeyboardEvent | FocusEvent | undefined) => void;

/** Callback hook used to notify parents or directives upon exit. */
onDeactivate?: (event: KeyboardEvent | FocusEvent | undefined) => void;
}

/** The UI pattern for a widget inside a grid cell. */
Expand All @@ -49,7 +55,12 @@ export class GridCellWidgetPattern {
);

/** The tab index for the widget. */
readonly tabIndex: SignalLike<-1 | 0> = computed(() => this.inputs.cell().widgetTabIndex());
readonly tabIndex: SignalLike<-1 | 0> = computed(() => {
if (this.inputs.focusTarget()) {
return -1;
}
return this.inputs.cell().widgetTabIndex();
});

/** Whether the widget is the active widget in the cell. */
readonly active: SignalLike<boolean> = computed(
Expand All @@ -71,18 +82,25 @@ export class GridCellWidgetPattern {
readonly keydown = computed(() => {
const manager = new KeyboardEventManager();

// Simple widgets emit notification on interaction without capturing event flow
if (this.inputs.widgetType() === 'simple') {
return manager
.on('Enter', e => this.inputs.onActivate?.(e), {
preventDefault: false,
stopPropagation: false,
})
.on(' ', e => this.inputs.onActivate?.(e), {
preventDefault: false,
stopPropagation: false,
});
}

// If a widget is activated, only listen to events that exits activate state.
if (this.isActivated()) {
manager.on('Escape', e => {
this.deactivate(e);
this.focus();
});
manager.on('Escape', e => this.deactivate(e));

if (this.inputs.widgetType() === 'editable') {
manager.on('Enter', e => {
this.deactivate(e);
this.focus();
});
manager.on('Enter', e => this.deactivate(e));
}

return manager;
Expand Down Expand Up @@ -135,6 +153,32 @@ export class GridCellWidgetPattern {
this.widgetHost().focus();
}

/** Side-effect executed whenever the widget activates. Runs in the write phase. */
activationEffect(): void {
if (this.isActivated()) {
const event = this.lastActivateEvent();
this.inputs.onActivate?.(event);

// Only automatically redirect focus if explicit configuration was supplied.
if (this.inputs.focusTarget()) {
this.focus();
}
}
}

/** Side-effect executed whenever the widget deactivates. Runs in the write phase. */
deactivationEffect(): void {
const event = this.lastDeactivateEvent();
if (event) {
this.inputs.onDeactivate?.(event);

// Only automatically restore focus if the deactivation was triggered by user keyboard interaction.
if (event instanceof KeyboardEvent) {
this.focus();
}
}
}

/** Activates the widget. */
activate(event?: KeyboardEvent | FocusEvent): void {
if (this.isActivated()) return;
Expand Down
8 changes: 4 additions & 4 deletions src/aria/simple-combobox/simple-combobox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1541,13 +1541,13 @@ const states = [
<div ngComboboxWidget #grid="ngGrid" ngGrid id="grid" focusMode="activedescendant" [tabIndex]="-1" colWrap="continuous" [activeDescendant]="grid.activeDescendant()">
@for (item of filteredItems(); track item; let i = $index) {
<div ngGridRow>
<div ngGridCell [id]="item + '-label'" [rowIndex]="i" [colIndex]="0" (activated)="selectItem(item)">
<button ngGridCellWidget (click)="selectItem(item)">
<div ngGridCell [id]="item + '-label'" [rowIndex]="i" [colIndex]="0">
<button ngGridCellWidget (activated)="selectItem(item)" (click)="selectItem(item)">
{{item}}
</button>
</div>
<div ngGridCell [id]="item + '-delete'" [rowIndex]="i" [colIndex]="1" (activated)="removeItem(item)">
<button ngGridCellWidget (click)="removeItem(item)" (pointerdown)="$event.preventDefault()">
<div ngGridCell [id]="item + '-delete'" [rowIndex]="i" [colIndex]="1">
<button ngGridCellWidget (activated)="removeItem(item)" (click)="removeItem(item)" (pointerdown)="$event.preventDefault()">
Delete
</button>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@
@for (item of filteredItems(); track item.label; let i = $index) {
<div ngGridRow class="example-grid-row" [attr.aria-selected]="item === selectedItem()">
<div ngGridCell [id]="item.label + '-label'" [rowIndex]="i" [colIndex]="0"
class="example-cell-label example-cell" (activated)="selectItem(item)">
<button ngGridCellWidget class="example-label-button example-no-active-outline" (click)="selectItem(item)">
class="example-cell-label example-cell">
<button ngGridCellWidget class="example-label-button example-no-active-outline" (click)="selectItem(item)" (activated)="selectItem(item)">
{{item.label}}
</button>
<mat-icon class="example-selected-icon">check</mat-icon>
</div>
<div ngGridCell [id]="item.label + '-delete'" [rowIndex]="i" [colIndex]="1"
class="example-cell-button example-cell " (activated)="removeItem(item)">
<button ngGridCellWidget class="example-button example-no-active-outline" (click)="removeItem(item)"
class="example-cell-button example-cell ">
<button ngGridCellWidget class="example-button example-no-active-outline" (click)="removeItem(item)" (activated)="removeItem(item)"
(pointerdown)="$event.preventDefault()">
<mat-icon>close</mat-icon>
</button>
Expand Down
Loading