diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 000000000000..b19269c3c2a7 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,13 @@ +# Jetski Global Instructions & Context + + ## Rules + - **Skip Tests by Default**: Do not run unit or integration tests automatically after making code changes. + - **Run Tests on Demand**: Only run tests when explicitly prompted by the user (e.g., "run tests", "verify with tests"). + - **Always allowed to run `git status`**: You can run `git status` at any time to check the state of the repository. + - **Git Commits**: Follow Angular's commit message format (`(): `). Use imperative mood, lowercase subject, and no trailing period. Valid scopes are in `.ng-dev/commit-message.mts`. Scopes can be omitted for `test` or refactoring types. Use `multiple` for cross-component changes. Do not use `aria` as a standalone scope. + - **Git Pushing**: Always push branches to the `tjshiu` remote (e.g., `git push tjshiu `), as `origin` is not configured. + + ## Aliases + - **start-fresh**: Run `git stash push --include-untracked -m "work-in-progress"`, checkout `main`, pull `origin main`, checkout a new branch, and stash pop. + - **update-main**: Sync local main with remote using `git checkout main && git pull upstream main && git checkout -`. + \ No newline at end of file diff --git a/goldens/aria/grid/index.api.md b/goldens/aria/grid/index.api.md index 9692ebfa562b..9d2ab2e6643d 100644 --- a/goldens/aria/grid/index.api.md +++ b/goldens/aria/grid/index.api.md @@ -15,16 +15,19 @@ export class Grid { readonly colWrap: _angular_core.InputSignal<"continuous" | "loop" | "nowrap">; readonly disabled: _angular_core.InputSignalWithTransform; readonly element: HTMLElement; + readonly enableRangeSelection: _angular_core.InputSignalWithTransform; readonly enableSelection: _angular_core.InputSignalWithTransform; readonly focusMode: _angular_core.InputSignal<"roving" | "activedescendant">; readonly multi: _angular_core.InputSignalWithTransform; readonly _pattern: GridPattern; readonly rowWrap: _angular_core.InputSignal<"continuous" | "loop" | "nowrap">; + scrollActiveCellIntoView(options?: ScrollIntoViewOptions): void; readonly selectionMode: _angular_core.InputSignal<"follow" | "explicit">; readonly softDisabled: _angular_core.InputSignalWithTransform; + readonly tabbable: _angular_core.InputSignal; readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } diff --git a/goldens/aria/listbox/index.api.md b/goldens/aria/listbox/index.api.md index 9bab3e7021fd..00c0979196a5 100644 --- a/goldens/aria/listbox/index.api.md +++ b/goldens/aria/listbox/index.api.md @@ -24,12 +24,13 @@ export class Listbox { scrollActiveItemIntoView(options?: ScrollIntoViewOptions): void; readonly selectionMode: _angular_core.InputSignal<"follow" | "explicit">; readonly softDisabled: _angular_core.InputSignalWithTransform; + tabbable: _angular_core.InputSignalWithTransform; protected readonly textDirection: _angular_core.Signal<_angular_cdk_bidi.Direction>; readonly typeaheadDelay: _angular_core.InputSignal; readonly value: _angular_core.ModelSignal; readonly wrap: _angular_core.InputSignalWithTransform; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration, "[ngListbox]", ["ngListbox"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "orientation": { "alias": "orientation"; "required": false; "isSignal": true; }; "multi": { "alias": "multi"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "focusMode": { "alias": "focusMode"; "required": false; "isSignal": true; }; "selectionMode": { "alias": "selectionMode"; "required": false; "isSignal": true; }; "typeaheadDelay": { "alias": "typeaheadDelay"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "readonly": { "alias": "readonly"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; }, ["_options"], never, true, [{ directive: typeof ComboboxPopup; inputs: {}; outputs: {}; }]>; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration, "[ngListbox]", ["ngListbox"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "orientation": { "alias": "orientation"; "required": false; "isSignal": true; }; "multi": { "alias": "multi"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "focusMode": { "alias": "focusMode"; "required": false; "isSignal": true; }; "selectionMode": { "alias": "selectionMode"; "required": false; "isSignal": true; }; "typeaheadDelay": { "alias": "typeaheadDelay"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "readonly": { "alias": "readonly"; "required": false; "isSignal": true; }; "tabbable": { "alias": "tabbable"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; }, ["_options"], never, true, [{ directive: typeof ComboboxPopup; inputs: {}; outputs: {}; }]>; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration, never>; } diff --git a/goldens/aria/private/index.api.md b/goldens/aria/private/index.api.md index b6c1d72b8fa7..31c775d8e969 100644 --- a/goldens/aria/private/index.api.md +++ b/goldens/aria/private/index.api.md @@ -656,6 +656,70 @@ export function signal(initialValue: T): WritableSignalLike; // @public (undocumented) export type SignalLike = () => T; +// @public +export interface SimpleComboboxInputs extends ExpansionItem { + alwaysExpanded: SignalLike; + disabled: SignalLike; + element: SignalLike; + inlineSuggestion: SignalLike; + popup: SignalLike; + softDisabled: SignalLike; + value: WritableSignalLike; +} + +// @public +export class SimpleComboboxPattern { + constructor(inputs: SimpleComboboxInputs); + readonly activeDescendant: _angular_core.Signal; + readonly autocomplete: _angular_core.Signal<"none" | "inline" | "list" | "both">; + click: _angular_core.Signal>; + closePopupOnBlurEffect(): void; + readonly disabled: () => boolean; + readonly element: () => HTMLElement; + readonly expanded: WritableSignalLike; + highlightEffect(): void; + readonly inlineSuggestion: () => string | undefined; + // (undocumented) + readonly inputs: SimpleComboboxInputs; + readonly isDeleting: _angular_core.WritableSignal; + readonly isEditable: _angular_core.Signal; + readonly isFocused: _angular_core.WritableSignal; + readonly keyboardEventRelay: _angular_core.WritableSignal; + keyboardEventRelayEffect(): void; + keydown: _angular_core.Signal>; + onClick(event: PointerEvent): void; + onFocusin(): void; + onFocusout(event: FocusEvent): void; + onInput(event: Event): void; + onKeydown(event: KeyboardEvent): void; + readonly popupId: _angular_core.Signal; + readonly popupType: _angular_core.Signal<"listbox" | "tree" | "grid" | "dialog" | undefined>; + readonly softDisabled: () => boolean; + readonly value: WritableSignalLike; +} + +// @public +export interface SimpleComboboxPopupInputs { + activeDescendant: SignalLike; + controlTarget: SignalLike; + popupId: SignalLike; + popupType: SignalLike<'listbox' | 'tree' | 'grid' | 'dialog'>; +} + +// @public +export class SimpleComboboxPopupPattern { + constructor(inputs: SimpleComboboxPopupInputs); + readonly activeDescendant: () => string | undefined; + readonly controlTarget: () => HTMLElement | undefined; + // (undocumented) + readonly inputs: SimpleComboboxPopupInputs; + readonly isFocused: _angular_core.WritableSignal; + onFocusin(): void; + onFocusout(event: FocusEvent): void; + readonly popupId: () => string | undefined; + readonly popupType: () => "listbox" | "tree" | "grid" | "dialog"; +} + // @public export function sortDirectives(a: HasElement, b: HasElement): 1 | -1; diff --git a/goldens/aria/simple-combobox/index.api.md b/goldens/aria/simple-combobox/index.api.md new file mode 100644 index 000000000000..2a4f3d9c88a0 --- /dev/null +++ b/goldens/aria/simple-combobox/index.api.md @@ -0,0 +1,78 @@ +## API Report File for "@angular/aria_simple-combobox" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import * as _angular_core from '@angular/core'; +import { DeferredContentAware } from '@angular/aria/private'; +import * as i1 from '@angular/aria/private'; +import { OnDestroy } from '@angular/core'; +import { OnInit } from '@angular/core'; +import { SimpleComboboxPattern } from '@angular/aria/private'; +import { SimpleComboboxPopupPattern } from '@angular/aria/private'; + +// @public +export class Combobox extends DeferredContentAware implements OnInit { + constructor(); + readonly alwaysExpanded: _angular_core.InputSignalWithTransform; + readonly disabled: _angular_core.InputSignalWithTransform; + readonly element: HTMLElement; + readonly expanded: _angular_core.ModelSignal; + readonly inlineSuggestion: _angular_core.InputSignal; + // (undocumented) + ngOnInit(): void; + readonly _pattern: SimpleComboboxPattern; + readonly _popup: _angular_core.WritableSignal; + _registerPopup(popup: ComboboxPopup): void; + readonly softDisabled: _angular_core.InputSignalWithTransform; + _unregisterPopup(): void; + readonly value: _angular_core.ModelSignal; + // (undocumented) + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + +// @public +export class ComboboxPopup implements OnInit, OnDestroy { + readonly activeDescendant: _angular_core.Signal; + readonly combobox: _angular_core.InputSignal; + readonly controlTarget: _angular_core.Signal; + // (undocumented) + ngOnDestroy(): void; + // (undocumented) + ngOnInit(): void; + readonly _pattern: SimpleComboboxPopupPattern; + readonly popupId: _angular_core.Signal; + readonly popupType: _angular_core.InputSignal<"listbox" | "tree" | "grid" | "dialog">; + _registerWidget(widget: ComboboxWidget): void; + _unregisterWidget(): void; + readonly _widget: _angular_core.WritableSignal; + // (undocumented) + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + +// @public +export class ComboboxWidget implements OnInit, OnDestroy { + constructor(); + readonly activeDescendant: _angular_core.WritableSignal; + readonly element: HTMLElement; + // (undocumented) + ngOnDestroy(): void; + // (undocumented) + ngOnInit(): void; + onFocusin(): void; + onFocusout(event: FocusEvent): void; + readonly popupId: _angular_core.WritableSignal; + // (undocumented) + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/aria/tree/index.api.md b/goldens/aria/tree/index.api.md index 5502e0dfa0dd..028d29f05c3c 100644 --- a/goldens/aria/tree/index.api.md +++ b/goldens/aria/tree/index.api.md @@ -28,6 +28,7 @@ export class Tree { scrollActiveItemIntoView(options?: ScrollIntoViewOptions): void; readonly selectionMode: _angular_core.InputSignal<"follow" | "explicit">; readonly softDisabled: _angular_core.InputSignalWithTransform; + readonly tabbable: _angular_core.InputSignalWithTransform; readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>; readonly typeaheadDelay: _angular_core.InputSignal; // (undocumented) @@ -35,7 +36,7 @@ export class Tree { readonly value: _angular_core.ModelSignal; readonly wrap: _angular_core.InputSignalWithTransform; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration, "[ngTree]", ["ngTree"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "orientation": { "alias": "orientation"; "required": false; "isSignal": true; }; "multi": { "alias": "multi"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "selectionMode": { "alias": "selectionMode"; "required": false; "isSignal": true; }; "focusMode": { "alias": "focusMode"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "typeaheadDelay": { "alias": "typeaheadDelay"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; "nav": { "alias": "nav"; "required": false; "isSignal": true; }; "currentType": { "alias": "currentType"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; }, never, never, true, [{ directive: typeof ComboboxPopup; inputs: {}; outputs: {}; }]>; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration, "[ngTree]", ["ngTree"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "orientation": { "alias": "orientation"; "required": false; "isSignal": true; }; "multi": { "alias": "multi"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "selectionMode": { "alias": "selectionMode"; "required": false; "isSignal": true; }; "focusMode": { "alias": "focusMode"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "typeaheadDelay": { "alias": "typeaheadDelay"; "required": false; "isSignal": true; }; "tabbable": { "alias": "tabbable"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; "nav": { "alias": "nav"; "required": false; "isSignal": true; }; "currentType": { "alias": "currentType"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; }, never, never, true, [{ directive: typeof ComboboxPopup; inputs: {}; outputs: {}; }]>; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration, never>; } diff --git a/src/aria/config.bzl b/src/aria/config.bzl index adba36ba70e9..7526a7d7ce61 100644 --- a/src/aria/config.bzl +++ b/src/aria/config.bzl @@ -8,6 +8,7 @@ ARIA_ENTRYPOINTS = [ "listbox/testing", "menu", "menu/testing", + "simple-combobox", "tabs", "tabs/testing", "toolbar", diff --git a/src/aria/grid/grid.ts b/src/aria/grid/grid.ts index bb6bb34ab44b..03d4334a3c61 100644 --- a/src/aria/grid/grid.ts +++ b/src/aria/grid/grid.ts @@ -120,6 +120,12 @@ export class Grid { */ readonly selectionMode = input<'follow' | 'explicit'>('follow'); + /** Whether enable range selections (with modifier keys or dragging). */ + readonly enableRangeSelection = input(false, {transform: booleanAttribute}); + + /** Whether the grid is tabbable. */ + readonly tabbable = input(undefined); + /** The UI pattern for the grid. */ readonly _pattern = new GridPattern({ ...this, @@ -136,6 +142,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; diff --git a/src/aria/listbox/listbox.spec.ts b/src/aria/listbox/listbox.spec.ts index 34f41a56ad73..543f2d4321a7 100644 --- a/src/aria/listbox/listbox.spec.ts +++ b/src/aria/listbox/listbox.spec.ts @@ -148,10 +148,10 @@ describe('Listbox', () => { expect(listboxElement.getAttribute('aria-multiselectable')).toBe('false'); }); - it('should set aria-selected to "false" for all options by default', () => { - optionElements.forEach(optionElement => { - expect(optionElement.getAttribute('aria-selected')).toBe('false'); - }); + it('should set aria-selected to "true" for the first option and "false" for others by default', () => { + expect(optionElements[0].getAttribute('aria-selected')).toBe('true'); + expect(optionElements[1].getAttribute('aria-selected')).toBe('false'); + expect(optionElements[2].getAttribute('aria-selected')).toBe('false'); }); }); diff --git a/src/aria/listbox/listbox.ts b/src/aria/listbox/listbox.ts index c599ed8e9257..29b2ddbbedde 100644 --- a/src/aria/listbox/listbox.ts +++ b/src/aria/listbox/listbox.ts @@ -133,6 +133,9 @@ export class Listbox { /** Whether the listbox is readonly. */ readonly readonly = input(false, {transform: booleanAttribute}); + /** Whether the list is tabbable. */ + tabbable = input(true, {transform: booleanAttribute}); + /** The values of the currently selected items. */ readonly value = model([]); @@ -146,6 +149,7 @@ export class Listbox { items: this.items, activeItem: signal(undefined), textDirection: this.textDirection, + tabbable: this.tabbable, element: () => this._elementRef.nativeElement, combobox: () => this._popup?.combobox?._pattern, }; diff --git a/src/aria/private/BUILD.bazel b/src/aria/private/BUILD.bazel index 22b2130483dc..2685cd2d34a4 100644 --- a/src/aria/private/BUILD.bazel +++ b/src/aria/private/BUILD.bazel @@ -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", diff --git a/src/aria/private/behaviors/grid/grid-focus.ts b/src/aria/private/behaviors/grid/grid-focus.ts index 31f0502e80f7..f947c54318a0 100644 --- a/src/aria/private/behaviors/grid/grid-focus.ts +++ b/src/aria/private/behaviors/grid/grid-focus.ts @@ -31,6 +31,9 @@ export interface GridFocusInputs { /** Whether disabled cells in the grid should be focusable. */ softDisabled: SignalLike; + + /** Whether the grid is tabbable. */ + tabbable?: SignalLike; } /** Dependencies for the `GridFocus` class. */ @@ -96,6 +99,14 @@ export class GridFocus { /** The tab index for the grid container. */ readonly gridTabIndex = computed<-1 | 0>(() => { + const isTabbable = this.inputs.tabbable?.(); + if (isTabbable === false) { + return -1; + } + if (isTabbable === true) { + return 0; + } + if (this.gridDisabled()) { return 0; } diff --git a/src/aria/private/behaviors/grid/grid.spec.ts b/src/aria/private/behaviors/grid/grid.spec.ts index 8db7756e7728..4bd80939aea2 100644 --- a/src/aria/private/behaviors/grid/grid.spec.ts +++ b/src/aria/private/behaviors/grid/grid.spec.ts @@ -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]); @@ -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}); }); }); }); diff --git a/src/aria/private/behaviors/grid/grid.ts b/src/aria/private/behaviors/grid/grid.ts index 6d0316395d38..37c6ec15def6 100644 --- a/src/aria/private/behaviors/grid/grid.ts +++ b/src/aria/private/behaviors/grid/grid.ts @@ -318,20 +318,57 @@ export class Grid { } 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; diff --git a/src/aria/private/behaviors/list-focus/list-focus.spec.ts b/src/aria/private/behaviors/list-focus/list-focus.spec.ts index c37c1e40f629..ab4b1fbdd3b8 100644 --- a/src/aria/private/behaviors/list-focus/list-focus.spec.ts +++ b/src/aria/private/behaviors/list-focus/list-focus.spec.ts @@ -107,6 +107,26 @@ describe('List Focus', () => { }); }); + describe('tabbable', () => { + it('should override getListTabIndex to -1 when tabbable is explicitly false', () => { + const focusManager = getListFocus({ + focusMode: signal('activedescendant'), + tabbable: signal(false), + }); + expect(focusManager.getListTabIndex()).toBe(-1); + }); + + it('should override getItemTabIndex to -1 when tabbable is explicitly false', () => { + const focusManager = getListFocus({ + focusMode: signal('roving'), + tabbable: signal(false), + }); + const items = focusManager.inputs.items(); + focusManager.inputs.activeItem.set(items[0]); + expect(focusManager.getItemTabIndex(items[0])).toBe(-1); + }); + }); + describe('#isFocusable', () => { it('should return true for enabled items', () => { const focusManager = getListFocus({softDisabled: signal(false)}); diff --git a/src/aria/private/behaviors/list-focus/list-focus.ts b/src/aria/private/behaviors/list-focus/list-focus.ts index 1aba209153d0..8ed8031b6dea 100644 --- a/src/aria/private/behaviors/list-focus/list-focus.ts +++ b/src/aria/private/behaviors/list-focus/list-focus.ts @@ -39,6 +39,9 @@ export interface ListFocusInputs { /** The html element that should receive focus. */ element: SignalLike; + + /** Whether the list is tabbable. */ + tabbable?: SignalLike; } /** Controls focus for a list of items. */ @@ -76,6 +79,9 @@ export class ListFocus { /** The tab index for the list. */ getListTabIndex(): -1 | 0 { + if (this.inputs.tabbable !== undefined && !this.inputs.tabbable()) { + return -1; + } if (this.isListDisabled()) { return 0; } @@ -84,6 +90,9 @@ export class ListFocus { /** Returns the tab index for the given item. */ getItemTabIndex(item: T): -1 | 0 { + if (this.inputs.tabbable !== undefined && !this.inputs.tabbable()) { + return -1; + } if (this.isListDisabled()) { return -1; } @@ -103,9 +112,11 @@ export class ListFocus { 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(); + } else if (this.inputs.focusMode() === 'activedescendant') { + this.inputs.element()?.focus(); + } } return true; diff --git a/src/aria/private/behaviors/tree/tree.spec.ts b/src/aria/private/behaviors/tree/tree.spec.ts index 721d4cca4750..6791d6f17050 100644 --- a/src/aria/private/behaviors/tree/tree.spec.ts +++ b/src/aria/private/behaviors/tree/tree.spec.ts @@ -118,6 +118,13 @@ describe('Tree Behavior', () => { }); }); + describe('with tabbable: false', () => { + it('should override tree container tabIndex to -1', () => { + const {tree} = getDefaultPatterns({tabbable: signal(false)}); + expect(tree.tabIndex()).toBe(-1); + }); + }); + describe('with focusMode: "roving"', () => { it('should set the list tab index to -1', () => { const {tree} = getDefaultPatterns({focusMode: signal('roving')}); diff --git a/src/aria/private/grid/grid.spec.ts b/src/aria/private/grid/grid.spec.ts index 5cd9dbe0a404..5f2d1b99ab69 100644 --- a/src/aria/private/grid/grid.spec.ts +++ b/src/aria/private/grid/grid.spec.ts @@ -274,6 +274,16 @@ describe('Grid', () => { expect(widget.isActivated()).toBe(true); }); + it('should trigger click on Enter for simple widget', () => { + const {grid} = createGrid([{cells: [{widget: {widgetType: 'simple'}}]}], gridInputs); + const widget = grid.cells()[0][0].inputs.widget()!; + const element = widget.element(); + spyOn(element, 'click'); + + widget.onKeydown(enter()); + expect(element.click).toHaveBeenCalled(); + }); + it('should not activate if disabled', () => { const {grid} = createGrid( [{cells: [{widget: {widgetType: 'complex', disabled: true}}]}], diff --git a/src/aria/private/grid/grid.ts b/src/aria/private/grid/grid.ts index 2f557b2e383c..28cb1a2cd2e5 100644 --- a/src/aria/private/grid/grid.ts +++ b/src/aria/private/grid/grid.ts @@ -257,7 +257,6 @@ export class GridPattern { /** Sets the default active state of the grid before receiving focus the first time. */ setDefaultStateEffect(): void { if (this.hasBeenInteracted()) return; - this.gridBehavior.setDefaultState(); } diff --git a/src/aria/private/grid/widget.ts b/src/aria/private/grid/widget.ts index 779e03640b73..0725e70661d6 100644 --- a/src/aria/private/grid/widget.ts +++ b/src/aria/private/grid/widget.ts @@ -72,7 +72,10 @@ export class GridCellWidgetPattern { 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') { + manager.on('Enter', () => this.element().click()); return manager; } diff --git a/src/aria/private/listbox/listbox.ts b/src/aria/private/listbox/listbox.ts index a97b1b224560..b71445b6e0c1 100644 --- a/src/aria/private/listbox/listbox.ts +++ b/src/aria/private/listbox/listbox.ts @@ -264,6 +264,9 @@ export class ListboxPattern { if (firstItem) { this.inputs.activeItem.set(firstItem); + if (this.followFocus()) { + this.listBehavior.select(); + } } } diff --git a/src/aria/private/public-api.ts b/src/aria/private/public-api.ts index 0b402dd342a6..ad2f87c2aafc 100644 --- a/src/aria/private/public-api.ts +++ b/src/aria/private/public-api.ts @@ -26,3 +26,4 @@ export * from './grid/cell'; export * from './grid/widget'; export * from './deferred-content'; export * from './utils/element'; +export * from './simple-combobox/simple-combobox'; diff --git a/src/aria/private/simple-combobox/BUILD.bazel b/src/aria/private/simple-combobox/BUILD.bazel new file mode 100644 index 000000000000..ed2c6582fc03 --- /dev/null +++ b/src/aria/private/simple-combobox/BUILD.bazel @@ -0,0 +1,38 @@ +load("//tools:defaults.bzl", "ng_web_test_suite", "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", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":simple-combobox", + "//:node_modules/@angular/core", + "//src/aria/private/behaviors/signal-like", + "//src/aria/private/listbox", + "//src/aria/private/tree", + "//src/cdk/keycodes", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/aria/private/simple-combobox/simple-combobox.spec.ts b/src/aria/private/simple-combobox/simple-combobox.spec.ts new file mode 100644 index 000000000000..ef318061e04e --- /dev/null +++ b/src/aria/private/simple-combobox/simple-combobox.spec.ts @@ -0,0 +1,225 @@ +import {SimpleComboboxPattern, SimpleComboboxPopupPattern} from './simple-combobox'; +import {signal} from '../behaviors/signal-like/signal-like'; +import {createKeyboardEvent} from '@angular/cdk/testing/private'; + +describe('SimpleComboboxPattern', () => { + function setup( + inputs: Partial<{ + disabled: boolean; + alwaysExpanded: boolean; + inlineSuggestion: string; + popupType: 'listbox' | 'tree' | 'grid' | 'dialog'; + }> = {}, + ) { + const element = document.createElement('input'); + const value = signal(''); + const expanded = signal(false); + const alwaysExpanded = signal(inputs.alwaysExpanded ?? false); + const disabled = signal(inputs.disabled ?? false); + const inlineSuggestion = signal(inputs.inlineSuggestion); + + // Mock a generic popup pattern + const popupId = signal('popup-1'); + const activeDescendant = signal('item-1'); + const controlTarget = document.createElement('div'); + const popupType = signal<'listbox' | 'tree' | 'grid' | 'dialog'>(inputs.popupType ?? 'listbox'); + + const popup = new SimpleComboboxPopupPattern({ + popupType, + controlTarget: signal(controlTarget), + activeDescendant, + popupId, + }); + + const pattern = new SimpleComboboxPattern({ + alwaysExpanded, + value, + element: signal(element), + popup: signal(popup), + inlineSuggestion, + disabled, + expanded, + expandable: signal(true), + }); + + return { + pattern, + element, + value, + expanded, + alwaysExpanded, + inlineSuggestion, + disabled, + popup, + controlTarget, + }; + } + + describe('Aria-autocomplete calculation', () => { + it('should return "list" when only popup is present', () => { + const {pattern} = setup(); + expect(pattern.autocomplete()).toBe('list'); + }); + + it('should return "both" when popup and inline suggestion are present', () => { + const {pattern} = setup({inlineSuggestion: 'suggestion'}); + expect(pattern.autocomplete()).toBe('both'); + }); + + it('should return "none" when only dialog popup is present', () => { + const {pattern} = setup({popupType: 'dialog'}); + expect(pattern.autocomplete()).toBe('none'); + }); + + it('should return "inline" when dialog popup and inline suggestion are present', () => { + const {pattern} = setup({popupType: 'dialog', inlineSuggestion: 'suggestion'}); + expect(pattern.autocomplete()).toBe('inline'); + }); + }); + + describe('Expansion via Keyboard', () => { + it('should open on ArrowDown when collapsed', () => { + const {pattern, expanded} = setup(); + expect(expanded()).toBe(false); + + pattern.onKeydown(createKeyboardEvent('keydown', 40, 'ArrowDown')); + expect(expanded()).toBe(true); + }); + + it('should close on Escape when expanded', () => { + const {pattern, expanded} = setup(); + expanded.set(true); + + pattern.onKeydown(createKeyboardEvent('keydown', 27, 'Escape')); + expect(expanded()).toBe(false); + }); + }); + + describe('Input handling', () => { + it('should update value and expand on input', () => { + const {pattern, element, value, expanded} = setup(); + expect(expanded()).toBe(false); + + element.value = 'hello'; + pattern.onInput({target: element} as unknown as Event); + + expect(value()).toBe('hello'); + expect(expanded()).toBe(true); + }); + }); + + describe('Focus handling', () => { + it('should track focus state', () => { + const {pattern} = setup(); + + pattern.onFocusin(); + expect(pattern.isFocused()).toBe(true); + + pattern.onFocusout(new FocusEvent('focusout')); + expect(pattern.isFocused()).toBe(false); + }); + }); + + describe('Inline Suggestion / Highlighting', () => { + it('should insert the inline suggestion into the input and select the remaining text', () => { + const {pattern, element, value, expanded, inlineSuggestion} = setup(); + + value.set('App'); + inlineSuggestion.set('Apple'); + expanded.set(true); + pattern.isFocused.set(true); + + pattern.highlightEffect(); + + expect(element.value).toBe('Apple'); + expect(element.selectionStart).toBe(3); + expect(element.selectionEnd).toBe(5); + }); + + it('should not highlight when deleting text', () => { + const {pattern, element, value, expanded, inlineSuggestion} = setup(); + + value.set('App'); + inlineSuggestion.set('Apple'); + expanded.set(true); + pattern.isFocused.set(true); + + const deleteEvent = new InputEvent('input', {inputType: 'deleteContentBackward'}); + Object.defineProperty(deleteEvent, 'target', {value: element}); + pattern.onInput(deleteEvent as Event); + + expect(pattern.isDeleting()).toBe(true); + + pattern.highlightEffect(); + + expect(element.value).not.toBe('Apple'); + }); + }); + + describe('Select-only combobox behavior', () => { + function setupSelectOnly() { + const selectOnlyElement = document.createElement('div'); + const {pattern, expanded, controlTarget} = setup(); + + // Override element to be select-only + pattern.inputs.element = signal(selectOnlyElement); + + return {pattern, expanded, selectOnlyElement, controlTarget}; + } + + it('should toggle expansion on click', () => { + const {pattern, expanded} = setupSelectOnly(); + expect(expanded()).toBe(false); + + pattern.onClick(new PointerEvent('click')); + expect(expanded()).toBe(true); + + pattern.onClick(new PointerEvent('click')); + expect(expanded()).toBe(false); + }); + + it('should open on Enter or Space when collapsed', () => { + const {pattern, expanded} = setupSelectOnly(); + + pattern.onKeydown(createKeyboardEvent('keydown', 13, 'Enter')); + expect(expanded()).toBe(true); + + expanded.set(false); + + pattern.onKeydown(createKeyboardEvent('keydown', 32, ' ')); + expect(expanded()).toBe(true); + }); + }); + + describe('alwaysExpanded behavior', () => { + it('should stay open on Escape when alwaysExpanded is true', () => { + const {pattern, expanded} = setup({alwaysExpanded: true}); + expanded.set(true); + + pattern.onKeydown(createKeyboardEvent('keydown', 27, 'Escape')); + expect(expanded()).toBe(true); + }); + }); + + describe('Blur behavior', () => { + it('should close when focus leaves both combobox and popup', () => { + const {pattern, expanded} = setup(); + expanded.set(true); + pattern.isFocused.set(false); + pattern.inputs.popup()!.isFocused.set(false); + + pattern.closePopupOnBlurEffect(); + expect(expanded()).toBe(false); + }); + + it('should remain open if popup is focused', () => { + const {pattern, expanded} = setup(); + expanded.set(true); + pattern.isFocused.set(false); + pattern.inputs.popup()!.isFocused.set(true); + + pattern.closePopupOnBlurEffect(); + expect(expanded()).toBe(true); + }); + }); +}); diff --git a/src/aria/private/simple-combobox/simple-combobox.ts b/src/aria/private/simple-combobox/simple-combobox.ts new file mode 100644 index 000000000000..01daa1fe1bfd --- /dev/null +++ b/src/aria/private/simple-combobox/simple-combobox.ts @@ -0,0 +1,292 @@ +/** + * @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 {KeyboardEventManager, ClickEventManager} from '../behaviors/event-manager'; +import {computed, signal, untracked} from '@angular/core'; +import {SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like'; +import {ExpansionItem} from '../behaviors/expansion/expansion'; + +/** Represents the required inputs for a simple combobox. */ +export interface SimpleComboboxInputs extends ExpansionItem { + /** Whether the combobox should always remain expanded. */ + alwaysExpanded: SignalLike; + + /** The value of the combobox. */ + value: WritableSignalLike; + + /** The element that the combobox is attached to. */ + element: SignalLike; + + /** The popup associated with the combobox. */ + popup: SignalLike; + + /** An inline suggestion to be displayed in the input. */ + inlineSuggestion: SignalLike; + + /** Whether the combobox is disabled. */ + disabled: SignalLike; + + /** Whether the combobox is soft disabled. */ + softDisabled: SignalLike; +} + +/** Controls the state of a simple combobox. */ +export class SimpleComboboxPattern { + /** Whether the combobox is expanded. */ + readonly expanded: WritableSignalLike; + + /** The value of the combobox. */ + readonly value: WritableSignalLike; + + /** The element that the combobox is attached to. */ + readonly element = () => this.inputs.element(); + + /** Whether the combobox is disabled. */ + readonly disabled = () => this.inputs.disabled(); + + /** Whether the combobox is soft disabled. */ + readonly softDisabled = () => this.inputs.softDisabled(); + + /** An inline suggestion to be displayed in the input. */ + readonly inlineSuggestion = () => this.inputs.inlineSuggestion(); + + /** The ID of the active descendant in the popup. */ + readonly activeDescendant = computed(() => this.inputs.popup()?.activeDescendant()); + + /** The ID of the popup. */ + readonly popupId = computed(() => this.inputs.popup()?.popupId()); + + /** The type of the popup. */ + readonly popupType = computed(() => this.inputs.popup()?.popupType()); + + /** The autocomplete behavior of the combobox. */ + readonly autocomplete = computed<'none' | 'inline' | 'list' | 'both'>(() => { + const popupType = this.popupType(); + const hasAutocompletePopup = !!this.inputs.popup() && popupType !== 'dialog'; + const hasInlineSuggestion = !!this.inlineSuggestion(); + if (hasAutocompletePopup && hasInlineSuggestion) { + return 'both'; + } + if (hasAutocompletePopup) { + return 'list'; + } + if (hasInlineSuggestion) { + return 'inline'; + } + return 'none'; + }); + + /** A relay for keyboard events to the popup. */ + readonly keyboardEventRelay = signal(undefined); + + /** Whether the combobox is focused. */ + readonly isFocused = signal(false); + + /** Whether the most recent input event was a deletion. */ + readonly isDeleting = signal(false); + + /** Whether the combobox is editable (i.e., an input or textarea). */ + readonly isEditable = computed( + () => + this.element().tagName.toLowerCase() === 'input' || + this.element().tagName.toLowerCase() === 'textarea', + ); + + /** The keydown event manager for the combobox. */ + keydown = computed(() => { + const manager = new KeyboardEventManager(); + + if (!this.expanded()) { + manager.on('ArrowDown', () => this.expanded.set(true)); + + if (!this.isEditable()) { + manager.on(/^(Enter| )$/, () => this.expanded.set(true)); + } + + return manager; + } + + manager + .on( + 'ArrowLeft', + e => { + this.keyboardEventRelay.set(e); + }, + {preventDefault: this.popupType() !== 'listbox', ignoreRepeat: false}, + ) + .on( + 'ArrowRight', + e => { + this.keyboardEventRelay.set(e); + }, + {preventDefault: this.popupType() !== 'listbox', ignoreRepeat: false}, + ) + .on('ArrowUp', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false}) + .on('ArrowDown', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false}) + .on('Home', e => this.keyboardEventRelay.set(e)) + .on('End', e => this.keyboardEventRelay.set(e)) + .on('Enter', e => this.keyboardEventRelay.set(e)) + .on('PageUp', e => this.keyboardEventRelay.set(e)) + .on('PageDown', e => this.keyboardEventRelay.set(e)) + .on('Escape', () => { + if (!this.inputs.alwaysExpanded()) { + this.expanded.set(false); + } + }); + + if (!this.isEditable()) { + manager + .on(' ', e => this.keyboardEventRelay.set(e)) + .on(/^.$/, e => { + this.keyboardEventRelay.set(e); + }); + } + + return manager; + }); + + /** The click event manager for the combobox. */ + click = computed(() => { + const manager = new ClickEventManager(); + + if (this.isEditable()) return manager; + + manager.on(() => this.expanded.update(v => !v)); + + return manager; + }); + + constructor(readonly inputs: SimpleComboboxInputs) { + this.expanded = inputs.expanded; + this.value = inputs.value; + } + + /** Handles keydown events for the combobox. */ + onKeydown(event: KeyboardEvent) { + if (!this.inputs.disabled()) { + this.keydown().handle(event); + } + } + + /** Handles click events for the combobox. */ + onClick(event: PointerEvent) { + if (!this.disabled()) { + this.click().handle(event); + } + } + + /** Handles focus in events for the combobox. */ + onFocusin() { + this.isFocused.set(true); + } + + /** Handles focus out events for the combobox. */ + onFocusout(event: FocusEvent) { + this.isFocused.set(false); + } + + /** Handles input events for the combobox. */ + onInput(event: Event) { + if (!(event.target instanceof HTMLInputElement)) return; + if (this.disabled()) return; + + this.expanded.set(true); + this.value.set(event.target.value); + this.isDeleting.set(event instanceof InputEvent && !!event.inputType.match(/^delete/)); + } + + /** Highlights the currently selected item in the combobox. */ + highlightEffect() { + const value = this.value(); + const inlineSuggestion = this.inlineSuggestion(); + + const isDeleting = untracked(() => this.isDeleting()); + const isFocused = untracked(() => this.isFocused()); + const isExpanded = this.expanded(); + + if (!inlineSuggestion || !isFocused || !isExpanded || isDeleting) return; + + const inputEl = this.element() as HTMLInputElement; + const isHighlightable = inlineSuggestion.toLowerCase().startsWith(value.toLowerCase()); + + if (isHighlightable) { + inputEl.value = value + inlineSuggestion.slice(value.length); + inputEl.setSelectionRange(value.length, inlineSuggestion.length); + } + } + + /** Relays keyboard events to the popup. */ + keyboardEventRelayEffect() { + const event = this.keyboardEventRelay(); + if (event === undefined) return; + + const popup = untracked(() => this.inputs.popup()); + const popupExpanded = untracked(() => this.expanded()); + if (popupExpanded) { + popup?.controlTarget()?.dispatchEvent(event); + } + } + + /** Closes the popup when focus leaves the combobox and popup. */ + closePopupOnBlurEffect() { + const expanded = this.expanded(); + const comboboxFocused = this.isFocused(); + const popupFocused = !!this.inputs.popup()?.isFocused(); + if (expanded && !this.inputs.alwaysExpanded() && !comboboxFocused && !popupFocused) { + this.expanded.set(false); + } + } +} + +/** Represents the required inputs for a simple combobox popup. */ +export interface SimpleComboboxPopupInputs { + /** The type of the popup. */ + popupType: SignalLike<'listbox' | 'tree' | 'grid' | 'dialog'>; + + /** The element that serves as the control target for the popup. */ + controlTarget: SignalLike; + + /** The ID of the active descendant in the popup. */ + activeDescendant: SignalLike; + + /** The ID of the popup. */ + popupId: SignalLike; +} + +/** Controls the state of a simple combobox popup. */ +export class SimpleComboboxPopupPattern { + /** The type of the popup. */ + readonly popupType = () => this.inputs.popupType(); + + /** The element that serves as the control target for the popup. */ + readonly controlTarget = () => this.inputs.controlTarget(); + + /** The ID of the active descendant in the popup. */ + readonly activeDescendant = () => this.inputs.activeDescendant(); + + /** The ID of the popup. */ + readonly popupId = () => this.inputs.popupId(); + + /** Whether the popup is focused. */ + readonly isFocused = signal(false); + + constructor(readonly inputs: SimpleComboboxPopupInputs) {} + + /** Handles focus in events for the popup. */ + onFocusin() { + this.isFocused.set(true); + } + + /** Handles focus out events for the popup. */ + onFocusout(event: FocusEvent) { + const focusTarget = event.relatedTarget as Element | null; + if (this.controlTarget()?.contains(focusTarget)) return; + + this.isFocused.set(false); + } +} diff --git a/src/aria/simple-combobox/BUILD.bazel b/src/aria/simple-combobox/BUILD.bazel new file mode 100644 index 000000000000..c75084791e61 --- /dev/null +++ b/src/aria/simple-combobox/BUILD.bazel @@ -0,0 +1,40 @@ +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "simple-combobox", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//:node_modules/@angular/core", + "//src/aria/private", + "//src/cdk/bidi", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = glob( + ["**/*.spec.ts"], + exclude = ["**/*.e2e.spec.ts"], + ), + deps = [ + ":simple-combobox", + "//:node_modules/@angular/common", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//src/aria/grid", + "//src/aria/listbox", + "//src/aria/tree", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/aria/simple-combobox/index.ts b/src/aria/simple-combobox/index.ts new file mode 100644 index 000000000000..2a3628897dec --- /dev/null +++ b/src/aria/simple-combobox/index.ts @@ -0,0 +1,9 @@ +/** + * @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 + */ + +export {Combobox, ComboboxPopup, ComboboxWidget} from './simple-combobox'; diff --git a/src/aria/simple-combobox/simple-combobox.spec.ts b/src/aria/simple-combobox/simple-combobox.spec.ts new file mode 100644 index 000000000000..5a9afcd3e9e4 --- /dev/null +++ b/src/aria/simple-combobox/simple-combobox.spec.ts @@ -0,0 +1,1704 @@ +import { + Component, + computed, + DebugElement, + signal, + ChangeDetectionStrategy, + untracked, + viewChild, + afterRenderEffect, +} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {Combobox, ComboboxPopup, ComboboxWidget} from './simple-combobox'; +import {Listbox, Option} from '../listbox'; +import {runAccessibilityChecks} from '@angular/cdk/testing/private'; +import {Tree, TreeItem, TreeItemGroup} from '../tree'; +import {NgTemplateOutlet} from '@angular/common'; +import {Grid, GridRow, GridCell, GridCellWidget} from '../grid'; +import {MutationObserverFactory} from '@angular/cdk/observers'; + +describe('Combobox', () => { + let currentFixture: ComponentFixture | null = null; + + const resetMutationState = () => { + // No-op, kept to avoid changing setup helpers + }; + + const waitForMutation = (ms = 50) => { + const factory = TestBed.inject(MutationObserverFactory); + return new Promise(resolve => { + let resolved = false; + const timeoutId = setTimeout(() => { + if (!resolved) { + resolved = true; + observer?.disconnect(); + currentFixture?.detectChanges(); + resolve(); + } + }, ms); + + const observer = factory.create(() => { + if (!resolved) { + resolved = true; + clearTimeout(timeoutId); + observer?.disconnect(); + currentFixture?.detectChanges(); + resolve(); + } + }); + observer?.observe(document.body, { + attributes: true, + childList: true, + subtree: true, + }); + }); + }; + + describe('with Listbox', () => { + let fixture: ComponentFixture; + let inputElement: HTMLInputElement; + + const keydown = (key: string, modifierKeys: {} = {}) => { + focus(); + inputElement.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + ...modifierKeys, + }), + ); + fixture.detectChanges(); + }; + + const input = (value: string) => { + focus(); + inputElement.value = value; + inputElement.dispatchEvent(new Event('input', {bubbles: true})); + fixture.detectChanges(); + }; + + const click = (element: HTMLElement, eventInit?: PointerEventInit) => { + focus(); + element.dispatchEvent(new PointerEvent('click', {bubbles: true, ...eventInit})); + fixture.detectChanges(); + }; + + const focus = () => { + inputElement.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + fixture.detectChanges(); + }; + + const blur = (relatedTarget?: EventTarget) => { + inputElement.dispatchEvent(new FocusEvent('focusout', {bubbles: true, relatedTarget})); + fixture.detectChanges(); + }; + + const up = (modifierKeys?: {}) => keydown('ArrowUp', modifierKeys); + const down = (modifierKeys?: {}) => keydown('ArrowDown', modifierKeys); + const enter = (modifierKeys?: {}) => keydown('Enter', modifierKeys); + const escape = (modifierKeys?: {}) => keydown('Escape', modifierKeys); + + function setupCombobox( + componentType: any = ComboboxListboxExample, + opts: {readonly?: boolean} = {}, + ) { + fixture = TestBed.createComponent(componentType); + const testComponent = fixture.componentInstance; + + if (opts.readonly) { + testComponent.readonly.set(true); + } + + fixture.detectChanges(); + defineTestVariables(); + currentFixture = fixture; + resetMutationState(); + } + + function defineTestVariables() { + const inputDebugElement = fixture.debugElement.query(By.directive(Combobox)); + inputElement = inputDebugElement.nativeElement as HTMLInputElement; + } + + function getOption(text: string): HTMLElement | null { + const options = Array.from(document.querySelectorAll('[ngoption]')) as HTMLElement[]; + return options.find(option => option.textContent?.trim() === text) || null; + } + + function getOptions(): HTMLElement[] { + return Array.from(document.querySelectorAll('[ngoption]')) as HTMLElement[]; + } + + afterEach(async () => await runAccessibilityChecks(fixture.nativeElement)); + + describe('ARIA attributes and roles', () => { + beforeEach(() => setupCombobox()); + + it('should have the combobox role on the input', () => { + expect(inputElement.getAttribute('role')).toBe('combobox'); + }); + + it('should have aria-haspopup set to listbox', () => { + focus(); + expect(inputElement.getAttribute('aria-haspopup')).toBe('listbox'); + }); + + it('should set aria-controls to the listbox id', () => { + down(); // Focus on Alabama + const listbox = fixture.debugElement.query(By.directive(Listbox)).nativeElement; + expect(inputElement.getAttribute('aria-controls')).toBe(listbox.id); + }); + + it('should set aria-multiselectable to false on the listbox', () => { + down(); // Focus on Alabama + const listbox = fixture.debugElement.query(By.directive(Listbox)).nativeElement; + expect(listbox.getAttribute('aria-multiselectable')).toBe('false'); + }); + + it('should set aria-selected on the selected option', async () => { + down(); // Focus on Alabama + expect(getOption('Alabama')!.getAttribute('aria-selected')).toBe('false'); + enter(); // Select Alabama + + down(); // Reopen popup and focus on Alabama + + expect(getOption('Alabama')!.getAttribute('aria-selected')).toBe('true'); + }); + + it('should set aria-expanded to false by default', () => { + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should toggle aria-expanded when opening and closing', () => { + down(); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + escape(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should not have aria-activedescendant by default', () => { + expect(inputElement.hasAttribute('aria-activedescendant')).toBe(false); + }); + + it('should set aria-activedescendant to the active option id', async () => { + down(); + const option = getOption('Alabama')!; + + await waitForMutation(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(option.id); + }); + }); + + describe('Navigation', () => { + beforeEach(() => setupCombobox()); + + it('should navigate to the first item on ArrowDown', async () => { + down(); + const options = getOptions(); + + await waitForMutation(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[0].id); + }); + + it('should navigate to the last item on ArrowUp', async () => { + down(); // Opens the focus on Alabama + up(); + const options = getOptions(); + + await waitForMutation(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe( + options[options.length - 1].id, + ); + }); + + it('should navigate to the next item on ArrowDown when open', async () => { + down(); // Open popup + down(); // Move to next item + const options = getOptions(); + await waitForMutation(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[1].id); + }); + + it('should navigate to the previous item on ArrowUp when open', async () => { + down(); // Open + down(); // Move to next item + up(); // Move back to first item + const options = getOptions(); + await waitForMutation(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[0].id); + }); + + it('should navigate to the first item on Home when open', async () => { + down(); // Open + down(); // Move to next item + keydown('Home'); + const options = getOptions(); + await waitForMutation(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[0].id); + }); + + it('should navigate to the last item on End when open', async () => { + down(); // Open + keydown('End'); + const options = getOptions(); + await waitForMutation(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe( + options[options.length - 1].id, + ); + }); + }); + + describe('Expansion', () => { + beforeEach(() => setupCombobox()); + + it('should open on ArrowDown', () => { + focus(); + keydown('ArrowDown'); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should close on Escape', () => { + down(); + escape(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on focusout', () => { + focus(); + blur(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on escape and maintain the current input value', async () => { + setupCombobox(ComboboxListboxHighlightExample); + + down(); // Use down() instead of focus() + input('Ala'); + expect(inputElement.value).toBe('Alabama'); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + + escape(); + expect(inputElement.value).toBe('Alabama'); + expect(inputElement.selectionEnd).toBe(7); + expect(inputElement.selectionStart).toBe(3); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on enter', () => { + down(); + enter(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on click to select an item', () => { + down(); + const fruitItem = getOption('Alabama')!; + click(fruitItem); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + }); + describe('Selection', () => { + describe('with manual filtering', () => { + beforeEach(() => setupCombobox(ComboboxListboxExample)); + + it('should select and commit on click', async () => { + down(); // Use down() to open + + const options = getOptions(); + click(options[0]); + + expect(fixture.componentInstance.value()).toEqual(['Alabama']); + expect(inputElement.value).toBe('Alabama'); + }); + + it('should select and commit to input on Enter', async () => { + focus(); + down(); + + enter(); + + expect(fixture.componentInstance.value()).toEqual(['Alabama']); + expect(inputElement.value).toBe('Alabama'); + }); + + it('should not select on navigation', () => { + down(); + down(); + + expect(fixture.componentInstance.value()).toEqual([]); + }); + + it('should select on focusout if the input text exactly matches an item', () => { + focus(); + input('Alabama'); + blur(); + + expect(fixture.componentInstance.value()).toEqual(['Alabama']); + }); + + it('should not select on focusout if the input text does not match an item', () => { + focus(); + input('Appl'); + blur(); + + expect(fixture.componentInstance.value()).toEqual([]); + expect(inputElement.value).toBe('Appl'); + }); + }); + + describe('with auto-select behavior', () => { + beforeEach(() => setupCombobox(ComboboxListboxAutoSelectExample)); + + it('should select and commit on click', async () => { + down(); // Use down() to open + + const options = getOptions(); + click(options[1]); + + expect(fixture.componentInstance.value()).toEqual(['Alaska']); + expect(inputElement.value).toBe('Alaska'); + }); + + it('should select and commit on Enter', () => { + down(); + down(); + enter(); + + expect(fixture.componentInstance.value()).toEqual(['Alaska']); + expect(inputElement.value).toBe('Alaska'); + }); + + it('should select on navigation in auto-select', async () => { + down(); + + expect(fixture.componentInstance.value()).toEqual(['Alabama']); + + down(); + + expect(fixture.componentInstance.value()).toEqual(['Alaska']); + + down(); + + expect(fixture.componentInstance.value()).toEqual(['Arizona']); + }); + it('should select the first option on input', () => { + focus(); + input('W'); + + expect(fixture.componentInstance.value()).toEqual(['Washington']); + }); + + it('should commit the selected option on focusout', () => { + focus(); + input('G'); + blur(); + + expect(inputElement.value).toBe('Georgia'); + expect(fixture.componentInstance.value()).toEqual(['Georgia']); + }); + }); + + describe('with highlight behavior', () => { + beforeEach(() => setupCombobox(ComboboxListboxHighlightExample)); + + it('should select and commit on click', async () => { + down(); // Use down() to open + + const options = getOptions(); + click(options[2]); + + expect(fixture.componentInstance.value()).toEqual(['Arizona']); + expect(inputElement.value).toBe('Arizona'); + }); + + it('should select and commit on Enter', async () => { + down(); + + down(); + down(); + enter(); + + expect(fixture.componentInstance.value()).toEqual(['Arizona']); + expect(inputElement.value).toBe('Arizona'); + }); + + it('should select on navigation', async () => { + down(); + + // Should auto-select the first option on open + expect(fixture.componentInstance.value()).toEqual(['Alabama']); + + down(); + + // Should update selection on navigation + expect(fixture.componentInstance.value()).toEqual(['Alaska']); + }); + + it('should update input value on navigation', async () => { + down(); + + expect(inputElement.value).toBe('Alabama'); + + down(); + + expect(inputElement.value).toBe('Alaska'); + }); + + it('should select the first option on input', async () => { + down(); // Use down() instead of focus() + + input('Cali'); + + expect(fixture.componentInstance.value()).toEqual(['California']); + }); + + it('should insert a highlighted completion string on input', async () => { + down(); // Use down() instead of focus() + + input('A'); + + expect(inputElement.value).toBe('Alabama'); + expect(inputElement.selectionStart).toBe(1); + expect(inputElement.selectionEnd).toBe(7); + }); + + it('should not insert a completion string on backspace', async () => { + down(); // Use down() instead of focus() + + input('New'); + + expect(inputElement.value).toBe('New Hampshire'); + expect(inputElement.selectionStart).toBe(3); + expect(inputElement.selectionEnd).toBe(13); + }); + + it('should insert a completion string even if the items are not changed', async () => { + down(); // Use down() instead of focus() + + input('New'); + await fixture.whenStable(); + fixture.detectChanges(); + + input('New '); + + expect(inputElement.value).toBe('New Hampshire'); + expect(inputElement.selectionStart).toBe(4); + expect(inputElement.selectionEnd).toBe(13); + }); + + it('should commit the selected option on focusout', async () => { + down(); // Use down() instead of focus() + + input('Cali'); + + blur(); + + expect(inputElement.value).toBe('California'); + expect(fixture.componentInstance.value()).toEqual(['California']); + }); + }); + }); + + describe('Filtering', () => { + it('should lazily render options', async () => { + setupCombobox(); + expect(getOptions().length).toBe(0); + + down(); + + expect(getOptions().length).toBe(50); + }); + + it('should filter the options based on the input value', () => { + setupCombobox(); + focus(); + input('New'); + + const options = getOptions(); + expect(options.length).toBe(4); + expect(options[0].textContent?.trim()).toBe('New Hampshire'); + expect(options[1].textContent?.trim()).toBe('New Jersey'); + expect(options[2].textContent?.trim()).toBe('New Mexico'); + expect(options[3].textContent?.trim()).toBe('New York'); + }); + + it('should show no options if nothing matches', () => { + setupCombobox(); + focus(); + input('xyz'); + const options = getOptions(); + expect(options.length).toBe(0); + }); + + it('should show all options when the input is cleared', () => { + setupCombobox(); + focus(); + input('Alabama'); + expect(getOptions().length).toBe(1); + + input(''); + expect(getOptions().length).toBe(50); + }); + }); + + describe('Readonly', () => { + beforeEach(() => setupCombobox(ComboboxListboxExample, {readonly: true})); + + it('should close on selection', () => { + focus(); + down(); + click(getOption('Alabama')!); + expect(inputElement.value).toBe('Alabama'); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on escape', () => { + focus(); + down(); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + escape(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + }); + + describe('Always Expanded', () => { + beforeEach(() => setupCombobox()); + + it('should not close on escape when alwaysExpanded is true', () => { + fixture.componentInstance.alwaysExpanded.set(true); + fixture.detectChanges(); + + focus(); + // Manually open since alwaysExpanded was set after init + fixture.componentInstance.popupExpanded.set(true); + fixture.detectChanges(); + + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + + escape(); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + }); + }); + + describe('Disabled', () => { + beforeEach(() => setupCombobox()); + + it('should keep the input focusable by default when disabled', () => { + fixture.componentInstance.disabled.set(true); + fixture.detectChanges(); + + expect(inputElement.disabled).toBe(false); + expect(inputElement.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should block interactions when disabled', () => { + fixture.componentInstance.disabled.set(true); + fixture.detectChanges(); + + focus(); + keydown('ArrowDown'); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should make the input unfocusable when softDisabled is false', () => { + fixture.componentInstance.disabled.set(true); + fixture.componentInstance.softDisabled.set(false); + fixture.detectChanges(); + + expect(inputElement.disabled).toBe(true); + expect(inputElement.getAttribute('aria-disabled')).toBe('true'); + }); + }); + }); + + describe('with Tree', () => { + let fixture: ComponentFixture; + let inputElement: HTMLInputElement; + + const keydown = (key: string, modifierKeys: {} = {}) => { + focus(); + inputElement.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + ...modifierKeys, + }), + ); + fixture.detectChanges(); + }; + + const input = (value: string) => { + focus(); + inputElement.value = value; + inputElement.dispatchEvent(new Event('input', {bubbles: true})); + fixture.detectChanges(); + }; + + const click = (element: HTMLElement, eventInit?: PointerEventInit) => { + focus(); + element.dispatchEvent(new PointerEvent('click', {bubbles: true, ...eventInit})); + fixture.detectChanges(); + }; + + const focus = () => { + inputElement.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + fixture.detectChanges(); + }; + + const blur = (relatedTarget?: EventTarget) => { + inputElement.dispatchEvent(new FocusEvent('focusout', {bubbles: true, relatedTarget})); + fixture.detectChanges(); + }; + + const up = (modifierKeys?: {}) => keydown('ArrowUp', modifierKeys); + const down = (modifierKeys?: {}) => keydown('ArrowDown', modifierKeys); + const left = (modifierKeys?: {}) => keydown('ArrowLeft', modifierKeys); + const right = (modifierKeys?: {}) => keydown('ArrowRight', modifierKeys); + const enter = (modifierKeys?: {}) => keydown('Enter', modifierKeys); + const escape = (modifierKeys?: {}) => keydown('Escape', modifierKeys); + + function setupCombobox(opts: {readonly?: boolean} = {}) { + fixture = TestBed.createComponent(ComboboxTreeExample); + const testComponent = fixture.componentInstance; + + if (opts.readonly) { + testComponent.readonly.set(true); + } + + fixture.detectChanges(); + defineTestVariables(); + currentFixture = fixture; + resetMutationState(); + } + + function defineTestVariables() { + const inputDebugElement = fixture.debugElement.query(By.directive(Combobox)); + inputElement = inputDebugElement.nativeElement as HTMLInputElement; + } + + function getTreeItem(text: string): HTMLElement | null { + const items = Array.from( + fixture.nativeElement.querySelectorAll('[ngTreeItem]'), + ) as HTMLElement[]; + return items.find(item => item.textContent?.trim().startsWith(text)) || null; + } + + function getTreeItems(): HTMLElement[] { + return Array.from(fixture.nativeElement.querySelectorAll('[ngTreeItem]')) as HTMLElement[]; + } + + function getVisibleTreeItems(): HTMLElement[] { + return fixture.debugElement + .queryAll(By.directive(TreeItem)) + .map((debugEl: DebugElement) => debugEl.nativeElement as HTMLElement) + .filter(el => { + if (el.parentElement?.role === 'group') { + return ( + el.parentElement.previousElementSibling?.getAttribute('aria-expanded') === 'true' + ); + } + return true; + }); + } + + afterEach(async () => { + await runAccessibilityChecks(fixture.nativeElement); + }); + + describe('ARIA attributes and roles', () => { + beforeEach(() => setupCombobox()); + + it('should have aria-haspopup set to tree', () => { + focus(); + expect(inputElement.getAttribute('aria-haspopup')).toBe('tree'); + }); + + it('should set aria-controls to the tree id', () => { + down(); + const tree = fixture.debugElement.query(By.directive(Tree)).nativeElement; + expect(inputElement.getAttribute('aria-controls')).toBe(tree.id); + }); + + it('should set aria-selected on the selected tree item', async () => { + down(); + const item = getTreeItem('Winter')!; + enter(); + expect(item.getAttribute('aria-selected')).toBe('true'); + }); + + it('should toggle aria-expanded on parent nodes', async () => { + down(); + await waitForMutation(20); + const item = getTreeItem('Winter')!; + expect(item.getAttribute('aria-expanded')).toBe('false'); + + right(); // Opens Winter + await waitForMutation(20); + expect(item.getAttribute('aria-expanded')).toBe('true'); + + left(); // Closes Winter + await waitForMutation(20); + expect(item.getAttribute('aria-expanded')).toBe('false'); + }); + }); + + describe('Navigation', () => { + beforeEach(() => setupCombobox()); + + it('should navigate to the first focusable item on ArrowDown', async () => { + down(); // Winter + await waitForMutation(10); + const item = getTreeItem('Winter')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); + }); + + it('should navigate to the last focusable item on ArrowUp', async () => { + down(); // Winter + up(); // Fall + await waitForMutation(10); + const item = getTreeItem('Fall')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); + }); + + it('should navigate to the next focusable item on ArrowDown when open', async () => { + down(); // Winter + down(); // Spring + await waitForMutation(10); + const item = getTreeItem('Spring')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); + }); + + it('should navigate to the previous item on ArrowUp when open', async () => { + down(); // Winter + down(); // Spring + down(); // Summer + down(); // Fall + up(); // Summer + await waitForMutation(10); + const item = getTreeItem('Summer')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); + }); + + it('should expand a closed node on ArrowRight', async () => { + down(); // Winter + expect(getVisibleTreeItems().length).toBe(4); + right(); // Expand Winter + expect(getVisibleTreeItems().length).toBe(7); + expect(getTreeItem('January')).not.toBeNull(); + }); + + it('should navigate to the next item on ArrowRight when already expanded', async () => { + down(); // Winter + right(); // Expand Winter + right(); // December + + const item = getTreeItem('December')!; + await waitForMutation(10); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); + }); + + it('should collapse an open node on ArrowLeft', async () => { + down(); // Winter + right(); // Winter Expanded + expect(getVisibleTreeItems().length).toBe(7); + left(); // Winter Collapsed + expect(getVisibleTreeItems().length).toBe(4); + await waitForMutation(10); + const item = getTreeItem('Winter')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); + }); + + it('should navigate to the parent node on ArrowLeft when in a child node', async () => { + down(); // Winter + right(); // Expand Winter + right(); // December + await waitForMutation(10); + + const item1 = getTreeItem('December')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item1.id); + + left(); + await waitForMutation(10); + + const item2 = getTreeItem('Winter')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item2.id); + }); + + it('should navigate to the first focusable item on Home when open', async () => { + down(); + down(); + keydown('Home'); + await waitForMutation(10); + + const item = getTreeItem('Winter')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); + }); + + it('should navigate to the last focusable item on End when open', async () => { + down(); + down(); + keydown('End'); + await waitForMutation(10); + + const grainsItem = getTreeItem('Fall')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(grainsItem.id); + }); + }); + + describe('Expansion', () => { + beforeEach(() => setupCombobox()); + + it('should open on ArrowDown', () => { + focus(); + keydown('ArrowDown'); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should close on Escape', () => { + down(); + escape(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on focusout', () => { + focus(); + blur(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on escape', () => { + focus(); + input('Mar'); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + escape(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on enter', () => { + down(); + enter(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on click to select an item', () => { + down(); + click(getTreeItem('Spring')!); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + }); + + describe('Selection', () => { + describe('with manual filtering', () => { + beforeEach(() => setupCombobox()); + + it('should select and commit on click', () => { + click(inputElement); + + // Iterate to the parent node and expand it so the child is visible + down(); // Winter + down(); // Spring + right(); // Expand Spring + + const item = getTreeItem('April')!; + click(item); + + expect(fixture.componentInstance.value()).toEqual(['April']); + expect(inputElement.value).toBe('April'); + }); + + it('should select and commit to input on Enter', () => { + down(); + enter(); + + expect(fixture.componentInstance.value()).toEqual(['Winter']); + expect(inputElement.value).toBe('Winter'); + }); + + it('should select on focusout if the input text exactly matches an item', () => { + focus(); + input('November'); + blur(); + + expect(fixture.componentInstance.value()).toEqual(['November']); + }); + + it('should not select on navigation', () => { + down(); + down(); + + expect(fixture.componentInstance.value()).toEqual([]); + }); + + it('should not select on focusout if the input text does not match an item', () => { + focus(); + input('Appl'); + blur(); + + expect(fixture.componentInstance.value()).toEqual([]); + expect(inputElement.value).toBe('Appl'); + }); + }); + }); + + describe('Filtering', () => { + beforeEach(() => setupCombobox()); + + it('should lazily render options', async () => { + expect(getTreeItems().length).toBe(0); + + focus(); + down(); + // Mutate dataSource to expand all + fixture.componentInstance.dataSource().forEach(node => (node.expanded = true)); + + // Force computed signal to re-evaluate by updating dataSource reference + fixture.componentInstance.dataSource.set([...fixture.componentInstance.dataSource()]); + fixture.detectChanges(); + await waitForMutation(); + expect(getTreeItems().length).toBe(16); + }); + + it('should filter the options based on the input value', () => { + focus(); + input('Summer'); + + let items = getVisibleTreeItems(); + expect(items.length).toBe(1); + expect(items[0].textContent?.trim()).toBe('Summer'); + }); + + it('should render parents if a child matches', () => { + focus(); + input('January'); + + let items = getVisibleTreeItems(); + expect(items.length).toBe(2); + expect(items[0].textContent?.trim()).toBe('Winter'); + expect(items[1].textContent?.trim()).toBe('January'); + }); + + it('should show no options if nothing matches', () => { + focus(); + input('xyz'); + expect(getVisibleTreeItems().length).toBe(0); + }); + + it('should show all options when the input is cleared', () => { + focus(); + input('Winter'); + expect(getVisibleTreeItems().length).toBe(1); + + input(''); + expect(getVisibleTreeItems().length).toBe(4); + }); + + it('should expand all nodes when filtering', () => { + focus(); + down(); + + expect(getVisibleTreeItems().length).toBe(4); + + input('J'); + + expect(getTreeItem('Winter')!.getAttribute('aria-expanded')).toBe('true'); + expect(getTreeItem('Summer')!.getAttribute('aria-expanded')).toBe('true'); + }); + }); + }); + + describe('with Grid', () => { + let fixture: ComponentFixture; + let inputElement: HTMLInputElement; + + const keydown = (key: string, modifierKeys: {} = {}) => { + focus(); + inputElement.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + ...modifierKeys, + }), + ); + fixture.detectChanges(); + }; + + const focus = () => { + inputElement.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + fixture.detectChanges(); + }; + + const blur = (relatedTarget?: EventTarget) => { + inputElement.dispatchEvent(new FocusEvent('focusout', {bubbles: true, relatedTarget})); + fixture.detectChanges(); + }; + + const up = (modifierKeys?: {}) => keydown('ArrowUp', modifierKeys); + const down = (modifierKeys?: {}) => keydown('ArrowDown', modifierKeys); + const left = (modifierKeys?: {}) => keydown('ArrowLeft', modifierKeys); + const right = (modifierKeys?: {}) => keydown('ArrowRight', modifierKeys); + const enter = (modifierKeys?: {}) => keydown('Enter', modifierKeys); + const escape = (modifierKeys?: {}) => keydown('Escape', modifierKeys); + const home = (modifierKeys?: {}) => keydown('Home', modifierKeys); + const end = (modifierKeys?: {}) => keydown('End', modifierKeys); + + function setupCombobox() { + fixture = TestBed.createComponent(ComboboxGridExample); + fixture.detectChanges(); + const inputDebugElement = fixture.debugElement.query(By.directive(Combobox)); + inputElement = inputDebugElement.nativeElement as HTMLInputElement; + currentFixture = fixture; + resetMutationState(); + } + + beforeEach(() => setupCombobox()); + + describe('ARIA attributes and roles', () => { + beforeEach(() => setupCombobox()); + + it('should have the combobox role on the input', () => { + expect(inputElement.getAttribute('role')).toBe('combobox'); + }); + + it('should have aria-haspopup set to grid', () => { + focus(); + expect(inputElement.getAttribute('aria-haspopup')).toBe('grid'); + }); + + it('should set aria-controls to the grid id', () => { + down(); + const grid = fixture.debugElement.query(By.directive(Grid)).nativeElement; + expect(inputElement.getAttribute('aria-controls')).toBe(grid.id); + }); + + it('should toggle aria-expanded when opening and closing', () => { + down(); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + escape(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should set aria-activedescendant to the active grid cell id', async () => { + focus(); + down(); // Open popup + await waitForMutation(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-label'); + }); + }); + + it('should navigate up and down with grid navigation', async () => { + focus(); + down(); // Open popup + + down(); // Navigate down to 'Bird-label' + await waitForMutation(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Bird-label'); + + up(); // Navigate back up to 'Antelope-label' + await waitForMutation(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-label'); + }); + + it('should navigate left and right with grid navigation', async () => { + focus(); + down(); // Open popup + + right(); // Move right to 'Antelope-delete' + await waitForMutation(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-delete'); + + left(); // Move back left to 'Antelope-label' + await waitForMutation(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-label'); + }); + + it('should navigate to the start of the row on Home', async () => { + focus(); + down(); // Open popup + + right(); // Move right to 'Antelope-delete' + await waitForMutation(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-delete'); + + home(); // Move back to 'Antelope-label' + await waitForMutation(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-label'); + }); + + it('should navigate to the end of the row on End', async () => { + focus(); + down(); // Open popup + + end(); // Move to end of row ('Antelope-delete') + await waitForMutation(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-delete'); + }); + + it('should update aria-activedescendant with grid navigation', async () => { + focus(); + down(); // Open popup + + down(); // Navigate down + await waitForMutation(20); + + // The active item is 'Bird' because we navigated down once more + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Bird-label'); + + right(); // Move right to delete button + await waitForMutation(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Bird-delete'); + + down(); // Move down to next row + await waitForMutation(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Cat-delete'); + }); + + it('should remove an item when delete is pressed in the delete cell', async () => { + down(); // On Antelope + right(); // Move right to delete button + enter(); // Click delete button + expect(fixture.componentInstance.items()).not.toContain('Antelope'); + }); + + it('should filter items and maintain selection', async () => { + down(); // Antelope + enter(); // Select active item + await waitForMutation(20); + + expect(fixture.componentInstance.searchString()).toBe('Antelope'); + + inputElement.value = ''; + inputElement.dispatchEvent(new Event('input', {bubbles: true})); + fixture.detectChanges(); + + expect(fixture.componentInstance.searchString()).toBe(''); + + down(); // Go to BirdLabel + await waitForMutation(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Bird-label'); + }); + + describe('Expansion', () => { + beforeEach(() => setupCombobox()); + + it('should close on Escape', () => { + down(); + escape(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on focusout', () => { + focus(); + blur(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on enter', () => { + down(); + enter(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + }); + + describe('Selection', () => { + beforeEach(() => setupCombobox()); + + it('should select and commit on click', async () => { + focus(); + down(); // Open popup + + const gridCells = fixture.nativeElement.querySelectorAll('[ngGridCellWidget]'); + gridCells[0].dispatchEvent(new PointerEvent('click', {bubbles: true})); + fixture.detectChanges(); + await waitForMutation(20); + + expect(fixture.componentInstance.selectedItem()).toBe('Antelope'); + expect(inputElement.value).toBe('Antelope'); + }); + + it('should not select on navigation', async () => { + focus(); + down(); // Open popup + + down(); // Move row down + await waitForMutation(20); + + expect(fixture.componentInstance.selectedItem()).toBeNull(); + }); + }); + }); +}); + +@Component({ + template: ` +
+ + + +
+ @for (option of options(); track option) { +
+ {{option}} +
+ } +
+
+
+ `, + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class ComboboxListboxExample { + readonly = signal(false); + disabled = signal(false); + softDisabled = signal(true); + alwaysExpanded = signal(false); + popupExpanded = signal(false); + searchString = signal(''); + value = signal([]); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + onCommit() { + const val = this.value(); + if (val.length > 0) { + this.searchString.set(val[0]); + } + this.popupExpanded.set(false); + } + + onBlur() { + const search = this.searchString().trim().toLowerCase(); + if (!search) return; + + const match = states.find(state => state.toLowerCase().startsWith(search)); + if (match) { + this.value.set([match]); + this.searchString.set(match); + } + } +} + +interface TreeNode { + name: string; + children?: TreeNode[]; + expanded?: boolean; +} + +function getTreeNodes(): TreeNode[] { + return [ + { + name: 'Winter', + expanded: false, + children: [{name: 'December'}, {name: 'January'}, {name: 'February'}], + }, + { + name: 'Spring', + expanded: false, + children: [{name: 'March'}, {name: 'April'}, {name: 'May'}], + }, + { + name: 'Summer', + expanded: false, + children: [{name: 'June'}, {name: 'July'}, {name: 'August'}], + }, + { + name: 'Fall', + expanded: false, + children: [{name: 'September'}, {name: 'October'}, {name: 'November'}], + }, + ]; +} + +@Component({ + template: ` +
+ + + +
    + +
