diff --git a/goldens/aria/combobox/index.api.md b/goldens/aria/combobox/index.api.md index 1955350fe6db..ba766173ade9 100644 --- a/goldens/aria/combobox/index.api.md +++ b/goldens/aria/combobox/index.api.md @@ -4,76 +4,72 @@ ```ts -import * as _angular_cdk_bidi from '@angular/cdk/bidi'; import * as _angular_core from '@angular/core'; +import { ComboboxPattern } from '@angular/aria/private'; +import { ComboboxPopupPattern } from '@angular/aria/private'; +import { DeferredContentAware } from '@angular/aria/private'; +import * as i1 from '@angular/aria/private'; import { OnDestroy } from '@angular/core'; +import { OnInit } from '@angular/core'; // @public -export class Combobox { +export class Combobox extends DeferredContentAware implements OnInit { constructor(); readonly alwaysExpanded: _angular_core.InputSignalWithTransform; - close(): void; readonly disabled: _angular_core.InputSignalWithTransform; readonly element: HTMLElement; - readonly expanded: _angular_core.Signal; - readonly filterMode: _angular_core.InputSignal<"manual" | "auto-select" | "highlight">; - readonly firstMatch: _angular_core.InputSignal; - readonly inputElement: _angular_core.Signal; - open(): void; - readonly _pattern: ComboboxPattern; - readonly popup: _angular_core.Signal | undefined>; - readonly readonly: _angular_core.InputSignalWithTransform; - protected readonly textDirection: _angular_core.Signal<_angular_cdk_bidi.Direction>; + readonly expanded: _angular_core.ModelSignal; + readonly inlineSuggestion: _angular_core.InputSignal; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration, "[ngCombobox]", ["ngCombobox"], { "filterMode": { "alias": "filterMode"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "readonly": { "alias": "readonly"; "required": false; "isSignal": true; }; "firstMatch": { "alias": "firstMatch"; "required": false; "isSignal": true; }; "alwaysExpanded": { "alias": "alwaysExpanded"; "required": false; "isSignal": true; }; }, {}, ["popup"], never, true, [{ directive: typeof DeferredContentAware; inputs: { "preserveContent": "preserveContent"; }; outputs: {}; }]>; + ngOnInit(): void; + readonly _pattern: ComboboxPattern; + readonly _popup: _angular_core.WritableSignal; + _registerPopup(popup: ComboboxPopup): void; + _unregisterPopup(): void; + readonly value: _angular_core.ModelSignal; + // (undocumented) + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) - static ɵfac: _angular_core.ɵɵFactoryDeclaration, never>; + static ɵfac: _angular_core.ɵɵFactoryDeclaration; } // @public -export class ComboboxDialog { - constructor(); +export class ComboboxPopup implements OnInit, OnDestroy { + readonly activeDescendant: _angular_core.Signal; + readonly combobox: _angular_core.InputSignal; + readonly controlTarget: _angular_core.Signal; // (undocumented) - close(): void; - readonly combobox: Combobox; - readonly element: HTMLDialogElement; - readonly id: _angular_core.InputSignal; + ngOnDestroy(): void; // (undocumented) - readonly _pattern: ComboboxDialogPattern; + ngOnInit(): void; + readonly _pattern: ComboboxPopupPattern; + 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; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) - static ɵfac: _angular_core.ɵɵFactoryDeclaration; + static ɵfac: _angular_core.ɵɵFactoryDeclaration; } // @public -export class ComboboxInput { +export class ComboboxWidget implements OnInit, OnDestroy { constructor(); - readonly combobox: Combobox; + readonly activeDescendant: _angular_core.InputSignal; readonly element: HTMLElement; - readonly value: _angular_core.ModelSignal; - // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; - // (undocumented) - static ɵfac: _angular_core.ɵɵFactoryDeclaration; -} - -// @public -export class ComboboxPopup { - readonly combobox: Combobox | null; - readonly _controls: _angular_core.WritableSignal | ComboboxTreeControls | ComboboxDialogPattern | undefined>; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration, "[ngComboboxPopup]", ["ngComboboxPopup"], {}, {}, never, never, true, never>; + ngOnDestroy(): void; // (undocumented) - static ɵfac: _angular_core.ɵɵFactoryDeclaration, never>; -} - -// @public -export class ComboboxPopupContainer { + ngOnInit(): void; + onFocusin(): void; + onFocusout(event: FocusEvent): void; + readonly popupId: _angular_core.WritableSignal; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) - static ɵfac: _angular_core.ɵɵFactoryDeclaration; + static ɵfac: _angular_core.ɵɵFactoryDeclaration; } // (No @packageDocumentation comment for this package) diff --git a/goldens/aria/listbox/index.api.md b/goldens/aria/listbox/index.api.md index d3c984e40aae..988e0253d1c0 100644 --- a/goldens/aria/listbox/index.api.md +++ b/goldens/aria/listbox/index.api.md @@ -36,7 +36,7 @@ export class Listbox implements OnDestroy { 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; }; "tabIndex": { "alias": "tabIndex"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; }, never, 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; }; "tabIndex": { "alias": "tabIndex"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; }, never, never, true, never>; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration, never>; } diff --git a/goldens/aria/private/index.api.md b/goldens/aria/private/index.api.md index 98f532b6403f..125e4b408c3d 100644 --- a/goldens/aria/private/index.api.md +++ b/goldens/aria/private/index.api.md @@ -59,207 +59,66 @@ export class AccordionTriggerPattern implements ListNavigationItem, ListFocusIte toggle(): void; } -// @public (undocumented) -export class ComboboxDialogPattern { - constructor(inputs: { - combobox: ComboboxPattern; - element: SignalLike; - id: SignalLike; - }); - // (undocumented) - readonly id: () => string; - // (undocumented) - readonly inputs: { - combobox: ComboboxPattern; - element: SignalLike; - id: SignalLike; - }; - // (undocumented) - readonly keydown: SignalLike>; - // (undocumented) - onClick(event: MouseEvent): void; - // (undocumented) - onKeydown(event: KeyboardEvent): void; - // (undocumented) - readonly role: () => "dialog"; -} - // @public -export interface ComboboxInputs, V> { +export interface ComboboxInputs extends ExpansionItem { alwaysExpanded: SignalLike; - containerEl: SignalLike; disabled: SignalLike; - filterMode: SignalLike<'manual' | 'auto-select' | 'highlight'>; - firstMatch: SignalLike; - inputEl: SignalLike; - inputValue?: WritableSignalLike; - popupControls: SignalLike | ComboboxTreeControls | ComboboxDialogPattern | undefined>; - readonly: SignalLike; - textDirection: SignalLike<'rtl' | 'ltr'>; -} - -// @public -export interface ComboboxListboxControls, V> { - activeId: SignalLike; - clearSelection: () => void; - first: () => void; - focus: (item: T, opts?: { - focusElement?: boolean; - }) => void; - getActiveItem: () => T | undefined; - getItem: (e: PointerEvent) => T | undefined; - getSelectedItems: () => T[]; - readonly id: () => string; - items: SignalLike; - last: () => void; - multi: SignalLike; - next: () => void; - prev: () => void; - role: SignalLike<'listbox' | 'tree' | 'grid'>; - select: (item?: T) => void; - setValue: (value: V | undefined) => void; - toggle: (item?: T) => void; - unfocus: () => void; -} - -// @public (undocumented) -export type ComboboxListboxInputs = ListboxInputs & { - combobox: SignalLike, V> | undefined>; -}; - -// @public (undocumented) -export class ComboboxListboxPattern extends ListboxPattern implements ComboboxListboxControls, V> { - constructor(inputs: ComboboxListboxInputs); - readonly activeId: SignalLike; - readonly clearSelection: () => void; - readonly first: () => void; - readonly focus: (item: OptionPattern, opts?: { - focusElement?: boolean; - }) => void; - readonly getActiveItem: () => OptionPattern | undefined; - readonly getItem: (e: PointerEvent) => OptionPattern | undefined; - readonly getSelectedItems: () => OptionPattern[]; - readonly id: SignalLike; - // (undocumented) - readonly inputs: ComboboxListboxInputs; - readonly items: SignalLike[]>; - readonly last: () => void; - multi: SignalLike; - readonly next: () => void; - onClick(_: PointerEvent): void; - onKeydown(_: KeyboardEvent): void; - readonly prev: () => void; - readonly role: SignalLike<"listbox">; - readonly select: (item?: OptionPattern) => void; - setDefaultState(): void; - readonly setValue: (value: V | undefined) => void; - tabIndex: SignalLike<-1 | 0>; - readonly toggle: (item?: OptionPattern) => void; - readonly unfocus: () => void; + element: SignalLike; + inlineSuggestion: SignalLike; + popup: SignalLike; + value: WritableSignalLike; } // @public -export class ComboboxPattern, V> { - constructor(inputs: ComboboxInputs); - readonly activeDescendant: SignalLike; - readonly autocomplete: SignalLike<"both" | "list">; - readonly click: SignalLike>; - close(opts?: { - reset: boolean; - }): void; - collapseItem(): void; - readonly collapseKey: SignalLike<"ArrowLeft" | "ArrowRight">; - commit(): void; +export class ComboboxPattern { + constructor(inputs: ComboboxInputs); + readonly activeDescendant: _angular_core.Signal; + readonly autocomplete: _angular_core.Signal<"none" | "inline" | "list" | "both">; + click: _angular_core.Signal>; + closePopupOnBlurEffect(): void; readonly disabled: () => boolean; - readonly expanded: WritableSignalLike; - expandItem(): void; - readonly expandKey: SignalLike<"ArrowLeft" | "ArrowRight">; - first(): void; - readonly firstMatch: SignalLike; - readonly hasBeenInteracted: WritableSignalLike; - readonly hasPopup: SignalLike<"listbox" | "tree" | "grid" | "dialog" | null>; - highlight(): void; - readonly highlightedItem: WritableSignalLike; + readonly element: () => HTMLElement; + highlightEffect(): void; + readonly inlineSuggestion: () => string | undefined; // (undocumented) - readonly inputs: ComboboxInputs; - readonly isFocused: WritableSignalLike; - readonly keydown: SignalLike>; - last(): void; - readonly listControls: () => ComboboxListboxControls | null | undefined; - next(): void; - onClick(event: MouseEvent): void; - onFilter(): void; - onFocusIn(): void; - onFocusOut(event: FocusEvent): void; + readonly inputs: ComboboxInputs; + readonly isDeleting: _angular_core.WritableSignal; + readonly isEditable: _angular_core.Signal; + readonly isExpanded: _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; - open(nav?: { - first?: boolean; - last?: boolean; - selected?: boolean; - }): void; - readonly popupId: SignalLike; - prev(): void; - readonly readonly: SignalLike; - select(opts?: { - item?: T; - commit?: boolean; - close?: boolean; - }): void; - readonly treeControls: () => ComboboxTreeControls | null; + readonly popupId: _angular_core.Signal; + readonly popupType: _angular_core.Signal<"listbox" | "tree" | "grid" | "dialog" | undefined>; + readonly value: WritableSignalLike; } -// @public (undocumented) -export interface ComboboxTreeControls, V> extends ComboboxListboxControls { - collapseAll: () => void; - collapseItem: () => void; - expandAll: () => void; - expandItem: () => void; - isItemCollapsible: () => boolean; - isItemExpandable: (item?: T) => boolean; - isItemSelectable: (item?: T) => boolean; - toggleExpansion: (item?: T) => void; +// @public +export interface ComboboxPopupInputs { + activeDescendant: SignalLike; + controlTarget: SignalLike; + popupId: SignalLike; + popupType: SignalLike<'listbox' | 'tree' | 'grid' | 'dialog'>; } -// @public (undocumented) -export type ComboboxTreeInputs = TreeInputs & { - combobox: SignalLike, V> | undefined>; -}; - -// @public (undocumented) -export class ComboboxTreePattern extends TreePattern implements ComboboxTreeControls, V> { - constructor(inputs: ComboboxTreeInputs); - // (undocumented) - readonly activeId: SignalLike; - readonly clearSelection: () => void; - readonly collapseAll: () => void; - readonly collapseItem: () => void; - readonly expandAll: () => void; - readonly expandItem: () => void; - readonly first: () => void; - readonly focus: (item: TreeItemPattern) => void; - readonly getActiveItem: () => TreeItemPattern | undefined; - readonly getItem: (e: PointerEvent) => TreeItemPattern | undefined; - readonly getSelectedItems: () => TreeItemPattern[]; - // (undocumented) - readonly inputs: ComboboxTreeInputs; - readonly isItemCollapsible: () => boolean; - isItemExpandable(item?: TreeItemPattern | undefined): boolean; - readonly isItemSelectable: (item?: TreeItemPattern | undefined) => boolean; - items: SignalLike[]>; - readonly last: () => void; - readonly next: () => void; - onClick(_: PointerEvent): void; - onKeydown(_: KeyboardEvent): void; - readonly prev: () => void; - readonly role: () => "tree"; - readonly select: (item?: TreeItemPattern) => void; - setDefaultState(): void; - readonly setValue: (value: V | undefined) => void; - readonly tabIndex: SignalLike<-1 | 0>; - readonly toggle: (item?: TreeItemPattern) => void; - readonly toggleExpansion: (item?: TreeItemPattern) => void; - readonly unfocus: () => void; +// @public +export class ComboboxPopupPattern { + constructor(inputs: ComboboxPopupInputs); + readonly activeDescendant: () => string | undefined; + readonly controlTarget: () => HTMLElement | undefined; + // (undocumented) + readonly inputs: ComboboxPopupInputs; + readonly isFocused: _angular_core.WritableSignal; + onFocusin(): void; + onFocusout(event: FocusEvent): void; + readonly popupId: () => string | undefined; + readonly popupType: () => "listbox" | "tree" | "grid" | "dialog"; } // @public (undocumented) @@ -665,70 +524,6 @@ 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; - highlightEffect(): void; - readonly inlineSuggestion: () => string | undefined; - // (undocumented) - readonly inputs: SimpleComboboxInputs; - readonly isDeleting: _angular_core.WritableSignal; - readonly isEditable: _angular_core.Signal; - readonly isExpanded: _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 deleted file mode 100644 index 5fff72ac2474..000000000000 --- a/goldens/aria/simple-combobox/index.api.md +++ /dev/null @@ -1,78 +0,0 @@ -## 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.InputSignal; - 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 e4dc6095b226..e8b7125b062e 100644 --- a/goldens/aria/tree/index.api.md +++ b/goldens/aria/tree/index.api.md @@ -37,7 +37,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; }; "tabIndex": { "alias": "tabIndex"; "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; }; "tabIndex": { "alias": "tabIndex"; "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, never>; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration, never>; } diff --git a/src/aria/combobox/BUILD.bazel b/src/aria/combobox/BUILD.bazel index 80905cae2544..5aa2777365d4 100644 --- a/src/aria/combobox/BUILD.bazel +++ b/src/aria/combobox/BUILD.bazel @@ -1,4 +1,4 @@ -load("//tools:defaults.bzl", "extract_api_to_json", "ng_project", "ng_web_test_suite", "ts_project") +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") package(default_visibility = ["//visibility:public"]) @@ -11,7 +11,6 @@ ng_project( deps = [ "//:node_modules/@angular/core", "//src/aria/private", - "//src/cdk/a11y", "//src/cdk/bidi", ], ) @@ -28,7 +27,7 @@ ts_project( "//:node_modules/@angular/common", "//:node_modules/@angular/core", "//:node_modules/@angular/platform-browser", - "//:node_modules/axe-core", + "//src/aria/grid", "//src/aria/listbox", "//src/aria/tree", "//src/cdk/testing/private", @@ -39,23 +38,3 @@ ng_web_test_suite( name = "unit_tests", deps = [":unit_test_sources"], ) - -filegroup( - name = "source-files", - srcs = glob( - ["**/*.ts"], - exclude = ["**/*.spec.ts"], - ), -) - -extract_api_to_json( - name = "json_api", - srcs = [ - ":source-files", - ], - entry_point = ":index.ts", - module_name = "@angular/aria/combobox", - output_name = "aria-combobox.json", - private_modules = [""], - repo = "angular/components", -) diff --git a/src/aria/combobox/combobox-dialog.ts b/src/aria/combobox/combobox-dialog.ts deleted file mode 100644 index 2d80f2b65ef8..000000000000 --- a/src/aria/combobox/combobox-dialog.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * @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, Directive, ElementRef, inject, input} from '@angular/core'; -import {_IdGenerator} from '@angular/cdk/a11y'; -import {ComboboxDialogPattern} from '../private'; -import {Combobox} from './combobox'; -import {ComboboxPopup} from './combobox-popup'; - -/** - * Integrates a native `` element with the combobox, allowing for - * a modal or non-modal popup experience. It handles the opening and closing of the dialog - * based on the combobox's expanded state. - * - * ```html - * - * - * - * - * - * ``` - * - * @developerPreview 21.0 - * - * @see [Combobox](guide/aria/combobox) - * @see [Select](guide/aria/select) - * @see [Multiselect](guide/aria/multiselect) - * @see [Autocomplete](guide/aria/autocomplete) - */ -@Directive({ - selector: 'dialog[ngComboboxDialog]', - exportAs: 'ngComboboxDialog', - host: { - '[attr.data-open]': 'combobox._pattern.expanded()', - '(keydown)': '_pattern.onKeydown($event)', - '(click)': '_pattern.onClick($event)', - }, - hostDirectives: [ComboboxPopup], -}) -export class ComboboxDialog { - /** The dialog element. */ - private readonly _elementRef = inject(ElementRef); - - /** A reference to the dialog element. */ - readonly element = this._elementRef.nativeElement as HTMLDialogElement; - - /** The combobox that the dialog belongs to. */ - readonly combobox = inject(Combobox); - - /** The unique identifier for the trigger. */ - readonly id = input(inject(_IdGenerator).getId('ng-combobox-dialog-', true)); - - /** A reference to the parent combobox popup, if one exists. */ - private readonly _popup = inject>(ComboboxPopup, { - optional: true, - }); - - readonly _pattern: ComboboxDialogPattern = new ComboboxDialogPattern({ - id: this.id, - element: () => this.element, - combobox: this.combobox._pattern, - }); - - constructor() { - if (this._popup) { - this._popup._controls.set(this._pattern); - } - - afterRenderEffect({ - write: () => { - this.combobox._pattern.expanded() ? this.element.showModal() : this.element.close(); - }, - }); - } - - close() { - this._popup?.combobox?._pattern.close(); - } -} diff --git a/src/aria/combobox/combobox-input.ts b/src/aria/combobox/combobox-input.ts deleted file mode 100644 index 9172a932e189..000000000000 --- a/src/aria/combobox/combobox-input.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * @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, - Directive, - ElementRef, - inject, - model, - untracked, - WritableSignal, -} from '@angular/core'; -import {ComboboxDialogPattern} from '../private'; -import {Combobox} from './combobox'; - -/** - * An input that is part of a combobox. It is responsible for displaying the - * current value and handling user input for filtering and selection. - * - * This directive should be applied to an `` element within an `ngCombobox` - * container. It automatically handles keyboard interactions, such as opening the - * popup and navigating through the options. - * - * ```html - * - * ``` - * - * @developerPreview 21.0 - * - * @see [Combobox](guide/aria/combobox) - * @see [Select](guide/aria/select) - * @see [Multiselect](guide/aria/multiselect) - * @see [Autocomplete](guide/aria/autocomplete) - */ -@Directive({ - selector: 'input[ngComboboxInput]', - exportAs: 'ngComboboxInput', - host: { - 'role': 'combobox', - '[value]': 'value()', - '[attr.aria-disabled]': 'combobox._pattern.disabled()', - '[attr.aria-expanded]': 'combobox._pattern.expanded()', - '[attr.aria-activedescendant]': 'combobox._pattern.activeDescendant()', - '[attr.aria-controls]': 'combobox._pattern.popupId()', - '[attr.aria-haspopup]': 'combobox._pattern.hasPopup()', - '[attr.aria-autocomplete]': 'combobox._pattern.autocomplete()', - '[attr.readonly]': 'combobox._pattern.readonly()', - }, -}) -export class ComboboxInput { - /** The element that the combobox is attached to. */ - private readonly _elementRef = inject>(ElementRef); - - /** A reference to the input element. */ - readonly element = this._elementRef.nativeElement as HTMLElement; - - /** The combobox that the input belongs to. */ - readonly combobox = inject(Combobox); - - /** The value of the input. */ - readonly value = model(''); - - constructor() { - (this.combobox._pattern.inputs.inputEl as WritableSignal).set( - this._elementRef.nativeElement, - ); - this.combobox._pattern.inputs.inputValue = this.value; - - const controls = this.combobox.popup()?._controls(); - if (controls instanceof ComboboxDialogPattern) { - return; - } - - /** Focuses & selects the first item in the combobox if the user changes the input value. */ - afterRenderEffect({ - write: () => { - this.value(); - controls?.items(); - untracked(() => this.combobox._pattern.onFilter()); - }, - }); - } -} diff --git a/src/aria/combobox/combobox-popup-container.ts b/src/aria/combobox/combobox-popup-container.ts deleted file mode 100644 index d25e3bb90531..000000000000 --- a/src/aria/combobox/combobox-popup-container.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * @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 {Directive} from '@angular/core'; -import {DeferredContent} from '../private'; - -/** - * 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 a `ngListbox`, `ngTree`, or `role="dialog"`, allowing for - * flexible and complex combobox implementations. The consumer is responsible for - * implementing the filtering logic based on the `ngComboboxInput`'s value. - * - * ```html - * - *
- * - *
- *
- * ``` - * - * When using CdkOverlay, this directive can be replaced by `cdkConnectedOverlay`. - * - * ```html - * - *
- * - *
- *
- * ``` - * - * @developerPreview 21.0 - * - * @see [Combobox](guide/aria/combobox) - * @see [Select](guide/aria/select) - * @see [Multiselect](guide/aria/multiselect) - * @see [Autocomplete](guide/aria/autocomplete) - */ -@Directive({ - selector: 'ng-template[ngComboboxPopupContainer]', - exportAs: 'ngComboboxPopupContainer', - hostDirectives: [DeferredContent], -}) -export class ComboboxPopupContainer {} diff --git a/src/aria/combobox/combobox-popup.ts b/src/aria/combobox/combobox-popup.ts index 3ecc0d227732..b220a5d548fb 100644 --- a/src/aria/combobox/combobox-popup.ts +++ b/src/aria/combobox/combobox-popup.ts @@ -6,39 +6,74 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Directive, inject, signal} from '@angular/core'; -import {ComboboxListboxControls, ComboboxTreeControls, ComboboxDialogPattern} from '../private'; +import {computed, Directive, inject, input, OnDestroy, OnInit, signal} from '@angular/core'; +import {DeferredContent, ComboboxPopupPattern} from '@angular/aria/private'; import type {Combobox} from './combobox'; -import {COMBOBOX} from './combobox-tokens'; +import type {ComboboxWidget} from './combobox-widget'; +import {COMBOBOX_POPUP} from './combobox-tokens'; /** - * Identifies an element as a popup for an `ngCombobox`. + * A structural directive that marks the `ng-template` to be used as the popup + * for a combobox. This content is conditionally rendered. * - * This directive acts as a bridge, allowing the `ngCombobox` to discover and interact - * with the underlying control (e.g., `ngListbox`, `ngTree`, or `ngComboboxDialog`) that - * manages the options. It's primarily used as a host directive and is responsible for - * exposing the popup's control pattern to the parent combobox. + * The content of the popup can be any element with the `ngComboboxWidget` directive. * - * @developerPreview 21.0 - * - * @see [Combobox](guide/aria/combobox) - * @see [Select](guide/aria/select) - * @see [Multiselect](guide/aria/multiselect) - * @see [Autocomplete](guide/aria/autocomplete) + * ```html + * + *
+ * + *
+ *
+ * ``` */ @Directive({ - selector: '[ngComboboxPopup]', + selector: 'ng-template[ngComboboxPopup]', exportAs: 'ngComboboxPopup', + hostDirectives: [DeferredContent], + providers: [{provide: COMBOBOX_POPUP, useExisting: ComboboxPopup}], }) -export class ComboboxPopup { +export class ComboboxPopup implements OnInit, OnDestroy { + private readonly _deferredContent = inject(DeferredContent); + /** The combobox that the popup belongs to. */ - readonly combobox = inject>(COMBOBOX, {optional: true}); - - /** The popup controls exposed to the combobox. */ - readonly _controls = signal< - | ComboboxListboxControls - | ComboboxTreeControls - | ComboboxDialogPattern - | undefined - >(undefined); + 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 ComboboxPopupPattern({ + ...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); + } } diff --git a/src/aria/combobox/combobox-tokens.ts b/src/aria/combobox/combobox-tokens.ts index bcc7d9d1c4d8..11ab06c43b40 100644 --- a/src/aria/combobox/combobox-tokens.ts +++ b/src/aria/combobox/combobox-tokens.ts @@ -7,7 +7,7 @@ */ import {InjectionToken} from '@angular/core'; -import type {Combobox} from './combobox'; +import type {ComboboxPopup} from './combobox-popup'; -/** Token used to provide the combobox to child components. */ -export const COMBOBOX = new InjectionToken>('COMBOBOX'); +/** Token used to expose the combobox popup. */ +export const COMBOBOX_POPUP = new InjectionToken('COMBOBOX_POPUP'); diff --git a/src/aria/simple-combobox/simple-combobox-widget.ts b/src/aria/combobox/combobox-widget.ts similarity index 94% rename from src/aria/simple-combobox/simple-combobox-widget.ts rename to src/aria/combobox/combobox-widget.ts index c5f21c4c7723..3f4636e68ab4 100644 --- a/src/aria/simple-combobox/simple-combobox-widget.ts +++ b/src/aria/combobox/combobox-widget.ts @@ -7,7 +7,7 @@ */ import {Directive, ElementRef, inject, input, OnDestroy, OnInit, signal} from '@angular/core'; -import {SIMPLE_COMBOBOX_POPUP} from './simple-combobox-tokens'; +import {COMBOBOX_POPUP} from './combobox-tokens'; /** * Identifies an element as a widget within a combobox popup. @@ -27,7 +27,7 @@ import {SIMPLE_COMBOBOX_POPUP} from './simple-combobox-tokens'; export class ComboboxWidget implements OnInit, OnDestroy { /** The element that the popup widget is attached to. */ private readonly _elementRef = inject>(ElementRef); - private readonly _popup = inject(SIMPLE_COMBOBOX_POPUP); + private readonly _popup = inject(COMBOBOX_POPUP); /** A reference to the popup widget element. */ readonly element = this._elementRef.nativeElement; diff --git a/src/aria/combobox/combobox.spec.ts b/src/aria/combobox/combobox.spec.ts index 86c851486538..21e0205f1b90 100644 --- a/src/aria/combobox/combobox.spec.ts +++ b/src/aria/combobox/combobox.spec.ts @@ -1,11 +1,23 @@ -import {Component, computed, DebugElement, signal, ChangeDetectionStrategy} from '@angular/core'; +import { + Component, + computed, + DebugElement, + signal, + untracked, + viewChild, + afterRenderEffect, +} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; -import {Combobox, ComboboxInput, ComboboxPopup, ComboboxPopupContainer} from '../combobox'; +import {Combobox} from './combobox'; +import {ComboboxPopup} from './combobox-popup'; +import {ComboboxWidget} from './combobox-widget'; + 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'; describe('Combobox', () => { describe('with Listbox', () => { @@ -53,14 +65,12 @@ describe('Combobox', () => { const escape = (modifierKeys?: {}) => keydown('Escape', modifierKeys); function setupCombobox( - opts: {readonly?: boolean; filterMode?: 'manual' | 'auto-select' | 'highlight'} = {}, + componentType: any = ComboboxListboxExample, + opts: {readonly?: boolean} = {}, ) { - fixture = TestBed.createComponent(ComboboxListboxExample); + fixture = TestBed.createComponent(componentType); const testComponent = fixture.componentInstance; - if (opts.filterMode) { - testComponent.filterMode.set(opts.filterMode); - } if (opts.readonly) { testComponent.readonly.set(true); } @@ -70,21 +80,17 @@ describe('Combobox', () => { } function defineTestVariables() { - const inputDebugElement = fixture.debugElement.query(By.directive(ComboboxInput)); + const inputDebugElement = fixture.debugElement.query(By.directive(Combobox)); inputElement = inputDebugElement.nativeElement as HTMLInputElement; } function getOption(text: string): HTMLElement | null { - const options = fixture.debugElement - .queryAll(By.directive(Option)) - .map((debugEl: DebugElement) => debugEl.nativeElement as HTMLElement); + const options = Array.from(document.querySelectorAll('[ngoption]')) as HTMLElement[]; return options.find(option => option.textContent?.trim() === text) || null; } function getOptions(): HTMLElement[] { - return fixture.debugElement - .queryAll(By.directive(Option)) - .map((debugEl: DebugElement) => debugEl.nativeElement as HTMLElement); + return Array.from(document.querySelectorAll('[ngoption]')) as HTMLElement[]; } afterEach(async () => await runAccessibilityChecks(fixture.nativeElement)); @@ -102,36 +108,24 @@ describe('Combobox', () => { }); it('should set aria-controls to the listbox id', () => { - focus(); + 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-autocomplete to list for manual mode', () => { - expect(inputElement.getAttribute('aria-autocomplete')).toBe('list'); - }); - - it('should set aria-autocomplete to list for auto-select mode', () => { - fixture.componentInstance.filterMode.set('auto-select'); - fixture.detectChanges(); - expect(inputElement.getAttribute('aria-autocomplete')).toBe('list'); - }); - - it('should set aria-autocomplete to both for highlight mode', () => { - fixture.componentInstance.filterMode.set('highlight'); - fixture.detectChanges(); - expect(inputElement.getAttribute('aria-autocomplete')).toBe('both'); - }); - it('should set aria-multiselectable to false on the listbox', () => { - focus(); + 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', () => { - down(); - enter(); + 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'); }); @@ -150,7 +144,7 @@ describe('Combobox', () => { expect(inputElement.hasAttribute('aria-activedescendant')).toBe(false); }); - it('should set aria-activedescendant to the active option id', () => { + it('should set aria-activedescendant to the active option id', async () => { down(); const option = getOption('Alabama')!; expect(inputElement.getAttribute('aria-activedescendant')).toBe(option.id); @@ -160,13 +154,15 @@ describe('Combobox', () => { describe('Navigation', () => { beforeEach(() => setupCombobox()); - it('should navigate to the first item on ArrowDown', () => { + it('should navigate to the first item on ArrowDown', async () => { down(); const options = getOptions(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[0].id); }); - it('should navigate to the last item on ArrowUp', () => { + it('should navigate to the last item on ArrowUp', async () => { + down(); // Opens the focus on Alabama up(); const options = getOptions(); expect(inputElement.getAttribute('aria-activedescendant')).toBe( @@ -174,31 +170,31 @@ describe('Combobox', () => { ); }); - it('should navigate to the next item on ArrowDown when open', () => { - down(); - down(); + it('should navigate to the next item on ArrowDown when open', async () => { + down(); // Open popup + down(); // Move to next item const options = getOptions(); expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[1].id); }); - it('should navigate to the previous item on ArrowUp when open', () => { - down(); - down(); - up(); + 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(); expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[0].id); }); - it('should navigate to the first item on Home when open', () => { - down(); - down(); + it('should navigate to the first item on Home when open', async () => { + down(); // Open + down(); // Move to next item keydown('Home'); const options = getOptions(); expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[0].id); }); - it('should navigate to the last item on End when open', () => { - down(); + it('should navigate to the last item on End when open', async () => { + down(); // Open keydown('End'); const options = getOptions(); expect(inputElement.getAttribute('aria-activedescendant')).toBe( @@ -216,12 +212,6 @@ describe('Combobox', () => { expect(inputElement.getAttribute('aria-expanded')).toBe('true'); }); - it('should open on ArrowUp', () => { - focus(); - keydown('ArrowUp'); - expect(inputElement.getAttribute('aria-expanded')).toBe('true'); - }); - it('should close on Escape', () => { down(); escape(); @@ -234,25 +224,18 @@ describe('Combobox', () => { expect(inputElement.getAttribute('aria-expanded')).toBe('false'); }); - it('should not close on focusout if focus moves to an element inside the container', () => { - down(); - blur(getOption('Alabama')!); - expect(inputElement.getAttribute('aria-expanded')).toBe('true'); - }); + it('should close on escape and maintain the current input value', async () => { + setupCombobox(ComboboxListboxHighlightExample); - it('should close then clear the completion string', () => { - fixture.componentInstance.filterMode.set('highlight'); - focus(); + 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'); // close - escape(); - expect(inputElement.value).toBe(''); // clear input expect(inputElement.getAttribute('aria-expanded')).toBe('false'); }); @@ -269,24 +252,24 @@ describe('Combobox', () => { expect(inputElement.getAttribute('aria-expanded')).toBe('false'); }); }); - describe('Selection', () => { - describe('when filterMode is "manual"', () => { - beforeEach(() => setupCombobox({filterMode: 'manual'})); + describe('with manual filtering', () => { + beforeEach(() => setupCombobox(ComboboxListboxExample)); + + it('should select and commit on click', async () => { + down(); // Use down() to open - it('should select and commit on click', () => { - click(inputElement); const options = getOptions(); click(options[0]); - fixture.detectChanges(); expect(fixture.componentInstance.value()).toEqual(['Alabama']); expect(inputElement.value).toBe('Alabama'); }); - it('should select and commit to input on Enter', () => { + it('should select and commit to input on Enter', async () => { focus(); down(); + enter(); expect(fixture.componentInstance.value()).toEqual(['Alabama']); @@ -318,14 +301,14 @@ describe('Combobox', () => { }); }); - describe('when filterMode is "auto-select"', () => { - beforeEach(() => setupCombobox({filterMode: 'auto-select'})); + describe('with auto-select behavior', () => { + beforeEach(() => setupCombobox(ComboboxListboxAutoSelectExample)); + + it('should select and commit on click', async () => { + down(); // Use down() to open - it('should select and commit on click', () => { - click(inputElement); const options = getOptions(); click(options[1]); - fixture.detectChanges(); expect(fixture.componentInstance.value()).toEqual(['Alaska']); expect(inputElement.value).toBe('Alaska'); @@ -340,17 +323,19 @@ describe('Combobox', () => { expect(inputElement.value).toBe('Alaska'); }); - it('should select on navigation', () => { + 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'); @@ -368,21 +353,22 @@ describe('Combobox', () => { }); }); - describe('when filterMode is "highlight"', () => { - beforeEach(() => setupCombobox({filterMode: 'highlight'})); + describe('with highlight behavior', () => { + beforeEach(() => setupCombobox(ComboboxListboxHighlightExample)); + + it('should select and commit on click', async () => { + down(); // Use down() to open - it('should select and commit on click', () => { - click(inputElement); const options = getOptions(); click(options[2]); - fixture.detectChanges(); expect(fixture.componentInstance.value()).toEqual(['Arizona']); expect(inputElement.value).toBe('Arizona'); }); - it('should select and commit on Enter', () => { + it('should select and commit on Enter', async () => { down(); + down(); down(); enter(); @@ -391,31 +377,39 @@ describe('Combobox', () => { expect(inputElement.value).toBe('Arizona'); }); - it('should select on navigation', () => { + 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', () => { + 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', () => { - focus(); + 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', () => { - focus(); + it('should insert a highlighted completion string on input', async () => { + down(); // Use down() instead of focus() + input('A'); expect(inputElement.value).toBe('Alabama'); @@ -423,8 +417,9 @@ describe('Combobox', () => { expect(inputElement.selectionEnd).toBe(7); }); - it('should not insert a completion string on backspace', () => { - focus(); + it('should not insert a completion string on backspace', async () => { + down(); // Use down() instead of focus() + input('New'); expect(inputElement.value).toBe('New Hampshire'); @@ -432,19 +427,25 @@ describe('Combobox', () => { expect(inputElement.selectionEnd).toBe(13); }); - it('should insert a completion string even if the items are not changed', () => { - focus(); + 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', () => { - focus(); + it('should commit the selected option on focusout', async () => { + down(); // Use down() instead of focus() + input('Cali'); + blur(); expect(inputElement.value).toBe('California'); @@ -453,13 +454,13 @@ describe('Combobox', () => { }); }); - // TODO(wagnermaciel): Add unit tests for disabled options. - describe('Filtering', () => { - it('should lazily render options', () => { + it('should lazily render options', async () => { setupCombobox(); expect(getOptions().length).toBe(0); - focus(); + + down(); + expect(getOptions().length).toBe(50); }); @@ -493,35 +494,10 @@ describe('Combobox', () => { input(''); expect(getOptions().length).toBe(50); }); - - it('should determine the highlighted state on open', () => { - setupCombobox({filterMode: 'highlight'}); - focus(); - input('N'); - expect(inputElement.value).toBe('Nebraska'); - expect(inputElement.selectionEnd).toBe(8); - expect(inputElement.selectionStart).toBe(1); - expect(getOptions().length).toBe(8); - - escape(); // close - inputElement.selectionStart = 2; // Change highlighting - down(); // open - - expect(inputElement.value).toBe('Nebraska'); - expect(inputElement.selectionEnd).toBe(8); - expect(inputElement.selectionStart).toBe(2); - expect(getOptions().length).toBe(6); - - escape(); // close - inputElement.selectionStart = 3; // Change highlighting - down(); // open - - expect(getOptions().length).toBe(1); - }); }); describe('Readonly', () => { - beforeEach(() => setupCombobox({readonly: true})); + beforeEach(() => setupCombobox(ComboboxListboxExample, {readonly: true})); it('should close on selection', () => { focus(); @@ -540,19 +516,25 @@ describe('Combobox', () => { }); }); - // describe('with programmatic value changes', () => { - // // TODO(wagnermaciel): Figure out if there's a way to automatically update the - // // input value when the popup value signal is updated programmatically. - // it('should update the selected item when the value is set programmatically', () => { - // setupCombobox(); - // focus(); - // fixture.componentInstance.value.set(['Banana']); - // fixture.detectChanges(); - // expect(fixture.componentInstance.value()).toEqual(['Banana']); - // const bananaOption = getOption('Banana')!; - // expect(bananaOption.getAttribute('aria-selected')).toBe('true'); - // }); - // }); + describe('Always Expanded', () => { + beforeEach(() => setupCombobox()); + + it('should not close on escape when alwaysExpanded is true', () => { + fixture.componentInstance.alwaysExpanded.set(true); + fixture.detectChanges(); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + + escape(); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should automatically report as expanded when alwaysExpanded is true', () => { + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + fixture.componentInstance.alwaysExpanded.set(true); + fixture.detectChanges(); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + }); + }); }); describe('with Tree', () => { @@ -571,13 +553,10 @@ describe('Combobox', () => { fixture.detectChanges(); }; - const input = (value: string, opts: {backspace?: boolean} = {}) => { + const input = (value: string) => { focus(); inputElement.value = value; - const event = opts.backspace - ? new InputEvent('input', {inputType: 'deleteContentBackward', bubbles: true}) - : new InputEvent('input', {bubbles: true}); - inputElement.dispatchEvent(event); + inputElement.dispatchEvent(new Event('input', {bubbles: true})); fixture.detectChanges(); }; @@ -604,15 +583,10 @@ describe('Combobox', () => { const enter = (modifierKeys?: {}) => keydown('Enter', modifierKeys); const escape = (modifierKeys?: {}) => keydown('Escape', modifierKeys); - function setupCombobox( - opts: {readonly?: boolean; filterMode?: 'manual' | 'auto-select' | 'highlight'} = {}, - ) { + function setupCombobox(opts: {readonly?: boolean} = {}) { fixture = TestBed.createComponent(ComboboxTreeExample); const testComponent = fixture.componentInstance; - if (opts.filterMode) { - testComponent.filterMode.set(opts.filterMode); - } if (opts.readonly) { testComponent.readonly.set(true); } @@ -622,21 +596,19 @@ describe('Combobox', () => { } function defineTestVariables() { - const inputDebugElement = fixture.debugElement.query(By.directive(ComboboxInput)); + const inputDebugElement = fixture.debugElement.query(By.directive(Combobox)); inputElement = inputDebugElement.nativeElement as HTMLInputElement; } function getTreeItem(text: string): HTMLElement | null { - const items = fixture.debugElement - .queryAll(By.directive(TreeItem)) - .map((debugEl: DebugElement) => debugEl.nativeElement as HTMLElement); - return items.find(item => item.textContent?.trim() === text) || 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 fixture.debugElement - .queryAll(By.directive(TreeItem)) - .map((debugEl: DebugElement) => debugEl.nativeElement as HTMLElement); + return Array.from(fixture.nativeElement.querySelectorAll('[ngTreeItem]')) as HTMLElement[]; } function getVisibleTreeItems(): HTMLElement[] { @@ -653,7 +625,9 @@ describe('Combobox', () => { }); } - afterEach(async () => await runAccessibilityChecks(fixture.nativeElement)); + afterEach(async () => { + await runAccessibilityChecks(fixture.nativeElement); + }); describe('ARIA attributes and roles', () => { beforeEach(() => setupCombobox()); @@ -669,23 +643,22 @@ describe('Combobox', () => { expect(inputElement.getAttribute('aria-controls')).toBe(tree.id); }); - it('should set aria-selected on the selected tree item', () => { + it('should set aria-selected on the selected tree item', async () => { down(); - enter(); - const item = getTreeItem('Winter')!; + enter(); expect(item.getAttribute('aria-selected')).toBe('true'); }); - it('should toggle aria-expanded on parent nodes', () => { + it('should toggle aria-expanded on parent nodes', async () => { down(); const item = getTreeItem('Winter')!; expect(item.getAttribute('aria-expanded')).toBe('false'); - right(); + right(); // Opens Winter expect(item.getAttribute('aria-expanded')).toBe('true'); - left(); + left(); // Closes Winter expect(item.getAttribute('aria-expanded')).toBe('false'); }); }); @@ -693,90 +666,143 @@ describe('Combobox', () => { describe('Navigation', () => { beforeEach(() => setupCombobox()); - it('should navigate to the first focusable item on ArrowDown', () => { - down(); + it('should navigate to the first focusable item on ArrowDown', async () => { + down(); // Winter const item = getTreeItem('Winter')!; expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); }); - it('should navigate to the last focusable item on ArrowUp', () => { - up(); + it('should navigate to the last focusable item on ArrowUp', async () => { + down(); // Winter + up(); // Fall const item = getTreeItem('Fall')!; expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); }); - it('should navigate to the next focusable item on ArrowDown when open', () => { - down(); - down(); + it('should navigate to the next focusable item on ArrowDown when open', async () => { + down(); // Winter + down(); // Spring const item = getTreeItem('Spring')!; expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); }); - it('should navigate to the previous item on ArrowUp when open', () => { - up(); - up(); + it('should navigate to the previous item on ArrowUp when open', async () => { + down(); // Winter + down(); // Spring + down(); // Summer + down(); // Fall + up(); // Summer const item = getTreeItem('Summer')!; expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); }); - it('should expand a closed node on ArrowRight', () => { - down(); + it('should expand a closed node on ArrowRight', async () => { + down(); // Winter expect(getVisibleTreeItems().length).toBe(4); - right(); - fixture.detectChanges(); + 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', () => { - down(); - right(); - right(); + it('should navigate to the next item on ArrowRight when already expanded', async () => { + down(); // Winter + right(); // Expand Winter + right(); // December + const item = getTreeItem('December')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); }); - it('should collapse an open node on ArrowLeft', () => { - down(); - right(); - fixture.detectChanges(); + it('should collapse an open node on ArrowLeft', async () => { + down(); // Winter + right(); // Winter Expanded expect(getVisibleTreeItems().length).toBe(7); - left(); - fixture.detectChanges(); + left(); // Winter Collapsed expect(getVisibleTreeItems().length).toBe(4); + 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', () => { - down(); - right(); - right(); + it('should navigate to the parent node on ArrowLeft when in a child node', async () => { + down(); // Winter + right(); // Expand Winter + right(); // December + const item1 = getTreeItem('December')!; expect(inputElement.getAttribute('aria-activedescendant')).toBe(item1.id); + left(); + const item2 = getTreeItem('Winter')!; expect(inputElement.getAttribute('aria-activedescendant')).toBe(item2.id); }); - it('should navigate to the first focusable item on Home when open', () => { - up(); + it('should navigate to the first focusable item on Home when open', async () => { + down(); + down(); keydown('Home'); + const item = getTreeItem('Winter')!; expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); }); - it('should navigate to the last focusable item on End when open', () => { + it('should navigate to the last focusable item on End when open', async () => { + down(); down(); keydown('End'); + 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('when filterMode is "manual"', () => { - beforeEach(() => setupCombobox({filterMode: 'manual'})); + describe('with manual filtering', () => { + beforeEach(() => setupCombobox()); it('should select and commit on click', () => { click(inputElement); @@ -785,11 +811,9 @@ describe('Combobox', () => { down(); // Winter down(); // Spring right(); // Expand Spring - fixture.detectChanges(); const item = getTreeItem('April')!; click(item); - fixture.detectChanges(); expect(fixture.componentInstance.value()).toEqual(['April']); expect(inputElement.value).toBe('April'); @@ -827,291 +851,306 @@ describe('Combobox', () => { expect(inputElement.value).toBe('Appl'); }); }); + }); - describe('when filterMode is "auto-select"', () => { - beforeEach(() => setupCombobox({filterMode: 'auto-select'})); + describe('Filtering', () => { + beforeEach(() => setupCombobox()); - it('should select and commit on click', () => { - click(inputElement); - down(); - right(); - const item = getTreeItem('February')!; - click(item); - fixture.detectChanges(); + it('should lazily render options', async () => { + expect(getTreeItems().length).toBe(0); - expect(fixture.componentInstance.value()).toEqual(['February']); - expect(inputElement.value).toBe('February'); - }); + focus(); + down(); + // Mutate dataSource to expand all + fixture.componentInstance.dataSource().forEach(node => (node.expanded = true)); - it('should select and commit on Enter', () => { - down(); - down(); - enter(); + // Force computed signal to re-evaluate by updating dataSource reference + fixture.componentInstance.dataSource.set([...fixture.componentInstance.dataSource()]); + fixture.detectChanges(); - expect(fixture.componentInstance.value()).toEqual(['Spring']); - expect(inputElement.value).toBe('Spring'); - }); + expect(getTreeItems().length).toBe(16); + }); - it('should select on navigation', () => { - down(); - expect(fixture.componentInstance.value()).toEqual(['Winter']); + it('should filter the options based on the input value', () => { + focus(); + input('Summer'); - down(); - expect(fixture.componentInstance.value()).toEqual(['Spring']); - }); + let items = getVisibleTreeItems(); + expect(items.length).toBe(1); + expect(items[0].textContent?.trim()).toBe('Summer'); + }); - it('should select the first option on input', () => { - focus(); - input('Dec'); - expect(fixture.componentInstance.value()).toEqual(['December']); - }); + it('should render parents if a child matches', () => { + focus(); + input('January'); - it('should commit the selected option on focusout', () => { - focus(); - input('Jun'); - blur(); + let items = getVisibleTreeItems(); + expect(items.length).toBe(2); + expect(items[0].textContent?.trim()).toBe('Winter'); + expect(items[1].textContent?.trim()).toBe('January'); + }); - expect(inputElement.value).toBe('June'); - expect(fixture.componentInstance.value()).toEqual(['June']); - }); + it('should show no options if nothing matches', () => { + focus(); + input('xyz'); + expect(getVisibleTreeItems().length).toBe(0); }); - describe('when filterMode is "highlight"', () => { - beforeEach(() => setupCombobox({filterMode: 'highlight'})); + it('should show all options when the input is cleared', () => { + focus(); + input('Winter'); + expect(getVisibleTreeItems().length).toBe(1); - it('should select and commit on click', () => { - click(inputElement); - down(); - right(); - const item = getTreeItem('February')!; - click(item); - fixture.detectChanges(); + input(''); + expect(getVisibleTreeItems().length).toBe(4); + }); - expect(fixture.componentInstance.value()).toEqual(['February']); - expect(inputElement.value).toBe('February'); - }); + it('should expand all nodes when filtering', () => { + focus(); + down(); - it('should select and commit on Enter', () => { - down(); - down(); - enter(); + expect(getVisibleTreeItems().length).toBe(4); - expect(fixture.componentInstance.value()).toEqual(['Spring']); - expect(inputElement.value).toBe('Spring'); - }); + input('J'); - it('should select on navigation', () => { - down(); - expect(fixture.componentInstance.value()).toEqual(['Winter']); + expect(getTreeItem('Winter')!.getAttribute('aria-expanded')).toBe('true'); + expect(getTreeItem('Summer')!.getAttribute('aria-expanded')).toBe('true'); + }); + }); + }); - down(); - expect(fixture.componentInstance.value()).toEqual(['Spring']); - }); + describe('with Grid', () => { + let fixture: ComponentFixture; + let inputElement: HTMLInputElement; - it('should update input value on navigation', () => { - down(); - expect(inputElement.value).toBe('Winter'); + const keydown = (key: string, modifierKeys: {} = {}) => { + focus(); + inputElement.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + ...modifierKeys, + }), + ); + fixture.detectChanges(); + }; - down(); - expect(inputElement.value).toBe('Spring'); - }); + const focus = () => { + inputElement.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + fixture.detectChanges(); + }; - it('should select the first option on input', () => { - focus(); - input('Sept'); + const blur = (relatedTarget?: EventTarget) => { + inputElement.dispatchEvent(new FocusEvent('focusout', {bubbles: true, relatedTarget})); + fixture.detectChanges(); + }; - expect(fixture.componentInstance.value()).toEqual(['September']); - }); + 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); - it('should insert a highlighted completion string on input', () => { - focus(); - input('Feb'); + function setupCombobox() { + fixture = TestBed.createComponent(ComboboxGridExample); + fixture.detectChanges(); + const inputDebugElement = fixture.debugElement.query(By.directive(Combobox)); + inputElement = inputDebugElement.nativeElement as HTMLInputElement; + } - expect(inputElement.value).toBe('February'); - expect(inputElement.selectionStart).toBe(3); - expect(inputElement.selectionEnd).toBe(8); - }); + beforeEach(() => setupCombobox()); - it('should commit the selected option on focusout', () => { - focus(); - input('Jan'); - blur(); + describe('ARIA attributes and roles', () => { + beforeEach(() => setupCombobox()); - expect(inputElement.value).toBe('January'); - expect(fixture.componentInstance.value()).toEqual(['January']); - }); + it('should have the combobox role on the input', () => { + expect(inputElement.getAttribute('role')).toBe('combobox'); }); - }); - describe('Expansion', () => { - beforeEach(() => setupCombobox()); - - it('should open on ArrowDown', () => { + it('should have aria-haspopup set to grid', () => { focus(); - keydown('ArrowDown'); - expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + expect(inputElement.getAttribute('aria-haspopup')).toBe('grid'); }); - it('should open on ArrowUp', () => { - focus(); - keydown('ArrowUp'); - expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + 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 close on Escape', () => { + 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 close on focusout', () => { + it('should set aria-activedescendant to the active grid cell id', async () => { focus(); - blur(); - expect(inputElement.getAttribute('aria-expanded')).toBe('false'); - }); + down(); // Open popup - it('should not close on focusout if focus moves to an element inside the container', () => { - down(); - blur(getTreeItem('Spring')!); - expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-label'); }); + }); - it('should close then clear the completion string', () => { - fixture.componentInstance.filterMode.set('highlight'); - focus(); - input('Mar'); - expect(inputElement.value).toBe('March'); - expect(inputElement.getAttribute('aria-expanded')).toBe('true'); - escape(); - expect(inputElement.value).toBe('March'); - expect(inputElement.getAttribute('aria-expanded')).toBe('false'); // close - escape(); - expect(inputElement.value).toBe(''); // clear input - expect(inputElement.getAttribute('aria-expanded')).toBe('false'); - }); + it('should navigate up and down with grid navigation', async () => { + focus(); + down(); // Open popup - it('should close on enter', () => { - down(); - enter(); - expect(inputElement.getAttribute('aria-expanded')).toBe('false'); - }); + down(); // Navigate down to 'Bird-label' - it('should close on click to select an item', () => { - down(); - click(getTreeItem('Spring')!); - expect(inputElement.getAttribute('aria-expanded')).toBe('false'); - }); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Bird-label'); + + up(); // Navigate back up to 'Antelope-label' + + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-label'); }); - // TODO(wagnermaciel): Add unit tests for disabled options. + it('should navigate left and right with grid navigation', async () => { + focus(); + down(); // Open popup - describe('Filtering', () => { - beforeEach(() => setupCombobox()); + right(); // Move right to 'Antelope-delete' - it('should lazily render options', () => { - expect(getTreeItems().length).toBe(0); - focus(); - expect(getTreeItems().length).toBe(16); - }); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-delete'); - it('should filter the options based on the input value', () => { - focus(); - input('Summer'); + left(); // Move back left to 'Antelope-label' - let items = getVisibleTreeItems(); - expect(items.length).toBe(1); - expect(items[0].textContent?.trim()).toBe('Summer'); - }); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-label'); + }); - it('should render parents if a child matches', () => { - focus(); - input('January'); + it('should navigate to the start of the row on Home', async () => { + focus(); + down(); // Open popup - let items = getVisibleTreeItems(); - expect(items.length).toBe(2); - expect(items[0].textContent?.trim()).toBe('Winter'); - expect(items[1].textContent?.trim()).toBe('January'); - }); + right(); // Move right to 'Antelope-delete' - it('should show no options if nothing matches', () => { - focus(); - input('xyz'); - expect(getVisibleTreeItems().length).toBe(0); - }); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-delete'); - it('should show all options when the input is cleared', () => { - focus(); - input('Winter'); - expect(getVisibleTreeItems().length).toBe(1); + home(); // Move back to 'Antelope-label' - input('', {backspace: true}); - fixture.detectChanges(); - expect(getVisibleTreeItems().length).toBe(4); - }); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-label'); + }); - it('should expand all nodes when filtering', () => { - focus(); - expect(getVisibleTreeItems().length).toBe(4); + it('should navigate to the end of the row on End', async () => { + focus(); + down(); // Open popup - input('J'); - expect(getTreeItem('Winter')!.getAttribute('aria-expanded')).toBe('true'); - expect(getTreeItem('Summer')!.getAttribute('aria-expanded')).toBe('true'); - }); + end(); // Move to end of row ('Antelope-delete') + + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-delete'); }); - describe('with programmatic value changes', () => { - // TODO(wagnermaciel): Figure out if there's a way to automatically update the - // input value when the popup value signal is updated programmatically. - it('should update the selected item when the value is set programmatically', () => { - setupCombobox(); - focus(); - fixture.componentInstance.value.set(['August']); - fixture.detectChanges(); - expect(fixture.componentInstance.value()).toEqual(['August']); - expect(getTreeItem('August')!.getAttribute('aria-selected')).toBe('true'); - }); + it('should update aria-activedescendant with grid navigation', async () => { + focus(); + down(); // Open popup + + down(); // Navigate down + + // 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 + + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Bird-delete'); + + down(); // Move down to next row + + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Cat-delete'); }); - describe('Readonly', () => { - beforeEach(() => setupCombobox({readonly: true})); + 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 close on selection', () => { - focus(); + it('should filter items and maintain selection', async () => { + down(); // Antelope + enter(); // Select active item + + 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 + + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Bird-label'); + }); + + describe('Expansion', () => { + beforeEach(() => setupCombobox()); + + it('should close on Escape', () => { down(); - right(); - right(); - enter(); - expect(inputElement.value).toBe('December'); + escape(); expect(inputElement.getAttribute('aria-expanded')).toBe('false'); }); - it('should close on escape', () => { + it('should close on focusout', () => { focus(); + blur(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on enter', () => { down(); - expect(inputElement.getAttribute('aria-expanded')).toBe('true'); - escape(); + 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(); + + 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 + + expect(fixture.componentInstance.selectedItem()).toBeNull(); + }); + }); }); }); @Component({ template: ` -
+
- -
+ +
@for (option of options(); track option) {
{
`, - imports: [Combobox, ComboboxInput, ComboboxPopup, ComboboxPopupContainer, Listbox, Option], - changeDetection: ChangeDetectionStrategy.Eager, + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option], }) class ComboboxListboxExample { readonly = signal(false); + alwaysExpanded = signal(false); + popupExpanded = signal(false); searchString = signal(''); value = signal([]); - filterMode = signal<'manual' | 'auto-select' | 'highlight'>('manual'); 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: ` -
+
- -
    + +
      {{ node.name }} @@ -1190,21 +1278,42 @@ class ComboboxListboxExample { `, imports: [ Combobox, - ComboboxInput, - ComboboxPopupContainer, + 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([]); - nodes = computed(() => this.filterTreeNodes(TREE_NODES)); - filterMode = signal<'manual' | 'auto-select' | 'highlight'>('manual'); + 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()); @@ -1212,17 +1321,44 @@ class ComboboxTreeExample { 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}); + acc.push({ + ...node, + children, + expanded: children && children.length > 0 ? true : node.expanded, + }); } return acc; }, [] as TreeNode[]); @@ -1233,30 +1369,6 @@ class ComboboxTreeExample { } } -interface TreeNode { - name: string; - children?: TreeNode[]; -} - -const TREE_NODES = [ - { - 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'}], - }, -]; - const states = [ 'Alabama', 'Alaska', @@ -1309,3 +1421,181 @@ const states = [ 'Wisconsin', 'Wyoming', ]; + +@Component({ + template: ` +
      + + + +
      + @for (item of filteredItems(); track item; let i = $index) { +
      +
      + +
      +
      + +
      +
      + } +
      +
      +
      + `, + imports: [Combobox, ComboboxPopup, ComboboxWidget, Grid, GridRow, GridCell, GridCellWidget], +}) +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], +}) +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], +}) +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/combobox/combobox.ts b/src/aria/combobox/combobox.ts index e5ee8bf56aaa..96fe1e690cc5 100644 --- a/src/aria/combobox/combobox.ts +++ b/src/aria/combobox/combobox.ts @@ -10,155 +10,126 @@ import { afterRenderEffect, booleanAttribute, computed, - contentChild, Directive, ElementRef, inject, input, + model, + OnInit, signal, + Renderer2, } from '@angular/core'; -import {DeferredContentAware, ComboboxPattern} from '../private'; -import {Directionality} from '@angular/cdk/bidi'; -import {COMBOBOX} from './combobox-tokens'; -import {ComboboxPopup} from './combobox-popup'; +import {DeferredContentAware, ComboboxPattern} from '@angular/aria/private'; +import type {ComboboxPopup} from './combobox-popup'; /** * 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 `ngComboboxInput` and the popup, which - * is defined by a `ng-template` with the `ngComboboxPopupContainer` directive. If using the - * `CdkOverlay`, the `cdkConnectedOverlay` directive takes the place of `ngComboboxPopupContainer`. + * behavior. It coordinates the interactions between the input and the popup. * * ```html - *
      - * + *
      + * * - * - *
      - * @for (option of filteredOptions(); track option) { - *
      - * {{option}} - *
      - * } + * + *
      + * *
      *
      *
      * ``` - * - * @developerPreview 21.0 - * - * @see [Combobox](guide/aria/combobox) - * @see [Select](guide/aria/select) - * @see [Multiselect](guide/aria/multiselect) - * @see [Autocomplete](guide/aria/autocomplete) */ @Directive({ selector: '[ngCombobox]', exportAs: 'ngCombobox', - hostDirectives: [ - { - directive: DeferredContentAware, - inputs: ['preserveContent'], - }, - ], host: { - '[attr.data-expanded]': 'expanded()', - '(input)': '_pattern.onInput($event)', + 'role': 'combobox', + '[attr.aria-autocomplete]': '_pattern.autocomplete()', + '[attr.aria-disabled]': '_pattern.disabled()', + '[attr.aria-expanded]': '_pattern.isExpanded()', + '[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)', - '(focusin)': '_pattern.onFocusIn()', - '(focusout)': '_pattern.onFocusOut($event)', + '(input)': '_pattern.onInput($event)', }, - providers: [{provide: COMBOBOX, useExisting: Combobox}], }) -export class Combobox { - /** A signal wrapper for directionality. */ - protected readonly textDirection = inject(Directionality).valueSignal.asReadonly(); +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); + private readonly _elementRef = inject>(ElementRef); - /** A reference to the combobox element. */ - readonly element = this._elementRef.nativeElement as HTMLElement; + /** A reference to the input element. */ + readonly element = this._elementRef.nativeElement; - /** The DeferredContentAware host directive. */ - private readonly _deferredContentAware = inject(DeferredContentAware, {optional: true}); - - /** The combobox popup. */ - readonly popup = contentChild>(ComboboxPopup); - - /** - * The filter mode for the combobox. - * - `manual`: The consumer is responsible for filtering the options. - * - `auto-select`: The combobox automatically selects the first matching option. - * - `highlight`: The combobox highlights matching text in the options without changing selection. - */ - readonly filterMode = input<'manual' | 'auto-select' | 'highlight'>('manual'); + /** 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 read-only. */ - readonly readonly = input(false, {transform: booleanAttribute}); + /** Whether the combobox is soft disabled (remains focusable). */ + readonly softDisabled = input(true, {transform: booleanAttribute}); - /** The value of the first matching item in the popup. */ - readonly firstMatch = input(undefined); + /** Whether the combobox should always remain expanded. */ + readonly alwaysExpanded = input(false, {transform: booleanAttribute}); /** Whether the combobox is expanded. */ - readonly expanded = computed(() => this.alwaysExpanded() || this._pattern.expanded()); + readonly expanded = model(false); - // TODO: Maybe make expanded a signal that can be passed in? - // Or an "always expanded" option? + /** The value of the combobox input. */ + readonly value = model(''); - /** Whether the combobox popup should always be expanded, regardless of user interaction. */ - readonly alwaysExpanded = input(false, {transform: booleanAttribute}); - - /** Input element connected to the combobox, if any. */ - readonly inputElement = computed(() => this._pattern.inputs.inputEl()); + /** An inline suggestion to be displayed in the input. */ + readonly inlineSuggestion = input(undefined); /** The combobox ui pattern. */ - readonly _pattern = new ComboboxPattern({ + readonly _pattern = new ComboboxPattern({ ...this, - textDirection: this.textDirection, - disabled: this.disabled, - readonly: this.readonly, - inputValue: signal(''), - inputEl: signal(undefined), - containerEl: () => this._elementRef.nativeElement, - popupControls: () => this.popup()?._controls(), + element: () => this.element, + expandable: () => true, + popup: computed(() => this._popup()?._pattern), }); constructor() { + super(); + + afterRenderEffect(() => this._pattern.keyboardEventRelayEffect()); + afterRenderEffect(() => this._pattern.closePopupOnBlurEffect()); afterRenderEffect(() => { - if (this.alwaysExpanded()) { - this._pattern.expanded.set(true); - } + this.contentVisible.set(this._pattern.isExpanded()); }); - afterRenderEffect({ - write: () => { - if ( - !this._deferredContentAware?.contentVisible() && - (this._pattern.isFocused() || this.alwaysExpanded()) - ) { - this._deferredContentAware?.contentVisible.set(true); - } - }, - }); + 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); + } } - /** Opens the combobox to the selected item. */ - open() { - this._pattern.open({selected: true}); + /** Registers a popup with the combobox. */ + _registerPopup(popup: ComboboxPopup) { + this._popup.set(popup); } - /** Closes the combobox. */ - close() { - this._pattern.close(); + /** Unregisters the popup from the combobox. */ + _unregisterPopup() { + this._popup.set(undefined); } } diff --git a/src/aria/combobox/public-api.ts b/src/aria/combobox/public-api.ts index b8ea7ceefa50..997e18d45952 100644 --- a/src/aria/combobox/public-api.ts +++ b/src/aria/combobox/public-api.ts @@ -7,10 +7,8 @@ */ export {Combobox} from './combobox'; -export {ComboboxDialog} from './combobox-dialog'; -export {ComboboxInput} from './combobox-input'; export {ComboboxPopup} from './combobox-popup'; -export {ComboboxPopupContainer} from './combobox-popup-container'; +export {ComboboxWidget} from './combobox-widget'; // This needs to be re-exported, because it's used by the combobox components. // See: https://github.com/angular/components/issues/30663. diff --git a/src/aria/config.bzl b/src/aria/config.bzl index 0e0578591b51..aaefe8672c7b 100644 --- a/src/aria/config.bzl +++ b/src/aria/config.bzl @@ -9,7 +9,6 @@ ARIA_ENTRYPOINTS = [ "listbox/testing", "menu", "menu/testing", - "simple-combobox", "tabs", "tabs/testing", "toolbar", diff --git a/src/aria/listbox/listbox.ts b/src/aria/listbox/listbox.ts index 11431b3d64d1..ddc43519eaa3 100644 --- a/src/aria/listbox/listbox.ts +++ b/src/aria/listbox/listbox.ts @@ -24,7 +24,8 @@ import { } from '@angular/core'; import {Directionality} from '@angular/cdk/bidi'; import {_IdGenerator} from '@angular/cdk/a11y'; -import {ComboboxListboxPattern, ListboxPattern, SortedCollection} from '../private'; +import {ListboxPattern} from '../private'; +import {SortedCollection} from '../private/utils/collection'; import {ComboboxPopup} from '../combobox'; import {Option} from './option'; import {LISTBOX} from './tokens'; @@ -69,7 +70,7 @@ import {LISTBOX} from './tokens'; '(click)': '_pattern.onClick($event)', '(focusin)': '_pattern.onFocusIn()', }, - hostDirectives: [ComboboxPopup], + providers: [{provide: LISTBOX, useExisting: Listbox}], }) export class Listbox implements OnDestroy { @@ -77,7 +78,7 @@ export class Listbox implements OnDestroy { readonly id = input(inject(_IdGenerator).getId('ng-listbox-', true)); /** A reference to the parent combobox popup, if one exists. */ - private readonly _popup = inject>(ComboboxPopup, { + private readonly _popup = inject(ComboboxPopup, { optional: true, }); @@ -159,12 +160,10 @@ export class Listbox implements OnDestroy { activeItem: signal(undefined), textDirection: this.textDirection, element: () => this._elementRef.nativeElement, - combobox: () => this._popup?.combobox?._pattern, + combobox: () => this._popup?.combobox()?._pattern, }; - this._pattern = this._popup?.combobox - ? new ComboboxListboxPattern(inputs) - : new ListboxPattern(inputs); + this._pattern = new ListboxPattern(inputs); this.activeDescendant = computed(() => this._pattern.activeDescendant()); @@ -172,10 +171,6 @@ export class Listbox implements OnDestroy { this._collection.startObserving(this.element); }); - if (this._popup) { - this._popup._controls.set(this._pattern as ComboboxListboxPattern); - } - // Check for any violationns after the DOM has been updated. afterRenderEffect({ read: () => { diff --git a/src/aria/listbox/public-api.ts b/src/aria/listbox/public-api.ts index b91aa4aeb6be..1c63ad5fc4fa 100644 --- a/src/aria/listbox/public-api.ts +++ b/src/aria/listbox/public-api.ts @@ -11,10 +11,4 @@ export {Option} from './option'; // This needs to be re-exported, because it's used by the listbox components. // See: https://github.com/angular/components/issues/30663. -export { - Combobox as ɵɵCombobox, - ComboboxDialog as ɵɵComboboxDialog, - ComboboxInput as ɵɵComboboxInput, - ComboboxPopup as ɵɵComboboxPopup, - ComboboxPopupContainer as ɵɵComboboxPopupContainer, -} from '../combobox'; +export {Combobox as ɵɵCombobox, ComboboxPopup as ɵɵComboboxPopup} from '../combobox'; diff --git a/src/aria/private/BUILD.bazel b/src/aria/private/BUILD.bazel index 3597476d8aa4..72eac82d2380 100644 --- a/src/aria/private/BUILD.bazel +++ b/src/aria/private/BUILD.bazel @@ -24,7 +24,6 @@ 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/combobox/BUILD.bazel b/src/aria/private/combobox/BUILD.bazel index 61c73f8a68a8..4b8ec2562657 100644 --- a/src/aria/private/combobox/BUILD.bazel +++ b/src/aria/private/combobox/BUILD.bazel @@ -11,6 +11,7 @@ ts_project( 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", ], diff --git a/src/aria/private/combobox/combobox.spec.ts b/src/aria/private/combobox/combobox.spec.ts index 92f802f82475..bd44ed6ee1b3 100644 --- a/src/aria/private/combobox/combobox.spec.ts +++ b/src/aria/private/combobox/combobox.spec.ts @@ -1,987 +1,225 @@ -/** - * @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 {ComboboxInputs, ComboboxPattern} from './combobox'; -import {OptionPattern} from '../listbox/option'; -import {ComboboxListboxPattern} from '../listbox/combobox-listbox'; +import {ComboboxPattern, ComboboxPopupPattern} from './combobox'; +import {signal} from '../behaviors/signal-like/signal-like'; import {createKeyboardEvent} from '@angular/cdk/testing/private'; -import {SignalLike, signal, WritableSignalLike} from '../behaviors/signal-like/signal-like'; -import {ModifierKeys} from '@angular/cdk/testing'; -import {TreeItemPattern} from '../tree/tree'; -import {ComboboxTreePattern} from '../tree/combobox-tree'; - -// Test types -type TestOption = OptionPattern & { - disabled: WritableSignalLike; -}; - -type TestInputs = { - readonly [K in keyof ComboboxInputs]: WritableSignalLike< - ComboboxInputs[K] extends SignalLike ? T : never - >; -}; - -type TreeItemData = {value: string; children?: TreeItemData[]}; - -// Keyboard event helpers -const up = () => createKeyboardEvent('keydown', 38, 'ArrowUp'); -const down = () => createKeyboardEvent('keydown', 40, 'ArrowDown'); -const home = () => createKeyboardEvent('keydown', 36, 'Home'); -const end = () => createKeyboardEvent('keydown', 35, 'End'); -const enter = () => createKeyboardEvent('keydown', 13, 'Enter'); -const escape = () => createKeyboardEvent('keydown', 27, 'Escape'); -const right = () => createKeyboardEvent('keydown', 39, 'ArrowRight'); -const left = () => createKeyboardEvent('keydown', 37, 'ArrowLeft'); - -function clickOption(options: OptionPattern[], index: number, mods?: ModifierKeys) { - return { - target: options[index].element(), - shiftKey: mods?.shift, - ctrlKey: mods?.control, - } as unknown as PointerEvent; -} - -function clickTreeItem(items: TreeItemPattern[], index: number, mods?: ModifierKeys) { - return { - target: items[index].element(), - shiftKey: mods?.shift, - ctrlKey: mods?.control, - } as unknown as PointerEvent; -} - -function clickInput(inputEl: HTMLInputElement) { - return {target: inputEl} as unknown as PointerEvent; -} - -function _type( - text: string, - inputEl: HTMLInputElement, - combobox: ComboboxPattern, - allOptions: TestOption[] | TreeItemPattern[], - popup: ComboboxListboxPattern | ComboboxTreePattern, - firstMatch: WritableSignalLike, - backspace = false, -) { - combobox.onFocusIn(); - inputEl.value = text; - combobox.onInput( - backspace - ? new InputEvent('input', {inputType: 'deleteContentBackward'}) - : new InputEvent('input'), - ); - const options = allOptions.filter(o => o.searchTerm().startsWith(text)); - if (popup instanceof ComboboxListboxPattern) { - (popup.inputs.items as WritableSignalLike).set(options); - } else if (popup instanceof ComboboxTreePattern) { - (popup.inputs.items as WritableSignalLike).set(options); - // Auto-expand parents of matched items so they are visible - options.forEach(option => { - if (option instanceof TreeItemPattern) { - let parent = option.parent(); - while (parent instanceof TreeItemPattern) { - (parent.expanded as WritableSignalLike).set(true); - parent = parent.parent(); - } - } - }); - } - firstMatch.set(options[0]?.value()); - combobox.onFilter(); -} - -function getComboboxPattern( - inputs: Partial<{ - [K in keyof TestInputs]: TestInputs[K] extends WritableSignalLike ? T : never; - }> = {}, -) { - const containerEl = signal(document.createElement('div')); - const inputEl = signal(document.createElement('input')); - containerEl()?.appendChild(inputEl()!); - const firstMatch = signal(undefined); - const inputValue = signal(''); - - const combobox = new ComboboxPattern({ - disabled: signal(inputs.disabled ?? false), - readonly: signal(inputs.readonly ?? false), - textDirection: signal(inputs.textDirection ?? 'ltr'), - popupControls: signal(undefined), // will be set later - inputEl, - containerEl, - filterMode: signal(inputs.filterMode ?? 'manual'), - firstMatch, - inputValue, - alwaysExpanded: signal(false), - }); - - return {combobox, inputEl, containerEl, firstMatch, inputValue}; -} - -function getListboxPattern( - combobox: ComboboxPattern, - value: string[], - initialValue?: string, -) { - const options = signal([]); - - const listbox = new ComboboxListboxPattern({ - id: signal('listbox-1'), - items: options, - value: signal(initialValue ? [initialValue] : []), - combobox: signal(combobox) as any, - activeItem: signal(undefined), - typeaheadDelay: signal(500), - wrap: signal(true), - readonly: signal(false), - disabled: signal(false), - softDisabled: signal(true), - multi: signal(false), - focusMode: signal('activedescendant'), - textDirection: signal('ltr'), - orientation: signal('vertical'), - selectionMode: signal('explicit'), - element: signal(document.createElement('div')), - }); - - options.set( - value.map((v, index) => { - const element = document.createElement('div'); - element.role = 'option'; - return new OptionPattern({ - value: signal(v), - id: signal(`option-${index}`), - disabled: signal(false), - searchTerm: signal(v), - listbox: signal(listbox), - element: signal(element), - }) as TestOption; - }), - ); - - return {listbox, options}; -} - -function getTreePattern( - combobox: ComboboxPattern, string>, - data: TreeItemData[], - initialValue?: string, -) { - const items = signal[]>([]); - - const tree = new ComboboxTreePattern({ - id: signal('tree-1'), - items, - value: signal(initialValue ? [initialValue] : []), - combobox: signal(combobox) as any, - activeItem: signal(undefined), - typeaheadDelay: signal(500), - wrap: signal(true), - disabled: signal(false), - softDisabled: signal(true), - multi: signal(false), - focusMode: signal('activedescendant'), - textDirection: signal('ltr'), - orientation: signal('vertical'), - selectionMode: signal('explicit'), - element: signal(document.createElement('div')), - nav: signal(false), - currentType: signal('false'), - }); - - class TestTreeItemPattern extends TreeItemPattern {} - // Recursive function to create tree items - function createTreeItems( - data: TreeItemData[], - parent: TreeItemPattern | ComboboxTreePattern, - ) { - return data.map((node, index) => { - const element = document.createElement('div'); - element.role = 'treeitem'; - const treeItem = new TestTreeItemPattern({ - value: signal(node.value), - id: signal('tree-item-' + tree.inputs.items().length), - disabled: signal(false), - selectable: signal(true), - expanded: signal(false), - searchTerm: signal(node.value), - tree: signal(tree), - parent: signal(parent), - element: signal(element), - hasChildren: signal(!!node.children), - children: signal([]), - }); - - (tree.inputs.items as WritableSignalLike[]>).update(items => - items.concat(treeItem), - ); - - if (node.children) { - const children = createTreeItems(node.children, treeItem); - (treeItem.inputs.children as WritableSignalLike[]>).set(children); - } - - return treeItem; - }); - } - - createTreeItems(data, tree); - return {tree, items}; -} - -describe('Combobox with Listbox Pattern', () => { - function getPatterns( +describe('ComboboxPattern', () => { + function setup( inputs: Partial<{ - [K in keyof TestInputs]: TestInputs[K] extends WritableSignalLike ? T : never; + disabled: boolean; + alwaysExpanded: boolean; + inlineSuggestion: string; + popupType: 'listbox' | 'tree' | 'grid' | 'dialog'; }> = {}, ) { - const {combobox, inputEl, containerEl, firstMatch, inputValue} = getComboboxPattern(inputs); - const {listbox, options} = getListboxPattern(combobox, [ - 'Apple', - 'Apricot', - 'Banana', - 'Blackberry', - 'Blueberry', - 'Cantaloupe', - 'Cherry', - 'Clementine', - 'Cranberry', - ]); - - (combobox.inputs.popupControls as WritableSignalLike).set(listbox); + 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 ComboboxPopupPattern({ + popupType, + controlTarget: signal(controlTarget), + activeDescendant, + popupId, + }); + + const pattern = new ComboboxPattern({ + alwaysExpanded, + value, + element: signal(element), + popup: signal(popup), + inlineSuggestion, + disabled, + expanded, + expandable: signal(true), + }); return { - combobox, - listbox, - options, - inputEl: inputEl()!, - containerEl: containerEl()!, - firstMatch, - inputValue, + pattern, + element, + value, + expanded, + alwaysExpanded, + inlineSuggestion, + disabled, + popup, + controlTarget, }; } - describe('Navigation', () => { - it('should navigate to the first item on ArrowDown', () => { - const {combobox, listbox} = getPatterns(); - combobox.onKeydown(down()); - expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[0]); + describe('Aria-autocomplete calculation', () => { + it('should return "list" when only popup is present', () => { + const {pattern} = setup(); + expect(pattern.autocomplete()).toBe('list'); }); - it('should navigate to the last item on ArrowUp', () => { - const {combobox, listbox} = getPatterns(); - combobox.onKeydown(up()); - expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[8]); + it('should return "both" when popup and inline suggestion are present', () => { + const {pattern} = setup({inlineSuggestion: 'suggestion'}); + expect(pattern.autocomplete()).toBe('both'); }); - it('should navigate to the next item on ArrowDown when open', () => { - const {combobox, listbox} = getPatterns(); - combobox.onKeydown(down()); - combobox.onKeydown(down()); - expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[1]); + it('should return "none" when only dialog popup is present', () => { + const {pattern} = setup({popupType: 'dialog'}); + expect(pattern.autocomplete()).toBe('none'); }); - it('should navigate to the previous item on ArrowUp when open in listbox', () => { - const {combobox, listbox} = getPatterns(); - combobox.onKeydown(up()); - combobox.onKeydown(up()); - expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[7]); - }); - - it('should navigate to the first item on Home when open', () => { - const {combobox, listbox} = getPatterns(); - combobox.onKeydown(up()); - combobox.onKeydown(home()); - expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[0]); - }); - - it('should navigate to the last item on End when open', () => { - const {combobox, listbox} = getPatterns(); - combobox.onKeydown(down()); - combobox.onKeydown(end()); - expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[8]); + 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', () => { - it('should open on ArrowDown', () => { - const {combobox} = getPatterns(); - expect(combobox.expanded()).toBe(false); - combobox.onKeydown(down()); - expect(combobox.expanded()).toBe(true); - }); - - it('should open on ArrowUp', () => { - const {combobox} = getPatterns(); - expect(combobox.expanded()).toBe(false); - combobox.onKeydown(up()); - expect(combobox.expanded()).toBe(true); - }); - - it('should close on Escape', () => { - const {combobox} = getPatterns(); - combobox.onKeydown(down()); - expect(combobox.expanded()).toBe(true); - combobox.onKeydown(escape()); - expect(combobox.expanded()).toBe(false); - }); + describe('Expansion via Keyboard', () => { + it('should open on ArrowDown when collapsed', () => { + const {pattern, expanded} = setup(); + expect(expanded()).toBe(false); - it('should close on Enter', () => { - const {combobox} = getPatterns(); - combobox.onKeydown(down()); - expect(combobox.expanded()).toBe(true); - combobox.onKeydown(enter()); - expect(combobox.expanded()).toBe(false); + pattern.onKeydown(createKeyboardEvent('keydown', 40, 'ArrowDown')); + expect(expanded()).toBe(true); }); - it('should not close on Enter if the option is disabled', () => { - const {combobox, options} = getPatterns(); - options()[0].disabled.set(true); - combobox.onKeydown(down()); - expect(combobox.expanded()).toBe(true); - combobox.onKeydown(enter()); - expect(combobox.expanded()).toBe(true); - }); + it('should close on Escape when expanded', () => { + const {pattern, expanded} = setup(); + expanded.set(true); - it('should close on focusout', () => { - const {combobox} = getPatterns(); - combobox.onKeydown(down()); - expect(combobox.expanded()).toBe(true); - combobox.onFocusOut(new FocusEvent('focusout')); - expect(combobox.expanded()).toBe(false); + pattern.onKeydown(createKeyboardEvent('keydown', 27, 'Escape')); + expect(expanded()).toBe(false); }); + }); - it('should not close on focusout if focus moves to an element inside the container', () => { - const {combobox, containerEl} = getPatterns(); - const internalElement = document.createElement('div'); - containerEl.appendChild(internalElement); - combobox.onKeydown(down()); - - expect(combobox.expanded()).toBe(true); - - const event = new FocusEvent('focusout', {relatedTarget: internalElement}); - combobox.onFocusOut(event); + describe('Input handling', () => { + it('should update value and expand on input', () => { + const {pattern, element, value, expanded} = setup(); + expect(expanded()).toBe(false); - expect(combobox.expanded()).toBe(true); - }); + element.value = 'hello'; + pattern.onInput({target: element} as unknown as Event); - it('should not expand when disabled', () => { - const {combobox, inputEl} = getPatterns({disabled: true}); - expect(combobox.expanded()).toBe(false); - combobox.onClick(clickInput(inputEl)); - expect(combobox.expanded()).toBe(false); + expect(value()).toBe('hello'); + expect(expanded()).toBe(true); }); }); - describe('Selection', () => { - let combobox: ComboboxPattern; - let listbox: ComboboxListboxPattern; - let inputEl: HTMLInputElement; - let options: () => TestOption[]; - let firstMatch: WritableSignalLike; - - function type(text: string, opts: {backspace?: boolean} = {}) { - _type(text, inputEl, combobox, options(), listbox, firstMatch, opts.backspace); - } - - describe('when filterMode is "manual"', () => { - beforeEach(() => { - ({combobox, listbox, inputEl, options, firstMatch} = getPatterns({ - filterMode: 'manual', - })); - }); - - it('should select and commit on click in manual mode', () => { - combobox.onClick(clickOption(listbox.inputs.items(), 0)); - expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[0]); - expect(listbox.inputs.value()).toEqual(['Apple']); - expect(inputEl.value).toBe('Apple'); - }); - - it('should select and commit to input on Enter in manual mode', () => { - combobox.onKeydown(down()); - combobox.onKeydown(enter()); - expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[0]); - expect(listbox.inputs.value()).toEqual(['Apple']); - expect(inputEl.value).toBe('Apple'); - }); - - it('should select on focusout if the input text exactly matches an item in manual mode', () => { - type('Apple'); - combobox.onFocusOut(new FocusEvent('focusout')); - expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[0]); - expect(listbox.inputs.value()).toEqual(['Apple']); - }); - - it('should deselect on close if the input text does not match any options in manual mode', () => { - combobox.onKeydown(down()); - combobox.onKeydown(enter()); - - expect(listbox.inputs.value()).toEqual(['Apple']); - type('Appl', {backspace: true}); - expect(listbox.inputs.value()).toEqual(['Apple']); - combobox.onKeydown(escape()); - expect(listbox.inputs.value()).toEqual([]); - }); - - it('should not select on navigation in manual mode', () => { - combobox.onKeydown(down()); - expect(listbox.getSelectedItems().length).toBe(0); - expect(listbox.inputs.value()).toEqual([]); - }); - - it('should not select on input in manual mode', () => { - type('A'); - expect(listbox.getSelectedItems().length).toBe(0); - expect(listbox.inputs.value()).toEqual([]); - }); - - it('should not select on focusout if the input text does not match an item in manual mode', () => { - type('Appl'); - combobox.onFocusOut(new FocusEvent('focusout')); - expect(listbox.getSelectedItems().length).toBe(0); - expect(listbox.inputs.value()).toEqual([]); - expect(inputEl.value).toBe('Appl'); - }); - }); + describe('Focus handling', () => { + it('should track focus state', () => { + const {pattern} = setup(); - describe('when filterMode is "auto-select"', () => { - beforeEach(() => { - ({combobox, listbox, inputEl, options, firstMatch} = getPatterns({ - filterMode: 'auto-select', - })); - }); - - it('should select and commit on click in auto-select mode', () => { - combobox.onClick(clickOption(listbox.inputs.items(), 3)); - expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[3]); - expect(listbox.inputs.value()).toEqual(['Blackberry']); - expect(inputEl.value).toBe('Blackberry'); - }); - - it('should select and commit on Enter in auto-select mode', () => { - combobox.onKeydown(down()); - combobox.onKeydown(down()); - combobox.onKeydown(down()); - combobox.onKeydown(enter()); - expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[2]); - expect(listbox.inputs.value()).toEqual(['Banana']); - expect(inputEl.value).toBe('Banana'); - }); - - it('should select the first item on arrow down when collapsed in auto-select mode', () => { - combobox.onKeydown(down()); - expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[0]); - expect(listbox.inputs.value()).toEqual(['Apple']); - }); - - it('should select the last item on arrow up when collapsed in auto-select mode', () => { - combobox.onKeydown(up()); - expect(listbox.getSelectedItems()[0]).toBe( - listbox.inputs.items()[listbox.inputs.items().length - 1], - ); - expect(listbox.inputs.value()).toEqual(['Cranberry']); - }); - - it('should select on navigation in auto-select mode', () => { - combobox.onKeydown(down()); - combobox.onKeydown(down()); - expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[1]); - expect(listbox.inputs.value()).toEqual(['Apricot']); - }); - - it('should select the first option on input in auto-select mode', () => { - type('A'); - expect(listbox.inputs.value()).toEqual(['Apple']); - - type('Apr'); - expect(listbox.inputs.value()).toEqual(['Apricot']); - }); - - it('should commit the selected option on focusout in auto-select mode', () => { - combobox.onKeydown(down()); - type('App'); - combobox.onFocusOut(new FocusEvent('focusout')); - expect(inputEl.value).toBe('Apple'); - }); - - it('should not commit an option on focusout if the popup is closed', () => { - type('A'); - combobox.onKeydown(escape()); - combobox.onFocusOut(new FocusEvent('focusout')); - expect(inputEl.value).toBe('A'); - }); - }); + pattern.onFocusin(); + expect(pattern.isFocused()).toBe(true); - describe('when filterMode is "highlight"', () => { - beforeEach(() => { - ({combobox, listbox, inputEl, options, firstMatch} = getPatterns({ - filterMode: 'highlight', - })); - }); - - it('should select and commit on click in highlight mode', () => { - combobox.onClick(clickOption(listbox.inputs.items(), 3)); - expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[3]); - expect(listbox.inputs.value()).toEqual(['Blackberry']); - expect(inputEl.value).toBe('Blackberry'); - }); - - it('should select and commit on Enter in highlight mode', () => { - combobox.onKeydown(down()); - combobox.onKeydown(down()); - combobox.onKeydown(down()); - combobox.onKeydown(enter()); - expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[2]); - expect(listbox.inputs.value()).toEqual(['Banana']); - expect(inputEl.value).toBe('Banana'); - }); - - it('should select the first item on arrow down when collapsed in highlight mode', () => { - combobox.onKeydown(down()); - expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[0]); - expect(listbox.inputs.value()).toEqual(['Apple']); - }); - - it('should select the last item on arrow up when collapsed in highlight mode', () => { - combobox.onKeydown(up()); - expect(listbox.getSelectedItems()[0]).toBe( - listbox.inputs.items()[listbox.inputs.items().length - 1], - ); - expect(listbox.inputs.value()).toEqual(['Cranberry']); - }); - - it('should select on navigation in highlight mode', () => { - combobox.onKeydown(down()); - combobox.onKeydown(down()); - expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[1]); - expect(listbox.inputs.value()).toEqual(['Apricot']); - }); - - it('should select the first option on input in highlight mode', () => { - type('A'); - expect(listbox.inputs.value()).toEqual(['Apple']); - - type('Apr'); - expect(listbox.inputs.value()).toEqual(['Apricot']); - }); - - it('should commit the selected option on navigation in highlight mode', () => { - combobox.onKeydown(down()); - expect(inputEl.value).toBe('Apple'); - combobox.onKeydown(down()); - expect(inputEl.value).toBe('Apricot'); - }); - - it('should commit the selected option on focusout in highlight mode', () => { - combobox.onKeydown(down()); - type('App'); - combobox.onFocusOut(new FocusEvent('focusout')); - expect(inputEl.value).toBe('Apple'); - }); - - it('should insert a highlighted completion string on input in highlight mode for listbox', () => { - type('A'); - expect(inputEl.value).toBe('Apple'); - expect(inputEl.selectionStart).toBe(1); - expect(inputEl.selectionEnd).toBe(5); - }); - - it('should should remember which option was highlighted after navigating', () => { - type('A'); - combobox.onKeydown(down()); - - expect(inputEl.value).toBe('Apricot'); - expect(inputEl.selectionStart).toBe(7); - expect(inputEl.selectionEnd).toBe(7); - - combobox.onKeydown(up()); - - expect(inputEl.value).toBe('Apple'); - expect(inputEl.selectionStart).toBe(1); - expect(inputEl.selectionEnd).toBe(5); - }); + pattern.onFocusout(new FocusEvent('focusout')); + expect(pattern.isFocused()).toBe(false); }); }); - describe('Readonly mode', () => { - describe('with single-select', () => { - it('should select and close on selection in single-select readonly mode', () => { - const {combobox, listbox, inputEl} = getPatterns({readonly: true}); - combobox.onClick(clickOption(listbox.inputs.items(), 2)); - expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[2]); - expect(listbox.inputs.value()).toEqual(['Banana']); - expect(inputEl.value).toBe('Banana'); - expect(combobox.expanded()).toBe(false); - }); - - it('should close on escape in single-select readonly mode', () => { - const {combobox} = getPatterns({readonly: true}); - combobox.onKeydown(down()); - expect(combobox.expanded()).toBe(true); - combobox.onKeydown(escape()); - expect(combobox.expanded()).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(); - describe('with multi-select', () => { - it('should allow users to select multiple options', () => { - const {combobox, listbox, inputEl} = getPatterns({readonly: true}); - (listbox.inputs.multi as WritableSignalLike).set(true); + value.set('App'); + inlineSuggestion.set('Apple'); + expanded.set(true); + pattern.isFocused.set(true); - combobox.onClick(clickOption(listbox.inputs.items(), 1)); - combobox.onClick(clickOption(listbox.inputs.items(), 2)); + pattern.highlightEffect(); - expect(listbox.inputs.value()).toEqual(['Apricot', 'Banana']); - expect(inputEl.value).toBe('Apricot, Banana'); - }); + expect(element.value).toBe('Apple'); + expect(element.selectionStart).toBe(3); + expect(element.selectionEnd).toBe(5); }); - }); -}); -describe('Combobox with Tree Pattern', () => { - function getPatterns( - inputs: Partial<{ - [K in keyof TestInputs]: TestInputs[K] extends WritableSignalLike ? T : never; - }> = {}, - ) { - const {combobox, inputEl, containerEl, firstMatch, inputValue} = getComboboxPattern(inputs); - const {tree, items} = getTreePattern(combobox, [ - {value: 'Fruit', children: [{value: 'Apple'}, {value: 'Banana'}, {value: 'Cantaloupe'}]}, - {value: 'Vegetables', children: [{value: 'Broccoli'}, {value: 'Carrot'}, {value: 'Lettuce'}]}, - {value: 'Grains', children: [{value: 'Rice'}, {value: 'Wheat'}]}, - ]); + it('should not highlight when deleting text', () => { + const {pattern, element, value, expanded, inlineSuggestion} = setup(); - (combobox.inputs.popupControls as WritableSignalLike).set(tree); + value.set('App'); + inlineSuggestion.set('Apple'); + expanded.set(true); + pattern.isFocused.set(true); - return { - combobox, - tree, - items: items, - inputEl: inputEl()!, - containerEl: containerEl()!, - firstMatch, - inputValue, - }; - } + const deleteEvent = new InputEvent('input', {inputType: 'deleteContentBackward'}); + Object.defineProperty(deleteEvent, 'target', {value: element}); + pattern.onInput(deleteEvent as Event); - describe('Navigation', () => { - it('should navigate to the first focusable item on ArrowDown', () => { - const {combobox, tree} = getPatterns(); - combobox.onKeydown(down()); - expect(tree.inputs.activeItem()?.searchTerm()).toBe('Fruit'); - }); + expect(pattern.isDeleting()).toBe(true); - it('should navigate to the last focusable item on ArrowUp', () => { - const {combobox, tree} = getPatterns(); - combobox.onKeydown(up()); - expect(tree.inputs.activeItem()?.searchTerm()).toBe('Grains'); - }); + pattern.highlightEffect(); - it('should navigate to the next focusable item on ArrowDown when open', () => { - const {combobox, tree} = getPatterns(); - combobox.onKeydown(down()); - combobox.onKeydown(down()); - expect(tree.inputs.activeItem()?.searchTerm()).toBe('Vegetables'); + expect(element.value).not.toBe('Apple'); }); + }); - it('should navigate to the previous item on ArrowUp when open in tree', () => { - const {combobox, tree} = getPatterns(); - combobox.onKeydown(up()); - combobox.onKeydown(up()); - expect(tree.inputs.activeItem()?.searchTerm()).toBe('Vegetables'); - }); + describe('Select-only combobox behavior', () => { + function setupSelectOnly() { + const selectOnlyElement = document.createElement('div'); + const {pattern, expanded, controlTarget} = setup(); - it('should expand a closed node on ArrowRight', () => { - const {combobox, tree} = getPatterns(); - const before = tree.inputs - .items() - .filter(i => i.visible()) - .map(i => i.searchTerm()); - expect(before).toEqual(['Fruit', 'Vegetables', 'Grains']); - combobox.onKeydown(down()); - combobox.onKeydown(right()); - const after = tree.inputs - .items() - .filter(i => i.visible()) - .map(i => i.searchTerm()); - expect(after).toEqual(['Fruit', 'Apple', 'Banana', 'Cantaloupe', 'Vegetables', 'Grains']); - }); + // Override element to be select-only + pattern.inputs.element = signal(selectOnlyElement); - it('should navigate to the next item on ArrowRight when already expanded', () => { - const {combobox, tree} = getPatterns(); - combobox.onKeydown(down()); - combobox.onKeydown(right()); - combobox.onKeydown(right()); - expect(tree.inputs.activeItem()?.searchTerm()).toBe('Apple'); - }); + return {pattern, expanded, selectOnlyElement, controlTarget}; + } - it('should collapse an open node on ArrowLeft', () => { - const {combobox, tree} = getPatterns(); - combobox.onKeydown(down()); - combobox.onKeydown(right()); - combobox.onKeydown(left()); - const after = tree.inputs - .items() - .filter(i => i.visible()) - .map(i => i.searchTerm()); - expect(after).toEqual(['Fruit', 'Vegetables', 'Grains']); - expect(tree.inputs.activeItem()?.searchTerm()).toBe('Fruit'); - }); + it('should toggle expansion on click', () => { + const {pattern, expanded} = setupSelectOnly(); + expect(expanded()).toBe(false); - it('should navigate to the parent node on ArrowLeft when in a child node', () => { - const {combobox, tree} = getPatterns(); - combobox.onKeydown(down()); - combobox.onKeydown(right()); - combobox.onKeydown(right()); - expect(tree.inputs.activeItem()?.searchTerm()).toBe('Apple'); - combobox.onKeydown(left()); - expect(tree.inputs.activeItem()?.searchTerm()).toBe('Fruit'); - }); + pattern.onClick(new PointerEvent('click')); + expect(expanded()).toBe(true); - it('should navigate to the first focusable item on Home when open', () => { - const {combobox, tree} = getPatterns(); - combobox.onKeydown(up()); - combobox.onKeydown(home()); - expect(tree.inputs.activeItem()?.searchTerm()).toBe('Fruit'); + pattern.onClick(new PointerEvent('click')); + expect(expanded()).toBe(false); }); - it('should navigate to the last focusable item on End when open', () => { - const {combobox, tree} = getPatterns(); - combobox.onKeydown(down()); - combobox.onKeydown(end()); - expect(tree.inputs.activeItem()?.searchTerm()).toBe('Grains'); - }); - }); + it('should open on Enter or Space when collapsed', () => { + const {pattern, expanded} = setupSelectOnly(); - describe('Selection', () => { - let combobox: ComboboxPattern; - let tree: ComboboxTreePattern; - let inputEl: HTMLInputElement; - let items: () => TreeItemPattern[]; - let firstMatch: WritableSignalLike; + pattern.onKeydown(createKeyboardEvent('keydown', 13, 'Enter')); + expect(expanded()).toBe(true); - function type(text: string, opts: {backspace?: boolean} = {}) { - _type(text, inputEl, combobox, items(), tree, firstMatch, opts.backspace); - } + expanded.set(false); - describe('when filterMode is "manual"', () => { - beforeEach(() => { - ({combobox, tree, inputEl, items, firstMatch} = getPatterns({ - filterMode: 'manual', - })); - }); - - it('should select and commit on click in manual mode for tree', () => { - combobox.onClick(clickTreeItem(tree.inputs.items(), 0)); - expect(tree.inputs.value()).toEqual(['Fruit']); - expect(inputEl.value).toBe('Fruit'); - }); - - it('should select and commit to input on Enter in manual mode for tree', () => { - combobox.onKeydown(down()); - combobox.onKeydown(enter()); - expect(tree.getSelectedItems()[0]).toBe(tree.inputs.items()[0]); - expect(tree.inputs.value()).toEqual(['Fruit']); - expect(inputEl.value).toBe('Fruit'); - }); - - it('should select on focusout if the input text exactly matches an item in manual mode for tree', () => { - combobox.onClick(clickInput(inputEl)); - type('Apple'); - combobox.onFocusOut(new FocusEvent('focusout')); - expect(tree.inputs.value()).toEqual(['Apple']); - }); - - it('should deselect on close if the input text does not match any options in manual mode for tree', () => { - combobox.onKeydown(down()); - combobox.onKeydown(enter()); - - expect(tree.inputs.value()).toEqual(['Fruit']); - type('Frui', {backspace: true}); - expect(tree.inputs.value()).toEqual(['Fruit']); - combobox.onKeydown(escape()); - expect(tree.inputs.value()).toEqual([]); - }); - - it('should not select on navigation in manual mode for tree', () => { - combobox.onKeydown(down()); - expect(tree.getSelectedItems().length).toBe(0); - expect(tree.inputs.value()).toEqual([]); - }); - - it('should not select on input in manual mode for tree', () => { - type('A'); - expect(tree.getSelectedItems().length).toBe(0); - expect(tree.inputs.value()).toEqual([]); - }); - - it('should not select on focusout if the input text does not match an item in manual mode for tree', () => { - type('Appl'); - combobox.onFocusOut(new FocusEvent('focusout')); - expect(tree.getSelectedItems().length).toBe(0); - expect(tree.inputs.value()).toEqual([]); - expect(inputEl.value).toBe('Appl'); - }); + pattern.onKeydown(createKeyboardEvent('keydown', 32, ' ')); + expect(expanded()).toBe(true); }); + }); - describe('when filterMode is "auto-select"', () => { - beforeEach(() => { - ({combobox, tree, inputEl, items, firstMatch} = getPatterns({ - filterMode: 'auto-select', - })); - }); - - it('should select and commit on click in auto-select mode for tree', () => { - // Expand Fruit: Down -> Right - combobox.onKeydown(down()); - combobox.onKeydown(right()); - combobox.onClick(clickTreeItem(tree.inputs.items(), 2)); - expect(tree.getSelectedItems()[0]).toBe(tree.inputs.items()[2]); - expect(tree.inputs.value()).toEqual(['Banana']); - expect(inputEl.value).toBe('Banana'); - }); - - it('should select and commit on Enter in auto-select mode for tree', () => { - combobox.onKeydown(down()); - combobox.onKeydown(down()); - combobox.onKeydown(down()); - combobox.onKeydown(enter()); - expect(tree.inputs.value()).toEqual(['Grains']); - expect(inputEl.value).toBe('Grains'); - }); - - it('should select the first item on arrow down when collapsed in auto-select mode for tree', () => { - combobox.onKeydown(down()); - expect(tree.getSelectedItems()[0]).toBe(tree.inputs.items()[0]); - expect(tree.inputs.value()).toEqual(['Fruit']); - }); - - it('should select the last focusable item on arrow up when collapsed in auto-select mode for tree', () => { - combobox.onKeydown(up()); - expect(tree.inputs.value()).toEqual(['Grains']); - }); - - it('should select on navigation in auto-select mode for tree', () => { - combobox.onKeydown(down()); - combobox.onKeydown(right()); - combobox.onKeydown(right()); - expect(tree.inputs.value()).toEqual(['Apple']); - }); - - it('should select the first option on input in auto-select mode for tree', () => { - type('B'); - expect(tree.inputs.value()).toEqual(['Banana']); - - type('Bro'); - expect(tree.inputs.value()).toEqual(['Broccoli']); - }); - - it('should commit the selected option on focusout in auto-select mode for tree', () => { - combobox.onKeydown(down()); - type('App'); - combobox.onFocusOut(new FocusEvent('focusout')); - expect(inputEl.value).toBe('Apple'); - }); - }); + describe('alwaysExpanded behavior', () => { + it('should stay open on Escape when alwaysExpanded is true', () => { + const {pattern, expanded} = setup({alwaysExpanded: true}); + expanded.set(true); - describe('when filterMode is "highlight"', () => { - beforeEach(() => { - ({combobox, tree, inputEl, items, firstMatch} = getPatterns({ - filterMode: 'highlight', - })); - }); - - it('should select and commit on click in highlight mode for tree', () => { - combobox.onKeydown(down()); - combobox.onKeydown(right()); - combobox.onClick(clickTreeItem(tree.inputs.items(), 2)); - expect(tree.getSelectedItems()[0]).toBe(tree.inputs.items()[2]); - expect(tree.inputs.value()).toEqual(['Banana']); - expect(inputEl.value).toBe('Banana'); - }); - - it('should select and commit on Enter in highlight mode for tree', () => { - combobox.onKeydown(down()); - combobox.onKeydown(down()); - combobox.onKeydown(down()); - combobox.onKeydown(enter()); - expect(tree.inputs.value()).toEqual(['Grains']); - expect(inputEl.value).toBe('Grains'); - }); - - it('should select the first item on arrow down when collapsed in highlight mode for tree', () => { - combobox.onKeydown(down()); - expect(tree.getSelectedItems()[0]).toBe(tree.inputs.items()[0]); - expect(tree.inputs.value()).toEqual(['Fruit']); - }); - - it('should select the last focusable item on arrow up when collapsed in highlight mode for tree', () => { - combobox.onKeydown(up()); - expect(tree.inputs.value()).toEqual(['Grains']); - }); - - it('should select on navigation in highlight mode for tree', () => { - combobox.onKeydown(down()); - combobox.onKeydown(right()); - combobox.onKeydown(right()); - expect(tree.inputs.value()).toEqual(['Apple']); - }); - - it('should select the first option on input in highlight mode for tree', () => { - type('B'); - expect(tree.inputs.value()).toEqual(['Banana']); - - type('Bro'); - expect(tree.inputs.value()).toEqual(['Broccoli']); - }); - - it('should commit the selected option on navigation in highlight mode for tree', () => { - combobox.onKeydown(down()); - expect(inputEl.value).toBe('Fruit'); - combobox.onKeydown(right()); - combobox.onKeydown(right()); - expect(inputEl.value).toBe('Apple'); - combobox.onKeydown(down()); - expect(tree.inputs.value()).toEqual(['Banana']); - }); - - it('should commit the selected option on focusout in highlight mode for tree', () => { - type('App'); - combobox.onFocusOut(new FocusEvent('focusout')); - expect(inputEl.value).toBe('Apple'); - }); - - it('should insert a highlighted completion string on input in highlight mode for tree', () => { - type('A'); - expect(inputEl.value).toBe('Apple'); - expect(inputEl.selectionStart).toBe(1); - expect(inputEl.selectionEnd).toBe(5); - }); + pattern.onKeydown(createKeyboardEvent('keydown', 27, 'Escape')); + expect(expanded()).toBe(true); }); }); - describe('Readonly mode', () => { - it('should select and close on selection in readonly mode for tree', () => { - const {combobox, tree, inputEl} = getPatterns({readonly: true}); - combobox.onClick(clickInput(inputEl)); - expect(combobox.expanded()).toBe(true); - combobox.onClick(clickTreeItem(tree.inputs.items(), 0)); - expect(tree.inputs.value()).toEqual(['Fruit']); - expect(inputEl.value).toBe('Fruit'); - expect(combobox.expanded()).toBe(false); + 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 close on escape in readonly mode for tree', () => { - const {combobox} = getPatterns({readonly: true}); - combobox.onKeydown(down()); - expect(combobox.expanded()).toBe(true); - combobox.onKeydown(escape()); - expect(combobox.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/combobox/combobox.ts b/src/aria/private/combobox/combobox.ts index 5fa3e70117ae..b357467808ca 100644 --- a/src/aria/private/combobox/combobox.ts +++ b/src/aria/private/combobox/combobox.ts @@ -6,327 +6,159 @@ * found in the LICENSE file at https://angular.dev/license */ -import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager'; -import { - computed, - signal, - SignalLike, - WritableSignalLike, -} from '../behaviors/signal-like/signal-like'; -import {ListItem} from '../behaviors/list/list'; - -/** Represents the required inputs for a combobox. */ -export interface ComboboxInputs, V> { - /** The controls for the popup associated with the combobox. */ - popupControls: SignalLike< - ComboboxListboxControls | ComboboxTreeControls | ComboboxDialogPattern | undefined - >; - - /** The HTML input element that serves as the combobox input. */ - inputEl: SignalLike; +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 ComboboxInputs extends ExpansionItem { + /** Whether the combobox should always remain expanded. */ + alwaysExpanded: SignalLike; - /** The HTML element that serves as the combobox container. */ - containerEl: SignalLike; + /** The value of the combobox. */ + value: WritableSignalLike; - /** The filtering mode for the combobox. */ - filterMode: SignalLike<'manual' | 'auto-select' | 'highlight'>; + /** The element that the combobox is attached to. */ + element: SignalLike; - /** The current value of the combobox. */ - inputValue?: WritableSignalLike; + /** The popup associated with the combobox. */ + popup: SignalLike; - /** The value of the first matching item in the popup. */ - firstMatch: SignalLike; + /** An inline suggestion to be displayed in the input. */ + inlineSuggestion: SignalLike; /** Whether the combobox is disabled. */ disabled: SignalLike; - - /** Whether the combobox is read-only. */ - readonly: SignalLike; - - /** Whether the combobox is in a right-to-left context. */ - textDirection: SignalLike<'rtl' | 'ltr'>; - - /** Whether the combobox is always expanded. */ - alwaysExpanded: SignalLike; -} - -/** An interface that allows combobox popups to expose the necessary controls for the combobox. */ -export interface ComboboxListboxControls, V> { - /** A unique identifier for the popup. */ - readonly id: () => string; - - /** The ARIA role for the popup. */ - role: SignalLike<'listbox' | 'tree' | 'grid'>; - - // TODO(wagnermaciel): Add validation that ensures only readonly comboboxes can have multi-select popups. - - /** Whether multiple items in the popup can be selected at once. */ - multi: SignalLike; - - /** The ID of the active item in the popup. */ - activeId: SignalLike; - - /** The list of items in the popup. */ - items: SignalLike; - - /** Navigates to the given item in the popup. */ - focus: (item: T, opts?: {focusElement?: boolean}) => void; - - /** Navigates to the next item in the popup. */ - next: () => void; - - /** Navigates to the previous item in the popup. */ - prev: () => void; - - /** Navigates to the first item in the popup. */ - first: () => void; - - /** Navigates to the last item in the popup. */ - last: () => void; - - /** Selects the current item in the popup. */ - select: (item?: T) => void; - - /** Toggles the selection state of the given item in the popup. */ - toggle: (item?: T) => void; - - /** Clears the selection state of the popup. */ - clearSelection: () => void; - - /** Removes focus from any item in the popup. */ - unfocus: () => void; - - /** Returns the item corresponding to the given event. */ - getItem: (e: PointerEvent) => T | undefined; - - /** Returns the currently active (focused) item in the popup. */ - getActiveItem: () => T | undefined; - - /** Returns the currently selected items in the popup. */ - getSelectedItems: () => T[]; - - /** Sets the value of the combobox based on the selected item. */ - setValue: (value: V | undefined) => void; // For re-setting the value if the popup was destroyed. } -export interface ComboboxTreeControls, V> extends ComboboxListboxControls< - T, - V -> { - /** Whether the currently active item in the popup is collapsible. */ - isItemCollapsible: () => boolean; - - /** Expands the currently active item in the popup. */ - expandItem: () => void; +/** Controls the state of a simple combobox. */ +export class ComboboxPattern { + /** The expanded state of the combobox. */ + readonly isExpanded = computed(() => this.inputs.alwaysExpanded() || this.inputs.expanded()); - /** Collapses the currently active item in the popup. */ - collapseItem: () => void; + /** The value of the combobox. */ + readonly value: WritableSignalLike; - /** Checks if the currently active item in the popup is expandable. */ - isItemExpandable: (item?: T) => boolean; + /** The element that the combobox is attached to. */ + readonly element = () => this.inputs.element(); - /** Expands all nodes in the tree. */ - expandAll: () => void; + /** Whether the combobox is disabled. */ + readonly disabled = () => this.inputs.disabled(); - /** Collapses all nodes in the tree. */ - collapseAll: () => void; + /** An inline suggestion to be displayed in the input. */ + readonly inlineSuggestion = () => this.inputs.inlineSuggestion(); - /** Toggles the expansion state of the currently active item in the popup. */ - toggleExpansion: (item?: T) => void; + /** The ID of the active descendant in the popup. */ + readonly activeDescendant = computed(() => this.inputs.popup()?.activeDescendant()); - /** Whether the current active item is selectable. */ - isItemSelectable: (item?: T) => boolean; -} + /** The ID of the popup. */ + readonly popupId = computed(() => this.inputs.popup()?.popupId()); -/** Controls the state of a combobox. */ -export class ComboboxPattern, V> { - /** Whether the combobox is expanded. */ - readonly expanded = signal(false); + /** The type of the popup. */ + readonly popupType = computed(() => this.inputs.popup()?.popupType()); - /** Whether the combobox is disabled. */ - readonly disabled = () => this.inputs.disabled(); - - /** The ID of the active item in the combobox. */ - readonly activeDescendant = computed(() => { - const popupControls = this.inputs.popupControls(); - if (popupControls instanceof ComboboxDialogPattern) { - return null; + /** 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'; } - - return popupControls?.activeId() ?? null; + if (hasAutocompletePopup) { + return 'list'; + } + if (hasInlineSuggestion) { + return 'inline'; + } + return 'none'; }); - /** The currently highlighted item in the combobox. */ - readonly highlightedItem = signal(undefined); - - /** Whether the most recent input event was a deletion. */ - private _isDeleting = false; + /** A relay for keyboard events to the popup. */ + readonly keyboardEventRelay = signal(undefined); /** Whether the combobox is focused. */ readonly isFocused = signal(false); - /** Whether the combobox has ever been focused. */ - readonly hasBeenInteracted = signal(false); - - /** The key used to navigate to the previous item in the list. */ - readonly expandKey = computed(() => - this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight', - ); - - /** The key used to navigate to the next item in the list. */ - readonly collapseKey = computed(() => - this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft', - ); - - /** The ID of the popup associated with the combobox. */ - readonly popupId = computed(() => this.inputs.popupControls()?.id() || null); + /** Whether the most recent input event was a deletion. */ + readonly isDeleting = signal(false); - /** The autocomplete behavior of the combobox. */ - readonly autocomplete = computed(() => - this.inputs.filterMode() === 'highlight' ? 'both' : 'list', + /** 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 ARIA role of the popup associated with the combobox. */ - readonly hasPopup = computed(() => this.inputs.popupControls()?.role() || null); - - /** Whether the combobox is read-only. */ - readonly readonly = computed(() => this.inputs.readonly() || this.inputs.disabled() || null); - - /** Returns the listbox controls for the combobox. */ - readonly listControls = () => { - const popupControls = this.inputs.popupControls(); - - if (popupControls instanceof ComboboxDialogPattern) { - return null; - } - - return popupControls; - }; - - /** Returns the tree controls for the combobox. */ - readonly treeControls = () => { - const popupControls = this.inputs.popupControls(); - - if (popupControls?.role() === 'tree') { - return popupControls as ComboboxTreeControls; - } - - return null; - }; - /** The keydown event manager for the combobox. */ - readonly keydown = computed(() => { + // TODO(tjshiu): Allow combo keys in combobox (#33101). + keydown = computed(() => { const manager = new KeyboardEventManager(); - const popupControls = this.inputs.popupControls(); - - if (!popupControls) { - return manager; - } - - if (popupControls instanceof ComboboxDialogPattern) { - if (!this.expanded()) { - manager.on('ArrowUp', () => this.open()).on('ArrowDown', () => this.open()); - - if (this.readonly()) { - manager.on('Enter', () => this.open()).on(' ', () => this.open()); - } - } - - return manager; - } - if (!this.inputs.alwaysExpanded()) { - manager.on('Escape', () => this.close({reset: !this.readonly()})); - } - - if (!this.expanded()) { - manager - .on('ArrowDown', () => this.open({first: true})) - .on('ArrowUp', () => this.open({last: true})); + if (!this.isExpanded()) { + manager.on('ArrowDown', () => this.inputs.expanded.set(true)); - if (this.readonly()) { - manager - .on('Enter', () => this.open({selected: true})) - .on(' ', () => this.open({selected: true})); + if (!this.isEditable()) { + manager.on(/^(Enter| )$/, () => this.inputs.expanded.set(true)); } return manager; } manager - .on('ArrowDown', () => this.next(), {ignoreRepeat: false}) - .on('ArrowUp', () => this.prev(), {ignoreRepeat: false}) - .on('Home', () => this.first()) - .on('End', () => this.last()); - - if (this.readonly()) { - manager.on(' ', () => this.select({commit: true, close: !popupControls.multi()})); - } - - if (popupControls.role() === 'listbox') { - manager.on('Enter', () => { - this.select({commit: true, close: !popupControls.multi()}); + .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.inputs.expanded.set(false); + } }); - } - - const treeControls = this.treeControls(); - - if (treeControls?.isItemSelectable()) { - manager.on('Enter', () => this.select({commit: true, close: true})); - } - if (treeControls?.isItemExpandable()) { + if (!this.isEditable()) { manager - .on(this.expandKey(), () => this.expandItem()) - .on(this.collapseKey(), () => this.collapseItem()); - - if (!treeControls.isItemSelectable()) { - manager.on('Enter', () => this.expandItem()); - } - } - - if (treeControls?.isItemCollapsible()) { - manager.on(this.collapseKey(), () => this.collapseItem()); + .on(' ', e => this.keyboardEventRelay.set(e)) + .on(/^.$/, e => { + this.keyboardEventRelay.set(e); + }); } return manager; }); /** The click event manager for the combobox. */ - readonly click = computed(() => - new PointerEventManager().on(e => { - if (e.target === this.inputs.inputEl()) { - if (this.readonly()) { - this.expanded() ? this.close() : this.open({selected: true}); - } - } + click = computed(() => { + const manager = new ClickEventManager(); - const controls = this.inputs.popupControls(); + if (this.isEditable()) return manager; - if (controls instanceof ComboboxDialogPattern) { - return; - } - - const item = controls?.getItem(e); + manager.on(() => this.inputs.expanded.update(v => !v)); - if (item) { - if (controls?.role() === 'tree') { - const treeControls = controls as ComboboxTreeControls; - - if (treeControls.isItemExpandable(item) && !treeControls.isItemSelectable(item)) { - treeControls.toggleExpansion(item); - this.inputs.inputEl()?.focus(); - return; - } - } - - this.select({item, commit: true, close: !controls?.multi()}); - this.inputs.inputEl()?.focus(); // Return focus to the input after selecting. - } - }), - ); + return manager; + }); - constructor(readonly inputs: ComboboxInputs) {} + constructor(readonly inputs: ComboboxInputs) { + this.value = inputs.value; + } /** Handles keydown events for the combobox. */ onKeydown(event: KeyboardEvent) { @@ -336,408 +168,119 @@ export class ComboboxPattern, V> { } /** Handles click events for the combobox. */ - onClick(event: MouseEvent) { - if (!this.inputs.disabled()) { - this.click().handle(event as PointerEvent); - } - } - - /** Handles input events for the combobox. */ - onInput(event: Event) { - if (this.inputs.disabled() || this.inputs.readonly()) { - return; - } - - const inputEl = this.inputs.inputEl(); - - if (!inputEl) { - return; - } - - const popupControls = this.inputs.popupControls(); - - if (popupControls instanceof ComboboxDialogPattern) { - return; - } - - this.open(); - this.inputs.inputValue?.set(inputEl.value); - this._isDeleting = event instanceof InputEvent && !!event.inputType.match(/^delete/); - - if (this.inputs.filterMode() === 'highlight' && !this._isDeleting) { - this.highlight(); + onClick(event: PointerEvent) { + if (!this.disabled()) { + this.click().handle(event); } } /** Handles focus in events for the combobox. */ - onFocusIn() { - if (this.inputs.alwaysExpanded() && !this.hasBeenInteracted()) { - const firstSelectedItem = this.listControls()?.getSelectedItems()[0]; - firstSelectedItem ? this.listControls()?.focus(firstSelectedItem) : this.first(); - } - + onFocusin() { this.isFocused.set(true); - this.hasBeenInteracted.set(true); } /** Handles focus out events for the combobox. */ - onFocusOut(event: FocusEvent) { - if (this.inputs.disabled()) { - return; - } - - const popupControls = this.inputs.popupControls(); - - if (popupControls instanceof ComboboxDialogPattern) { - return; - } - - if ( - !(event.relatedTarget instanceof HTMLElement) || - !this.inputs.containerEl()?.contains(event.relatedTarget) - ) { - this.isFocused.set(false); - - if (!this.expanded()) { - return; - } - - if (this.readonly()) { - this.close(); - return; - } - - if (this.inputs.filterMode() !== 'manual') { - this.commit(); - } else { - const item = popupControls - ?.items() - .find(i => i.searchTerm() === this.inputs.inputEl()?.value); - - if (item) { - this.select({item}); - } - } - - this.close(); - } + onFocusout(event: FocusEvent) { + this.isFocused.set(false); } - /** The first matching item in the combobox. */ - readonly firstMatch = computed(() => { - // TODO(wagnermaciel): Consider whether we should not provide this default behavior for the - // listbox. Instead, we may want to allow users to have no match so that typing does not focus - // any option. - if (this.listControls()?.role() === 'listbox') { - return this.listControls()?.items()[0]; - } - - return this.listControls() - ?.items() - .find(i => i.value() === this.inputs.firstMatch()); - }); - - /** Handles filtering logic for the combobox. */ - onFilter() { - if (this.readonly()) { - return; - } - - const popupControls = this.inputs.popupControls(); - - if (popupControls instanceof ComboboxDialogPattern) { - return; - } - - // TODO(wagnermaciel) - // When the user first interacts with the combobox, the popup will lazily render for the first - // time. This is a simple way to detect this and avoid auto-focus & selection logic, but this - // should probably be moved to the component layer instead. - const isInitialRender = !this.inputs.inputValue?.().length && !this._isDeleting; - - if (isInitialRender) { - return; - } - - // Avoid refocusing the input if a filter event occurs after focus has left the combobox. - if (!this.isFocused()) { - return; - } - - if (this.inputs.popupControls()?.role() === 'tree') { - const treeControls = this.inputs.popupControls() as ComboboxTreeControls; - this.inputs.inputValue?.().length ? treeControls.expandAll() : treeControls.collapseAll(); - } - - const item = this.firstMatch(); - - if (!item) { - popupControls?.clearSelection(); - popupControls?.unfocus(); - return; - } - - popupControls?.focus(item); - - if (this.inputs.filterMode() !== 'manual') { - this.select({item}); - } + /** Handles input events for the combobox. */ + onInput(event: Event) { + if (!(event.target instanceof HTMLInputElement)) return; + if (this.disabled()) return; - if (this.inputs.filterMode() === 'highlight' && !this._isDeleting) { - this.highlight(); - } + this.inputs.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. */ - highlight() { - const inputEl = this.inputs.inputEl(); - const selectedItems = this.listControls()?.getSelectedItems(); - const item = selectedItems?.[0]; + highlightEffect() { + const value = this.value(); + const inlineSuggestion = this.inlineSuggestion(); - if (!inputEl || !item) { - return; - } + const isDeleting = untracked(() => this.isDeleting()); + const isFocused = untracked(() => this.isFocused()); + const isExpanded = this.isExpanded(); - const isHighlightable = item - .searchTerm() - .toLowerCase() - .startsWith(this.inputs.inputValue!().toLowerCase()); + if (!inlineSuggestion || !isFocused || !isExpanded || isDeleting) return; + + const inputEl = this.element() as HTMLInputElement; + const isHighlightable = inlineSuggestion.toLowerCase().startsWith(value.toLowerCase()); if (isHighlightable) { - inputEl.value = - this.inputs.inputValue!() + item.searchTerm().slice(this.inputs.inputValue!().length); - inputEl.setSelectionRange(this.inputs.inputValue!().length, item.searchTerm().length); - this.highlightedItem.set(item); + inputEl.value = value + inlineSuggestion.slice(value.length); + inputEl.setSelectionRange(value.length, inlineSuggestion.length); } } - /** Closes the combobox. */ - close(opts?: {reset: boolean}) { - const popupControls = this.inputs.popupControls(); - - if (this.inputs.alwaysExpanded()) { - return; - } - - if (popupControls instanceof ComboboxDialogPattern) { - this.expanded.set(false); - return; - } - - if (this.readonly()) { - this.expanded.set(false); - popupControls?.unfocus(); - return; - } - - if (!opts?.reset) { - if (this.inputs.filterMode() === 'manual') { - if ( - !this.listControls() - ?.items() - .some(i => i.searchTerm() === this.inputs.inputEl()?.value) - ) { - this.listControls()?.clearSelection(); - } - } - - this.expanded.set(false); - popupControls?.unfocus(); - return; - } - - if (!this.expanded()) { - this.inputs.inputValue?.set(''); - popupControls?.clearSelection(); - - const inputEl = this.inputs.inputEl(); - - if (inputEl) { - inputEl.value = ''; - } - } else if (this.expanded()) { - this.expanded.set(false); - const selectedItem = popupControls?.getSelectedItems()?.[0]; - - if (selectedItem?.searchTerm() !== this.inputs.inputValue!()) { - popupControls?.clearSelection(); - } - - return; - } - - this.close(); + /** Relays keyboard events to the popup. */ + keyboardEventRelayEffect() { + const event = this.keyboardEventRelay(); + if (event === undefined) return; - if (!this.readonly()) { - popupControls?.clearSelection(); + const popup = untracked(() => this.inputs.popup()); + const popupExpanded = untracked(() => this.isExpanded()); + if (popupExpanded) { + popup?.controlTarget()?.dispatchEvent(event); } } - /** Opens the combobox. */ - open(nav?: {first?: boolean; last?: boolean; selected?: boolean}) { - this.expanded.set(true); - const popupControls = this.inputs.popupControls(); - - if (popupControls instanceof ComboboxDialogPattern) { - return; - } - - const inputEl = this.inputs.inputEl(); - - if (inputEl && this.inputs.filterMode() === 'highlight') { - const isHighlighting = inputEl.selectionStart !== inputEl.value.length; - this.inputs.inputValue?.set(inputEl.value.slice(0, inputEl.selectionStart || 0)); - if (!isHighlighting) { - this.highlightedItem.set(undefined); - } - } - - if (nav?.first) { - this.first(); - } - if (nav?.last) { - this.last(); - } - if (nav?.selected) { - const selectedItem = popupControls - ?.items() - .find(i => popupControls?.getSelectedItems().includes(i)); - - if (selectedItem) { - popupControls?.focus(selectedItem); - } + /** Closes the popup when focus leaves the combobox and popup. */ + closePopupOnBlurEffect() { + const expanded = this.isExpanded(); + const comboboxFocused = this.isFocused(); + const popupFocused = !!this.inputs.popup()?.isFocused(); + if (expanded && !this.inputs.alwaysExpanded() && !comboboxFocused && !popupFocused) { + this.inputs.expanded.set(false); } } +} - /** Navigates to the next focusable item in the combobox popup. */ - next() { - this._navigate(() => this.listControls()?.next()); - } - - /** Navigates to the previous focusable item in the combobox popup. */ - prev() { - this._navigate(() => this.listControls()?.prev()); - } - - /** Navigates to the first focusable item in the combobox popup. */ - first() { - this._navigate(() => this.listControls()?.first()); - } - - /** Navigates to the last focusable item in the combobox popup. */ - last() { - this._navigate(() => this.listControls()?.last()); - } - - /** Collapses the currently focused item in the combobox. */ - collapseItem() { - const controls = this.inputs.popupControls() as ComboboxTreeControls; - this._navigate(() => controls?.collapseItem()); - } - - /** Expands the currently focused item in the combobox. */ - expandItem() { - const controls = this.inputs.popupControls() as ComboboxTreeControls; - this._navigate(() => controls?.expandItem()); - } - - /** Selects an item in the combobox popup. */ - select(opts: {item?: T; commit?: boolean; close?: boolean} = {}) { - const controls = this.listControls(); - - // When no item is specified (e.g. on keyboard toggle), get the active item instead. - // Note: this is only necessary for disabled check, as select/toggle will check active item too. - const item = opts.item ?? controls?.getActiveItem(); - - // Check if item is disabled before proceeding. - if (item?.disabled()) { - return; - } - - if (opts.item) { - controls?.focus(opts.item, {focusElement: false}); - } - - controls?.multi() ? controls.toggle(opts.item) : controls?.select(opts.item); +/** Represents the required inputs for a simple combobox popup. */ +export interface ComboboxPopupInputs { + /** The type of the popup. */ + popupType: SignalLike<'listbox' | 'tree' | 'grid' | 'dialog'>; - if (opts.commit) { - this.commit(); - } - if (opts.close) { - this.close(); - } - } + /** The element that serves as the control target for the popup. */ + controlTarget: SignalLike; - /** Updates the value of the input based on the currently selected item. */ - commit() { - const inputEl = this.inputs.inputEl(); - const selectedItems = this.listControls()?.getSelectedItems(); + /** The ID of the active descendant in the popup. */ + activeDescendant: SignalLike; - if (!inputEl) { - return; - } + /** The ID of the popup. */ + popupId: SignalLike; +} - inputEl.value = selectedItems?.map(i => i.searchTerm()).join(', ') || ''; - this.inputs.inputValue?.set(inputEl.value); +/** Controls the state of a simple combobox popup. */ +export class ComboboxPopupPattern { + /** The type of the popup. */ + readonly popupType = () => this.inputs.popupType(); - if (this.inputs.filterMode() === 'highlight' && !this.readonly()) { - const length = inputEl.value.length; - inputEl.setSelectionRange(length, length); - } - } + /** The element that serves as the control target for the popup. */ + readonly controlTarget = () => this.inputs.controlTarget(); - /** Navigates and handles additional actions based on filter mode. */ - private _navigate(operation: () => void) { - operation(); + /** The ID of the active descendant in the popup. */ + readonly activeDescendant = () => this.inputs.activeDescendant(); - if (this.inputs.filterMode() !== 'manual') { - this.select(); - } + /** The ID of the popup. */ + readonly popupId = () => this.inputs.popupId(); - if (this.inputs.filterMode() === 'highlight') { - // This is to handle when the user navigates back to the originally highlighted item. - // E.g. User types "Al", highlights "Alice", then navigates down and back up to "Alice". - const selectedItem = this.listControls()?.getSelectedItems()[0]; + /** Whether the popup is focused. */ + readonly isFocused = signal(false); - if (!selectedItem) { - return; - } + constructor(readonly inputs: ComboboxPopupInputs) {} - if (selectedItem === this.highlightedItem()) { - this.highlight(); - } else { - const inputEl = this.inputs.inputEl()!; - inputEl.value = selectedItem?.searchTerm()!; - } - } + /** Handles focus in events for the popup. */ + onFocusin() { + this.isFocused.set(true); } -} - -export class ComboboxDialogPattern { - readonly id = () => this.inputs.id(); - readonly role = () => 'dialog' as const; + /** Handles focus out events for the popup. */ + onFocusout(event: FocusEvent) { + const focusTarget = event.relatedTarget as Element | null; + if (this.controlTarget()?.contains(focusTarget)) return; - readonly keydown = computed(() => { - return new KeyboardEventManager().on('Escape', () => this.inputs.combobox.close()); - }); - - constructor( - readonly inputs: { - combobox: ComboboxPattern; - element: SignalLike; - id: SignalLike; - }, - ) {} - - onKeydown(event: KeyboardEvent) { - this.keydown().handle(event); - } - - onClick(event: MouseEvent) { - // The "click" event fires on the dialog when the user clicks outside of the dialog content. - if (event.target === this.inputs.element()) { - this.inputs.combobox.close(); - } + this.isFocused.set(false); } } diff --git a/src/aria/private/listbox/BUILD.bazel b/src/aria/private/listbox/BUILD.bazel index 92feb7008ac3..5c5fe8ecf3cc 100644 --- a/src/aria/private/listbox/BUILD.bazel +++ b/src/aria/private/listbox/BUILD.bazel @@ -13,7 +13,6 @@ ts_project( "//src/aria/private/behaviors/event-manager", "//src/aria/private/behaviors/list", "//src/aria/private/behaviors/signal-like", - "//src/aria/private/combobox", ], ) diff --git a/src/aria/private/listbox/combobox-listbox.ts b/src/aria/private/listbox/combobox-listbox.ts deleted file mode 100644 index acbda822082a..000000000000 --- a/src/aria/private/listbox/combobox-listbox.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * @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 {ListboxInputs, ListboxPattern} from './listbox'; -import {SignalLike, computed} from '../behaviors/signal-like/signal-like'; -import {OptionPattern} from './option'; -import {ComboboxPattern, ComboboxListboxControls} from '../combobox/combobox'; - -export type ComboboxListboxInputs = ListboxInputs & { - /** The combobox controlling the listbox. */ - combobox: SignalLike, V> | undefined>; -}; - -export class ComboboxListboxPattern - extends ListboxPattern - implements ComboboxListboxControls, V> -{ - /** A unique identifier for the popup. */ - readonly id = computed(() => this.inputs.id()); - - /** The ARIA role for the listbox. */ - readonly role = computed(() => 'listbox' as const); - - /** The id of the active (focused) item in the listbox. */ - readonly activeId = computed(() => this.listBehavior.activeDescendant()); - - /** The list of options in the listbox. */ - readonly items: SignalLike[]> = computed(() => this.inputs.items()); - - /** The tab index for the listbox. Always -1 because the combobox handles focus. */ - override tabIndex: SignalLike<-1 | 0> = () => -1; - - /** Whether multiple items in the list can be selected at once. */ - override multi = computed(() => { - return this.inputs.combobox()?.readonly() ? this.inputs.multi() : false; - }); - - constructor(override readonly inputs: ComboboxListboxInputs) { - if (inputs.combobox()) { - inputs.focusMode = () => 'activedescendant'; - inputs.element = inputs.combobox()!.inputs.inputEl; - } - - super(inputs); - } - - /** Noop. The combobox handles keydown events. */ - override onKeydown(_: KeyboardEvent): void {} - - /** Noop. The combobox handles pointerdown events. */ - override onClick(_: PointerEvent): void {} - - /** Noop. The combobox controls the open state. */ - override setDefaultState(): void {} - - /** Navigates to the specified item in the listbox. */ - readonly focus = (item: OptionPattern, opts?: {focusElement?: boolean}) => { - this.listBehavior.goto(item, opts); - }; - - /** Navigates to the previous focusable item in the listbox. */ - readonly getActiveItem = () => this.inputs.activeItem(); - - /** Navigates to the next focusable item in the listbox. */ - readonly next = () => this.listBehavior.next(); - - /** Navigates to the previous focusable item in the listbox. */ - readonly prev = () => this.listBehavior.prev(); - - /** Navigates to the last focusable item in the listbox. */ - readonly last = () => this.listBehavior.last(); - - /** Navigates to the first focusable item in the listbox. */ - readonly first = () => this.listBehavior.first(); - - /** Unfocuses the currently focused item in the listbox. */ - readonly unfocus = () => this.listBehavior.unfocus(); - - /** Selects the specified item in the listbox. */ - readonly select = (item?: OptionPattern) => this.listBehavior.select(item); - - /** Toggles the selection state of the given item in the listbox. */ - readonly toggle = (item?: OptionPattern) => this.listBehavior.toggle(item); - - /** Clears the selection in the listbox. */ - readonly clearSelection = () => this.listBehavior.deselectAll(); - - /** Retrieves the OptionPattern associated with a pointer event. */ - readonly getItem = (e: PointerEvent) => this._getItem(e); - - /** Retrieves the currently selected items in the listbox. */ - readonly getSelectedItems = () => { - // NOTE: We need to do this funky for loop to preserve the order of the selected values. - const items = []; - for (const value of this.inputs.value()) { - const item = this.items().find(i => i.value() === value); - if (item) { - items.push(item); - } - } - return items; - }; - - /** Sets the value of the combobox listbox. */ - readonly setValue = (value: V | undefined) => this.inputs.value.set(value ? [value] : []); -} diff --git a/src/aria/private/public-api.ts b/src/aria/private/public-api.ts index 870df47ec199..44b20e224f99 100644 --- a/src/aria/private/public-api.ts +++ b/src/aria/private/public-api.ts @@ -9,7 +9,7 @@ export * from './combobox/combobox'; export * from './listbox/listbox'; export * from './listbox/option'; -export * from './listbox/combobox-listbox'; + export * from './menu/menu'; export * from './behaviors/signal-like/signal-like'; export * from './tabs/tabs'; @@ -19,7 +19,7 @@ export * from './toolbar/toolbar-widget-group'; export * from './accordion/accordion'; export * from './toolbar/toolbar'; export * from './tree/tree'; -export * from './tree/combobox-tree'; + export * from './grid/grid'; export * from './grid/row'; export * from './grid/cell'; @@ -28,4 +28,3 @@ export * from './deferred-content'; export * from './utils/collection'; export * from './utils/element'; export * from './utils/element-resolver'; -export * from './simple-combobox/simple-combobox'; diff --git a/src/aria/private/simple-combobox/BUILD.bazel b/src/aria/private/simple-combobox/BUILD.bazel deleted file mode 100644 index ed2c6582fc03..000000000000 --- a/src/aria/private/simple-combobox/BUILD.bazel +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index ef318061e04e..000000000000 --- a/src/aria/private/simple-combobox/simple-combobox.spec.ts +++ /dev/null @@ -1,225 +0,0 @@ -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 deleted file mode 100644 index 85cd85fffbc4..000000000000 --- a/src/aria/private/simple-combobox/simple-combobox.ts +++ /dev/null @@ -1,292 +0,0 @@ -/** - * @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 { - /** The expanded state of the combobox. */ - readonly isExpanded = computed(() => this.inputs.alwaysExpanded() || this.inputs.expanded()); - - /** 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?.() ?? true; - - /** 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. */ - // TODO(tjshiu): Allow combo keys in combobox (#33101). - keydown = computed(() => { - const manager = new KeyboardEventManager(); - - if (!this.isExpanded()) { - manager.on('ArrowDown', () => this.inputs.expanded.set(true)); - - if (!this.isEditable()) { - manager.on(/^(Enter| )$/, () => this.inputs.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.inputs.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.inputs.expanded.update(v => !v)); - - return manager; - }); - - constructor(readonly inputs: SimpleComboboxInputs) { - 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.inputs.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.isExpanded(); - - 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.isExpanded()); - if (popupExpanded) { - popup?.controlTarget()?.dispatchEvent(event); - } - } - - /** Closes the popup when focus leaves the combobox and popup. */ - closePopupOnBlurEffect() { - const expanded = this.isExpanded(); - const comboboxFocused = this.isFocused(); - const popupFocused = !!this.inputs.popup()?.isFocused(); - if (expanded && !this.inputs.alwaysExpanded() && !comboboxFocused && !popupFocused) { - this.inputs.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/private/tree/BUILD.bazel b/src/aria/private/tree/BUILD.bazel index 91d1b20dd507..287f3d90787e 100644 --- a/src/aria/private/tree/BUILD.bazel +++ b/src/aria/private/tree/BUILD.bazel @@ -5,7 +5,6 @@ package(default_visibility = ["//visibility:public"]) ts_project( name = "tree", srcs = [ - "combobox-tree.ts", "tree.ts", ], deps = [ @@ -14,7 +13,6 @@ ts_project( "//src/aria/private/behaviors/expansion", "//src/aria/private/behaviors/signal-like", "//src/aria/private/behaviors/tree", - "//src/aria/private/combobox", ], ) diff --git a/src/aria/private/tree/combobox-tree.ts b/src/aria/private/tree/combobox-tree.ts deleted file mode 100644 index 10b40eca9b36..000000000000 --- a/src/aria/private/tree/combobox-tree.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * @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 {TreeInputs, TreePattern, TreeItemPattern} from './tree'; -import {computed, SignalLike} from '../behaviors/signal-like/signal-like'; -import {ComboboxPattern, ComboboxTreeControls} from '../combobox/combobox'; - -export type ComboboxTreeInputs = TreeInputs & { - /** The combobox controlling the tree. */ - combobox: SignalLike, V> | undefined>; -}; - -export class ComboboxTreePattern - extends TreePattern - implements ComboboxTreeControls, V> -{ - /** Toggles to expand or collapse a tree item. */ - readonly toggleExpansion = (item?: TreeItemPattern) => this.treeBehavior.toggleExpansion(item); - - /** Whether the currently focused item is collapsible. */ - readonly isItemCollapsible = () => this.inputs.activeItem()?.parent() instanceof TreeItemPattern; - - /** The ARIA role for the tree. */ - readonly role = () => 'tree' as const; - - /* The id of the active (focused) item in the tree. */ - readonly activeId = computed(() => this.treeBehavior.activeDescendant()); - - /** Returns the currently active (focused) item in the tree. */ - readonly getActiveItem = () => this.inputs.activeItem(); - - /** The list of items in the tree. */ - override items = computed(() => this.inputs.items()); - - /** The tab index for the tree. Always -1 because the combobox handles focus. */ - override readonly tabIndex: SignalLike<-1 | 0> = () => -1; - - constructor(override readonly inputs: ComboboxTreeInputs) { - if (inputs.combobox()) { - inputs.multi = () => false; - inputs.focusMode = () => 'activedescendant'; - inputs.element = inputs.combobox()!.inputs.inputEl; - } - - super(inputs); - } - - /** Noop. The combobox handles keydown events. */ - override onKeydown(_: KeyboardEvent): void {} - - /** Noop. The combobox handles click events. */ - override onClick(_: PointerEvent): void {} - - /** Noop. The combobox controls the open state. */ - override setDefaultState(): void {} - - /** Navigates to the specified item in the tree. */ - readonly focus = (item: TreeItemPattern) => this.treeBehavior.goto(item); - - /** Navigates to the next focusable item in the tree. */ - readonly next = () => this.treeBehavior.next(); - - /** Navigates to the previous focusable item in the tree. */ - readonly prev = () => this.treeBehavior.prev(); - - /** Navigates to the last focusable item in the tree. */ - readonly last = () => this.treeBehavior.last(); - - /** Navigates to the first focusable item in the tree. */ - readonly first = () => this.treeBehavior.first(); - - /** Unfocuses the currently focused item in the tree. */ - readonly unfocus = () => this.treeBehavior.unfocus(); - - // TODO: handle non-selectable parent nodes. - /** Selects the specified item in the tree or the current active item if not provided. */ - readonly select = (item?: TreeItemPattern) => this.treeBehavior.select(item); - - /** Toggles the selection state of the given item in the tree or the current active item if not provided. */ - readonly toggle = (item?: TreeItemPattern) => this.treeBehavior.toggle(item); - - /** Clears the selection in the tree. */ - readonly clearSelection = () => this.treeBehavior.deselectAll(); - - /** Retrieves the TreeItemPattern associated with a pointer event. */ - readonly getItem = (e: PointerEvent) => this._getItem(e); - - /** Retrieves the currently selected items in the tree */ - readonly getSelectedItems = () => this.inputs.items().filter(item => item.selected()); - - /** Sets the value of the combobox tree. */ - readonly setValue = (value: V | undefined) => this.inputs.value.set(value ? [value] : []); - - /** Expands the currently focused item if it is expandable, or navigates to the first child. */ - readonly expandItem = () => this._expandOrFirstChild(); - - /** Collapses the currently focused item if it is expandable, or navigates to the parent. */ - readonly collapseItem = () => this._collapseOrParent(); - - /** Whether the specified item or the currently active item is expandable. */ - isItemExpandable(item: TreeItemPattern | undefined = this.inputs.activeItem()) { - return item ? item.expandable() : false; - } - - /** Expands all of the tree items. */ - readonly expandAll = () => this.treeBehavior.expandAll(); - - /** Collapses all of the tree items. */ - readonly collapseAll = () => this.treeBehavior.collapseAll(); - - /** Whether the currently active item is selectable. */ - readonly isItemSelectable = (item: TreeItemPattern | undefined = this.inputs.activeItem()) => { - return item ? item.selectable() : false; - }; -} diff --git a/src/aria/simple-combobox/BUILD.bazel b/src/aria/simple-combobox/BUILD.bazel deleted file mode 100644 index c75084791e61..000000000000 --- a/src/aria/simple-combobox/BUILD.bazel +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 52b3c7a5156f..000000000000 --- a/src/aria/simple-combobox/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * @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 * from './public-api'; diff --git a/src/aria/simple-combobox/public-api.ts b/src/aria/simple-combobox/public-api.ts deleted file mode 100644 index 9056bb44c8bb..000000000000 --- a/src/aria/simple-combobox/public-api.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * @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} from './simple-combobox'; -export {ComboboxPopup} from './simple-combobox-popup'; -export {ComboboxWidget} from './simple-combobox-widget'; - -// This needs to be re-exported, because it's used by the combobox components. -// See: https://github.com/angular/components/issues/30663. -export { - DeferredContent as ɵɵDeferredContent, - DeferredContentAware as ɵɵDeferredContentAware, -} from '../private'; diff --git a/src/aria/simple-combobox/simple-combobox-popup.ts b/src/aria/simple-combobox/simple-combobox-popup.ts deleted file mode 100644 index 0c206e03b584..000000000000 --- a/src/aria/simple-combobox/simple-combobox-popup.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * @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 {computed, Directive, inject, input, OnDestroy, OnInit, signal} from '@angular/core'; -import {DeferredContent, SimpleComboboxPopupPattern} from '@angular/aria/private'; -import type {Combobox} from './simple-combobox'; -import type {ComboboxWidget} from './simple-combobox-widget'; -import {SIMPLE_COMBOBOX_POPUP} from './simple-combobox-tokens'; - -/** - * 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], - providers: [{provide: SIMPLE_COMBOBOX_POPUP, useExisting: ComboboxPopup}], -}) -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); - } -} diff --git a/src/aria/simple-combobox/simple-combobox-tokens.ts b/src/aria/simple-combobox/simple-combobox-tokens.ts deleted file mode 100644 index b6199b2b997f..000000000000 --- a/src/aria/simple-combobox/simple-combobox-tokens.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * @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 {InjectionToken} from '@angular/core'; -import type {ComboboxPopup} from './simple-combobox-popup'; - -/** Token used to expose the combobox popup. */ -export const SIMPLE_COMBOBOX_POPUP = new InjectionToken('SIMPLE_COMBOBOX_POPUP'); diff --git a/src/aria/simple-combobox/simple-combobox.spec.ts b/src/aria/simple-combobox/simple-combobox.spec.ts deleted file mode 100644 index 6cd3792efcde..000000000000 --- a/src/aria/simple-combobox/simple-combobox.spec.ts +++ /dev/null @@ -1,1635 +0,0 @@ -import { - Component, - computed, - DebugElement, - signal, - untracked, - viewChild, - afterRenderEffect, -} from '@angular/core'; -import {ComponentFixture, TestBed} from '@angular/core/testing'; -import {By} from '@angular/platform-browser'; -import {Combobox} from './simple-combobox'; -import {ComboboxPopup} from './simple-combobox-popup'; -import {ComboboxWidget} from './simple-combobox-widget'; - -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'; - -describe('Combobox', () => { - 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(); - } - - 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')!; - 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(); - - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - expect(inputElement.getAttribute('aria-expanded')).toBe('true'); - - escape(); - expect(inputElement.getAttribute('aria-expanded')).toBe('true'); - }); - - it('should automatically report as expanded when alwaysExpanded is true', () => { - expect(inputElement.getAttribute('aria-expanded')).toBe('false'); - fixture.componentInstance.alwaysExpanded.set(true); - fixture.detectChanges(); - 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(); - } - - 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(); - const item = getTreeItem('Winter')!; - expect(item.getAttribute('aria-expanded')).toBe('false'); - - right(); // Opens Winter - expect(item.getAttribute('aria-expanded')).toBe('true'); - - left(); // Closes Winter - expect(item.getAttribute('aria-expanded')).toBe('false'); - }); - }); - - describe('Navigation', () => { - beforeEach(() => setupCombobox()); - - it('should navigate to the first focusable item on ArrowDown', async () => { - down(); // Winter - 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 - 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 - 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 - 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')!; - - 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); - - 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 - - const item1 = getTreeItem('December')!; - expect(inputElement.getAttribute('aria-activedescendant')).toBe(item1.id); - - left(); - - 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'); - - 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'); - - 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(); - - 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; - } - - 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 - - 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' - - expect(inputElement.getAttribute('aria-activedescendant')).toBe('Bird-label'); - - up(); // Navigate back up to 'Antelope-label' - - 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' - - expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-delete'); - - left(); // Move back left to 'Antelope-label' - - 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' - - expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-delete'); - - home(); // Move back to 'Antelope-label' - - 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') - - expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-delete'); - }); - - it('should update aria-activedescendant with grid navigation', async () => { - focus(); - down(); // Open popup - - down(); // Navigate down - - // 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 - - expect(inputElement.getAttribute('aria-activedescendant')).toBe('Bird-delete'); - - down(); // Move down to next row - - 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 - - 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 - - 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(); - - 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 - - expect(fixture.componentInstance.selectedItem()).toBeNull(); - }); - }); - }); -}); - -@Component({ - template: ` -
      - - - -
      - @for (option of options(); track option) { -
      - {{option}} -
      - } -
      -
      -
      - `, - imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option], -}) -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, - ], -}) -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], -}) -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], -}) -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], -}) -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 deleted file mode 100644 index a8410b394b24..000000000000 --- a/src/aria/simple-combobox/simple-combobox.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * @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, - OnInit, - signal, - Renderer2, -} from '@angular/core'; -import {DeferredContentAware, SimpleComboboxPattern} from '@angular/aria/private'; -import type {ComboboxPopup} from './simple-combobox-popup'; - -/** - * 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.isExpanded()', - '[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.isExpanded()); - }); - - 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); - } -} diff --git a/src/aria/tree/public-api.ts b/src/aria/tree/public-api.ts index a072a42a53d9..d04714d349f1 100644 --- a/src/aria/tree/public-api.ts +++ b/src/aria/tree/public-api.ts @@ -13,10 +13,4 @@ export {TreeItemGroup} from './tree-item-group'; // This needs to be re-exported, because it's used by the tree components. // See: https://github.com/angular/components/issues/30663. export {DeferredContent as ɵɵDeferredContent} from '../private'; -export { - Combobox as ɵɵCombobox, - ComboboxDialog as ɵɵComboboxDialog, - ComboboxInput as ɵɵComboboxInput, - ComboboxPopup as ɵɵComboboxPopup, - ComboboxPopupContainer as ɵɵComboboxPopupContainer, -} from '../combobox'; +export {Combobox as ɵɵCombobox, ComboboxPopup as ɵɵComboboxPopup} from '../combobox'; diff --git a/src/aria/tree/tree-item.ts b/src/aria/tree/tree-item.ts index 52253ad75f86..9cc3b01fc183 100644 --- a/src/aria/tree/tree-item.ts +++ b/src/aria/tree/tree-item.ts @@ -22,7 +22,7 @@ import { afterNextRender, } from '@angular/core'; import {_IdGenerator} from '@angular/cdk/a11y'; -import {ComboboxTreePattern, TreeItemPattern, DeferredContentAware, HasElement} from '../private'; +import {TreeItemPattern, DeferredContentAware, HasElement} from '../private'; import {Tree} from './tree'; import {TreeItemGroup} from './tree-item-group'; @@ -122,17 +122,11 @@ export class TreeItem extends DeferredContentAware implements OnInit, OnDestr constructor() { super(); - afterNextRender(() => { - if (this.tree()._pattern instanceof ComboboxTreePattern) { - this.preserveContent.set(true); - } - }); + // Connect the group's hidden state to the DeferredContentAware's visibility. afterRenderEffect({ write: () => { - this.tree()._pattern instanceof ComboboxTreePattern - ? this.contentVisible.set(true) - : this.contentVisible.set(this._pattern.expanded()); + this.contentVisible.set(this._pattern.expanded()); }, }); } diff --git a/src/aria/tree/tree.ts b/src/aria/tree/tree.ts index f3b59a844129..cfe869ad017f 100644 --- a/src/aria/tree/tree.ts +++ b/src/aria/tree/tree.ts @@ -22,7 +22,7 @@ import { } from '@angular/core'; import {_IdGenerator} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; -import {ComboboxTreePattern, TreeItemPattern, TreePattern, sortDirectives} from '../private'; +import {TreeItemPattern, TreePattern, sortDirectives} from '../private'; import {ComboboxPopup} from '../combobox'; import type {TreeItem} from './tree-item'; @@ -77,7 +77,6 @@ import type {TreeItem} from './tree-item'; '(click)': '_pattern.onClick($event)', '(focusin)': '_pattern.onFocusIn()', }, - hostDirectives: [ComboboxPopup], }) export class Tree { /** A reference to the host element. */ @@ -87,7 +86,7 @@ export class Tree { readonly element = this._elementRef.nativeElement as HTMLElement; /** A reference to the parent combobox popup, if one exists. */ - private readonly _popup = inject>(ComboboxPopup, { + private readonly _popup = inject(ComboboxPopup, { optional: true, }); @@ -169,20 +168,14 @@ export class Tree { [...this._unorderedItems()].sort(sortDirectives).map(item => item._pattern), ), activeItem: signal | undefined>(undefined), - combobox: () => this._popup?.combobox?._pattern, + combobox: () => this._popup?.combobox()?._pattern, element: () => this.element, }; - this._pattern = this._popup?.combobox - ? new ComboboxTreePattern(inputs) - : new TreePattern(inputs); + this._pattern = new TreePattern(inputs); this.activeDescendant = computed(() => this._pattern.activeDescendant()); - if (this._popup?.combobox) { - this._popup?._controls?.set(this._pattern as ComboboxTreePattern); - } - // Check for any violationns after the DOM has been updated. afterRenderEffect({ read: () => { @@ -211,7 +204,7 @@ export class Tree { afterRenderEffect({ write: () => { - if (!(this._pattern instanceof ComboboxTreePattern)) return; + if (!this._popup?.combobox) return; const items = inputs.items(); const value = untracked(() => this.value()); 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..6a04d3e23612 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..fe72cef225bd 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/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..5866ba3415b1 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,41 +1,32 @@ -
      +
      search - -
      - +
      {{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 +
      }
      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..53818d020cc5 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,57 +6,39 @@ * 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/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'; import {FormsModule} from '@angular/forms'; -/** @title Autocomplete with manual filtering. */ +/** @title Simple Combobox Autocomplete with manual filtering. */ @Component({ 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 0d8846301879..69be159ef2e5 100644 --- a/src/components-examples/aria/autocomplete/autocomplete.css +++ b/src/components-examples/aria/autocomplete/autocomplete.css @@ -18,7 +18,7 @@ position: absolute; } -[ngComboboxInput] { +[ngCombobox] { width: 13rem; font-size: 0.9rem; border-radius: var(--mat-sys-corner-extra-small); @@ -28,7 +28,7 @@ background-color: var(--mat-sys-surface); } -[ngComboboxInput][aria-disabled='true'] { +[ngCombobox][aria-disabled='true'] { cursor: default; opacity: 0.5; background-color: var(--mat-sys-surface-dim); diff --git a/src/components-examples/aria/combobox/BUILD.bazel b/src/components-examples/aria/combobox/BUILD.bazel index 7eebc8fcaf3a..6b358e41a296 100644 --- a/src/components-examples/aria/combobox/BUILD.bazel +++ b/src/components-examples/aria/combobox/BUILD.bazel @@ -14,9 +14,15 @@ ng_project( "//:node_modules/@angular/core", "//:node_modules/@angular/forms", "//src/aria/combobox", + "//src/aria/grid", "//src/aria/listbox", "//src/aria/tree", + "//src/cdk/a11y", "//src/cdk/overlay", + "//src/material/checkbox", + "//src/material/core", + "//src/material/icon", + "//src/material/tooltip", ], ) diff --git a/src/components-examples/aria/simple-combobox/autocomplete.css b/src/components-examples/aria/combobox/autocomplete.css similarity index 100% rename from src/components-examples/aria/simple-combobox/autocomplete.css rename to src/components-examples/aria/combobox/autocomplete.css diff --git a/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.html b/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.html index bce80529017e..ed6af34585d9 100644 --- a/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.html +++ b/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.html @@ -1,33 +1,23 @@ -
      -
      +
      +
      search - +
      -
      - -
      + + +
      @for (option of options(); track option) { -
      - {{option}} - -
      +
      + {{option}} + +
      }
      -
      -
      + +
      \ No newline at end of file diff --git a/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.ts b/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.ts index 5393e0af1fd3..2e6e16f43e9d 100644 --- a/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.ts +++ b/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.ts @@ -6,37 +6,24 @@ * 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/combobox'; import {Listbox, Option} from '@angular/aria/listbox'; -import { - afterRenderEffect, - ChangeDetectionStrategy, - Component, - computed, - ElementRef, - signal, - viewChild, -} from '@angular/core'; +import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; -/** @title Combobox with auto-select filtering. */ +/** @title Simple Combobox Auto Select */ @Component({ selector: 'combobox-auto-select-example', templateUrl: 'combobox-auto-select-example.html', - styleUrl: '../combobox-examples.css', - imports: [Combobox, ComboboxInput, ComboboxPopup, ComboboxPopupContainer, Listbox, Option], - changeDetection: ChangeDetectionStrategy.OnPush, + styleUrl: '../combobox-example.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], }) export class ComboboxAutoSelectExample { - popover = viewChild('popover'); - listbox = viewChild>(Listbox); - combobox = viewChild>(Combobox); + readonly listbox = viewChild(Listbox); + popupExpanded = signal(false); searchString = signal(''); + selectedOption = signal([]); options = computed(() => states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), @@ -44,27 +31,16 @@ export class ComboboxAutoSelectExample { constructor() { afterRenderEffect(() => { - const popover = this.popover()!; - const combobox = this.combobox()!; - combobox.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); this.listbox()?.scrollActiveItemIntoView(); }); } - showPopover() { - const popover = this.popover()!; - const combobox = this.combobox()!; - - const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); - const popoverEl = popover.nativeElement; - - if (comboboxRect) { - popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom + 4}px`; - popoverEl.style.left = `${comboboxRect.left - 1}px`; + onCommit() { + const selectedOption = this.selectedOption(); + if (selectedOption.length > 0) { + this.searchString.set(selectedOption[0]); } - - popover.nativeElement.showPopover(); + this.popupExpanded.set(false); } } diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.css b/src/components-examples/aria/combobox/combobox-datepicker/combobox-datepicker-example.css similarity index 100% rename from src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.css rename to src/components-examples/aria/combobox/combobox-datepicker/combobox-datepicker-example.css diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.html b/src/components-examples/aria/combobox/combobox-datepicker/combobox-datepicker-example.html similarity index 100% rename from src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.html rename to src/components-examples/aria/combobox/combobox-datepicker/combobox-datepicker-example.html diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.ts b/src/components-examples/aria/combobox/combobox-datepicker/combobox-datepicker-example.ts similarity index 95% rename from src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.ts rename to src/components-examples/aria/combobox/combobox-datepicker/combobox-datepicker-example.ts index 93f0832cabb1..f0e79345c040 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.ts +++ b/src/components-examples/aria/combobox/combobox-datepicker/combobox-datepicker-example.ts @@ -18,7 +18,7 @@ import { } 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 {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox'; import {OverlayModule} from '@angular/cdk/overlay'; import {A11yModule} from '@angular/cdk/a11y'; @@ -33,9 +33,9 @@ interface CalendarCell { /** @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'], + selector: 'combobox-datepicker-example', + templateUrl: 'combobox-datepicker-example.html', + styleUrls: ['../combobox-example.css', 'combobox-datepicker-example.css'], imports: [ Grid, GridRow, @@ -48,7 +48,7 @@ interface CalendarCell { A11yModule, ], }) -export class SimpleComboboxDatepickerExample { +export class ComboboxDatepickerExample { private readonly _dateAdapter = inject>(DateAdapter, {optional: true})!; private readonly _dateFormats = inject(MAT_DATE_FORMATS, {optional: true})!; diff --git a/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.css b/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.css deleted file mode 100644 index 68d4cf47c377..000000000000 --- a/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.css +++ /dev/null @@ -1,222 +0,0 @@ -.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); -} - -.example-combobox-container:has([readonly='true']) { - width: 225px; -} - -.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'] { - cursor: pointer; - padding: 0.7rem 1rem; -} - -.example-combobox-container:focus-within .example-combobox-input { - outline: 1.5px solid var(--mat-sys-primary); - box-shadow: 0 0 0 4px color-mix(in srgb, var(--mat-sys-primary) 25%, transparent); -} - -.example-icon { - width: 24px; - height: 24px; - font-size: 20px; - 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-listbox { - display: flex; - flex-direction: column; - overflow: auto; - max-height: 10rem; - padding: 0.5rem; - gap: 4px; -} - -.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-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:focus-within [data-active='true'] { - outline: 2px solid color-mix(in srgb, var(--mat-sys-primary) 80%, transparent); -} - -.example-tree { - padding: 10px; - overflow-x: scroll; -} - -.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-dialog { - position: absolute; - left: auto; - right: auto; - top: auto; - bottom: auto; - padding: 0; - border: 1px solid var(--mat-sys-outline); - border-radius: var(--mat-sys-corner-extra-small); -} - -.example-dialog .example-combobox-input-container { - border-radius: 0; -} - -.example-dialog .example-combobox-container, -.example-dialog .example-combobox-input-container { - border: none; -} - -.example-dialog .example-combobox-input { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; -} - -.example-dialog .example-combobox-container:focus-within .example-combobox-input { - outline: none; - box-shadow: none; -} - -.example-dialog .example-combobox-input-container { - border-bottom: 1px solid var(--mat-sys-outline); -} - -.example-dialog::backdrop { - opacity: 0; -} diff --git a/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.html b/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.html index 4a8d62f95840..a9b0e512cca5 100644 --- a/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.html +++ b/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.html @@ -1,49 +1,40 @@ -
      -
      - +
      +
      + arrow_drop_down
      - - -
      - -
      - search - -
      + + - -
      - @for (option of options(); track option) { -
      - {{option}} - +
      +
      +
      +
      + search + +
      + +
      + @for (option of options(); track option) { +
      + {{option}} + +
      + }
      - } +
      - +
      -
      +
      -
      +
      \ No newline at end of file diff --git a/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.ts b/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.ts index fc0de32bcc93..00a9243f40e9 100644 --- a/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.ts +++ b/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.ts @@ -6,12 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { - Combobox, - ComboboxDialog, - ComboboxInput, - ComboboxPopupContainer, -} from '@angular/aria/combobox'; +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox'; import {Listbox, Option} from '@angular/aria/listbox'; import { afterRenderEffect, @@ -19,31 +14,25 @@ import { Component, computed, signal, - untracked, 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: 'combobox-dialog-example', templateUrl: 'combobox-dialog-example.html', - styleUrl: 'combobox-dialog-example.css', - imports: [ - ComboboxDialog, - Combobox, - ComboboxInput, - ComboboxPopupContainer, - Listbox, - Option, - FormsModule, - ], + styleUrls: ['../combobox-example.css'], + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ComboboxDialogExample { - dialog = viewChild(ComboboxDialog); listbox = viewChild>(Listbox); - combobox = viewChild>(Combobox); + combobox = viewChild(Combobox); + searchInput = viewChild>('searchInput'); value = signal(''); searchString = signal(''); @@ -53,42 +42,40 @@ export class ComboboxDialogExample { ); selectedStates = signal([]); + popupExpanded = signal(false); constructor() { afterRenderEffect(() => { - if (this.dialog() && this.combobox()?.expanded()) { - untracked(() => this.listbox()?.gotoFirst()); - this.positionDialog(); + if (this.popupExpanded()) { + untracked(() => { + setTimeout(() => { + this.searchInput()?.nativeElement.focus(); + }); + }); } }); afterRenderEffect(() => { - if (this.selectedStates().length > 0) { - untracked(() => this.dialog()?.close()); - this.value.set(this.selectedStates()[0]); - this.searchString.set(''); + if (this.popupExpanded()) { + this.listbox()?.scrollActiveItemIntoView(); } }); - - afterRenderEffect(() => this.listbox()?.scrollActiveItemIntoView()); } - // TODO(wagnermaciel): Switch to using the CDK for positioning. - - positionDialog() { - const dialog = this.dialog()!; - const combobox = this.combobox()!; - - const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); - - const scrollY = window.scrollY; - - if (comboboxRect) { - dialog.element.style.width = `${comboboxRect.width}px`; - dialog.element.style.top = `${comboboxRect.bottom + scrollY + 4}px`; - dialog.element.style.left = `${comboboxRect.left - 1}px`; + 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 = [ diff --git a/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.html b/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.html index a791c1d1f5b4..faf4954d7eba 100644 --- a/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.html +++ b/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.html @@ -1,33 +1,26 @@ -
      -
      +
      +
      search - +
      -
      - -
      - @for (option of options(); track option) { -
      + + + +
      +
      + @for (option of options(); track option) { +
      {{option}} - +
      - } + } +
      -
      -
      +
      +
      \ No newline at end of file diff --git a/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.ts b/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.ts index bf8e2808fd40..2e55962fa4b3 100644 --- a/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.ts +++ b/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.ts @@ -6,46 +6,24 @@ * 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/combobox'; import {Listbox, Option} from '@angular/aria/listbox'; -import { - afterRenderEffect, - ChangeDetectionStrategy, - Component, - computed, - ElementRef, - signal, - viewChild, -} from '@angular/core'; -import {FormsModule} from '@angular/forms'; +import {afterRenderEffect, Component, computed, signal, viewChild, untracked} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; -/** @title Disabled combobox example. */ +/** @title Simple Combobox Disabled */ @Component({ selector: 'combobox-disabled-example', templateUrl: 'combobox-disabled-example.html', - styleUrl: '../combobox-examples.css', - imports: [ - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, - Listbox, - Option, - FormsModule, - ], - changeDetection: ChangeDetectionStrategy.OnPush, + styleUrl: '../combobox-example.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], }) export class ComboboxDisabledExample { - popover = viewChild('popover'); - listbox = viewChild>(Listbox); - combobox = viewChild>(Combobox); + readonly listbox = viewChild(Listbox); + popupExpanded = signal(false); searchString = signal(''); + selectedOption = signal([]); options = computed(() => states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), @@ -53,28 +31,22 @@ export class ComboboxDisabledExample { constructor() { afterRenderEffect(() => { - const popover = this.popover()!; - const combobox = this.combobox()!; - combobox.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); - this.listbox()?.scrollActiveItemIntoView(); }); - } - showPopover() { - const popover = this.popover()!; - const combobox = this.combobox()!; - - const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); - const popoverEl = popover.nativeElement; + afterRenderEffect(() => { + if (this.popupExpanded()) { + untracked(() => setTimeout(() => this.listbox()?.gotoFirst())); + } + }); + } - if (comboboxRect) { - popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom + 4}px`; - popoverEl.style.left = `${comboboxRect.left - 1}px`; + onCommit() { + const selectedOption = this.selectedOption(); + if (selectedOption.length > 0) { + this.searchString.set(selectedOption[0]); + this.popupExpanded.set(false); } - - popover.nativeElement.showPopover(); } } diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-example.css b/src/components-examples/aria/combobox/combobox-example.css similarity index 100% rename from src/components-examples/aria/simple-combobox/simple-combobox-example.css rename to src/components-examples/aria/combobox/combobox-example.css diff --git a/src/components-examples/aria/combobox/combobox-examples.css b/src/components-examples/aria/combobox/combobox-examples.css deleted file mode 100644 index 266f47980eb6..000000000000 --- a/src/components-examples/aria/combobox/combobox-examples.css +++ /dev/null @@ -1,189 +0,0 @@ -.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); -} - -.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 .example-combobox-input { - outline: 1.5px solid var(--mat-sys-primary); - box-shadow: 0 0 0 4px color-mix(in srgb, var(--mat-sys-primary) 25%, transparent); -} - -.example-icon { - width: 24px; - height: 24px; - font-size: 20px; - 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-listbox { - display: flex; - flex-direction: column; - overflow: auto; - max-height: 10rem; - padding: 0.5rem; - gap: 4px; -} - -.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-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:focus-within [data-active='true'] { - outline: 2px solid color-mix(in srgb, var(--mat-sys-primary) 80%, transparent); -} - -.example-tree { - padding: 10px; - overflow-x: scroll; -} - -.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([aria-disabled='true']) { - opacity: 0.4; - cursor: default; -} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.html b/src/components-examples/aria/combobox/combobox-grid/combobox-grid-example.html similarity index 100% rename from src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.html rename to src/components-examples/aria/combobox/combobox-grid/combobox-grid-example.html diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.ts b/src/components-examples/aria/combobox/combobox-grid/combobox-grid-example.ts similarity index 92% rename from src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.ts rename to src/components-examples/aria/combobox/combobox-grid/combobox-grid-example.ts index 01fa4011fe49..dd513aefcbd8 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.ts +++ b/src/components-examples/aria/combobox/combobox-grid/combobox-grid-example.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/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'; @@ -14,9 +14,9 @@ import {MatIconModule} from '@angular/material/icon'; /** @title */ @Component({ - selector: 'simple-combobox-grid-example', - templateUrl: 'simple-combobox-grid-example.html', - styleUrl: '../simple-combobox-example.css', + selector: 'combobox-grid-example', + templateUrl: 'combobox-grid-example.html', + styleUrl: '../combobox-example.css', imports: [ Combobox, ComboboxPopup, @@ -29,7 +29,7 @@ import {MatIconModule} from '@angular/material/icon'; MatIconModule, ], }) -export class SimpleComboboxGridExample { +export class ComboboxGridExample { readonly grid = viewChild(Grid); popupExpanded = signal(true); diff --git a/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.html b/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.html index 1d64419534d7..ebf1ebc9d044 100644 --- a/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.html +++ b/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.html @@ -1,33 +1,25 @@ -
      -
      +
      +
      search - +
      -
      - -
      - @for (option of options(); track option) { -
      - {{option}} - -
      + + +
      + @for (option of options(); track option.name) { +
      + {{option.name}} + +
      }
      -
      -
      + +
      \ No newline at end of file diff --git a/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.ts b/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.ts index 86f8ef5e07cf..4c2fee561171 100644 --- a/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.ts +++ b/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.ts @@ -6,127 +6,99 @@ * 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/combobox'; import {Listbox, Option} from '@angular/aria/listbox'; -import { - afterRenderEffect, - ChangeDetectionStrategy, - Component, - computed, - ElementRef, - signal, - viewChild, -} from '@angular/core'; -import {FormsModule} from '@angular/forms'; +import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; -/** @title Combobox with highlight filtering. */ +/** @title Simple Combobox Highlight */ @Component({ selector: 'combobox-highlight-example', templateUrl: 'combobox-highlight-example.html', - styleUrl: '../combobox-examples.css', - imports: [ - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, - Listbox, - Option, - FormsModule, - ], - changeDetection: ChangeDetectionStrategy.OnPush, + styleUrl: '../combobox-example.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], }) export class ComboboxHighlightExample { - popover = viewChild('popover'); - listbox = viewChild>(Listbox); - combobox = viewChild>(Combobox); + readonly listbox = viewChild(Listbox); + popupExpanded = signal(false); searchString = signal(''); + selectedOption = signal([]); options = computed(() => - states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + states.filter(state => state.name.toLowerCase().startsWith(this.searchString().toLowerCase())), ); constructor() { afterRenderEffect(() => { - const popover = this.popover()!; - const combobox = this.combobox()!; - combobox.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); - this.listbox()?.scrollActiveItemIntoView(); }); } - showPopover() { - const popover = this.popover()!; - const combobox = this.combobox()!; - - const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); - const popoverEl = popover.nativeElement; - - if (comboboxRect) { - popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom + 4}px`; - popoverEl.style.left = `${comboboxRect.left - 1}px`; + 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(''); } - - popover.nativeElement.showPopover(); + 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', + {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/combobox/combobox-listbox/combobox-listbox-example.html similarity index 100% rename from src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.html rename to src/components-examples/aria/combobox/combobox-listbox/combobox-listbox-example.html diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.ts b/src/components-examples/aria/combobox/combobox-listbox/combobox-listbox-example.ts similarity index 89% rename from src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.ts rename to src/components-examples/aria/combobox/combobox-listbox/combobox-listbox-example.ts index 46292f6cd990..2eb600ff543c 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.ts +++ b/src/components-examples/aria/combobox/combobox-listbox/combobox-listbox-example.ts @@ -6,19 +6,19 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/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', + selector: 'combobox-listbox-example', + templateUrl: 'combobox-listbox-example.html', + styleUrl: '../combobox-example.css', imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], }) -export class SimpleComboboxListboxExample { +export class ComboboxListboxExample { readonly listbox = viewChild(Listbox); popupExpanded = signal(false); diff --git a/src/components-examples/aria/combobox/combobox-manual/combobox-manual-example.html b/src/components-examples/aria/combobox/combobox-manual/combobox-manual-example.html deleted file mode 100644 index e80a360531a5..000000000000 --- a/src/components-examples/aria/combobox/combobox-manual/combobox-manual-example.html +++ /dev/null @@ -1,33 +0,0 @@ -
      -
      - search - -
      - -
      - -
      - @for (option of options(); track option) { -
      - {{option}} - -
      - } -
      -
      -
      -
      diff --git a/src/components-examples/aria/combobox/combobox-manual/combobox-manual-example.ts b/src/components-examples/aria/combobox/combobox-manual/combobox-manual-example.ts deleted file mode 100644 index 4075b3cdae62..000000000000 --- a/src/components-examples/aria/combobox/combobox-manual/combobox-manual-example.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * @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, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, -} from '@angular/aria/combobox'; -import {Listbox, Option} from '@angular/aria/listbox'; -import { - afterRenderEffect, - ChangeDetectionStrategy, - Component, - computed, - ElementRef, - signal, - viewChild, -} from '@angular/core'; -import {FormsModule} from '@angular/forms'; - -/** @title Combobox with manual selection. */ -@Component({ - selector: 'combobox-manual-example', - templateUrl: 'combobox-manual-example.html', - styleUrl: '../combobox-examples.css', - imports: [ - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, - Listbox, - Option, - FormsModule, - ], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ComboboxManualExample { - popover = viewChild('popover'); - listbox = viewChild>(Listbox); - combobox = viewChild>(Combobox); - - searchString = signal(''); - - options = computed(() => - states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), - ); - - constructor() { - afterRenderEffect(() => { - const popover = this.popover()!; - const combobox = this.combobox()!; - combobox.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); - - this.listbox()?.scrollActiveItemIntoView(); - }); - } - - showPopover() { - const popover = this.popover()!; - const combobox = this.combobox()!; - - const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); - const popoverEl = popover.nativeElement; - - if (comboboxRect) { - popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom + 4}px`; - popoverEl.style.left = `${comboboxRect.left - 1}px`; - } - - popover.nativeElement.showPopover(); - } -} - -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/combobox/combobox-readonly-disabled/combobox-readonly-disabled-example.html b/src/components-examples/aria/combobox/combobox-readonly-disabled/combobox-readonly-disabled-example.html index 0bffcd456559..a332bd10109a 100644 --- a/src/components-examples/aria/combobox/combobox-readonly-disabled/combobox-readonly-disabled-example.html +++ b/src/components-examples/aria/combobox/combobox-readonly-disabled/combobox-readonly-disabled-example.html @@ -1,26 +1,28 @@ -
      -
      - {{ displayValue() }} - - arrow_drop_down -
      +
      + {{value()}} + arrow_drop_down +
      - - -
      -
      - @for (label of labels; track label.value) { -
      - {{label.icon}} - {{label.value}} - check -
      + + +
      +
      + @for (option of options(); track option.value) { +
      + @if (option.icon) { + {{option.icon}} + } + {{option.value}} + @if (selectedValues().includes(option.value)) { + }
      + }
      - +
      -
      + \ No newline at end of file diff --git a/src/components-examples/aria/combobox/combobox-readonly-disabled/combobox-readonly-disabled-example.ts b/src/components-examples/aria/combobox/combobox-readonly-disabled/combobox-readonly-disabled-example.ts index 30c5019f333f..8b02b5307614 100644 --- a/src/components-examples/aria/combobox/combobox-readonly-disabled/combobox-readonly-disabled-example.ts +++ b/src/components-examples/aria/combobox/combobox-readonly-disabled/combobox-readonly-disabled-example.ts @@ -6,12 +6,7 @@ * 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/combobox'; import {Listbox, Option} from '@angular/aria/listbox'; import { afterRenderEffect, @@ -19,7 +14,6 @@ import { Component, signal, viewChild, - viewChildren, } from '@angular/core'; import {OverlayModule} from '@angular/cdk/overlay'; @@ -27,33 +21,15 @@ import {OverlayModule} from '@angular/cdk/overlay'; @Component({ selector: 'combobox-readonly-disabled-example', templateUrl: 'combobox-readonly-disabled-example.html', - styleUrl: '../select-examples.css', - imports: [ - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, - Listbox, - Option, - OverlayModule, - ], + styleUrl: '../combobox-select/combobox-select-example.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ComboboxReadonlyDisabledExample { - /** The string that is displayed in the combobox. */ - displayValue = signal(''); - - /** The combobox listbox popup. */ - listbox = viewChild>(Listbox); - - /** The options available in the listbox. */ - options = viewChildren>(Option); + readonly listbox = viewChild(Listbox); - /** A reference to the ng aria combobox. */ - combobox = viewChild>(Combobox); - - /** The labels that are available for selection. */ - labels = [ + readonly options = signal([ + {value: 'Select a label', icon: ''}, {value: 'Important', icon: 'label'}, {value: 'Starred', icon: 'star'}, {value: 'Work', icon: 'work'}, @@ -62,33 +38,22 @@ export class ComboboxReadonlyDisabledExample { {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() { - // Updates the display value when the listbox values change. - afterRenderEffect(() => { - const value = this.listbox()?.value() || []; - if (value.length === 0) { - this.displayValue.set('Select a label'); - } else if (value.length === 1) { - this.displayValue.set(value[0]); - } else { - this.displayValue.set(`${value[0]} + ${value.length - 1} more`); - } - }); - - // Scrolls to the active item when the active option changes. - // The slight delay here is to ensure animations are done before scrolling. afterRenderEffect(() => { - const option = this.options().find(opt => opt.active()); - setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50); + this.listbox()?.scrollActiveItemIntoView(); }); + } - // Resets the listbox scroll position when the combobox is closed. - afterRenderEffect(() => { - if (!this.combobox()?.expanded()) { - setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150); - } - }); + onCommit() { + const values = this.selectedValues(); + if (values.length) { + this.value.set(values[0]); + this.popupExpanded.set(false); + } } } diff --git a/src/components-examples/aria/combobox/combobox-readonly-multiselect/combobox-readonly-multiselect-example.html b/src/components-examples/aria/combobox/combobox-readonly-multiselect/combobox-readonly-multiselect-example.html index 07a5bc78a105..a84980f220ad 100644 --- a/src/components-examples/aria/combobox/combobox-readonly-multiselect/combobox-readonly-multiselect-example.html +++ b/src/components-examples/aria/combobox/combobox-readonly-multiselect/combobox-readonly-multiselect-example.html @@ -1,26 +1,46 @@ -
      -
      - {{ displayValue() }} - - arrow_drop_down -
      +
      + {{value()}} + arrow_drop_down +
      - - -
      -
      - @for (label of labels; track label.value) { -
      - {{label.icon}} - {{label.value}} - check -
      - } -
      + + +
      +
      + @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/combobox/combobox-readonly-multiselect/combobox-readonly-multiselect-example.ts b/src/components-examples/aria/combobox/combobox-readonly-multiselect/combobox-readonly-multiselect-example.ts index 61de9550d4ba..eecc2db1b6fc 100644 --- a/src/components-examples/aria/combobox/combobox-readonly-multiselect/combobox-readonly-multiselect-example.ts +++ b/src/components-examples/aria/combobox/combobox-readonly-multiselect/combobox-readonly-multiselect-example.ts @@ -6,54 +6,30 @@ * 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/combobox'; import {Listbox, Option} from '@angular/aria/listbox'; -import {OverlayModule} from '@angular/cdk/overlay'; import { afterRenderEffect, ChangeDetectionStrategy, Component, + computed, signal, viewChild, - viewChildren, } from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; /** @title Readonly multiselectable combobox. */ @Component({ selector: 'combobox-readonly-multiselect-example', templateUrl: 'combobox-readonly-multiselect-example.html', - styleUrl: '../select-examples.css', - imports: [ - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, - Listbox, - Option, - OverlayModule, - ], + styleUrl: '../combobox-select/combobox-select-example.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ComboboxReadonlyMultiselectExample { - /** The string that is displayed in the combobox. */ - displayValue = signal(''); - - /** The combobox listbox popup. */ - 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); - /** The labels that are available for selection. */ - labels = [ + readonly options = signal([ {value: 'Important', icon: 'label'}, {value: 'Starred', icon: 'star'}, {value: 'Work', icon: 'work'}, @@ -62,33 +38,23 @@ export class ComboboxReadonlyMultiselectExample { {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() { - // Updates the display value when the listbox values change. - afterRenderEffect(() => { - const value = this.listbox()?.value() || []; - if (value.length === 0) { - this.displayValue.set('Select a label'); - } else if (value.length === 1) { - this.displayValue.set(value[0]); - } else { - this.displayValue.set(`${value[0]} + ${value.length - 1} more`); - } - }); - - // Scrolls to the active item when the active option changes. - // The slight delay here is to ensure animations are done before scrolling. - afterRenderEffect(() => { - const option = this.options().find(opt => opt.active()); - setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50); - }); - - // Resets the listbox scroll position when the combobox is closed. afterRenderEffect(() => { - if (!this.combobox()?.expanded()) { - setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150); - } + this.listbox()?.scrollActiveItemIntoView(); }); } } diff --git a/src/components-examples/aria/combobox/combobox-readonly/combobox-readonly-example.html b/src/components-examples/aria/combobox/combobox-readonly/combobox-readonly-example.html deleted file mode 100644 index eb10faa22c2b..000000000000 --- a/src/components-examples/aria/combobox/combobox-readonly/combobox-readonly-example.html +++ /dev/null @@ -1,26 +0,0 @@ -
      -
      - {{ displayValue() }} - - arrow_drop_down -
      - - - -
      -
      - @for (label of labels; track label.value) { -
      - {{label.icon}} - {{label.value}} - check -
      - } -
      -
      -
      -
      -
      diff --git a/src/components-examples/aria/combobox/combobox-readonly/combobox-readonly-example.ts b/src/components-examples/aria/combobox/combobox-readonly/combobox-readonly-example.ts deleted file mode 100644 index 62c482a1c35a..000000000000 --- a/src/components-examples/aria/combobox/combobox-readonly/combobox-readonly-example.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * @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, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, -} from '@angular/aria/combobox'; -import {Listbox, Option} from '@angular/aria/listbox'; -import { - afterRenderEffect, - ChangeDetectionStrategy, - Component, - signal, - viewChild, - viewChildren, -} from '@angular/core'; -import {OverlayModule} from '@angular/cdk/overlay'; - -/** @title Readonly combobox. */ -@Component({ - selector: 'combobox-readonly-example', - templateUrl: 'combobox-readonly-example.html', - styleUrl: '../select-examples.css', - imports: [ - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, - Listbox, - Option, - OverlayModule, - ], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ComboboxReadonlyExample { - /** The string that is displayed in the combobox. */ - displayValue = signal(''); - - /** The combobox listbox popup. */ - listbox = viewChild>(Listbox); - - /** The options available in the listbox. */ - options = viewChildren>(Option); - - /** A reference to the ng aria combobox. */ - combobox = viewChild>(Combobox); - - /** The labels that are available for selection. */ - labels = [ - {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'}, - ]; - - constructor() { - // Updates the display value when the listbox values change. - afterRenderEffect(() => { - const value = this.listbox()?.value() || []; - const displayValue = value.length ? value[0] : 'Select a label'; - this.displayValue.set(displayValue); - }); - - // Scrolls to the active item when the active option changes. - // The slight delay here is to ensure animations are done before scrolling. - afterRenderEffect(() => { - const option = this.options().find(opt => opt.active()); - setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50); - }); - - // Resets the listbox scroll position when the combobox is closed. - afterRenderEffect(() => { - if (!this.combobox()?.expanded()) { - setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150); - } - }); - } -} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.css b/src/components-examples/aria/combobox/combobox-select/combobox-select-example.css similarity index 100% rename from src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.css rename to src/components-examples/aria/combobox/combobox-select/combobox-select-example.css diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.html b/src/components-examples/aria/combobox/combobox-select/combobox-select-example.html similarity index 100% rename from src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.html rename to src/components-examples/aria/combobox/combobox-select/combobox-select-example.html diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.ts b/src/components-examples/aria/combobox/combobox-select/combobox-select-example.ts similarity index 86% rename from src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.ts rename to src/components-examples/aria/combobox/combobox-select/combobox-select-example.ts index 5bf81a940e24..f35319aef26f 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.ts +++ b/src/components-examples/aria/combobox/combobox-select/combobox-select-example.ts @@ -7,17 +7,17 @@ */ import {Component, signal, afterRenderEffect, viewChild} from '@angular/core'; -import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/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', + selector: 'combobox-select-example', + templateUrl: 'combobox-select-example.html', + styleUrl: 'combobox-select-example.css', imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], }) -export class SimpleComboboxSelectExample { +export class ComboboxSelectExample { readonly listbox = viewChild(Listbox); readonly options = signal([ diff --git a/src/components-examples/aria/combobox/combobox-tree-auto-select/combobox-tree-auto-select-example.html b/src/components-examples/aria/combobox/combobox-tree-auto-select/combobox-tree-auto-select-example.html index fe7a5fa8af62..0dd6ccbca5c7 100644 --- a/src/components-examples/aria/combobox/combobox-tree-auto-select/combobox-tree-auto-select-example.html +++ b/src/components-examples/aria/combobox/combobox-tree-auto-select/combobox-tree-auto-select-example.html @@ -1,63 +1,38 @@ -
      -
      +
      +
      search - +
      -
      - -
        - + + +
          +
        -
      +
      @for (node of nodes; track node.name) { -
    • - - {{ node.name }} - -
    • - - @if (node.children) { -
        - - - -
      - } +
    • + + {{ node.name }} + +
    • + @if (node.children) { +
        + + + +
      + } } -
      + \ No newline at end of file diff --git a/src/components-examples/aria/combobox/combobox-tree-auto-select/combobox-tree-auto-select-example.ts b/src/components-examples/aria/combobox/combobox-tree-auto-select/combobox-tree-auto-select-example.ts index d35c710b8dfe..5af24fecf091 100644 --- a/src/components-examples/aria/combobox/combobox-tree-auto-select/combobox-tree-auto-select-example.ts +++ b/src/components-examples/aria/combobox/combobox-tree-auto-select/combobox-tree-auto-select-example.ts @@ -6,99 +6,103 @@ * 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/combobox'; import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree'; import { - afterRenderEffect, - ChangeDetectionStrategy, Component, + afterRenderEffect, computed, - ElementRef, signal, viewChild, + untracked, + ChangeDetectionStrategy, } from '@angular/core'; -import {TREE_NODES, TreeNode} from '../data'; 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: 'combobox-tree-auto-select-example', templateUrl: 'combobox-tree-auto-select-example.html', - styleUrl: '../combobox-examples.css', + styleUrl: '../combobox-example.css', imports: [ Combobox, - ComboboxInput, ComboboxPopup, - ComboboxPopupContainer, + ComboboxWidget, + NgTemplateOutlet, Tree, TreeItem, TreeItemGroup, - NgTemplateOutlet, + OverlayModule, ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ComboboxTreeAutoSelectExample { - popover = viewChild('popover'); - tree = viewChild>(Tree); - combobox = viewChild>(Combobox); + readonly tree = viewChild(Tree); + popupExpanded = signal(false); searchString = signal(''); + selectedValues = signal([]); - nodes = computed(() => this.filterTreeNodes(TREE_NODES)); - - firstMatch = computed(() => { - const flatNodes = this.flattenTreeNodes(this.nodes()); - const node = flatNodes.find(n => this.isMatch(n)); - return node?.name; - }); + readonly dataSource = signal(FOOD_DATA); - flattenTreeNodes(nodes: TreeNode[]): TreeNode[] { - return nodes.flatMap(node => { - return node.children ? [node, ...this.flattenTreeNodes(node.children)] : [node]; + constructor() { + afterRenderEffect(() => { + const active = this.tree()?._pattern.inputs.activeItem(); + if (active) { + untracked(() => { + active.element()?.scrollIntoView({block: 'nearest'}); + }); + } }); } - filterTreeNodes(nodes: TreeNode[]): TreeNode[] { - 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}); - } - return acc; - }, [] as TreeNode[]); - } + filteredGroups = computed(() => { + const search = this.searchString().trim().toLowerCase(); + const data = this.dataSource(); - isMatch(node: TreeNode) { - return node.name.toLowerCase().includes(this.searchString().toLowerCase()); - } + if (!search) { + return data; + } - constructor() { - afterRenderEffect(() => { - const popover = this.popover()!; - const combobox = this.combobox()!; - combobox.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); - this.tree()?.scrollActiveItemIntoView(); - }); - } + 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); - showPopover() { - const popover = this.popover()!; - const combobox = this.combobox()!; + if (matches || (children && children.length > 0)) { + return { + ...node, + children, + expanded: children && children.length > 0 ? true : node.expanded, + }; + } - const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); - const popoverEl = popover.nativeElement; + return null; + }; - if (comboboxRect) { - popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom + 4}px`; - popoverEl.style.left = `${comboboxRect.left - 1}px`; - } + return data.map(node => filterNode(node)).filter((node): node is FoodNode => node !== null); + }); - popover.nativeElement.showPopover(); + 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/combobox/combobox-tree-highlight/combobox-tree-highlight-example.html b/src/components-examples/aria/combobox/combobox-tree-highlight/combobox-tree-highlight-example.html index 14f4f5999c41..cfdf9bfe700b 100644 --- a/src/components-examples/aria/combobox/combobox-tree-highlight/combobox-tree-highlight-example.html +++ b/src/components-examples/aria/combobox/combobox-tree-highlight/combobox-tree-highlight-example.html @@ -1,63 +1,41 @@ -
      -
      +
      +
      search - +
      -
      - -
        - -
      + + +
      +
        + +
      +
      -
      +
      @for (node of nodes; track node.name) { -
    • - - {{ node.name }} - -
    • - - @if (node.children) { -
        - - - -
      - } +
    • + + {{ node.name }} + +
    • + @if (node.children) { +
        + + + +
      + } } -
      + \ No newline at end of file diff --git a/src/components-examples/aria/combobox/combobox-tree-highlight/combobox-tree-highlight-example.ts b/src/components-examples/aria/combobox/combobox-tree-highlight/combobox-tree-highlight-example.ts index aefd4830422e..4305872df266 100644 --- a/src/components-examples/aria/combobox/combobox-tree-highlight/combobox-tree-highlight-example.ts +++ b/src/components-examples/aria/combobox/combobox-tree-highlight/combobox-tree-highlight-example.ts @@ -6,99 +6,121 @@ * 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/combobox'; import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree'; import { - afterRenderEffect, - ChangeDetectionStrategy, Component, + afterRenderEffect, computed, - ElementRef, signal, viewChild, + untracked, + ChangeDetectionStrategy, } from '@angular/core'; -import {TREE_NODES, TreeNode} from '../data'; 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: 'combobox-tree-highlight-example', templateUrl: 'combobox-tree-highlight-example.html', - styleUrl: '../combobox-examples.css', + styleUrl: '../combobox-example.css', imports: [ Combobox, - ComboboxInput, ComboboxPopup, - ComboboxPopupContainer, + ComboboxWidget, + NgTemplateOutlet, Tree, TreeItem, TreeItemGroup, - NgTemplateOutlet, + OverlayModule, ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ComboboxTreeHighlightExample { - popover = viewChild('popover'); - tree = viewChild>(Tree); - combobox = viewChild>(Combobox); + readonly tree = viewChild(Tree); + popupExpanded = signal(false); searchString = signal(''); + selectedValues = signal([]); - nodes = computed(() => this.filterTreeNodes(TREE_NODES)); + readonly dataSource = signal(FOOD_DATA); - firstMatch = computed(() => { - const flatNodes = this.flattenTreeNodes(this.nodes()); - const node = flatNodes.find(n => this.isMatch(n)); - return node?.name; - }); + constructor() { + // Highlight mode focus update + afterRenderEffect(() => { + this.filteredGroups(); + }); - flattenTreeNodes(nodes: TreeNode[]): TreeNode[] { - return nodes.flatMap(node => { - return node.children ? [node, ...this.flattenTreeNodes(node.children)] : [node]; + afterRenderEffect(() => { + const active = this.tree()?._pattern.inputs.activeItem(); + if (active) { + untracked(() => { + active.element()?.scrollIntoView({block: 'nearest'}); + }); + } }); } - filterTreeNodes(nodes: TreeNode[]): TreeNode[] { - 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}); + 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; } - return acc; - }, [] as TreeNode[]); - } - isMatch(node: TreeNode) { - return node.name.toLowerCase().includes(this.searchString().toLowerCase()); - } + const matches = node.name.toLowerCase().includes(search); + const children = node.children + ?.map(child => filterNode(child)) + .filter((child): child is FoodNode => child !== null); - constructor() { - afterRenderEffect(() => { - const popover = this.popover()!; - const combobox = this.combobox()!; - combobox.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); - this.tree()?.scrollActiveItemIntoView(); - }); - } + if (matches || (children && children.length > 0)) { + return { + ...node, + children, + expanded: children && children.length > 0 ? true : node.expanded, + }; + } - showPopover() { - const popover = this.popover()!; - const combobox = this.combobox()!; + return null; + }; - const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); - const popoverEl = popover.nativeElement; + const groups = data + .map(node => filterNode(node)) + .filter((node): node is FoodNode => node !== null); + return {groups, firstMatch}; + }); - if (comboboxRect) { - popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom + 4}px`; - popoverEl.style.left = `${comboboxRect.left - 1}px`; - } + filteredGroups = computed(() => this.filteredData().groups); + firstMatchingOption = computed(() => this.filteredData().firstMatch); - popover.nativeElement.showPopover(); + 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/combobox/combobox-tree-manual/combobox-tree-manual-example.html b/src/components-examples/aria/combobox/combobox-tree-manual/combobox-tree-manual-example.html deleted file mode 100644 index 41c67e436ab0..000000000000 --- a/src/components-examples/aria/combobox/combobox-tree-manual/combobox-tree-manual-example.html +++ /dev/null @@ -1,63 +0,0 @@ -
      -
      - search - -
      - -
      - -
        - -
      -
      -
      -
      - - - @for (node of nodes; track node.name) { -
    • - - {{ node.name }} - -
    • - - @if (node.children) { -
        - - - -
      - } - } -
      diff --git a/src/components-examples/aria/combobox/combobox-tree-manual/combobox-tree-manual-example.ts b/src/components-examples/aria/combobox/combobox-tree-manual/combobox-tree-manual-example.ts deleted file mode 100644 index 5a7967f17d9f..000000000000 --- a/src/components-examples/aria/combobox/combobox-tree-manual/combobox-tree-manual-example.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * @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, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, -} from '@angular/aria/combobox'; -import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree'; -import { - afterRenderEffect, - ChangeDetectionStrategy, - Component, - computed, - ElementRef, - signal, - viewChild, -} from '@angular/core'; -import {TREE_NODES, TreeNode} from '../data'; -import {NgTemplateOutlet} from '@angular/common'; - -/** @title Combobox with tree popup and manual filtering. */ -@Component({ - selector: 'combobox-tree-manual-example', - templateUrl: 'combobox-tree-manual-example.html', - styleUrl: '../combobox-examples.css', - imports: [ - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, - Tree, - TreeItem, - TreeItemGroup, - NgTemplateOutlet, - ], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ComboboxTreeManualExample { - popover = viewChild('popover'); - tree = viewChild>(Tree); - combobox = viewChild>(Combobox); - - searchString = signal(''); - - nodes = computed(() => this.filterTreeNodes(TREE_NODES)); - - firstMatch = computed(() => { - const flatNodes = this.flattenTreeNodes(this.nodes()); - const node = flatNodes.find(n => this.isMatch(n)); - return node?.name; - }); - - flattenTreeNodes(nodes: TreeNode[]): TreeNode[] { - return nodes.flatMap(node => { - return node.children ? [node, ...this.flattenTreeNodes(node.children)] : [node]; - }); - } - - filterTreeNodes(nodes: TreeNode[]): TreeNode[] { - 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}); - } - return acc; - }, [] as TreeNode[]); - } - - isMatch(node: TreeNode) { - return node.name.toLowerCase().includes(this.searchString().toLowerCase()); - } - - constructor() { - afterRenderEffect(() => { - const popover = this.popover()!; - const combobox = this.combobox()!; - combobox.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); - this.tree()?.scrollActiveItemIntoView(); - }); - } - - showPopover() { - const popover = this.popover()!; - const combobox = this.combobox()!; - - const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); - const popoverEl = popover.nativeElement; - - if (comboboxRect) { - popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom + 4}px`; - popoverEl.style.left = `${comboboxRect.left - 1}px`; - } - - popover.nativeElement.showPopover(); - } -} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html b/src/components-examples/aria/combobox/combobox-tree/combobox-tree-example.html similarity index 100% rename from src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html rename to src/components-examples/aria/combobox/combobox-tree/combobox-tree-example.html diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts b/src/components-examples/aria/combobox/combobox-tree/combobox-tree-example.ts similarity index 92% rename from src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts rename to src/components-examples/aria/combobox/combobox-tree/combobox-tree-example.ts index c1faeb5bf87c..ce982227c78c 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts +++ b/src/components-examples/aria/combobox/combobox-tree/combobox-tree-example.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox'; import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree'; import {Component, afterRenderEffect, computed, signal, viewChild, untracked} from '@angular/core'; import {NgTemplateOutlet} from '@angular/common'; @@ -20,9 +20,9 @@ interface FoodNode { /** @title */ @Component({ - selector: 'simple-combobox-tree-example', - templateUrl: 'simple-combobox-tree-example.html', - styleUrl: '../simple-combobox-example.css', + selector: 'combobox-tree-example', + templateUrl: 'combobox-tree-example.html', + styleUrl: '../combobox-example.css', imports: [ Combobox, ComboboxPopup, @@ -34,7 +34,7 @@ interface FoodNode { OverlayModule, ], }) -export class SimpleComboboxTreeExample { +export class ComboboxTreeExample { readonly tree = viewChild(Tree); popupExpanded = signal(false); diff --git a/src/components-examples/aria/simple-combobox/countries.ts b/src/components-examples/aria/combobox/countries.ts similarity index 100% rename from src/components-examples/aria/simple-combobox/countries.ts rename to src/components-examples/aria/combobox/countries.ts diff --git a/src/components-examples/aria/combobox/data.ts b/src/components-examples/aria/combobox/data.ts deleted file mode 100644 index ecc8db3bc4aa..000000000000 --- a/src/components-examples/aria/combobox/data.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface TreeNode { - name: string; - children?: TreeNode[]; -} - -export const TREE_NODES = [ - { - 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/combobox/index.ts b/src/components-examples/aria/combobox/index.ts index e19dfd098d76..9894b5059141 100644 --- a/src/components-examples/aria/combobox/index.ts +++ b/src/components-examples/aria/combobox/index.ts @@ -1,13 +1,13 @@ -export {ComboboxDialogExample} from './combobox-dialog/combobox-dialog-example'; -export {ComboboxManualExample} from './combobox-manual/combobox-manual-example'; +export {ComboboxListboxExample} from './combobox-listbox/combobox-listbox-example'; +export {ComboboxTreeExample} from './combobox-tree/combobox-tree-example'; +export {ComboboxSelectExample} from './combobox-select/combobox-select-example'; +export {ComboboxGridExample} from './combobox-grid/combobox-grid-example'; +export {ComboboxDatepickerExample} from './combobox-datepicker/combobox-datepicker-example'; export {ComboboxAutoSelectExample} from './combobox-auto-select/combobox-auto-select-example'; export {ComboboxHighlightExample} from './combobox-highlight/combobox-highlight-example'; export {ComboboxDisabledExample} from './combobox-disabled/combobox-disabled-example'; - -export {ComboboxReadonlyExample} from './combobox-readonly/combobox-readonly-example'; -export {ComboboxReadonlyMultiselectExample} from './combobox-readonly-multiselect/combobox-readonly-multiselect-example'; export {ComboboxReadonlyDisabledExample} from './combobox-readonly-disabled/combobox-readonly-disabled-example'; - -export {ComboboxTreeManualExample} from './combobox-tree-manual/combobox-tree-manual-example'; +export {ComboboxReadonlyMultiselectExample} from './combobox-readonly-multiselect/combobox-readonly-multiselect-example'; +export {ComboboxDialogExample} from './combobox-dialog/combobox-dialog-example'; export {ComboboxTreeAutoSelectExample} from './combobox-tree-auto-select/combobox-tree-auto-select-example'; export {ComboboxTreeHighlightExample} from './combobox-tree-highlight/combobox-tree-highlight-example'; diff --git a/src/components-examples/aria/combobox/select-examples.css b/src/components-examples/aria/combobox/select-examples.css deleted file mode 100644 index 2e4dde0cd4d1..000000000000 --- a/src/components-examples/aria/combobox/select-examples.css +++ /dev/null @@ -1,120 +0,0 @@ -.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); -} - -.example-select:has([ngComboboxInput][aria-disabled='true']) { - opacity: 0.6; - cursor: default; -} - -.example-select:hover { - background-color: color-mix(in srgb, var(--mat-sys-primary) 90%, transparent); -} - -[ngComboboxInput] { - opacity: 0; - cursor: pointer; - padding: 0 3rem; - height: 3rem; - border: none; -} - -[ngCombobox]:focus-within .example-select { - outline-offset: 2px; - outline: 2px solid var(--mat-sys-primary); -} - -.example-combobox-text { - left: 2rem; - position: absolute; -} - -.example-arrow { - right: 1rem; - position: absolute; - pointer-events: none; - transition: transform 150ms ease-in-out; -} - -[ngComboboxInput][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); - - max-height: 13rem; - opacity: 1; - visibility: visible; - transition: max-height 150ms ease-out, visibility 0s, opacity 25ms ease-out; -} - -[ngListbox] { - gap: 4px; - height: 100%; - display: flex; - overflow: auto; - flex-direction: column; -} - -[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container { - max-height: 0; - opacity: 0; - visibility: hidden; - transition: max-height 150ms ease-in, visibility 0s 150ms, opacity 150ms ease-in; -} - -[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] { - display: flex; -} - -[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); -} - -[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/select/select-disabled/select-disabled-example.html b/src/components-examples/aria/select/select-disabled/select-disabled-example.html index a64296a5d06d..9b445ceb51e6 100644 --- a/src/components-examples/aria/select/select-disabled/select-disabled-example.html +++ b/src/components-examples/aria/select/select-disabled/select-disabled-example.html @@ -1,9 +1,9 @@ -
      +
      Select an option
      - + arrow_drop_down
      @@ -11,7 +11,7 @@ [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}" [cdkConnectedOverlayOpen]="true" > - +
      @for (item of items; track item) { diff --git a/src/components-examples/aria/select/select-disabled/select-disabled-example.ts b/src/components-examples/aria/select/select-disabled/select-disabled-example.ts index aacc40f17b4d..dc48b132011e 100644 --- a/src/components-examples/aria/select/select-disabled/select-disabled-example.ts +++ b/src/components-examples/aria/select/select-disabled/select-disabled-example.ts @@ -6,12 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, -} from '@angular/aria/combobox'; +import {Combobox, ComboboxPopup} from '@angular/aria/combobox'; import {Listbox, Option} from '@angular/aria/listbox'; import {ChangeDetectionStrategy, Component} from '@angular/core'; import {OverlayModule} from '@angular/cdk/overlay'; @@ -21,15 +16,7 @@ import {OverlayModule} from '@angular/cdk/overlay'; selector: 'select-disabled-example', templateUrl: 'select-disabled-example.html', styleUrl: '../select.css', - imports: [ - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, - Listbox, - Option, - OverlayModule, - ], + imports: [Combobox, ComboboxPopup, Listbox, Option, OverlayModule], changeDetection: ChangeDetectionStrategy.OnPush, }) export class SelectDisabledExample { diff --git a/src/components-examples/aria/select/select-multi/select-multi-example.html b/src/components-examples/aria/select/select-multi/select-multi-example.html index 29e40ca53586..e6d8e664f315 100644 --- a/src/components-examples/aria/select/select-multi/select-multi-example.html +++ b/src/components-examples/aria/select/select-multi/select-multi-example.html @@ -1,9 +1,9 @@ -
      +
      {{ displayValue() }}
      - + arrow_drop_down
      @@ -11,7 +11,7 @@ [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}" [cdkConnectedOverlayOpen]="true" > - +
      @for (item of items; track item) { diff --git a/src/components-examples/aria/select/select-multi/select-multi-example.ts b/src/components-examples/aria/select/select-multi/select-multi-example.ts index 939e0110fa7b..b9cbca1c06ce 100644 --- a/src/components-examples/aria/select/select-multi/select-multi-example.ts +++ b/src/components-examples/aria/select/select-multi/select-multi-example.ts @@ -6,12 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, -} from '@angular/aria/combobox'; +import {Combobox, ComboboxPopup} from '@angular/aria/combobox'; import {Listbox, Option} from '@angular/aria/listbox'; import { afterRenderEffect, @@ -28,15 +23,7 @@ import {OverlayModule} from '@angular/cdk/overlay'; selector: 'select-multi-example', templateUrl: 'select-multi-example.html', styleUrl: '../select.css', - imports: [ - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, - Listbox, - Option, - OverlayModule, - ], + imports: [Combobox, ComboboxPopup, Listbox, Option, OverlayModule], changeDetection: ChangeDetectionStrategy.OnPush, }) export class SelectMultiExample { diff --git a/src/components-examples/aria/select/select.css b/src/components-examples/aria/select/select.css index 282b043531f1..fb7090ef4516 100644 --- a/src/components-examples/aria/select/select.css +++ b/src/components-examples/aria/select/select.css @@ -9,7 +9,7 @@ width: fit-content; } -.example-select:has([ngComboboxInput][aria-disabled='true']) { +.example-select:has([ngCombobox][aria-disabled='true']) { opacity: 0.7; background-color: var(--mat-sys-surface-dim); } @@ -49,17 +49,17 @@ transition: transform 0.2s ease-in-out; } -[ngComboboxInput] { +[ngCombobox] { cursor: pointer; padding: 0.7rem 3rem; opacity: 0; } -[ngComboboxInput][aria-disabled='true'] { +[ngCombobox][aria-disabled='true'] { cursor: default; } -[ngComboboxInput][aria-expanded='true'] + .example-arrow { +[ngCombobox][aria-expanded='true'] + .example-arrow { transform: rotate(180deg); } diff --git a/src/components-examples/aria/select/select/select-example.html b/src/components-examples/aria/select/select/select-example.html index 00ed809cfa31..637e36b2564c 100644 --- a/src/components-examples/aria/select/select/select-example.html +++ b/src/components-examples/aria/select/select/select-example.html @@ -1,10 +1,10 @@ -
      +
      {{ value().icon }} {{ value().label }}
      - + arrow_drop_down
      @@ -12,7 +12,7 @@ [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}" [cdkConnectedOverlayOpen]="true" > - +
      @for (item of items; track item.label) { diff --git a/src/components-examples/aria/select/select/select-example.ts b/src/components-examples/aria/select/select/select-example.ts index 29eb5513707a..03805235800b 100644 --- a/src/components-examples/aria/select/select/select-example.ts +++ b/src/components-examples/aria/select/select/select-example.ts @@ -6,12 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, -} from '@angular/aria/combobox'; +import {Combobox, ComboboxPopup} from '@angular/aria/combobox'; import {Listbox, Option} from '@angular/aria/listbox'; import { afterRenderEffect, @@ -28,15 +23,7 @@ import {OverlayModule} from '@angular/cdk/overlay'; selector: 'select-example', templateUrl: 'select-example.html', styleUrl: '../select.css', - imports: [ - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, - Listbox, - Option, - OverlayModule, - ], + imports: [Combobox, ComboboxPopup, Listbox, Option, OverlayModule], changeDetection: ChangeDetectionStrategy.OnPush, }) export class SelectExample { diff --git a/src/components-examples/aria/simple-combobox/BUILD.bazel b/src/components-examples/aria/simple-combobox/BUILD.bazel deleted file mode 100644 index 331dcf7e195d..000000000000 --- a/src/components-examples/aria/simple-combobox/BUILD.bazel +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 9f2366140249..000000000000 --- a/src/components-examples/aria/simple-combobox/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -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'; -export {SimpleComboboxAutocompleteAutoSelectExample} from './simple-combobox-autocomplete-auto-select/simple-combobox-autocomplete-auto-select-example'; -export {SimpleComboboxAutocompleteDisabledExample} from './simple-combobox-autocomplete-disabled/simple-combobox-autocomplete-disabled-example'; -export {SimpleComboboxAutocompleteHighlightExample} from './simple-combobox-autocomplete-highlight/simple-combobox-autocomplete-highlight-example'; -export {SimpleComboboxAutocompleteManualExample} from './simple-combobox-autocomplete-manual/simple-combobox-autocomplete-manual-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 deleted file mode 100644 index ed6af34585d9..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-auto-select/simple-combobox-auto-select-example.html +++ /dev/null @@ -1,23 +0,0 @@ -
      -
      - 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 deleted file mode 100644 index 11521886179e..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-auto-select/simple-combobox-auto-select-example.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * @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-autocomplete-auto-select/simple-combobox-autocomplete-auto-select-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-auto-select/simple-combobox-autocomplete-auto-select-example.html deleted file mode 100644 index 6a04d3e23612..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-auto-select/simple-combobox-autocomplete-auto-select-example.html +++ /dev/null @@ -1,44 +0,0 @@ -
      -
      - search - - -
      - -
      - {{countries().length === 0 ? 'No results found for ' + query() : ''}} -
      - - - -
      - @if (countries().length === 0) { -
      No results found
      - } - -
      - @for (country of countries(); track country) { -
      - {{country}} - check -
      - } -
      -
      -
      -
      -
      diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-auto-select/simple-combobox-autocomplete-auto-select-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-auto-select/simple-combobox-autocomplete-auto-select-example.ts deleted file mode 100644 index 3e1527722dea..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-auto-select/simple-combobox-autocomplete-auto-select-example.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * @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 {COUNTRIES} from '../countries'; -import {OverlayModule} from '@angular/cdk/overlay'; -import {FormsModule} from '@angular/forms'; - -/** @title Autocomplete with auto-select filtering. */ -@Component({ - selector: 'simple-combobox-autocomplete-auto-select-example', - templateUrl: 'simple-combobox-autocomplete-auto-select-example.html', - styleUrl: '../autocomplete.css', - imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class SimpleComboboxAutocompleteAutoSelectExample { - /** The selected value of the combobox. */ - readonly listbox = viewChild(Listbox); - readonly combobox = viewChild(Combobox); - - popupExpanded = signal(false); - searchString = signal(''); - selectedOption = signal([]); - - /** The query string used to filter the list of countries. */ - query = computed(() => this.searchString()); - - /** The list of countries filtered by the query. */ - countries = computed(() => - COUNTRIES.filter(country => country.toLowerCase().startsWith(this.query().toLowerCase())), - ); - - constructor() { - afterRenderEffect(() => { - this.listbox()?.scrollActiveItemIntoView(); - }); - } - - /** Clears the query and the listbox value. */ - clear(): void { - 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.popupExpanded.set(false); - event.stopPropagation(); - } - } -} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-disabled/simple-combobox-autocomplete-disabled-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-disabled/simple-combobox-autocomplete-disabled-example.html deleted file mode 100644 index 4ae9c89500a5..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-disabled/simple-combobox-autocomplete-disabled-example.html +++ /dev/null @@ -1,34 +0,0 @@ -
      -
      - search - -
      - - - -
      - @if (countries().length === 0) { -
      No results found
      - } - -
      - @for (country of countries(); track country) { -
      - {{country}} - check -
      - } -
      -
      -
      -
      -
      diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-disabled/simple-combobox-autocomplete-disabled-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-disabled/simple-combobox-autocomplete-disabled-example.ts deleted file mode 100644 index 9eb2955709ad..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-disabled/simple-combobox-autocomplete-disabled-example.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @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 {COUNTRIES} from '../countries'; -import {OverlayModule} from '@angular/cdk/overlay'; -import {FormsModule} from '@angular/forms'; - -/** @title Disabled autocomplete. */ -@Component({ - selector: 'simple-combobox-autocomplete-disabled-example', - templateUrl: 'simple-combobox-autocomplete-disabled-example.html', - styleUrl: '../autocomplete.css', - imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class SimpleComboboxAutocompleteDisabledExample { - /** The selected value of the combobox. */ - readonly listbox = viewChild(Listbox); - readonly combobox = viewChild(Combobox); - - popupExpanded = signal(false); - searchString = signal('United States of America'); - selectedOption = signal([]); - - /** The query string used to filter the list of countries. */ - query = computed(() => this.searchString()); - - /** The list of countries filtered by the query. */ - countries = computed(() => - COUNTRIES.filter(country => country.toLowerCase().startsWith(this.query().toLowerCase())), - ); - - constructor() { - afterRenderEffect(() => { - this.listbox()?.scrollActiveItemIntoView(); - }); - } -} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-highlight/simple-combobox-autocomplete-highlight-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-highlight/simple-combobox-autocomplete-highlight-example.html deleted file mode 100644 index 617e706877cf..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-highlight/simple-combobox-autocomplete-highlight-example.html +++ /dev/null @@ -1,45 +0,0 @@ -
      -
      - search - - -
      - -
      - {{countries().length === 0 ? 'No results found for ' + query() : ''}} -
      - - - -
      - @if (countries().length === 0) { -
      No results found
      - } - -
      - @for (country of countries(); track country) { -
      - {{country}} - check -
      - } -
      -
      -
      -
      -
      diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-highlight/simple-combobox-autocomplete-highlight-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-highlight/simple-combobox-autocomplete-highlight-example.ts deleted file mode 100644 index b56ddc14fa81..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-highlight/simple-combobox-autocomplete-highlight-example.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * @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 {COUNTRIES} from '../countries'; -import {OverlayModule} from '@angular/cdk/overlay'; -import {FormsModule} from '@angular/forms'; - -/** @title Autocomplete with highlighted filtering. */ -@Component({ - selector: 'simple-combobox-autocomplete-highlight-example', - templateUrl: 'simple-combobox-autocomplete-highlight-example.html', - styleUrl: '../autocomplete.css', - imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class SimpleComboboxAutocompleteHighlightExample { - /** The selected value of the combobox. */ - readonly listbox = viewChild(Listbox); - readonly combobox = viewChild(Combobox); - - popupExpanded = signal(false); - searchString = signal(''); - selectedOption = signal([]); - - /** The query string used to filter the list of countries. */ - query = computed(() => this.searchString()); - - /** The list of countries filtered by the query. */ - countries = computed(() => - COUNTRIES.filter(country => country.toLowerCase().startsWith(this.query().toLowerCase())), - ); - - constructor() { - afterRenderEffect(() => { - this.listbox()?.scrollActiveItemIntoView(); - }); - } - - /** Clears the query and the listbox value. */ - clear(): void { - 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.popupExpanded.set(false); - event.stopPropagation(); - } - } -} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-manual/simple-combobox-autocomplete-manual-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-manual/simple-combobox-autocomplete-manual-example.html deleted file mode 100644 index 5866ba3415b1..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-manual/simple-combobox-autocomplete-manual-example.html +++ /dev/null @@ -1,35 +0,0 @@ -
      -
      - search - - -
      - -
      - {{countries().length === 0 ? 'No results found for ' + query() : ''}} -
      - - - -
      - @if (countries().length === 0) { -
      No results found
      - } - -
      - @for (country of countries(); track country) { -
      - {{country}} - check -
      - } -
      -
      -
      -
      -
      diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-manual/simple-combobox-autocomplete-manual-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-manual/simple-combobox-autocomplete-manual-example.ts deleted file mode 100644 index 909ab171ffea..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-manual/simple-combobox-autocomplete-manual-example.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * @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 {COUNTRIES} from '../countries'; -import {OverlayModule} from '@angular/cdk/overlay'; -import {FormsModule} from '@angular/forms'; - -/** @title Simple Combobox Autocomplete with manual filtering. */ -@Component({ - selector: 'simple-combobox-autocomplete-manual-example', - templateUrl: 'simple-combobox-autocomplete-manual-example.html', - styleUrl: '../autocomplete.css', - imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class SimpleComboboxAutocompleteManualExample { - /** The selected value of the combobox. */ - readonly listbox = viewChild(Listbox); - readonly combobox = viewChild(Combobox); - - popupExpanded = signal(false); - searchString = signal(''); - selectedOption = signal([]); - - /** The query string used to filter the list of countries. */ - query = computed(() => this.searchString()); - - /** The list of countries filtered by the query. */ - countries = computed(() => - COUNTRIES.filter(country => country.toLowerCase().startsWith(this.query().toLowerCase())), - ); - - constructor() { - afterRenderEffect(() => { - this.listbox()?.scrollActiveItemIntoView(); - }); - } - - /** Clears the query and the listbox value. */ - clear(): void { - 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.popupExpanded.set(false); - event.stopPropagation(); - } - } -} 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 deleted file mode 100644 index a9b0e512cca5..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-dialog/simple-combobox-dialog-example.html +++ /dev/null @@ -1,40 +0,0 @@ -
      -
      - - 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 deleted file mode 100644 index 55edada06f67..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-dialog/simple-combobox-dialog-example.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * @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 deleted file mode 100644 index faf4954d7eba..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-disabled/simple-combobox-disabled-example.html +++ /dev/null @@ -1,26 +0,0 @@ -
      -
      - 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 deleted file mode 100644 index 0e7fe2b7dadb..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-disabled/simple-combobox-disabled-example.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * @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-highlight/simple-combobox-highlight-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-highlight/simple-combobox-highlight-example.html deleted file mode 100644 index ebf1ebc9d044..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-highlight/simple-combobox-highlight-example.html +++ /dev/null @@ -1,25 +0,0 @@ -
      -
      - 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 deleted file mode 100644 index b3b3ac084f63..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-highlight/simple-combobox-highlight-example.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * @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-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 deleted file mode 100644 index a332bd10109a..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-readonly-disabled/simple-combobox-readonly-disabled-example.html +++ /dev/null @@ -1,28 +0,0 @@ -
      - {{value()}} - arrow_drop_down -
      - - - -
      -
      - @for (option of options(); track option.value) { -
      - @if (option.icon) { - {{option.icon}} - } - {{option.value}} - @if (selectedValues().includes(option.value)) { - - } -
      - } -
      -
      -
      -
      \ No newline at end of file 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 deleted file mode 100644 index 21b7e8df9af2..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-readonly-disabled/simple-combobox-readonly-disabled-example.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @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 deleted file mode 100644 index a84980f220ad..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-readonly-multiselect/simple-combobox-readonly-multiselect-example.html +++ /dev/null @@ -1,46 +0,0 @@ -
      - {{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 deleted file mode 100644 index 328cb8efdb10..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-readonly-multiselect/simple-combobox-readonly-multiselect-example.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * @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-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 deleted file mode 100644 index 0dd6ccbca5c7..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.html +++ /dev/null @@ -1,38 +0,0 @@ -
      -
      - 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 deleted file mode 100644 index c362d3aa3d7a..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * @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 deleted file mode 100644 index cfdf9bfe700b..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.html +++ /dev/null @@ -1,41 +0,0 @@ -
      -
      - 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 deleted file mode 100644 index 6de3da7b55a4..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * @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/toolbar/simple-toolbar.ts b/src/components-examples/aria/toolbar/simple-toolbar.ts index 93cff9a7c677..d018e3de117f 100644 --- a/src/components-examples/aria/toolbar/simple-toolbar.ts +++ b/src/components-examples/aria/toolbar/simple-toolbar.ts @@ -1,9 +1,4 @@ -import { - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, -} from '@angular/aria/combobox'; +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox'; import {Listbox, Option} from '@angular/aria/listbox'; import {ToolbarWidget} from '@angular/aria/toolbar'; import {Dir, Directionality} from '@angular/cdk/bidi'; @@ -16,9 +11,11 @@ import { signal, viewChild, } from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; @Directive({ selector: 'button[toolbar-button]', + standalone: true, hostDirectives: [{directive: ToolbarWidget, inputs: ['value', 'disabled']}], host: { type: 'button', @@ -32,6 +29,7 @@ export class SimpleToolbarButton { @Directive({ selector: 'button[toolbar-toggle-button]', + standalone: true, hostDirectives: [{directive: ToolbarWidget, inputs: ['value']}], host: { type: 'button', @@ -46,6 +44,7 @@ export class SimpleToolbarToggleButton { @Directive({ selector: 'button[toolbar-radio-button]', + standalone: true, hostDirectives: [{directive: ToolbarWidget, inputs: ['value', 'disabled']}], host: { role: 'radio', @@ -61,38 +60,45 @@ export class SimpleToolbarRadioButton { @Component({ selector: 'combobox', + standalone: true, imports: [ Dir, Combobox, - ComboboxInput, ComboboxPopup, - ComboboxPopupContainer, + ComboboxWidget, Listbox, Option, ToolbarWidget, + OverlayModule, ], styleUrl: 'toolbar-common.css', host: {class: 'example-combobox-container'}, template: ` -
      -
      - +
      +
      +
      + {{ value() }} +
      arrow_drop_down
      -
      - -
      + + +
      @for (option of options; track option) { -
      +
      {{option}} -
      +
      `, }) export class SimpleCombobox { dir = inject(Directionality).valueSignal; - popover = viewChild('popover'); - listbox = viewChild>(Listbox); - combobox = viewChild>(Combobox); + listbox = viewChild(Listbox); + combobox = viewChild(Combobox); + popupExpanded = signal(false); + selectedOption = signal([]); value = signal('Normal text'); options = ['Normal text', 'Title', 'Subtitle', 'Heading 1', 'Heading 2', 'Heading 3']; constructor() { afterRenderEffect(() => { - const popover = this.popover()!; - const combobox = this.combobox()!; - combobox.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); - this.listbox()?.scrollActiveItemIntoView(); }); } - showPopover() { - const popover = this.popover()!; - const combobox = this.combobox()!; - - const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); - const popoverEl = popover.nativeElement; - - if (comboboxRect) { - popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom + 4}px`; - popoverEl.style.left = `${comboboxRect.left - 1}px`; + onCommit() { + const selectedOption = this.selectedOption(); + if (selectedOption.length > 0) { + this.value.set(selectedOption[0]); } - - popover.nativeElement.showPopover(); + this.popupExpanded.set(false); } } diff --git a/src/components-examples/aria/toolbar/toolbar-common.css b/src/components-examples/aria/toolbar/toolbar-common.css index b3d258f780bc..fdb47e540a0c 100644 --- a/src/components-examples/aria/toolbar/toolbar-common.css +++ b/src/components-examples/aria/toolbar/toolbar-common.css @@ -150,6 +150,7 @@ border: 1px solid color-mix(in srgb, var(--mat-sys-outline) 50%, transparent); border-radius: var(--mat-sys-corner-extra-small); background-color: var(--mat-sys-surface); + width: 100%; } .example-option { diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index f4b2184f1ba3..b18b11e22ea1 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -33,7 +33,6 @@ 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-autocomplete/BUILD.bazel b/src/dev-app/aria-autocomplete/BUILD.bazel index 8a28a19bfecf..f2273d7e713c 100644 --- a/src/dev-app/aria-autocomplete/BUILD.bazel +++ b/src/dev-app/aria-autocomplete/BUILD.bazel @@ -13,6 +13,6 @@ ng_project( "//:node_modules/@angular/core", "//:node_modules/@angular/forms", "//src/components-examples/aria/autocomplete", - "//src/components-examples/aria/simple-combobox", + "//src/components-examples/aria/combobox", ], ) diff --git a/src/dev-app/aria-autocomplete/autocomplete-demo.html b/src/dev-app/aria-autocomplete/autocomplete-demo.html index 1bb48e44cdfe..bb22c24ebd9d 100644 --- a/src/dev-app/aria-autocomplete/autocomplete-demo.html +++ b/src/dev-app/aria-autocomplete/autocomplete-demo.html @@ -19,27 +19,4 @@

      Highlighted auto selection

      Disabled autocomplete

      -
      - -

      SimpleCombobox

      -
      -
      -

      Auto select

      - -
      - -
      -

      Manual selection

      - -
      - -
      -

      Highlighted auto selection

      - -
      - -
      -

      Disabled autocomplete

      - -
      \ No newline at end of file diff --git a/src/dev-app/aria-autocomplete/autocomplete-demo.ts b/src/dev-app/aria-autocomplete/autocomplete-demo.ts index 2c745ad7dee2..38179f50296e 100644 --- a/src/dev-app/aria-autocomplete/autocomplete-demo.ts +++ b/src/dev-app/aria-autocomplete/autocomplete-demo.ts @@ -5,12 +5,6 @@ import { AutocompleteHighlightExample, AutocompleteDisabledExample, } from '@angular/components-examples/aria/autocomplete'; -import { - SimpleComboboxAutocompleteAutoSelectExample, - SimpleComboboxAutocompleteManualExample, - SimpleComboboxAutocompleteHighlightExample, - SimpleComboboxAutocompleteDisabledExample, -} from '@angular/components-examples/aria/simple-combobox'; @Component({ selector: 'autocomplete-demo', @@ -21,10 +15,6 @@ import { AutocompleteManualExample, AutocompleteHighlightExample, AutocompleteDisabledExample, - SimpleComboboxAutocompleteAutoSelectExample, - SimpleComboboxAutocompleteManualExample, - SimpleComboboxAutocompleteHighlightExample, - SimpleComboboxAutocompleteDisabledExample, ], changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/src/dev-app/aria-combobox/combobox-demo.css b/src/dev-app/aria-combobox/combobox-demo.css index 607c068d07ef..4083f0414ecc 100644 --- a/src/dev-app/aria-combobox/combobox-demo.css +++ b/src/dev-app/aria-combobox/combobox-demo.css @@ -20,3 +20,6 @@ h2 { h3 { font-size: 1rem; } +.demo-combobox { + padding-bottom: 300px; +} diff --git a/src/dev-app/aria-combobox/combobox-demo.html b/src/dev-app/aria-combobox/combobox-demo.html index 01048429e8d6..e7af0497b53b 100644 --- a/src/dev-app/aria-combobox/combobox-demo.html +++ b/src/dev-app/aria-combobox/combobox-demo.html @@ -1,24 +1,23 @@ -
      +

      Listbox autocomplete examples

      Combobox with manual filtering

      - +
      - +
      -

      Combobox with auto-select filtering

      +

      Combobox with auto-select

      -
      -

      Combobox with highlight filtering

      +

      Combobox with highlight

      -

      Disabled combobox

      +

      Combobox with disabled

      @@ -28,11 +27,11 @@

      Tree autocomplete examples

      Combobox with tree popup and manual filtering

      - +
      -

      Combobox with tree popup and auto-select filtering

      +

      Combobox with tree popup and auto-select

      @@ -42,31 +41,44 @@

      Combobox with tree popup and highlight filtering

      -

      Select examples

      +

      Combobox select examples

      -

      Readonly Combobox

      - +

      Combobox with select

      +
      -

      Readonly Multiselect Combobox

      +

      Combobox with Multi-Select

      -

      Disabled Readonly Combobox

      +

      Combobox with Readonly + Disabled

      -

      Combobox with dialog popup

      +

      Combobox with Dialog Popup

      + +
      +
      +

      Combobox with Dialog Popup

      + +
      +
      -
      +

      Combobox Grid Examples

      + +
      -

      Combobox with dialog popup

      - +

      Combobox with Grid

      + +
      +
      +

      Combobox with Datepicker Grid

      +
      -
      +
      \ No newline at end of file diff --git a/src/dev-app/aria-combobox/combobox-demo.ts b/src/dev-app/aria-combobox/combobox-demo.ts index efb033b6a777..ab141bc8d095 100644 --- a/src/dev-app/aria-combobox/combobox-demo.ts +++ b/src/dev-app/aria-combobox/combobox-demo.ts @@ -6,37 +6,41 @@ * found in the LICENSE file at https://angular.dev/license */ +import {ChangeDetectionStrategy, Component} from '@angular/core'; import { - ComboboxDialogExample, + ComboboxListboxExample, + ComboboxTreeExample, + ComboboxSelectExample, + ComboboxGridExample, + ComboboxDatepickerExample, ComboboxAutoSelectExample, ComboboxHighlightExample, - ComboboxManualExample, ComboboxDisabledExample, - ComboboxReadonlyExample, - ComboboxReadonlyMultiselectExample, ComboboxReadonlyDisabledExample, + ComboboxReadonlyMultiselectExample, + ComboboxDialogExample, ComboboxTreeAutoSelectExample, ComboboxTreeHighlightExample, - ComboboxTreeManualExample, } from '@angular/components-examples/aria/combobox'; -import {ChangeDetectionStrategy, Component} from '@angular/core'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: 'combobox-demo.html', styleUrl: 'combobox-demo.css', imports: [ - ComboboxDialogExample, - ComboboxManualExample, + ComboboxListboxExample, + ComboboxTreeExample, + ComboboxSelectExample, + ComboboxGridExample, + ComboboxDatepickerExample, ComboboxAutoSelectExample, ComboboxHighlightExample, ComboboxDisabledExample, - ComboboxReadonlyExample, - ComboboxReadonlyMultiselectExample, ComboboxReadonlyDisabledExample, - ComboboxTreeManualExample, + ComboboxReadonlyMultiselectExample, + ComboboxDialogExample, ComboboxTreeAutoSelectExample, ComboboxTreeHighlightExample, ], - changeDetection: ChangeDetectionStrategy.OnPush, }) export class ComboboxDemo {} diff --git a/src/dev-app/aria-simple-combobox/BUILD.bazel b/src/dev-app/aria-simple-combobox/BUILD.bazel deleted file mode 100644 index 0226eb758e65..000000000000 --- a/src/dev-app/aria-simple-combobox/BUILD.bazel +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 78b87b236202..000000000000 --- a/src/dev-app/aria-simple-combobox/simple-combobox-demo.css +++ /dev/null @@ -1,25 +0,0 @@ -.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 deleted file mode 100644 index d651ab1aa070..000000000000 --- a/src/dev-app/aria-simple-combobox/simple-combobox-demo.html +++ /dev/null @@ -1,84 +0,0 @@ -
      -

      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 deleted file mode 100644 index 63eaff4dea51..000000000000 --- a/src/dev-app/aria-simple-combobox/simple-combobox-demo.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @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 7fb023a4d0cc..618b4e3f56cd 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -65,7 +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 2f2daeb6d542..19209a4a74f9 100644 --- a/src/dev-app/routes.ts +++ b/src/dev-app/routes.ts @@ -48,11 +48,7 @@ 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),