Skip to content
Draft
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
15 changes: 14 additions & 1 deletion src/aria/combobox/combobox-popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
*/

import {Directive, inject, signal} from '@angular/core';
import {ComboboxListboxControls, ComboboxTreeControls, ComboboxDialogPattern} from '../private';
import {
ComboboxListboxControls,
ComboboxTreeControls,
ComboboxDialogPattern,
ComboboxNavigation,
} from '../private';
import type {Combobox} from './combobox';
import {COMBOBOX} from './combobox-tokens';

Expand Down Expand Up @@ -41,4 +46,12 @@ export class ComboboxPopup<V> {
| ComboboxDialogPattern
| undefined
>(undefined);

/** The navigation state to apply when the popup expands. */
readonly pendingNavigation = signal<ComboboxNavigation | undefined>(undefined);

/** Sets the navigation state to be applied when the popup is ready. */
focusOnReady(nav: ComboboxNavigation) {
this.pendingNavigation.set(nav);
}
}
8 changes: 8 additions & 0 deletions src/aria/grid/grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ export class Grid {
/** Whether enable range selections (with modifier keys or dragging). */
readonly enableRangeSelection = input(false, {transform: booleanAttribute});

/** Whether the grid is tabbable. */
readonly tabbable = input<boolean | undefined>(undefined);

/** The UI pattern for the grid. */
readonly _pattern = new GridPattern({
...this,
Expand Down Expand Up @@ -157,6 +160,11 @@ export class Grid {
afterRenderEffect(() => this._pattern.focusEffect());
}

/** Scrolls the active cell into view. */
scrollActiveCellIntoView(options: ScrollIntoViewOptions = {block: 'nearest'}) {
this._pattern.activeCell()?.element().scrollIntoView(options);
}

/** Gets the cell pattern for a given element. */
private _getCell(element: Element | null | undefined): GridCellPattern | undefined {
let target = element;
Expand Down
22 changes: 18 additions & 4 deletions src/aria/listbox/listbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {_IdGenerator} from '@angular/cdk/a11y';
import {ComboboxListboxPattern, ListboxPattern, OptionPattern} from '../private';
import {ComboboxPopup} from '../combobox';
import {Option} from './option';
import {LISTBOX} from './tokens';
import {COMBOBOX_WIDGET, LISTBOX} from './tokens';

/**
* Represents a container used to display a list of items for a user to select from.
Expand Down Expand Up @@ -78,6 +78,8 @@ export class Listbox<V> {
optional: true,
});

private readonly _widget = inject<any>(COMBOBOX_WIDGET, {optional: true});

/** A reference to the host element. */
private readonly _elementRef = inject(ElementRef);

Expand Down Expand Up @@ -151,6 +153,7 @@ export class Listbox<V> {
textDirection: this.textDirection,
element: () => this._elementRef.nativeElement,
combobox: () => this._popup?.combobox?._pattern,
hasPopup: () => !!this._popup?.combobox || !!this._widget,
};

this._pattern = this._popup?.combobox
Expand All @@ -171,8 +174,14 @@ export class Listbox<V> {
});

afterRenderEffect(() => {
if (!this._hasFocused()) {
this._pattern.setDefaultState();
const active = this._pattern.inputs.activeItem();

if (!this._widget || (this._widget as any).filterMode() !== 'manual') {
untracked(() => this._pattern.listBehavior.select());
}

if (this._widget) {
untracked(() => (this._widget as any).activeValue.set(active?.value()));
}
});

Expand All @@ -192,7 +201,12 @@ export class Listbox<V> {
const items = inputs.items();
const values = untracked(() => this.values());

if (items && values.some(v => !items.some(i => i.value() === v))) {
// If using simple combobx, the combobox should handle the value.
if (this._popup?.combobox || this._widget) {
return;
}

if (items.length > 0 && values.some(v => !items.some(i => i.value() === v))) {
this.values.set(values.filter(v => items.some(i => i.value() === v)));
}
});
Expand Down
2 changes: 2 additions & 0 deletions src/aria/listbox/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ import {InjectionToken} from '@angular/core';
import type {Listbox} from './listbox';

export const LISTBOX = new InjectionToken<Listbox<any>>('LISTBOX');

export const COMBOBOX_WIDGET = new InjectionToken<any>('COMBOBOX_WIDGET');
1 change: 1 addition & 0 deletions src/aria/private/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ ts_project(
"//src/aria/private/grid",
"//src/aria/private/listbox",
"//src/aria/private/menu",
"//src/aria/private/simple-combobox",
"//src/aria/private/tabs",
"//src/aria/private/toolbar",
"//src/aria/private/tree",
Expand Down
13 changes: 12 additions & 1 deletion src/aria/private/behaviors/grid/grid-focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ export interface GridFocusInputs {

/** Whether disabled cells in the grid should be focusable. */
softDisabled: SignalLike<boolean>;

/** Whether the grid is tabbable. */
tabbable?: SignalLike<boolean | undefined>;
}

/** Dependencies for the `GridFocus` class. */
Expand Down Expand Up @@ -95,7 +98,15 @@ export class GridFocus<T extends GridFocusCell> {
});

/** The tab index for the grid container. */
readonly gridTabIndex = computed<-1 | 0>(() => {
readonly gridTabIndex = computed<-1 | 0 | null>(() => {
const isTabbable = this.inputs.tabbable?.();
if (isTabbable === false) {
return -1;
}
if (isTabbable === true) {
return 0;
}

if (this.gridDisabled()) {
return 0;
}
Expand Down
6 changes: 3 additions & 3 deletions src/aria/private/behaviors/grid/grid.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ describe('Grid', () => {
expect(grid.focusBehavior.activeCoords()).toEqual({row: 1, col: 1});
});

it('should focus the first cell if active cell and coords are no longer valid', () => {
it('should focus the row above when the last row is deleted', () => {
const cellsSignal = signal(createTestGrid(createGridA));
const grid = setupGrid(cellsSignal);
grid.gotoCell(cellsSignal()[2][2]);
Expand All @@ -416,8 +416,8 @@ describe('Grid', () => {
expect(grid.focusBehavior.stateStale()).toBe(true);
const result = grid.resetState();
expect(result).toBe(true);
expect(grid.focusBehavior.activeCell()).toBe(newCells[0][0]);
expect(grid.focusBehavior.activeCoords()).toEqual({row: 0, col: 0});
expect(grid.focusBehavior.activeCell()).toBe(newCells[1][1]);
expect(grid.focusBehavior.activeCoords()).toEqual({row: 1, col: 1});
});
});
});
45 changes: 41 additions & 4 deletions src/aria/private/behaviors/grid/grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export class Grid<T extends GridCell> {
);

/** The tab index for the grid container. */
readonly gridTabIndex: SignalLike<-1 | 0> = () => this.focusBehavior.gridTabIndex();
readonly gridTabIndex: SignalLike<-1 | 0 | null> = () => this.focusBehavior.gridTabIndex();

/** Whether the grid is in a disabled state. */
readonly gridDisabled: SignalLike<boolean> = () => this.focusBehavior.gridDisabled();
Expand Down Expand Up @@ -318,20 +318,57 @@ export class Grid<T extends GridCell> {
}

if (this.focusBehavior.stateStale()) {
const activeCell = this.focusBehavior.activeCell();
const activeCoords = this.focusBehavior.activeCoords();

// Try focus on the same active cell after if a reordering happened.
if (this.focusBehavior.focusCell(this.focusBehavior.activeCell()!)) {
if (activeCell && this.focusBehavior.focusCell(activeCell)) {
return true;
}

// If the active cell is no longer exist, focus on the coordinates instead.
if (this.focusBehavior.focusCoordinates(this.focusBehavior.activeCoords())) {
if (this.focusBehavior.focusCoordinates(activeCoords)) {
return true;
}

// If the coordinates are no longer valid (e.g. because the row was deleted at the end),
// try to focus on the previous row focusing on the same column.
const maxRow = this.data.maxRowCount() - 1;
const targetRow = Math.min(activeCoords.row, maxRow);

if (targetRow >= 0) {
// Try same column in the clamped row.
if (this.focusBehavior.focusCoordinates({row: targetRow, col: activeCoords.col})) {
return true;
}

// Try clamping the column as well.
const colCount = this.data.getColCount(targetRow);
if (colCount !== undefined) {
const targetCol = Math.min(activeCoords.col, colCount - 1);
if (
targetCol >= 0 &&
this.focusBehavior.focusCoordinates({row: targetRow, col: targetCol})
) {
return true;
}
}

// If that fails, try to find ANY cell in that row.
const firstInRow = this.navigationBehavior.peekFirst(targetRow);
if (firstInRow !== undefined && this.focusBehavior.focusCoordinates(firstInRow)) {
return true;
}
}

// If the coordinates no longer valid, go back to the first available cell.
if (this.focusBehavior.focusCoordinates(this.navigationBehavior.peekFirst()!)) {
const firstAvailable = this.navigationBehavior.peekFirst();
if (firstAvailable !== undefined && this.focusBehavior.focusCoordinates(firstAvailable)) {
return true;
}

this.focusBehavior.activeCell.set(undefined);
this.focusBehavior.activeCoords.set({row: -1, col: -1});
}

return false;
Expand Down
6 changes: 3 additions & 3 deletions src/aria/private/behaviors/list-focus/list-focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ export class ListFocus<T extends ListFocusItem> {
this.inputs.activeItem.set(item);

if (opts?.focusElement || opts?.focusElement === undefined) {
this.inputs.focusMode() === 'roving'
? item.element()?.focus()
: this.inputs.element()?.focus();
if (this.inputs.focusMode() === 'roving') {
item.element()?.focus();
}
}

return true;
Expand Down
16 changes: 14 additions & 2 deletions src/aria/private/combobox/combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export interface ComboboxTreeControls<T extends ListItem<V>, V> extends Combobox
}

/** Controls the state of a combobox. */
export class ComboboxPattern<T extends ListItem<V>, V> {
export class ComboboxPattern<T extends ListItem<V>, V> implements ComboboxLike<T> {
/** Whether the combobox is expanded. */
expanded = signal(false);

Expand Down Expand Up @@ -570,7 +570,7 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
}

/** Opens the combobox. */
open(nav?: {first?: boolean; last?: boolean; selected?: boolean}) {
open(nav?: ComboboxNavigation) {
this.expanded.set(true);
const popupControls = this.inputs.popupControls();

Expand Down Expand Up @@ -737,3 +737,15 @@ export class ComboboxDialogPattern {
}
}
}

export interface ComboboxNavigation {
first?: boolean;
last?: boolean;
selected?: boolean;
}

export interface ComboboxLike<T> {
expanded: WritableSignalLike<boolean>;
highlightedItem: WritableSignalLike<T | undefined>;
open(nav?: ComboboxNavigation): void;
}
10 changes: 10 additions & 0 deletions src/aria/private/grid/grid.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,16 @@ describe('Grid', () => {
expect(widget.isActivated()).toBe(true);
});

it('should trigger click on Enter for simple widget', () => {
const {grid} = createGrid([{cells: [{widgets: [{widgetType: 'simple'}]}]}], gridInputs);
const widget = grid.cells()[0][0].inputs.widgets()[0];
const element = widget.element();
spyOn(element, 'click');

widget.onKeydown(enter());
expect(element.click).toHaveBeenCalled();
});

it('should not activate if disabled', () => {
const {grid} = createGrid(
[{cells: [{widgets: [{widgetType: 'complex', disabled: true}]}]}],
Expand Down
6 changes: 5 additions & 1 deletion src/aria/private/grid/grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ export class GridPattern {
onKeydown(event: KeyboardEvent) {
if (this.disabled()) return;

this.hasBeenFocused.set(true);
this.activeCell()?.onKeydown(event);
this.keydown().handle(event);
}
Expand Down Expand Up @@ -328,7 +329,10 @@ export class GridPattern {

/** Sets the default active state of the grid before receiving focus the first time. */
setDefaultStateEffect(): void {
if (this.hasBeenFocused()) return;
if (this.hasBeenFocused() || !this.gridBehavior.focusBehavior.stateEmpty()) {
this.hasBeenFocused.set(true);
return;
}

this.gridBehavior.setDefaultState();
}
Expand Down
5 changes: 5 additions & 0 deletions src/aria/private/grid/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ export class GridCellWidgetPattern implements ListNavigationItem {
const manager = new KeyboardEventManager();

// Simple widget does not need to pause default grid behaviors.
// However, it does need to capture Enter key and trigger a click on the host element
// since the browser won't do it for us in activedescendant mode.
if (this.inputs.widgetType() === 'simple') {
console.log('simple widget keydown');
manager.on('Enter', () => this.element().click());
return manager;
}

Expand Down Expand Up @@ -116,6 +120,7 @@ export class GridCellWidgetPattern implements ListNavigationItem {
/** Handles keydown events for the widget. */
onKeydown(event: KeyboardEvent): void {
if (this.disabled()) return;
console.log('keydown of widget.ts');

this.keydown().handle(event);
}
Expand Down
11 changes: 9 additions & 2 deletions src/aria/private/listbox/listbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export type ListboxInputs<V> = ListInputs<OptionPattern<V>, V> & {

/** Whether the listbox is readonly. */
readonly: SignalLike<boolean>;

/** Whether the listbox is in a popup or widget context. */
hasPopup?: SignalLike<boolean>;
};

/** Controls the state of a listbox. */
Expand Down Expand Up @@ -135,8 +138,12 @@ export class ListboxPattern<V> {
}

if (!this.followFocus() && !this.inputs.multi()) {
manager.on(this.dynamicSpaceKey, () => this.listBehavior.toggleOne());
manager.on('Enter', () => this.listBehavior.toggleOne());
manager.on(this.dynamicSpaceKey, () =>
this.inputs.hasPopup?.() ? this.listBehavior.selectOne() : this.listBehavior.toggleOne(),
);
manager.on('Enter', () =>
this.inputs.hasPopup?.() ? this.listBehavior.selectOne() : this.listBehavior.toggleOne(),
);
}

if (this.inputs.multi() && this.followFocus()) {
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 './simple-combobox/simple-combobox';
19 changes: 19 additions & 0 deletions src/aria/private/simple-combobox/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
load("//tools:defaults.bzl", "ts_project")

package(default_visibility = ["//visibility:public"])

ts_project(
name = "simple-combobox",
srcs = glob(
["**/*.ts"],
exclude = ["**/*.spec.ts"],
),
deps = [
"//:node_modules/@angular/core",
"//src/aria/private/behaviors/event-manager",
"//src/aria/private/behaviors/expansion",
"//src/aria/private/behaviors/list",
"//src/aria/private/behaviors/signal-like",
"//src/aria/private/combobox",
],
)
Loading
Loading