+
+
+ + + @for (node of nodes; track node.name) { +
  • + {{ node.name }} +
  • + + @if (node.children) { +
      + + + +
    + } + } +
    + `, + imports: [ + Combobox, + ComboboxPopup, + ComboboxWidget, + Tree, + TreeItem, + TreeItemGroup, + NgTemplateOutlet, + ], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class ComboboxTreeExample { + readonly tree = viewChild(Tree); + + readonly = signal(false); + popupExpanded = signal(false); + searchString = signal(''); + value = signal([]); + readonly dataSource = signal(getTreeNodes()); + nodes = computed(() => { + const res = this.filterTreeNodes(this.dataSource()); + return res; + }); + + onCommit() { + const selected = this.value(); + if (selected.length > 0) { + this.searchString.set(selected[0]); + } + this.popupExpanded.set(false); + } + + onBlur() { + const flatNodes = this.flattenTreeNodes(this.dataSource()); + const match = flatNodes.find(n => n.name.toLowerCase() === this.searchString().toLowerCase()); + if (match) { + this.value.set([match.name]); + } + } + + firstMatch = computed(() => { + const flatNodes = this.flattenTreeNodes(this.nodes()); + const node = flatNodes.find(n => this.isMatch(n)); + return node?.name; + }); + + constructor() { + afterRenderEffect(() => { + const active = this.tree()?._pattern.inputs.activeItem(); + if (active) { + untracked(() => { + active.element()?.scrollIntoView({block: 'nearest'}); + }); + } + }); + } + + flattenTreeNodes(nodes: TreeNode[]): TreeNode[] { + return nodes.flatMap(node => { + return node.children ? [node, ...this.flattenTreeNodes(node.children)] : [node]; + }); + } + + deepCopyNodes(nodes: TreeNode[]): TreeNode[] { + return nodes.map(node => ({ + ...node, + children: node.children ? this.deepCopyNodes(node.children) : undefined, + })); + } + + filterTreeNodes(nodes: TreeNode[]): TreeNode[] { + const search = this.searchString().trim().toLowerCase(); + if (!search) { + return nodes; + } + + return nodes.reduce((acc, node) => { + const children = node.children ? this.filterTreeNodes(node.children) : undefined; + if (this.isMatch(node) || (children && children.length > 0)) { + acc.push({ + ...node, + children, + expanded: children && children.length > 0 ? true : node.expanded, + }); + } + return acc; + }, [] as TreeNode[]); + } + + isMatch(node: TreeNode) { + return node.name.toLowerCase().includes(this.searchString().toLowerCase()); + } +} + +const states = [ + 'Alabama', + 'Alaska', + 'Arizona', + 'Arkansas', + 'California', + 'Colorado', + 'Connecticut', + 'Delaware', + 'Florida', + 'Georgia', + 'Hawaii', + 'Idaho', + 'Illinois', + 'Indiana', + 'Iowa', + 'Kansas', + 'Kentucky', + 'Louisiana', + 'Maine', + 'Maryland', + 'Massachusetts', + 'Michigan', + 'Minnesota', + 'Mississippi', + 'Missouri', + 'Montana', + 'Nebraska', + 'Nevada', + 'New Hampshire', + 'New Jersey', + 'New Mexico', + 'New York', + 'North Carolina', + 'North Dakota', + 'Ohio', + 'Oklahoma', + 'Oregon', + 'Pennsylvania', + 'Rhode Island', + 'South Carolina', + 'South Dakota', + 'Tennessee', + 'Texas', + 'Utah', + 'Vermont', + 'Virginia', + 'Washington', + 'West Virginia', + 'Wisconsin', + 'Wyoming', +]; + +@Component({ + template: ` +
    + + + +
    + @for (item of filteredItems(); track item; let i = $index) { +
    +
    + +
    +
    + +
    +
    + } +
    +
    +
    + `, + imports: [Combobox, ComboboxPopup, ComboboxWidget, Grid, GridRow, GridCell, GridCellWidget], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class ComboboxGridExample { + popupExpanded = signal(false); + searchString = signal(''); + selectedItem = signal(null); + + items = signal(['Antelope', 'Bird', 'Cat', 'Dog']); + + filteredItems = computed(() => { + const search = this.searchString().toLowerCase(); + return this.items().filter(item => item.toLowerCase().includes(search)); + }); + + selectItem(item: string) { + this.selectedItem.set(item); + this.searchString.set(item); + this.popupExpanded.set(false); + } + + removeItem(itemToRemove: string) { + this.items.update(items => items.filter(item => item !== itemToRemove)); + } +} + +@Component({ + template: ` +
    + + + +
    + @for (option of options(); track option) { +
    + {{option}} +
    + } +
    +
    +
    + `, + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class ComboboxListboxAutoSelectExample { + readonly = signal(false); + popupExpanded = signal(false); + searchString = signal(''); + value = signal([]); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + onInput() { + const filtered = this.options(); + if (filtered.length > 0) { + this.value.set([filtered[0]]); + } + } + + onCommit() { + const val = this.value(); + if (val.length > 0) { + this.searchString.set(val[0]); + } + this.popupExpanded.set(false); + } + + onBlur() { + const search = this.searchString().trim().toLowerCase(); + if (!search) return; + + const match = states.find(state => state.toLowerCase().startsWith(search)); + if (match) { + this.value.set([match]); + this.searchString.set(match); + } + } +} + +@Component({ + template: ` +
    + + + +
    + @for (option of options(); track option) { +
    + {{option}} +
    + } +
    +
    +
    + `, + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class ComboboxListboxHighlightExample { + readonly combobox = viewChild(Combobox); + readonly = signal(false); + popupExpanded = signal(false); + searchString = signal(''); + value = signal([]); + readonly activeDescendantValue = signal(undefined); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + constructor() { + afterRenderEffect(() => { + const id = this.combobox()?._pattern.activeDescendant(); + if (id) { + const el = document.getElementById(id); + this.activeDescendantValue.set(el?.textContent?.trim()); + } else { + this.activeDescendantValue.set(undefined); + } + }); + } + + onCommit() { + const val = this.value(); + if (val.length > 0) { + this.searchString.set(val[0]); + } + this.popupExpanded.set(false); + } +} diff --git a/src/aria/simple-combobox/simple-combobox.ts b/src/aria/simple-combobox/simple-combobox.ts new file mode 100644 index 000000000000..93a422f707d8 --- /dev/null +++ b/src/aria/simple-combobox/simple-combobox.ts @@ -0,0 +1,288 @@ +/** + * @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 { + afterRenderEffect, + booleanAttribute, + computed, + Directive, + ElementRef, + inject, + input, + model, + OnDestroy, + OnInit, + signal, + Renderer2, +} from '@angular/core'; +import { + DeferredContent, + DeferredContentAware, + SimpleComboboxPattern, + SimpleComboboxPopupPattern, +} from '@angular/aria/private'; + +/** + * The container element that wraps a combobox input and popup, and orchestrates its behavior. + * + * The `ngCombobox` directive is the main entry point for creating a combobox and customizing its + * behavior. It coordinates the interactions between the input and the popup. + * + * ```html + *
    + * + * + * + *
    + * + *
    + *
    + *
    + * ``` + */ +@Directive({ + selector: '[ngCombobox]', + exportAs: 'ngCombobox', + host: { + 'role': 'combobox', + '[attr.aria-autocomplete]': '_pattern.autocomplete()', + '[attr.aria-disabled]': '_pattern.disabled()', + '[attr.aria-expanded]': '_pattern.expanded()', + '[attr.aria-activedescendant]': '_pattern.activeDescendant()', + '[attr.aria-controls]': '_pattern.popupId()', + '[attr.aria-haspopup]': '_pattern.popupType()', + '[attr.tabindex]': 'disabled() && !softDisabled() ? -1 : null', + '[attr.disabled]': 'disabled() && !softDisabled() ? "" : null', + '(keydown)': '_pattern.onKeydown($event)', + '(focusin)': '_pattern.onFocusin()', + '(focusout)': '_pattern.onFocusout($event)', + '(click)': '_pattern.onClick($event)', + '(input)': '_pattern.onInput($event)', + }, +}) +export class Combobox extends DeferredContentAware implements OnInit { + private readonly _renderer = inject(Renderer2); + + /** The element that the combobox is attached to. */ + private readonly _elementRef = inject>(ElementRef); + + /** A reference to the input element. */ + readonly element = this._elementRef.nativeElement; + + /** The popup associated with the combobox. */ + readonly _popup = signal(undefined); + + /** Whether the combobox is disabled. */ + readonly disabled = input(false, {transform: booleanAttribute}); + + /** Whether the combobox is soft disabled (remains focusable). */ + readonly softDisabled = input(true, {transform: booleanAttribute}); + + /** Whether the combobox should always remain expanded. */ + readonly alwaysExpanded = input(false, {transform: booleanAttribute}); + + /** Whether the combobox is expanded. */ + readonly expanded = model(false); + + /** The value of the combobox input. */ + readonly value = model(''); + + /** An inline suggestion to be displayed in the input. */ + readonly inlineSuggestion = input(undefined); + + /** The combobox ui pattern. */ + readonly _pattern = new SimpleComboboxPattern({ + ...this, + element: () => this.element, + expandable: () => true, + popup: computed(() => this._popup()?._pattern), + }); + + constructor() { + super(); + + afterRenderEffect(() => this._pattern.keyboardEventRelayEffect()); + afterRenderEffect(() => this._pattern.closePopupOnBlurEffect()); + afterRenderEffect(() => { + this.contentVisible.set(this._pattern.expanded()); + }); + + if (this._pattern.isEditable()) { + afterRenderEffect(() => { + this._renderer.setProperty(this.element, 'value', this.value()); + }); + afterRenderEffect(() => { + this._pattern.highlightEffect(); + }); + } + } + + ngOnInit() { + if (this.alwaysExpanded()) { + this.expanded.set(true); + } + } + + /** Registers a popup with the combobox. */ + _registerPopup(popup: ComboboxPopup) { + this._popup.set(popup); + } + + /** Unregisters the popup from the combobox. */ + _unregisterPopup() { + this._popup.set(undefined); + } +} + +/** + * A structural directive that marks the `ng-template` to be used as the popup + * for a combobox. This content is conditionally rendered. + * + * The content of the popup can be any element with the `ngComboboxWidget` directive. + * + * ```html + * + *
    + * + *
    + *
    + * ``` + */ +@Directive({ + selector: 'ng-template[ngComboboxPopup]', + exportAs: 'ngComboboxPopup', + hostDirectives: [DeferredContent], +}) +export class ComboboxPopup implements OnInit, OnDestroy { + private readonly _deferredContent = inject(DeferredContent); + + /** The combobox that the popup belongs to. */ + readonly combobox = input.required(); + + /** The widget contained within the popup. */ + readonly _widget = signal(undefined); + + /** The element that serves as the control target for the popup. */ + readonly controlTarget = computed(() => this._widget()?.element); + + /** The ID of the popup. */ + readonly popupId = computed(() => this._widget()?.popupId()); + + /** The ID of the active descendant in the popup. */ + readonly activeDescendant = computed(() => this._widget()?.activeDescendant()); + + /** The type of the popup (e.g., listbox, tree, grid, dialog). */ + readonly popupType = input<'listbox' | 'tree' | 'grid' | 'dialog'>('listbox'); + + /** The popup pattern. */ + readonly _pattern = new SimpleComboboxPopupPattern({ + ...this, + }); + + ngOnInit() { + this.combobox()._registerPopup(this); + this._deferredContent.deferredContentAware.set(this.combobox()); + } + + ngOnDestroy() { + this.combobox()._unregisterPopup(); + } + + /** Registers a widget with the popup. */ + _registerWidget(widget: ComboboxWidget) { + this._widget.set(widget); + } + + /** Unregisters the widget from the popup. */ + _unregisterWidget() { + this._widget.set(undefined); + } +} + +/** + * Identifies an element as a widget within a combobox popup. + * + * This directive should be applied to the element that contains the options or content + * of the popup. It handles the communication of ID and active descendant information + * to the combobox. + */ +@Directive({ + selector: '[ngComboboxWidget]', + exportAs: 'ngComboboxWidget', + host: { + '(focusin)': 'onFocusin()', + '(focusout)': 'onFocusout($event)', + }, +}) +export class ComboboxWidget implements OnInit, OnDestroy { + /** The element that the popup widget is attached to. */ + private readonly _elementRef = inject>(ElementRef); + private readonly _popup = inject(ComboboxPopup); + + private _observer: MutationObserver | undefined; + + /** A reference to the popup widget element. */ + readonly element = this._elementRef.nativeElement; + + /** The ID of the popup widget. */ + readonly popupId = signal(undefined); + + /** The ID of the active descendant in the widget. */ + readonly activeDescendant = signal(undefined); + + constructor() { + afterRenderEffect(() => { + const controlTarget = this.element; + + this.popupId.set(controlTarget.id); + + this._observer?.disconnect(); + this._observer = new MutationObserver((mutationsList: MutationRecord[]) => { + for (const mutation of mutationsList) { + if (mutation.type === 'attributes' && mutation.attributeName) { + const attributeName = mutation.attributeName; + + if (attributeName === 'aria-activedescendant') { + const activeDescendant = controlTarget.getAttribute('aria-activedescendant'); + if (activeDescendant !== null) { + this.activeDescendant.set(activeDescendant); + } + } + + if (attributeName === 'id') { + this.popupId.set(controlTarget.id); + } + } + } + }); + this._observer.observe(controlTarget, { + attributes: true, + attributeFilter: ['id', 'aria-activedescendant'], + }); + }); + } + + ngOnInit() { + this._popup._registerWidget(this); + } + + ngOnDestroy(): void { + this._observer?.disconnect(); + this._popup._unregisterWidget(); + } + + /** Handles focus in events for the widget. */ + onFocusin() { + this._popup._pattern.onFocusin(); + } + + /** Handles focus out events for the widget. */ + onFocusout(event: FocusEvent) { + this._popup._pattern.onFocusout(event); + } +} diff --git a/src/aria/tree/tree.ts b/src/aria/tree/tree.ts index 5ab3285bc2ba..7b0f6cc3bb8a 100644 --- a/src/aria/tree/tree.ts +++ b/src/aria/tree/tree.ts @@ -130,6 +130,9 @@ export class Tree { /** The delay in seconds before the typeahead search is reset. */ readonly typeaheadDelay = input(500); + /** Whether the tree is tabbable. */ + readonly tabbable = input(true, {transform: booleanAttribute}); + /** The values of the currently selected items. */ readonly value = model([]); diff --git a/src/components-examples/aria/autocomplete/BUILD.bazel b/src/components-examples/aria/autocomplete/BUILD.bazel index 7b5c57c7ef81..329cbfd90b7b 100644 --- a/src/components-examples/aria/autocomplete/BUILD.bazel +++ b/src/components-examples/aria/autocomplete/BUILD.bazel @@ -13,8 +13,8 @@ ng_project( "//:node_modules/@angular/common", "//:node_modules/@angular/core", "//:node_modules/@angular/forms", - "//src/aria/combobox", "//src/aria/listbox", + "//src/aria/simple-combobox", "//src/cdk/overlay", ], ) diff --git a/src/components-examples/aria/autocomplete/autocomplete-auto-select/autocomplete-auto-select-example.html b/src/components-examples/aria/autocomplete/autocomplete-auto-select/autocomplete-auto-select-example.html index b7ec4065b427..c31b8ba1147a 100644 --- a/src/components-examples/aria/autocomplete/autocomplete-auto-select/autocomplete-auto-select-example.html +++ b/src/components-examples/aria/autocomplete/autocomplete-auto-select/autocomplete-auto-select-example.html @@ -1,10 +1,13 @@ -
    +
    search
    - - - + + +
    @if (countries().length === 0) {
    No results found
    } -
    +
    @for (country of countries(); track country) {
    {{country}} diff --git a/src/components-examples/aria/autocomplete/autocomplete-highlight/autocomplete-highlight-example.ts b/src/components-examples/aria/autocomplete/autocomplete-highlight/autocomplete-highlight-example.ts index fc490c2ed96b..160bb3f30535 100644 --- a/src/components-examples/aria/autocomplete/autocomplete-highlight/autocomplete-highlight-example.ts +++ b/src/components-examples/aria/autocomplete/autocomplete-highlight/autocomplete-highlight-example.ts @@ -6,20 +6,15 @@ * found in the LICENSE file at https://angular.dev/license */ -import { - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, -} from '@angular/aria/combobox'; +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; import {Listbox, Option} from '@angular/aria/listbox'; import { afterRenderEffect, ChangeDetectionStrategy, Component, computed, + signal, viewChild, - viewChildren, } from '@angular/core'; import {COUNTRIES} from '../countries'; import {OverlayModule} from '@angular/cdk/overlay'; @@ -30,33 +25,20 @@ import {FormsModule} from '@angular/forms'; selector: 'autocomplete-highlight-example', templateUrl: 'autocomplete-highlight-example.html', styleUrl: '../autocomplete.css', - imports: [ - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, - Listbox, - Option, - OverlayModule, - FormsModule, - ], + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule], changeDetection: ChangeDetectionStrategy.OnPush, }) export class AutocompleteHighlightExample { /** The selected value of the combobox. */ - listbox = viewChild>(Listbox); - - /** The options available in the listbox. */ - options = viewChildren>(Option); - - /** A reference to the ng aria combobox. */ - combobox = viewChild>(Combobox); + readonly listbox = viewChild(Listbox); + readonly combobox = viewChild(Combobox); - /** A reference to the ng aria combobox input. */ - comboboxInput = viewChild(ComboboxInput); + popupExpanded = signal(false); + searchString = signal(''); + selectedOption = signal([]); /** The query string used to filter the list of countries. */ - query = computed(() => this.comboboxInput()?.value() || ''); + query = computed(() => this.searchString()); /** The list of countries filtered by the query. */ countries = computed(() => @@ -64,26 +46,31 @@ export class AutocompleteHighlightExample { ); constructor() { - // Scrolls to the active item when the active option changes. afterRenderEffect(() => { - if (this.combobox()?.expanded()) { - const option = this.options().find(opt => opt.active()); - option?.element.scrollIntoView({block: 'nearest'}); - } + this.listbox()?.scrollActiveItemIntoView(); }); } /** Clears the query and the listbox value. */ clear(): void { - this.comboboxInput()?.value.set(''); - this.listbox?.()?.value.set([]); + this.searchString.set(''); + this.selectedOption.set([]); + } + + onCommit() { + const selectedOption = this.selectedOption(); + if (selectedOption.length > 0) { + this.searchString.set(selectedOption[0]); + } + this.popupExpanded.set(false); + this.combobox()?.element.focus(); } /** Handles keydown events on the clear button. */ onKeydown(event: KeyboardEvent): void { if (event.key === 'Enter') { this.clear(); - this.combobox?.()?.close(); + this.popupExpanded.set(false); event.stopPropagation(); } } diff --git a/src/components-examples/aria/autocomplete/autocomplete-manual/autocomplete-manual-example.html b/src/components-examples/aria/autocomplete/autocomplete-manual/autocomplete-manual-example.html index 3571019382ec..b033cbaf96fa 100644 --- a/src/components-examples/aria/autocomplete/autocomplete-manual/autocomplete-manual-example.html +++ b/src/components-examples/aria/autocomplete/autocomplete-manual/autocomplete-manual-example.html @@ -1,17 +1,9 @@ -
    +
    search - -
    @@ -20,25 +12,24 @@ {{countries().length === 0 ? 'No results found for ' + query() : ''}}
    - - + +
    @if (countries().length === 0) { -
    No results found
    +
    No results found
    } -
    +
    @for (country of countries(); track country) { -
    - {{country}} - check -
    +
    + {{country}} + check +
    }
    -
    +
    \ No newline at end of file diff --git a/src/components-examples/aria/autocomplete/autocomplete-manual/autocomplete-manual-example.ts b/src/components-examples/aria/autocomplete/autocomplete-manual/autocomplete-manual-example.ts index 7d1a725ad324..376208860da4 100644 --- a/src/components-examples/aria/autocomplete/autocomplete-manual/autocomplete-manual-example.ts +++ b/src/components-examples/aria/autocomplete/autocomplete-manual/autocomplete-manual-example.ts @@ -6,20 +6,15 @@ * found in the LICENSE file at https://angular.dev/license */ -import { - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, -} from '@angular/aria/combobox'; +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; import {Listbox, Option} from '@angular/aria/listbox'; import { afterRenderEffect, ChangeDetectionStrategy, Component, computed, + signal, viewChild, - viewChildren, } from '@angular/core'; import {COUNTRIES} from '../countries'; import {OverlayModule} from '@angular/cdk/overlay'; @@ -30,33 +25,20 @@ import {FormsModule} from '@angular/forms'; selector: 'autocomplete-manual-example', templateUrl: 'autocomplete-manual-example.html', styleUrl: '../autocomplete.css', - imports: [ - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, - Listbox, - Option, - OverlayModule, - FormsModule, - ], + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule], changeDetection: ChangeDetectionStrategy.OnPush, }) export class AutocompleteManualExample { /** The selected value of the combobox. */ - listbox = viewChild>(Listbox); - - /** The options available in the listbox. */ - options = viewChildren>(Option); - - /** A reference to the ng aria combobox. */ - combobox = viewChild>(Combobox); + readonly listbox = viewChild(Listbox); + readonly combobox = viewChild(Combobox); - /** A reference to the ng aria combobox input. */ - comboboxInput = viewChild(ComboboxInput); + popupExpanded = signal(false); + searchString = signal(''); + selectedOption = signal([]); /** The query string used to filter the list of countries. */ - query = computed(() => this.comboboxInput()?.value() || ''); + query = computed(() => this.searchString()); /** The list of countries filtered by the query. */ countries = computed(() => @@ -64,26 +46,31 @@ export class AutocompleteManualExample { ); constructor() { - // Scrolls to the active item when the active option changes. afterRenderEffect(() => { - if (this.combobox()?.expanded()) { - const option = this.options().find(opt => opt.active()); - option?.element.scrollIntoView({block: 'nearest'}); - } + this.listbox()?.scrollActiveItemIntoView(); }); } /** Clears the query and the listbox value. */ clear(): void { - this.comboboxInput()?.value.set(''); - this.listbox?.()?.value.set([]); + this.searchString.set(''); + this.selectedOption.set([]); + } + + onCommit() { + const selectedOption = this.selectedOption(); + if (selectedOption.length > 0) { + this.searchString.set(selectedOption[0]); + } + this.popupExpanded.set(false); + this.combobox()?.element.focus(); } /** Handles keydown events on the clear button. */ onKeydown(event: KeyboardEvent): void { if (event.key === 'Enter') { this.clear(); - this.combobox?.()?.close(); + this.popupExpanded.set(false); event.stopPropagation(); } } diff --git a/src/components-examples/aria/autocomplete/autocomplete.css b/src/components-examples/aria/autocomplete/autocomplete.css index d0a57a1f7def..ffa2d0bb763c 100644 --- a/src/components-examples/aria/autocomplete/autocomplete.css +++ b/src/components-examples/aria/autocomplete/autocomplete.css @@ -18,7 +18,7 @@ position: absolute; } -[ngComboboxInput] { +input[ngCombobox] { width: 13rem; font-size: 0.9rem; border-radius: var(--mat-sys-corner-extra-small); @@ -28,15 +28,13 @@ background-color: var(--mat-sys-surface); } -[ngComboboxInput][aria-disabled='true'] { +input[ngCombobox][aria-disabled='true'], +input[ngCombobox]:disabled { cursor: default; opacity: 0.5; background-color: var(--mat-sys-surface-dim); } -[ngCombobox]:has([aria-expanded='false']) .example-popup { - display: none; -} .example-clear-button { position: absolute; diff --git a/src/components-examples/aria/simple-combobox/BUILD.bazel b/src/components-examples/aria/simple-combobox/BUILD.bazel new file mode 100644 index 000000000000..331dcf7e195d --- /dev/null +++ b/src/components-examples/aria/simple-combobox/BUILD.bazel @@ -0,0 +1,36 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "simple-combobox", + srcs = glob(["**/*.ts"]), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + deps = [ + "//:node_modules/@angular/common", + "//:node_modules/@angular/core", + "//:node_modules/@angular/forms", + "//src/aria/grid", + "//src/aria/listbox", + "//src/aria/simple-combobox", + "//src/aria/tree", + "//src/cdk/a11y", + "//src/cdk/overlay", + "//src/material/checkbox", + "//src/material/core", + "//src/material/icon", + "//src/material/tooltip", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.ts", + ]), +) diff --git a/src/components-examples/aria/simple-combobox/index.ts b/src/components-examples/aria/simple-combobox/index.ts new file mode 100644 index 000000000000..e42d699eefae --- /dev/null +++ b/src/components-examples/aria/simple-combobox/index.ts @@ -0,0 +1,14 @@ +export {SimpleComboboxListboxExample} from './simple-combobox-listbox/simple-combobox-listbox-example'; +export {SimpleComboboxTreeExample} from './simple-combobox-tree/simple-combobox-tree-example'; +export {SimpleComboboxSelectExample} from './simple-combobox-select/simple-combobox-select-example'; +export {SimpleComboboxGridExample} from './simple-combobox-grid/simple-combobox-grid-example'; +export {SimpleComboboxDatepickerExample} from './simple-combobox-datepicker/simple-combobox-datepicker-example'; +export {SimpleComboboxAutoSelectExample} from './simple-combobox-auto-select/simple-combobox-auto-select-example'; +export {SimpleComboboxHighlightExample} from './simple-combobox-highlight/simple-combobox-highlight-example'; +export {SimpleComboboxDisabledExample} from './simple-combobox-disabled/simple-combobox-disabled-example'; +export {SimpleComboboxReadonlyDisabledExample} from './simple-combobox-readonly-disabled/simple-combobox-readonly-disabled-example'; +export {SimpleComboboxReadonlyMultiselectExample} from './simple-combobox-readonly-multiselect/simple-combobox-readonly-multiselect-example'; +export {SimpleComboboxDialogExample} from './simple-combobox-dialog/simple-combobox-dialog-example'; +export {SimpleComboboxTreeAutoSelectExample} from './simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example'; +export {SimpleComboboxTreeHighlightExample} from './simple-combobox-tree-highlight/simple-combobox-tree-highlight-example'; +// Force watcher update diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-auto-select/simple-combobox-auto-select-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-auto-select/simple-combobox-auto-select-example.html new file mode 100644 index 000000000000..994d1778939c --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-auto-select/simple-combobox-auto-select-example.html @@ -0,0 +1,22 @@ +
    +
    + search + +
    + + + +
    + @for (option of options(); track option) { +
    + {{option}} + +
    + } +
    +
    +
    +
    \ No newline at end of file diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-auto-select/simple-combobox-auto-select-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-auto-select/simple-combobox-auto-select-example.ts new file mode 100644 index 000000000000..11521886179e --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-auto-select/simple-combobox-auto-select-example.ts @@ -0,0 +1,98 @@ +/** + * @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 {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; + +/** @title Simple Combobox Auto Select */ +@Component({ + selector: 'simple-combobox-auto-select-example', + templateUrl: 'simple-combobox-auto-select-example.html', + styleUrl: '../simple-combobox-example.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], +}) +export class SimpleComboboxAutoSelectExample { + readonly listbox = viewChild(Listbox); + + popupExpanded = signal(false); + searchString = signal(''); + selectedOption = signal([]); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + constructor() { + afterRenderEffect(() => { + this.listbox()?.scrollActiveItemIntoView(); + }); + } + + onCommit() { + const selectedOption = this.selectedOption(); + if (selectedOption.length > 0) { + this.searchString.set(selectedOption[0]); + } + this.popupExpanded.set(false); + } +} + +const states = [ + 'Alabama', + 'Alaska', + 'Arizona', + 'Arkansas', + 'California', + 'Colorado', + 'Connecticut', + 'Delaware', + 'Florida', + 'Georgia', + 'Hawaii', + 'Idaho', + 'Illinois', + 'Indiana', + 'Iowa', + 'Kansas', + 'Kentucky', + 'Louisiana', + 'Maine', + 'Maryland', + 'Massachusetts', + 'Michigan', + 'Minnesota', + 'Mississippi', + 'Missouri', + 'Montana', + 'Nebraska', + 'Nevada', + 'New Hampshire', + 'New Jersey', + 'New Mexico', + 'New York', + 'North Carolina', + 'North Dakota', + 'Ohio', + 'Oklahoma', + 'Oregon', + 'Pennsylvania', + 'Rhode Island', + 'South Carolina', + 'South Dakota', + 'Tennessee', + 'Texas', + 'Utah', + 'Vermont', + 'Virginia', + 'Washington', + 'West Virginia', + 'Wisconsin', + 'Wyoming', +]; diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.css b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.css new file mode 100644 index 000000000000..942d3636b229 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.css @@ -0,0 +1,106 @@ +.example-datepicker-popup { + padding: 16px; + width: 320px; + max-height: none; + overflow: visible; + background-color: var(--mat-sys-surface); + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); + box-shadow: var(--mat-sys-level2-shadow); +} + +.example-datepicker-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 12px; + border-bottom: 1px solid var(--mat-sys-outline-variant); + margin-bottom: 12px; +} + +.example-datepicker-title { + font-weight: 600; + font-size: 0.9rem; + color: var(--mat-sys-on-surface); +} + +.example-datepicker-nav-button { + background-color: transparent; + border: none; + border-radius: 50%; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--mat-sys-on-surface); + transition: background-color 0.2s ease; +} + +.example-datepicker-nav-button:hover { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent); +} + +.example-datepicker-grid { + width: 100%; + border-collapse: collapse; +} + +.example-datepicker-cell { + width: 40px; + height: 40px; + text-align: center; + vertical-align: middle; + padding: 0; +} + +.example-datepicker-weekday { + font-size: 0.75rem; + font-weight: 500; + color: var(--mat-sys-on-surface-variant); + padding-bottom: 8px; +} + +.example-datepicker-empty { + color: color-mix(in srgb, var(--mat-sys-on-surface) 30%, transparent); + font-size: 0.8rem; +} + +.example-datepicker-day-button { + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background-color: transparent; + cursor: pointer; + font-size: 0.85rem; + color: var(--mat-sys-on-surface); + transition: + background-color 0.2s ease, + color 0.2s ease; +} + +.example-datepicker-cell:hover .example-datepicker-day-button { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent); +} + +.example-datepicker-cell:focus-within { + outline: 2px solid var(--mat-sys-primary); + outline-offset: -2px; +} + +.example-datepicker-day-button:focus { + outline: none; +} + +.example-datepicker-cell[aria-selected='true'] .example-datepicker-day-button { + background-color: var(--mat-sys-primary); + color: var(--mat-sys-on-primary); +} + +.example-combobox-hint { + font-size: 0.75rem; + color: var(--mat-sys-on-surface-variant); + margin-top: 4px; +} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.html new file mode 100644 index 000000000000..756eaf9b6954 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.html @@ -0,0 +1,68 @@ +
    +
    + calendar_month + +
    + + + +
    +
    +
    + +
    {{ monthYearLabel() }}
    + +
    + + + + + @for (day of weekdays(); track day.long) { + + } + + + + + @for (week of weeks(); track $index) { + + @if ($first) { + @for (day of daysFromPrevMonth(); track $index) { + + } + } + + @for (day of week; track $index) { + + } + + @if ($last && week.length < 7) { @for (day of [].constructor(7 - week.length); track $index) { + } + } + + } + +
    + {{ day.long }} + +
    {{ day }} + + {{ $index + 1 }} +
    +
    +
    +
    +
    +
    +
    Format: MM/DD/YYYY
    \ No newline at end of file diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.ts new file mode 100644 index 000000000000..c79d8c240a4f --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.ts @@ -0,0 +1,193 @@ +/** + * @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 { + inject, + Component, + WritableSignal, + signal, + Signal, + computed, + untracked, + viewChild, + ElementRef, +} from '@angular/core'; +import {DateAdapter, MAT_DATE_FORMATS, MatDateFormats} from '@angular/material/core'; +import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid'; +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {OverlayModule} from '@angular/cdk/overlay'; +import {A11yModule} from '@angular/cdk/a11y'; + +const DAYS_PER_WEEK = 7; + +interface CalendarCell { + displayName: string; + ariaLabel: string; + date: D; + selected: WritableSignal; +} + +/** @title Combobox with Datepicker Grid. */ +@Component({ + selector: 'simple-combobox-datepicker-example', + templateUrl: 'simple-combobox-datepicker-example.html', + styleUrls: ['../simple-combobox-example.css', 'simple-combobox-datepicker-example.css'], + imports: [ + Grid, + GridRow, + GridCell, + GridCellWidget, + Combobox, + ComboboxPopup, + ComboboxWidget, + OverlayModule, + A11yModule, + ], +}) +export class SimpleComboboxDatepickerExample { + private readonly _dateAdapter = inject>(DateAdapter, {optional: true})!; + private readonly _dateFormats = inject(MAT_DATE_FORMATS, {optional: true})!; + + readonly grid = viewChild(Grid); + readonly gridTable = viewChild>('gridTable'); + + readonly selection = signal(''); + readonly popupExpanded = signal(false); + + private readonly _firstWeekOffset: Signal = computed(() => { + const firstOfMonth = this._dateAdapter.createDate( + this._dateAdapter.getYear(this.viewMonth()), + this._dateAdapter.getMonth(this.viewMonth()), + 1, + ); + + return ( + (DAYS_PER_WEEK + + this._dateAdapter.getDayOfWeek(firstOfMonth) - + this._dateAdapter.getFirstDayOfWeek()) % + DAYS_PER_WEEK + ); + }); + + private readonly _activeDate: WritableSignal = signal(this._dateAdapter.today()); + + readonly monthYearLabel: Signal = computed(() => + this._dateAdapter + .format(this.viewMonth(), this._dateFormats.display.monthYearLabel) + .toLocaleUpperCase(), + ); + readonly prevMonthNumDays: Signal = computed(() => + this._dateAdapter.getNumDaysInMonth(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1)), + ); + readonly daysFromPrevMonth: Signal = computed(() => { + const days: number[] = []; + for (let i = this._firstWeekOffset() - 1; i >= 0; i--) { + days.push(this.prevMonthNumDays() - i); + } + return days; + }); + readonly viewMonth: WritableSignal = signal(this._dateAdapter.today()); + readonly weekdays: Signal<{long: string; narrow: string}[]> = computed(() => { + const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek(); + const narrowWeekdays = this._dateAdapter.getDayOfWeekNames('narrow'); + const longWeekdays = this._dateAdapter.getDayOfWeekNames('long'); + + const weekdays = longWeekdays.map((long, i) => { + return {long, narrow: narrowWeekdays[i]}; + }); + return weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek)); + }); + readonly weeks: Signal[][]> = computed(() => + this._createWeekCells(this.viewMonth()), + ); + + nextMonth(): void { + this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), 1)); + } + + prevMonth(): void { + this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1)); + } + + readonly comboboxInput = viewChild>('comboboxInput'); + + selectDate(cell: CalendarCell): void { + const formatted = this._dateAdapter.format(cell.date, this._dateFormats.display.dateInput); + this.selection.set(formatted); + this._activeDate.set(cell.date); + this.popupExpanded.set(false); + this.comboboxInput()?.nativeElement.focus(); + } + + onInputKeydown(event: KeyboardEvent) { + if (event.key === 'Enter') { + const value = this.selection(); + const parsedDate = this._dateAdapter.parse(value, this._dateFormats.display.dateInput); + if (parsedDate && this._dateAdapter.isValid(parsedDate)) { + this._activeDate.set(parsedDate); + this.viewMonth.set(parsedDate); + this.popupExpanded.set(false); + event.stopPropagation(); + } + } else if (event.key === 'ArrowDown' && this.popupExpanded()) { + setTimeout(() => { + const tableEl = this.gridTable()?.nativeElement; + if (tableEl) { + const tabbable = tableEl.querySelector('[tabindex="0"]') as HTMLElement; + (tabbable || tableEl).focus(); + } + }); + } + } + + /** Handles keydown events on the widget container. */ + handleWidgetKeydown(event: KeyboardEvent) { + if (event.key === 'Escape') { + this.popupExpanded.set(false); + this.comboboxInput()?.nativeElement.focus(); + event.preventDefault(); + event.stopPropagation(); + return; + } + + if (event.target === event.currentTarget) { + this.grid()?._pattern.onKeydown(event); + } + } + + private _createWeekCells(viewMonth: D): CalendarCell[][] { + const daysInMonth = this._dateAdapter.getNumDaysInMonth(viewMonth); + const dateNames = this._dateAdapter.getDateNames(); + const weeks: CalendarCell[][] = [[]]; + for (let i = 0, cell = this._firstWeekOffset(); i < daysInMonth; i++, cell++) { + if (cell == DAYS_PER_WEEK) { + weeks.push([]); + cell = 0; + } + const date = this._dateAdapter.createDate( + this._dateAdapter.getYear(viewMonth), + this._dateAdapter.getMonth(viewMonth), + i + 1, + ); + const ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel); + + weeks[weeks.length - 1].push({ + displayName: dateNames[i], + ariaLabel, + date, + selected: signal( + this._dateAdapter.compareDate( + date, + untracked(() => this._activeDate()), + ) === 0, + ), + }); + } + return weeks; + } +} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-dialog/simple-combobox-dialog-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-dialog/simple-combobox-dialog-example.html new file mode 100644 index 000000000000..572614d47187 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-dialog/simple-combobox-dialog-example.html @@ -0,0 +1,39 @@ +
    +
    + + arrow_drop_down +
    + + + + +
    +
    +
    +
    + search + +
    + +
    + @for (option of options(); track option) { +
    + {{option}} + +
    + } +
    +
    +
    +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-dialog/simple-combobox-dialog-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-dialog/simple-combobox-dialog-example.ts new file mode 100644 index 000000000000..55edada06f67 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-dialog/simple-combobox-dialog-example.ts @@ -0,0 +1,132 @@ +/** + * @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 {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + computed, + signal, + viewChild, + untracked, + ElementRef, +} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; +import {FormsModule} from '@angular/forms'; + +/** @title Combobox with a dialog popup. */ +@Component({ + selector: 'simple-combobox-dialog-example', + templateUrl: 'simple-combobox-dialog-example.html', + styleUrls: ['../simple-combobox-example.css'], + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SimpleComboboxDialogExample { + listbox = viewChild>(Listbox); + combobox = viewChild(Combobox); + searchInput = viewChild>('searchInput'); + + value = signal(''); + searchString = signal(''); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + selectedStates = signal([]); + popupExpanded = signal(false); + + constructor() { + afterRenderEffect(() => { + if (this.popupExpanded()) { + untracked(() => { + setTimeout(() => { + this.searchInput()?.nativeElement.focus(); + }); + }); + } + }); + + afterRenderEffect(() => { + if (this.popupExpanded()) { + this.listbox()?.scrollActiveItemIntoView(); + } + }); + } + + onCommit() { + const selected = this.selectedStates(); + if (selected.length > 0) { + this.value.set(selected[0]); + this.searchString.set(''); + this.popupExpanded.set(false); + this.combobox()?.element.focus(); + } + } + + onSearchEscape(event: Event) { + this.popupExpanded.set(false); + this.combobox()?.element.focus(); // Focus back to main trigger! + } +} + +const states = [ + 'Alabama', + 'Alaska', + 'Arizona', + 'Arkansas', + 'California', + 'Colorado', + 'Connecticut', + 'Delaware', + 'Florida', + 'Georgia', + 'Hawaii', + 'Idaho', + 'Illinois', + 'Indiana', + 'Iowa', + 'Kansas', + 'Kentucky', + 'Louisiana', + 'Maine', + 'Maryland', + 'Massachusetts', + 'Michigan', + 'Minnesota', + 'Mississippi', + 'Missouri', + 'Montana', + 'Nebraska', + 'Nevada', + 'New Hampshire', + 'New Jersey', + 'New Mexico', + 'New York', + 'North Carolina', + 'North Dakota', + 'Ohio', + 'Oklahoma', + 'Oregon', + 'Pennsylvania', + 'Rhode Island', + 'South Carolina', + 'South Dakota', + 'Tennessee', + 'Texas', + 'Utah', + 'Vermont', + 'Virginia', + 'Washington', + 'West Virginia', + 'Wisconsin', + 'Wyoming', +]; diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-disabled/simple-combobox-disabled-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-disabled/simple-combobox-disabled-example.html new file mode 100644 index 000000000000..c7a6b1d9005c --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-disabled/simple-combobox-disabled-example.html @@ -0,0 +1,25 @@ +
    +
    + search + +
    + + + + +
    +
    + @for (option of options(); track option) { +
    + {{option}} + +
    + } +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-disabled/simple-combobox-disabled-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-disabled/simple-combobox-disabled-example.ts new file mode 100644 index 000000000000..0e7fe2b7dadb --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-disabled/simple-combobox-disabled-example.ts @@ -0,0 +1,104 @@ +/** + * @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 {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import {afterRenderEffect, Component, computed, signal, viewChild, untracked} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; + +/** @title Simple Combobox Disabled */ +@Component({ + selector: 'simple-combobox-disabled-example', + templateUrl: 'simple-combobox-disabled-example.html', + styleUrl: '../simple-combobox-example.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], +}) +export class SimpleComboboxDisabledExample { + readonly listbox = viewChild(Listbox); + + popupExpanded = signal(false); + searchString = signal(''); + selectedOption = signal([]); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + constructor() { + afterRenderEffect(() => { + this.listbox()?.scrollActiveItemIntoView(); + }); + + afterRenderEffect(() => { + if (this.popupExpanded()) { + untracked(() => setTimeout(() => this.listbox()?.gotoFirst())); + } + }); + } + + onCommit() { + const selectedOption = this.selectedOption(); + if (selectedOption.length > 0) { + this.searchString.set(selectedOption[0]); + this.popupExpanded.set(false); + } + } +} + +const states = [ + 'Alabama', + 'Alaska', + 'Arizona', + 'Arkansas', + 'California', + 'Colorado', + 'Connecticut', + 'Delaware', + 'Florida', + 'Georgia', + 'Hawaii', + 'Idaho', + 'Illinois', + 'Indiana', + 'Iowa', + 'Kansas', + 'Kentucky', + 'Louisiana', + 'Maine', + 'Maryland', + 'Massachusetts', + 'Michigan', + 'Minnesota', + 'Mississippi', + 'Missouri', + 'Montana', + 'Nebraska', + 'Nevada', + 'New Hampshire', + 'New Jersey', + 'New Mexico', + 'New York', + 'North Carolina', + 'North Dakota', + 'Ohio', + 'Oklahoma', + 'Oregon', + 'Pennsylvania', + 'Rhode Island', + 'South Carolina', + 'South Dakota', + 'Tennessee', + 'Texas', + 'Utah', + 'Vermont', + 'Virginia', + 'Washington', + 'West Virginia', + 'Wisconsin', + 'Wyoming', +]; diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-example.css b/src/components-examples/aria/simple-combobox/simple-combobox-example.css new file mode 100644 index 000000000000..6f239afd12e3 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-example.css @@ -0,0 +1,362 @@ +.example-combobox-container { + position: relative; + width: 100%; + display: flex; + flex-direction: column; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); + transition: + box-shadow 0.2s ease, + border-color 0.2s ease; +} + +.example-combobox-container:has([readonly='true']:not([aria-disabled='true'])) { + width: 200px; +} + +.example-combobox-input-container { + display: flex; + position: relative; + align-items: center; + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-combobox-input { + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-combobox-input[readonly='true']:not([aria-disabled='true']) { + cursor: pointer; + padding: 0.7rem 1rem; +} + +.example-combobox-container:focus-within { + border-color: var(--mat-sys-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--mat-sys-primary) 25%, transparent); +} + +.example-icon { + width: 24px; + height: 24px; + font-size: 24px; + color: var(--mat-sys-on-surface-variant); + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + display: grid; + place-items: center; + pointer-events: none; +} + +.example-search-icon { + padding: 0 0.5rem; + position: absolute; + opacity: 0.8; +} + +.example-arrow-icon { + padding: 0 0.5rem; + position: absolute; + right: 0; + opacity: 0.8; + transition: transform 0.2s ease; +} + +.example-combobox-input[aria-expanded='true'] + .example-arrow-icon { + transform: rotate(180deg); +} + +.example-combobox-input { + width: 100%; + border: none; + outline: none; + font-size: 1rem; + padding: 0.7rem 1rem 0.7rem 2.5rem; + background-color: var(--mat-sys-surface); +} + +.example-popover { + margin: 0; + padding: 0; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); + background-color: var(--mat-sys-surface); +} + +.example-popup { + width: 100%; + margin-block-start: 0.25rem; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); + background-color: var(--mat-sys-surface); + max-height: 15rem; + overflow: auto; +} + +.example-listbox { + display: flex; + flex-direction: column; + overflow: auto; + max-height: 10rem; + padding: 0.5rem; + gap: 4px; + outline: none; +} + +/* --- 2. LISTBOX & OPTIONS --- */ +.example-option { + cursor: pointer; + padding: 0.3rem 1rem; + border-radius: var(--mat-sys-corner-extra-small); + display: flex; + overflow: hidden; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.example-option[aria-disabled='true'] { + cursor: not-allowed; + opacity: 0.5; + color: var(--mat-sys-on-surface-variant); + pointer-events: none; +} + +.example-option-text { + flex: 1; +} + +.example-checkbox-blank-icon, +.example-option[aria-selected='true'] .example-checkbox-filled-icon { + display: flex; + align-items: center; +} + +.example-checkbox-filled-icon, +.example-option[aria-selected='true'] .example-checkbox-blank-icon { + display: none; +} + +.example-checkbox-blank-icon { + opacity: 0.6; +} + +.example-selected-icon { + visibility: hidden; +} + +.example-option[aria-selected='true'] .example-selected-icon { + visibility: visible; +} + +.example-option[aria-selected='true'] { + color: var(--mat-sys-primary); + background-color: color-mix(in srgb, var(--mat-sys-primary) 10%, transparent); +} + +.example-option:hover { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent); +} + +.example-combobox-container:not(.example-no-active-outline):focus-within + [data-active='true']:not(.example-no-active-outline) { + outline: 2px solid color-mix(in srgb, var(--mat-sys-primary) 80%, transparent); + outline-offset: -2px; +} + +.example-dialog .example-combobox-input-container { + border-bottom: 1px solid var(--mat-sys-outline); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + + +.example-tree { + padding: 10px; + overflow-x: scroll; + width: 100%; + box-sizing: border-box; +} + +.example-tree-item { + cursor: pointer; + list-style: none; + text-decoration: none; + display: flex; + align-items: center; + gap: 1rem; + padding: 0.3rem 1rem; +} + +li[aria-expanded='false'] + ul[role='group'] { + display: none; +} + +ul[role='group'] { + padding-inline-start: 1rem; +} + +.example-icon { + margin: 0; + width: 24px; +} + +.example-parent-icon { + transition: transform 0.2s ease; +} + +.example-tree-item[aria-expanded='true'] .example-parent-icon { + transform: rotate(90deg); +} + +.example-selected-icon { + visibility: hidden; + margin-left: auto; +} + +.example-tree-item[aria-current] .example-selected-icon, +.example-tree-item[aria-selected='true'] .example-selected-icon { + visibility: visible; +} + +.example-combobox-container:has(.example-combobox-input[aria-disabled='true']) { + opacity: 0.4; + cursor: default; +} + +.example-grid-row { + display: flex; + align-items: center; + border-radius: var(--mat-sys-corner-extra-small); + transition: background-color 0.2s ease; +} + +.example-grid-row.example-selectable { + cursor: pointer; +} + +.example-grid-row.example-selectable:hover { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 5%, transparent); +} + +.example-grid-row.example-selectable:active { + background-color: color-mix(in srgb, var(--mat-sys-primary) 20%, transparent); +} + +.example-grid-row[data-active='true'] { + background-color: color-mix(in srgb, var(--mat-sys-primary) 10%, transparent); + outline: 2px solid var(--mat-sys-primary); +} + +.example-grid-row[aria-selected='true'] { + color: var(--mat-sys-primary); + background-color: color-mix(in srgb, var(--mat-sys-primary) 10%, transparent); +} + +.example-grid-header-row { + display: flex; + gap: 12px; + padding: 8px; + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 5%, transparent); + border-bottom: 1px solid var(--mat-sys-outline); + font-weight: 600; + font-size: 0.85rem; +} + +.example-cell { + flex: 1; + display: flex; + align-items: center; + min-height: 40px; +} + +.example-cell-header { + flex: 1; + display: flex; + align-items: center; +} + +.example-cell-label { + border-radius: var(--mat-sys-corner-extra-small); + flex: 2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0 12px; +} + +.example-cell-checkbox, +.example-cell-button { + flex: 0 0 auto; + justify-content: center; +} + +.example-cell-input { + flex: 1; +} + +.example-button { + cursor: pointer; + opacity: 0.6; + padding: 0; + margin: 0; + height: 100%; + width: 40px; + border: none; + background: transparent; + display: grid; + place-items: center; + color: var(--mat-sys-on-surface); + transition: opacity 0.2s, color 0.2s, transform 0.2s; +} + +.example-button mat-icon { + font-size: 20px; + width: 20px; + height: 20px; +} + +.example-button:focus, +.example-button:hover, +.example-button[data-active='true'] { + opacity: 1; + color: var(--mat-sys-primary); + outline: none; +} + +.example-button:active { + transform: scale(0.95); +} + +.example-button[aria-pressed='true'], +.example-button[aria-checked='true'] { + color: var(--mat-sys-primary); +} + +.example-button[aria-disabled='true'] { + cursor: default; + opacity: 0.45; +} + +.example-label-button { + background: transparent; + border: none; + padding: 0; + margin: 0; + font: inherit; + color: inherit; + cursor: pointer; + text-align: left; + width: 100%; + height: 100%; + outline: none; +} + +.example-popup .example-button[data-active='true'] { + color: inherit; +} + +.example-grid-row[aria-selected='true'] .example-selected-icon { + visibility: visible; +} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.html new file mode 100644 index 000000000000..d0633934620d --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.html @@ -0,0 +1,34 @@ +
    +
    + search + +
    + + + +
    + @for (item of filteredItems(); track item.label; let i = $index) { +
    +
    + + check +
    +
    + +
    +
    + } +
    +
    +
    +
    \ No newline at end of file diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.ts new file mode 100644 index 000000000000..01fa4011fe49 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.ts @@ -0,0 +1,99 @@ +/** + * @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 {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; +import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid'; +import {MatIconModule} from '@angular/material/icon'; + +/** @title */ +@Component({ + selector: 'simple-combobox-grid-example', + templateUrl: 'simple-combobox-grid-example.html', + styleUrl: '../simple-combobox-example.css', + imports: [ + Combobox, + ComboboxPopup, + ComboboxWidget, + OverlayModule, + Grid, + GridRow, + GridCell, + GridCellWidget, + MatIconModule, + ], +}) +export class SimpleComboboxGridExample { + readonly grid = viewChild(Grid); + + popupExpanded = signal(true); + searchString = signal(''); + readonly selectedItem = signal<{label: string} | null>(null); + + constructor() { + afterRenderEffect(() => { + this.grid()?.scrollActiveCellIntoView({block: 'nearest'}); + }); + } + + readonly items = signal([ + {label: 'Antelope'}, + {label: 'Bird'}, + {label: 'Cat'}, + {label: 'Dog'}, + {label: 'Elephant'}, + {label: 'Fox'}, + {label: 'Giraffe'}, + {label: 'Hamster'}, + {label: 'Hippo'}, + {label: 'Iguana'}, + {label: 'Jaguar'}, + {label: 'Koala'}, + {label: 'Lion'}, + {label: 'Monkey'}, + {label: 'Nightingale'}, + {label: 'Owl'}, + {label: 'Panda'}, + {label: 'Quokka'}, + {label: 'Rabbit'}, + {label: 'Snake'}, + {label: 'Tiger'}, + {label: 'Umbrella Bird'}, + {label: 'Vulture'}, + {label: 'Whale'}, + {label: 'X-ray Tetra'}, + {label: 'Yak'}, + {label: 'Zebra'}, + ]); + + readonly filteredItems = computed(() => { + const search = this.searchString().toLowerCase(); + return [...this.items()].filter(item => item.label.toLowerCase().includes(search)); + }); + + removeItem(itemToRemove: {label: string}) { + this.items.update(items => items.filter(item => item !== itemToRemove)); + } + + selectItem(item: {label: string}) { + this.selectedItem.set(item); + this.searchString.set(item.label); + this.popupExpanded.set(false); + } + + onBlur() { + const selectedItem = this.selectedItem(); + if ( + this.searchString() === '' || + (selectedItem !== null && this.searchString() === selectedItem.label) + ) { + this.selectedItem.set(null); + } + } +} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-highlight/simple-combobox-highlight-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-highlight/simple-combobox-highlight-example.html new file mode 100644 index 000000000000..4719ba14616d --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-highlight/simple-combobox-highlight-example.html @@ -0,0 +1,24 @@ +
    +
    + search + +
    + + + +
    + @for (option of options(); track option.name) { +
    + {{option.name}} + +
    + } +
    +
    +
    +
    \ No newline at end of file diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-highlight/simple-combobox-highlight-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-highlight/simple-combobox-highlight-example.ts new file mode 100644 index 000000000000..b3b3ac084f63 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-highlight/simple-combobox-highlight-example.ts @@ -0,0 +1,104 @@ +/** + * @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 {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; + +/** @title Simple Combobox Highlight */ +@Component({ + selector: 'simple-combobox-highlight-example', + templateUrl: 'simple-combobox-highlight-example.html', + styleUrl: '../simple-combobox-example.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], +}) +export class SimpleComboboxHighlightExample { + readonly listbox = viewChild(Listbox); + + popupExpanded = signal(false); + searchString = signal(''); + selectedOption = signal([]); + + options = computed(() => + states.filter(state => state.name.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + constructor() { + afterRenderEffect(() => { + this.listbox()?.scrollActiveItemIntoView(); + }); + } + + onCommit() { + const selectedOption = this.selectedOption(); + if (selectedOption.length > 0) { + const matchedState = states.find(s => s.name === selectedOption[0]); + if (matchedState?.disabled) { + return; + } + this.searchString.set(selectedOption[0]); + } else { + this.searchString.set(''); + } + this.popupExpanded.set(false); + } +} + +const states = [ + {name: 'Alabama', disabled: false}, + {name: 'Alaska', disabled: true}, + {name: 'Arizona', disabled: false}, + {name: 'Arkansas', disabled: true}, + {name: 'California', disabled: true}, + {name: 'Colorado', disabled: false}, + {name: 'Connecticut', disabled: false}, + {name: 'Delaware', disabled: false}, + {name: 'Florida', disabled: false}, + {name: 'Georgia', disabled: false}, + {name: 'Hawaii', disabled: false}, + {name: 'Idaho', disabled: false}, + {name: 'Illinois', disabled: false}, + {name: 'Indiana', disabled: false}, + {name: 'Iowa', disabled: false}, + {name: 'Kansas', disabled: false}, + {name: 'Kentucky', disabled: false}, + {name: 'Louisiana', disabled: false}, + {name: 'Maine', disabled: false}, + {name: 'Maryland', disabled: false}, + {name: 'Massachusetts', disabled: false}, + {name: 'Michigan', disabled: false}, + {name: 'Minnesota', disabled: false}, + {name: 'Mississippi', disabled: false}, + {name: 'Missouri', disabled: false}, + {name: 'Montana', disabled: false}, + {name: 'Nebraska', disabled: false}, + {name: 'Nevada', disabled: false}, + {name: 'New Hampshire', disabled: false}, + {name: 'New Jersey', disabled: false}, + {name: 'New Mexico', disabled: false}, + {name: 'New York', disabled: false}, + {name: 'North Carolina', disabled: false}, + {name: 'North Dakota', disabled: false}, + {name: 'Ohio', disabled: false}, + {name: 'Oklahoma', disabled: false}, + {name: 'Oregon', disabled: false}, + {name: 'Pennsylvania', disabled: false}, + {name: 'Rhode Island', disabled: false}, + {name: 'South Carolina', disabled: false}, + {name: 'South Dakota', disabled: false}, + {name: 'Tennessee', disabled: false}, + {name: 'Texas', disabled: false}, + {name: 'Utah', disabled: false}, + {name: 'Vermont', disabled: false}, + {name: 'Virginia', disabled: false}, + {name: 'Washington', disabled: false}, + {name: 'West Virginia', disabled: false}, + {name: 'Wisconsin', disabled: false}, + {name: 'Wyoming', disabled: false}, +]; diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.html new file mode 100644 index 000000000000..ad36d94e4142 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.html @@ -0,0 +1,23 @@ +
    +
    + search + +
    + + + +
    + @for (option of options(); track option) { +
    + {{option}} + +
    + } +
    +
    +
    +
    \ No newline at end of file diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.ts new file mode 100644 index 000000000000..46292f6cd990 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.ts @@ -0,0 +1,98 @@ +/** + * @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 {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; + +/** @title */ +@Component({ + selector: 'simple-combobox-listbox-example', + templateUrl: 'simple-combobox-listbox-example.html', + styleUrl: '../simple-combobox-example.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], +}) +export class SimpleComboboxListboxExample { + readonly listbox = viewChild(Listbox); + + popupExpanded = signal(false); + searchString = signal(''); + selectedOption = signal([]); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + constructor() { + afterRenderEffect(() => { + this.listbox()?.scrollActiveItemIntoView(); + }); + } + + onCommit() { + const selectedOption = this.selectedOption(); + if (selectedOption.length > 0) { + this.searchString.set(selectedOption[0]); + } + this.popupExpanded.set(false); + } +} + +const states = [ + 'Alabama', + 'Alaska', + 'Arizona', + 'Arkansas', + 'California', + 'Colorado', + 'Connecticut', + 'Delaware', + 'Florida', + 'Georgia', + 'Hawaii', + 'Idaho', + 'Illinois', + 'Indiana', + 'Iowa', + 'Kansas', + 'Kentucky', + 'Louisiana', + 'Maine', + 'Maryland', + 'Massachusetts', + 'Michigan', + 'Minnesota', + 'Mississippi', + 'Missouri', + 'Montana', + 'Nebraska', + 'Nevada', + 'New Hampshire', + 'New Jersey', + 'New Mexico', + 'New York', + 'North Carolina', + 'North Dakota', + 'Ohio', + 'Oklahoma', + 'Oregon', + 'Pennsylvania', + 'Rhode Island', + 'South Carolina', + 'South Dakota', + 'Tennessee', + 'Texas', + 'Utah', + 'Vermont', + 'Virginia', + 'Washington', + 'West Virginia', + 'Wisconsin', + 'Wyoming', +]; diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-readonly-disabled/simple-combobox-readonly-disabled-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-readonly-disabled/simple-combobox-readonly-disabled-example.html new file mode 100644 index 000000000000..2c2c3bd96168 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-readonly-disabled/simple-combobox-readonly-disabled-example.html @@ -0,0 +1,48 @@ +
    + {{value()}} + arrow_drop_down +
    + + + +
    +
    + @for (option of options(); track option.value) { +
    + @if (option.icon) { + {{option.icon}} + } + {{option.value}} + @if (selectedValues().includes(option.value)) { + + } +
    + } +
    +
    +
    +
    diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-readonly-disabled/simple-combobox-readonly-disabled-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-readonly-disabled/simple-combobox-readonly-disabled-example.ts new file mode 100644 index 000000000000..21b7e8df9af2 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-readonly-disabled/simple-combobox-readonly-disabled-example.ts @@ -0,0 +1,59 @@ +/** + * @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 {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + signal, + viewChild, +} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; + +/** @title Disabled readonly combobox. */ +@Component({ + selector: 'simple-combobox-readonly-disabled-example', + templateUrl: 'simple-combobox-readonly-disabled-example.html', + styleUrl: '../simple-combobox-select/simple-combobox-select-example.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SimpleComboboxReadonlyDisabledExample { + readonly listbox = viewChild(Listbox); + + readonly options = signal([ + {value: 'Select a label', icon: ''}, + {value: 'Important', icon: 'label'}, + {value: 'Starred', icon: 'star'}, + {value: 'Work', icon: 'work'}, + {value: 'Personal', icon: 'person'}, + {value: 'To Do', icon: 'checklist'}, + {value: 'Later', icon: 'schedule'}, + {value: 'Read', icon: 'menu_book'}, + {value: 'Travel', icon: 'flight'}, + ]); + readonly value = signal('Select a label'); + readonly selectedValues = signal(['Select a label']); + readonly popupExpanded = signal(false); + + constructor() { + afterRenderEffect(() => { + this.listbox()?.scrollActiveItemIntoView(); + }); + } + + onCommit() { + const values = this.selectedValues(); + if (values.length) { + this.value.set(values[0]); + this.popupExpanded.set(false); + } + } +} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-readonly-multiselect/simple-combobox-readonly-multiselect-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-readonly-multiselect/simple-combobox-readonly-multiselect-example.html new file mode 100644 index 000000000000..3b3df47de863 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-readonly-multiselect/simple-combobox-readonly-multiselect-example.html @@ -0,0 +1,44 @@ +
    + {{value()}} + arrow_drop_down +
    + + + +
    +
    + @for (option of options(); track option.value) { +
    + @if (option.icon) { + {{option.icon}} + } + {{option.value}} + @if (selectedValues().includes(option.value)) { + + } +
    + } +
    +
    +
    +
    diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-readonly-multiselect/simple-combobox-readonly-multiselect-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-readonly-multiselect/simple-combobox-readonly-multiselect-example.ts new file mode 100644 index 000000000000..328cb8efdb10 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-readonly-multiselect/simple-combobox-readonly-multiselect-example.ts @@ -0,0 +1,60 @@ +/** + * @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 {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + computed, + signal, + viewChild, +} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; + +/** @title Readonly multiselectable combobox. */ +@Component({ + selector: 'simple-combobox-readonly-multiselect-example', + templateUrl: 'simple-combobox-readonly-multiselect-example.html', + styleUrl: '../simple-combobox-select/simple-combobox-select-example.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SimpleComboboxReadonlyMultiselectExample { + readonly listbox = viewChild(Listbox); + + readonly options = signal([ + {value: 'Important', icon: 'label'}, + {value: 'Starred', icon: 'star'}, + {value: 'Work', icon: 'work'}, + {value: 'Personal', icon: 'person'}, + {value: 'To Do', icon: 'checklist'}, + {value: 'Later', icon: 'schedule'}, + {value: 'Read', icon: 'menu_book'}, + {value: 'Travel', icon: 'flight'}, + ]); + readonly selectedValues = signal([]); + readonly value = computed(() => { + const values = this.selectedValues(); + if (values.length === 0) { + return 'Select a label'; + } else if (values.length === 1) { + return values[0]; + } else { + return `${values[0]} + ${values.length - 1} more`; + } + }); + readonly popupExpanded = signal(false); + + constructor() { + afterRenderEffect(() => { + this.listbox()?.scrollActiveItemIntoView(); + }); + } +} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.css b/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.css new file mode 100644 index 000000000000..bdff7dd05f3e --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.css @@ -0,0 +1,102 @@ +.example-select { + display: flex; + position: relative; + align-items: center; + color: var(--mat-sys-on-primary); + font-size: var(--mat-sys-label-large); + background-color: var(--mat-sys-primary); + border-radius: var(--mat-sys-corner-extra-large); + padding: 0 2rem; + height: 3rem; + cursor: pointer; + user-select: none; + outline: none; +} + +.example-select:hover { + background-color: color-mix(in srgb, var(--mat-sys-primary) 90%, transparent); +} + +.example-select:focus { + outline-offset: 2px; + outline: 2px solid var(--mat-sys-primary); +} + +.example-select[aria-disabled='true'] { + opacity: 0.4; + cursor: default; + pointer-events: none; +} + +.example-combobox-text { + width: 9rem; +} + +.example-arrow { + pointer-events: none; + transition: transform 150ms ease-in-out; +} + +[ngCombobox][aria-expanded='true'] .example-arrow { + transform: rotate(180deg); +} + +.example-popup-container { + width: 100%; + padding: 0.5rem; + margin-top: 8px; + border-radius: var(--mat-sys-corner-large); + background-color: var(--mat-sys-surface-container); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +[ngListbox] { + gap: 4px; + display: flex; + overflow: auto; + flex-direction: column; + max-height: 13rem; +} + +[ngOption] { + display: flex; + cursor: pointer; + align-items: center; + padding: 0 1rem; + min-height: 3rem; + color: var(--mat-sys-on-surface); + font-size: var(--mat-sys-label-large); + border-radius: var(--mat-sys-corner-extra-large); +} + +[ngOption]:hover { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 5%, transparent); +} + +[ngOption][data-active='true'] { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent); + outline: 2px solid var(--mat-sys-on-surface); + outline-offset: -2px; +} + +[ngOption][aria-selected='true'] { + color: var(--mat-sys-primary); + background-color: color-mix(in srgb, var(--mat-sys-primary) 10%, transparent); +} + +.example-option-icon { + padding-right: 1rem; +} + +.example-option-check, +.example-option-icon { + font-size: var(--mat-sys-label-large); +} + +[ngOption]:not([aria-selected='true']) .example-option-check { + display: none; +} + +.example-option-text { + flex: 1; +} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.html new file mode 100644 index 000000000000..9c1221efd572 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.html @@ -0,0 +1,46 @@ +
    + {{value()}} + arrow_drop_down +
    + + + +
    +
    + @for (option of options(); track option.value) { +
    + @if (option.icon) { + {{option.icon}} + } + {{option.value}} + @if (selectedValues().includes(option.value)) { + + } +
    + } +
    +
    +
    +
    diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.ts new file mode 100644 index 000000000000..5bf81a940e24 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.ts @@ -0,0 +1,51 @@ +/** + * @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 {Component, signal, afterRenderEffect, viewChild} from '@angular/core'; +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import {OverlayModule} from '@angular/cdk/overlay'; + +@Component({ + selector: 'simple-combobox-select-example', + templateUrl: 'simple-combobox-select-example.html', + styleUrl: 'simple-combobox-select-example.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], +}) +export class SimpleComboboxSelectExample { + readonly listbox = viewChild(Listbox); + + readonly options = signal([ + {value: 'Select a label', icon: ''}, + {value: 'Important', icon: 'label'}, + {value: 'Starred', icon: 'star'}, + {value: 'Work', icon: 'work'}, + {value: 'Personal', icon: 'person'}, + {value: 'To Do', icon: 'checklist'}, + {value: 'Later', icon: 'schedule'}, + {value: 'Read', icon: 'menu_book'}, + {value: 'Travel', icon: 'flight'}, + ]); + readonly value = signal('Select a label'); + readonly selectedValues = signal(['Select a label']); + readonly popupExpanded = signal(false); + + constructor() { + afterRenderEffect(() => { + this.listbox()?.scrollActiveItemIntoView(); + }); + } + + onCommit() { + const values = this.selectedValues(); + if (values.length) { + this.value.set(values[0]); + this.popupExpanded.set(false); + } + } +} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.html new file mode 100644 index 000000000000..ef19038f94cc --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.html @@ -0,0 +1,38 @@ +
    +
    + search + +
    + + + +
      + +
    +
    +
    +
    + + + @for (node of nodes; track node.name) { +
  • + + {{ node.name }} + +
  • + @if (node.children) { +
      + + + +
    + } + } +
    \ No newline at end of file diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.ts new file mode 100644 index 000000000000..c362d3aa3d7a --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.ts @@ -0,0 +1,108 @@ +/** + * @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 {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree'; +import { + Component, + afterRenderEffect, + computed, + signal, + viewChild, + untracked, + ChangeDetectionStrategy, +} from '@angular/core'; +import {NgTemplateOutlet} from '@angular/common'; +import {OverlayModule} from '@angular/cdk/overlay'; + +interface FoodNode { + name: string; + children?: FoodNode[]; + expanded?: boolean; +} + +/** @title Combobox with tree popup and auto-select filtering. */ +@Component({ + selector: 'simple-combobox-tree-auto-select-example', + templateUrl: 'simple-combobox-tree-auto-select-example.html', + styleUrl: '../simple-combobox-example.css', + imports: [ + Combobox, + ComboboxPopup, + ComboboxWidget, + NgTemplateOutlet, + Tree, + TreeItem, + TreeItemGroup, + OverlayModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SimpleComboboxTreeAutoSelectExample { + readonly tree = viewChild(Tree); + + popupExpanded = signal(false); + searchString = signal(''); + selectedValues = signal([]); + + readonly dataSource = signal(FOOD_DATA); + + constructor() { + afterRenderEffect(() => { + const active = this.tree()?._pattern.inputs.activeItem(); + if (active) { + untracked(() => { + active.element()?.scrollIntoView({block: 'nearest'}); + }); + } + }); + } + + filteredGroups = computed(() => { + const search = this.searchString().trim().toLowerCase(); + const data = this.dataSource(); + + if (!search) { + return data; + } + + const filterNode = (node: FoodNode): FoodNode | null => { + const matches = node.name.toLowerCase().includes(search); + const children = node.children + ?.map(child => filterNode(child)) + .filter((child): child is FoodNode => child !== null); + + if (matches || (children && children.length > 0)) { + return { + ...node, + children, + expanded: children && children.length > 0 ? true : node.expanded, + }; + } + + return null; + }; + + return data.map(node => filterNode(node)).filter((node): node is FoodNode => node !== null); + }); + + onCommit() { + const selected = this.selectedValues(); + if (selected.length > 0) { + this.searchString.set(selected[0]); + this.popupExpanded.set(false); + } + } +} + +const FOOD_DATA: FoodNode[] = [ + {name: 'Winter', children: [{name: 'December'}, {name: 'January'}, {name: 'February'}]}, + {name: 'Spring', children: [{name: 'March'}, {name: 'April'}, {name: 'May'}]}, + {name: 'Summer', children: [{name: 'June'}, {name: 'July'}, {name: 'August'}]}, + {name: 'Fall', children: [{name: 'September'}, {name: 'October'}, {name: 'November'}]}, +]; diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.html new file mode 100644 index 000000000000..6f7535c71ff7 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.html @@ -0,0 +1,41 @@ +
    +
    + search + +
    + + + +
    +
      + +
    +
    +
    +
    +
    + + + @for (node of nodes; track node.name) { +
  • + + {{ node.name }} + +
  • + @if (node.children) { +
      + + + +
    + } + } +
    \ No newline at end of file diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.ts new file mode 100644 index 000000000000..6de3da7b55a4 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.ts @@ -0,0 +1,126 @@ +/** + * @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 {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree'; +import { + Component, + afterRenderEffect, + computed, + signal, + viewChild, + untracked, + ChangeDetectionStrategy, +} from '@angular/core'; +import {NgTemplateOutlet} from '@angular/common'; +import {OverlayModule} from '@angular/cdk/overlay'; + +interface FoodNode { + name: string; + children?: FoodNode[]; + expanded?: boolean; +} + +/** @title Combobox with tree popup and highlight filtering. */ +@Component({ + selector: 'simple-combobox-tree-highlight-example', + templateUrl: 'simple-combobox-tree-highlight-example.html', + styleUrl: '../simple-combobox-example.css', + imports: [ + Combobox, + ComboboxPopup, + ComboboxWidget, + NgTemplateOutlet, + Tree, + TreeItem, + TreeItemGroup, + OverlayModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SimpleComboboxTreeHighlightExample { + readonly tree = viewChild(Tree); + + popupExpanded = signal(false); + searchString = signal(''); + selectedValues = signal([]); + + readonly dataSource = signal(FOOD_DATA); + + constructor() { + // Highlight mode focus update + afterRenderEffect(() => { + this.filteredGroups(); + }); + + afterRenderEffect(() => { + const active = this.tree()?._pattern.inputs.activeItem(); + if (active) { + untracked(() => { + active.element()?.scrollIntoView({block: 'nearest'}); + }); + } + }); + } + + filteredData = computed(() => { + const search = this.searchString().trim().toLowerCase(); + const data = this.dataSource(); + + if (!search) { + return {groups: data, firstMatch: undefined}; + } + + let firstMatch: string | undefined = undefined; + + const filterNode = (node: FoodNode): FoodNode | null => { + // Find the first leaf node that starts with the search string + if (!firstMatch && !node.children && node.name.toLowerCase().startsWith(search)) { + firstMatch = node.name; + } + + const matches = node.name.toLowerCase().includes(search); + const children = node.children + ?.map(child => filterNode(child)) + .filter((child): child is FoodNode => child !== null); + + if (matches || (children && children.length > 0)) { + return { + ...node, + children, + expanded: children && children.length > 0 ? true : node.expanded, + }; + } + + return null; + }; + + const groups = data + .map(node => filterNode(node)) + .filter((node): node is FoodNode => node !== null); + return {groups, firstMatch}; + }); + + filteredGroups = computed(() => this.filteredData().groups); + firstMatchingOption = computed(() => this.filteredData().firstMatch); + + onCommit() { + const selected = this.selectedValues(); + if (selected.length > 0) { + this.searchString.set(selected[0]); + this.popupExpanded.set(false); + } + } +} + +const FOOD_DATA: FoodNode[] = [ + {name: 'Winter', children: [{name: 'December'}, {name: 'January'}, {name: 'February'}]}, + {name: 'Spring', children: [{name: 'March'}, {name: 'April'}, {name: 'May'}]}, + {name: 'Summer', children: [{name: 'June'}, {name: 'July'}, {name: 'August'}]}, + {name: 'Fall', children: [{name: 'September'}, {name: 'October'}, {name: 'November'}]}, +]; diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html new file mode 100644 index 000000000000..3169aee5d382 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html @@ -0,0 +1,69 @@ +
    +
    + search + +
    + + + +
      + +
    +
    +
    +
    + + + @for (node of nodes; track node.name) { +
  • + + {{ node.name }} + +
  • + @if (node.children) { +
      + + + +
    + } + } +
    diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts new file mode 100644 index 000000000000..c1faeb5bf87c --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts @@ -0,0 +1,100 @@ +/** + * @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 {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree'; +import {Component, afterRenderEffect, computed, signal, viewChild, untracked} from '@angular/core'; +import {NgTemplateOutlet} from '@angular/common'; +import {OverlayModule} from '@angular/cdk/overlay'; + +interface FoodNode { + name: string; + children?: FoodNode[]; + expanded?: boolean; +} + +/** @title */ +@Component({ + selector: 'simple-combobox-tree-example', + templateUrl: 'simple-combobox-tree-example.html', + styleUrl: '../simple-combobox-example.css', + imports: [ + Combobox, + ComboboxPopup, + ComboboxWidget, + NgTemplateOutlet, + Tree, + TreeItem, + TreeItemGroup, + OverlayModule, + ], +}) +export class SimpleComboboxTreeExample { + readonly tree = viewChild(Tree); + + popupExpanded = signal(false); + searchString = signal(''); + selectedValues = signal([]); + + readonly dataSource = signal(FOOD_DATA); + + constructor() { + afterRenderEffect(() => { + const active = this.tree()?._pattern.inputs.activeItem(); + if (active) { + untracked(() => { + active.element()?.scrollIntoView({block: 'nearest'}); + }); + } + }); + } + + filteredGroups = computed(() => { + const search = this.searchString().trim().toLowerCase(); + const data = this.dataSource(); + + if (!search) { + return data; + } + + const filterNode = (node: FoodNode): FoodNode | null => { + const matches = node.name.toLowerCase().includes(search); + const children = node.children + ?.map(child => filterNode(child)) + .filter((child): child is FoodNode => child !== null); + + if (matches || (children && children.length > 0)) { + return { + ...node, + children, + expanded: children && children.length > 0 ? true : node.expanded, + }; + } + + return null; + }; + + return data.map(node => filterNode(node)).filter((node): node is FoodNode => node !== null); + }); + + onCommit() { + const selected = this.selectedValues(); + if (selected.length > 0) { + const value = selected[0]; + this.searchString.set(value); + this.popupExpanded.set(false); + } + } +} + +const FOOD_DATA: FoodNode[] = [ + {name: 'Winter', children: [{name: 'December'}, {name: 'January'}, {name: 'February'}]}, + {name: 'Spring', children: [{name: 'March'}, {name: 'April'}, {name: 'May'}]}, + {name: 'Summer', children: [{name: 'June'}, {name: 'July'}, {name: 'August'}]}, + {name: 'Fall', children: [{name: 'September'}, {name: 'October'}, {name: 'November'}]}, +]; diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index b18b11e22ea1..f4b2184f1ba3 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -33,6 +33,7 @@ ng_project( "//src/dev-app/aria-menu", "//src/dev-app/aria-menubar", "//src/dev-app/aria-select", + "//src/dev-app/aria-simple-combobox", "//src/dev-app/aria-tabs", "//src/dev-app/aria-toolbar", "//src/dev-app/aria-tree", diff --git a/src/dev-app/aria-simple-combobox/BUILD.bazel b/src/dev-app/aria-simple-combobox/BUILD.bazel new file mode 100644 index 000000000000..0226eb758e65 --- /dev/null +++ b/src/dev-app/aria-simple-combobox/BUILD.bazel @@ -0,0 +1,16 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "aria-simple-combobox", + srcs = glob(["**/*.ts"]), + assets = [ + "simple-combobox-demo.html", + "simple-combobox-demo.css", + ], + deps = [ + "//:node_modules/@angular/core", + "//src/components-examples/aria/simple-combobox", + ], +) diff --git a/src/dev-app/aria-simple-combobox/simple-combobox-demo.css b/src/dev-app/aria-simple-combobox/simple-combobox-demo.css new file mode 100644 index 000000000000..78b87b236202 --- /dev/null +++ b/src/dev-app/aria-simple-combobox/simple-combobox-demo.css @@ -0,0 +1,25 @@ +.example-combobox-row { + display: flex; + gap: 20px; +} + +.example-combobox-container { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + min-width: 350px; + padding: 20px 0; +} + +h2 { + font-size: 1.5rem; + padding-top: 20px; +} + +h3 { + font-size: 1rem; +} +.demo-simple-combobox { + padding-bottom: 300px; +} diff --git a/src/dev-app/aria-simple-combobox/simple-combobox-demo.html b/src/dev-app/aria-simple-combobox/simple-combobox-demo.html new file mode 100644 index 000000000000..d651ab1aa070 --- /dev/null +++ b/src/dev-app/aria-simple-combobox/simple-combobox-demo.html @@ -0,0 +1,84 @@ +
    +

    Listbox autocomplete examples

    + +
    +
    +

    Combobox with manual filtering

    + +
    + +
    +

    Combobox with auto-select

    + +
    +
    +

    Combobox with highlight

    + +
    + +
    +

    Combobox with disabled

    + +
    +
    + +

    Tree autocomplete examples

    + +
    +
    +

    Combobox with tree popup and manual filtering

    + +
    + +
    +

    Combobox with tree popup and auto-select

    + +
    + +
    +

    Combobox with tree popup and highlight filtering

    + +
    +
    + +

    Combobox select examples

    + +
    +
    +

    Combobox with select

    + +
    + +
    +

    Combobox with Multi-Select

    + +
    + +
    +

    Combobox with Readonly + Disabled

    + +
    +
    + +

    Combobox with Dialog Popup

    + +
    +
    +

    Combobox with Dialog Popup

    + +
    +
    + +

    Combobox Grid Examples

    + +
    +
    +

    Combobox with Grid

    + +
    +
    +

    Combobox with Datepicker Grid

    + +
    +
    +
    \ No newline at end of file diff --git a/src/dev-app/aria-simple-combobox/simple-combobox-demo.ts b/src/dev-app/aria-simple-combobox/simple-combobox-demo.ts new file mode 100644 index 000000000000..63eaff4dea51 --- /dev/null +++ b/src/dev-app/aria-simple-combobox/simple-combobox-demo.ts @@ -0,0 +1,46 @@ +/** + * @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 {ChangeDetectionStrategy, Component} from '@angular/core'; +import { + SimpleComboboxListboxExample, + SimpleComboboxTreeExample, + SimpleComboboxSelectExample, + SimpleComboboxGridExample, + SimpleComboboxDatepickerExample, + SimpleComboboxAutoSelectExample, + SimpleComboboxHighlightExample, + SimpleComboboxDisabledExample, + SimpleComboboxReadonlyDisabledExample, + SimpleComboboxReadonlyMultiselectExample, + SimpleComboboxDialogExample, + SimpleComboboxTreeAutoSelectExample, + SimpleComboboxTreeHighlightExample, +} from '@angular/components-examples/aria/simple-combobox'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: 'simple-combobox-demo.html', + styleUrl: 'simple-combobox-demo.css', + imports: [ + SimpleComboboxListboxExample, + SimpleComboboxTreeExample, + SimpleComboboxSelectExample, + SimpleComboboxGridExample, + SimpleComboboxDatepickerExample, + SimpleComboboxAutoSelectExample, + SimpleComboboxHighlightExample, + SimpleComboboxDisabledExample, + SimpleComboboxReadonlyDisabledExample, + SimpleComboboxReadonlyMultiselectExample, + SimpleComboboxDialogExample, + SimpleComboboxTreeAutoSelectExample, + SimpleComboboxTreeHighlightExample, + ], +}) +export class ComboboxDemo {} diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index 8e552a3d0727..7fb023a4d0cc 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -65,6 +65,7 @@ export class DevAppLayout { {name: 'Aria Accordion', route: '/aria-accordion'}, {name: 'Aria Combobox', route: '/aria-combobox'}, {name: 'Aria Autocomplete', route: '/aria-autocomplete'}, + {name: 'Aria Simple Combobox', route: '/aria-simple-combobox'}, {name: 'Aria Grid', route: '/aria-grid'}, {name: 'Aria Listbox', route: '/aria-listbox'}, {name: 'Aria Menu', route: '/aria-menu'}, diff --git a/src/dev-app/routes.ts b/src/dev-app/routes.ts index 2fab9c4af821..2f2daeb6d542 100644 --- a/src/dev-app/routes.ts +++ b/src/dev-app/routes.ts @@ -48,6 +48,11 @@ export const DEV_APP_ROUTES: Routes = [ path: 'aria-select', loadComponent: () => import('./aria-select/select-demo').then(m => m.SelectDemo), }, + { + path: 'aria-simple-combobox', + loadComponent: () => + import('./aria-simple-combobox/simple-combobox-demo').then(m => m.ComboboxDemo), + }, { path: 'aria-grid', loadComponent: () => import('./aria-grid/grid-demo').then(m => m.GridDemo),