diff --git a/goldens/aria/combobox/index.api.md b/goldens/aria/combobox/index.api.md index 1955350fe6db..6ecc5febf20e 100644 --- a/goldens/aria/combobox/index.api.md +++ b/goldens/aria/combobox/index.api.md @@ -4,76 +4,74 @@ ```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; + readonly softDisabled: _angular_core.InputSignalWithTransform; + readonly tabIndex: _angular_core.InputSignalWithTransform; + _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/simple-combobox/testing/index.api.md b/goldens/aria/combobox/testing/index.api.md similarity index 96% rename from goldens/aria/simple-combobox/testing/index.api.md rename to goldens/aria/combobox/testing/index.api.md index a9b87fd9d748..b732c006dc99 100644 --- a/goldens/aria/simple-combobox/testing/index.api.md +++ b/goldens/aria/combobox/testing/index.api.md @@ -1,4 +1,4 @@ -## API Report File for "@angular/aria_simple-combobox_testing" +## API Report File for "@angular/aria_combobox_testing" > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). diff --git a/goldens/aria/listbox/index.api.md b/goldens/aria/listbox/index.api.md index da48a7d889cf..c851b04d170e 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 1a32be956d1d..5e5403a31d63 100644 --- a/goldens/aria/private/index.api.md +++ b/goldens/aria/private/index.api.md @@ -59,207 +59,68 @@ 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; + softDisabled?: 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 softDisabled: () => boolean; + 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) @@ -668,70 +529,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 706075dce478..000000000000 --- a/goldens/aria/simple-combobox/index.api.md +++ /dev/null @@ -1,79 +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; - readonly tabIndex: _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 5867a4cb6e8a..fdc2b867dead 100644 --- a/goldens/aria/tree/index.api.md +++ b/goldens/aria/tree/index.api.md @@ -36,7 +36,7 @@ export class Tree implements OnDestroy { 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..d39dc069bf79 100644 --- a/src/aria/combobox/BUILD.bazel +++ b/src/aria/combobox/BUILD.bazel @@ -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", 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..08b587b2181e 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,34 +427,69 @@ 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'); expect(fixture.componentInstance.value()).toEqual(['California']); }); + + it('should resume inserting completion strings on navigation after a backspace deletion', async () => { + down(); // Open popup + + // 1. Type 'A', completion should pop up 'Alabama' + input('A'); + expect(inputElement.value).toBe('Alabama'); + + // 2. Simulate Backspace deletion (dispatch InputEvent with deleteContentBackward) + inputElement.value = ''; + inputElement.dispatchEvent( + new InputEvent('input', { + bubbles: true, + inputType: 'deleteContentBackward', + }), + ); + fixture.detectChanges(); + + // Confirm no completion gets inserted during deletion + expect(inputElement.value).toBe(''); + + // 3. Press ArrowDown key to navigate to the next option (Alaska) + down(); + + // Active descendant navigation resets `isDeleting`, so highlight/completion should successfully populate the current active match! + const options = getOptions(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[1].id); + expect(inputElement.value).toBe('Alaska'); + }); }); }); - // 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 +523,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 +545,93 @@ 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('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('disabled')).toBeNull(); + expect(inputElement.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should make the input read-only when disabled and softDisabled is true', () => { + fixture.componentInstance.disabled.set(true); + fixture.detectChanges(); + + expect(inputElement.getAttribute('readonly')).toBe(''); + }); + + 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('disabled')).toBe(''); + expect(inputElement.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should respect user-defined tabindex when softDisabled is true', () => { + fixture.componentInstance.disabled.set(true); + fixture.componentInstance.tabIndex.set(0); + fixture.detectChanges(); + + expect(inputElement.getAttribute('tabindex')).toBe('0'); + }); + + it('should respect user-defined tabindex when not disabled', () => { + fixture.componentInstance.tabIndex.set(0); + fixture.detectChanges(); + + expect(inputElement.getAttribute('tabindex')).toBe('0'); + }); + + it('should default to tabindex 0 when not disabled', () => { + fixture.detectChanges(); + expect(inputElement.getAttribute('tabindex')).toBe('0'); + }); + + it('should force tabindex to -1 when hard-disabled, ignoring user-defined tabindex', () => { + fixture.componentInstance.disabled.set(true); + fixture.componentInstance.softDisabled.set(false); + fixture.componentInstance.tabIndex.set(0); + fixture.detectChanges(); + + expect(inputElement.getAttribute('tabindex')).toBe('-1'); + }); + }); }); describe('with Tree', () => { @@ -571,13 +650,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 +680,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 +693,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 +722,9 @@ describe('Combobox', () => { }); } - afterEach(async () => await runAccessibilityChecks(fixture.nativeElement)); + afterEach(async () => { + await runAccessibilityChecks(fixture.nativeElement); + }); describe('ARIA attributes and roles', () => { beforeEach(() => setupCombobox()); @@ -669,23 +740,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 +763,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 +908,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 +948,310 @@ describe('Combobox', () => { expect(inputElement.value).toBe('Appl'); }); }); + }); - describe('when filterMode is "auto-select"', () => { - beforeEach(() => setupCombobox({filterMode: 'auto-select'})); - - it('should select and commit on click', () => { - click(inputElement); - down(); - right(); - const item = getTreeItem('February')!; - click(item); - fixture.detectChanges(); + describe('Filtering', () => { + beforeEach(() => setupCombobox()); - expect(fixture.componentInstance.value()).toEqual(['February']); - expect(inputElement.value).toBe('February'); - }); + it('should lazily render options', async () => { + expect(getTreeItems().length).toBe(0); - it('should select and commit on Enter', () => { - down(); - down(); - enter(); + focus(); + down(); + // Mutate dataSource to expand all + fixture.componentInstance.dataSource().forEach(node => (node.expanded = true)); - expect(fixture.componentInstance.value()).toEqual(['Spring']); - expect(inputElement.value).toBe('Spring'); - }); + // Force computed signal to re-evaluate by updating dataSource reference + fixture.componentInstance.dataSource.set([...fixture.componentInstance.dataSource()]); + fixture.detectChanges(); - it('should select on navigation', () => { - down(); - expect(fixture.componentInstance.value()).toEqual(['Winter']); + expect(getTreeItems().length).toBe(16); + }); - down(); - expect(fixture.componentInstance.value()).toEqual(['Spring']); - }); + it('should filter the options based on the input value', () => { + focus(); + input('Summer'); - it('should select the first option on input', () => { - focus(); - input('Dec'); - expect(fixture.componentInstance.value()).toEqual(['December']); - }); + let items = getVisibleTreeItems(); + expect(items.length).toBe(1); + expect(items[0].textContent?.trim()).toBe('Summer'); + }); - it('should commit the selected option on focusout', () => { - focus(); - input('Jun'); - blur(); + it('should render parents if a child matches', () => { + focus(); + input('January'); - expect(inputElement.value).toBe('June'); - expect(fixture.componentInstance.value()).toEqual(['June']); - }); + let items = getVisibleTreeItems(); + expect(items.length).toBe(2); + expect(items[0].textContent?.trim()).toBe('Winter'); + expect(items[1].textContent?.trim()).toBe('January'); }); - describe('when filterMode is "highlight"', () => { - beforeEach(() => setupCombobox({filterMode: 'highlight'})); - - it('should select and commit on click', () => { - click(inputElement); - down(); - right(); - const item = getTreeItem('February')!; - click(item); - fixture.detectChanges(); + it('should show no options if nothing matches', () => { + focus(); + input('xyz'); + expect(getVisibleTreeItems().length).toBe(0); + }); - expect(fixture.componentInstance.value()).toEqual(['February']); - expect(inputElement.value).toBe('February'); - }); + it('should show all options when the input is cleared', () => { + focus(); + input('Winter'); + expect(getVisibleTreeItems().length).toBe(1); - it('should select and commit on Enter', () => { - down(); - down(); - enter(); + input(''); + expect(getVisibleTreeItems().length).toBe(4); + }); - expect(fixture.componentInstance.value()).toEqual(['Spring']); - expect(inputElement.value).toBe('Spring'); - }); + it('should expand all nodes when filtering', () => { + focus(); + down(); - it('should select on navigation', () => { - down(); - expect(fixture.componentInstance.value()).toEqual(['Winter']); + expect(getVisibleTreeItems().length).toBe(4); - down(); - expect(fixture.componentInstance.value()).toEqual(['Spring']); - }); + input('J'); - it('should update input value on navigation', () => { - down(); - expect(inputElement.value).toBe('Winter'); + expect(getTreeItem('Winter')!.getAttribute('aria-expanded')).toBe('true'); + expect(getTreeItem('Summer')!.getAttribute('aria-expanded')).toBe('true'); + }); + }); + }); - down(); - expect(inputElement.value).toBe('Spring'); - }); + describe('with Grid', () => { + let fixture: ComponentFixture; + let inputElement: HTMLInputElement; - it('should select the first option on input', () => { - focus(); - input('Sept'); + const keydown = (key: string, modifierKeys: {} = {}) => { + focus(); + inputElement.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + ...modifierKeys, + }), + ); + fixture.detectChanges(); + }; - expect(fixture.componentInstance.value()).toEqual(['September']); - }); + const focus = () => { + inputElement.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + fixture.detectChanges(); + }; - it('should insert a highlighted completion string on input', () => { - focus(); - input('Feb'); + const blur = (relatedTarget?: EventTarget) => { + inputElement.dispatchEvent(new FocusEvent('focusout', {bubbles: true, relatedTarget})); + fixture.detectChanges(); + }; - expect(inputElement.value).toBe('February'); - expect(inputElement.selectionStart).toBe(3); - expect(inputElement.selectionEnd).toBe(8); - }); + 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 commit the selected option on focusout', () => { - focus(); - input('Jan'); - blur(); + 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('January'); - expect(fixture.componentInstance.value()).toEqual(['January']); - }); - }); - }); + beforeEach(() => setupCombobox()); - describe('Expansion', () => { + describe('ARIA attributes and roles', () => { beforeEach(() => setupCombobox()); - it('should open on ArrowDown', () => { - focus(); - keydown('ArrowDown'); - expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + it('should have the combobox role on the input', () => { + expect(inputElement.getAttribute('role')).toBe('combobox'); }); - it('should open on ArrowUp', () => { + it('should have aria-haspopup set to grid', () => { focus(); - keydown('ArrowUp'); - expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + expect(inputElement.getAttribute('aria-haspopup')).toBe('grid'); }); - it('should close on Escape', () => { + it('should set aria-controls to the grid id', () => { down(); - escape(); - expect(inputElement.getAttribute('aria-expanded')).toBe('false'); - }); - - it('should close on focusout', () => { - focus(); - blur(); - expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + const grid = fixture.debugElement.query(By.directive(Grid)).nativeElement; + expect(inputElement.getAttribute('aria-controls')).toBe(grid.id); }); - it('should not close on focusout if focus moves to an element inside the container', () => { + it('should toggle aria-expanded when opening and closing', () => { down(); - blur(getTreeItem('Spring')!); - expect(inputElement.getAttribute('aria-expanded')).toBe('true'); - }); - - 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 close on enter', () => { - down(); - enter(); - expect(inputElement.getAttribute('aria-expanded')).toBe('false'); - }); + it('should set aria-activedescendant to the active grid cell id', async () => { + focus(); + down(); // Open popup - 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('Antelope-label'); }); }); - // TODO(wagnermaciel): Add unit tests for disabled options. + it('should navigate up and down with grid navigation', async () => { + focus(); + down(); // Open popup - describe('Filtering', () => { - beforeEach(() => setupCombobox()); + down(); // Navigate down to 'Bird-label' - it('should lazily render options', () => { - expect(getTreeItems().length).toBe(0); - focus(); - expect(getTreeItems().length).toBe(16); - }); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Bird-label'); - it('should filter the options based on the input value', () => { - focus(); - input('Summer'); + up(); // Navigate back up 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 left and right with grid navigation', 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); + left(); // Move back left 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 start of the row on Home', async () => { + focus(); + down(); // Open popup - input('J'); - expect(getTreeItem('Winter')!.getAttribute('aria-expanded')).toBe('true'); - expect(getTreeItem('Summer')!.getAttribute('aria-expanded')).toBe('true'); - }); + 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'); }); - 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 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'); }); - describe('Readonly', () => { - beforeEach(() => setupCombobox({readonly: true})); + it('should update aria-activedescendant with grid navigation', async () => { + focus(); + down(); // Open popup - it('should close on selection', () => { - focus(); + 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(); - 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); + disabled = signal(false); + softDisabled = signal(true); + alwaysExpanded = signal(false); + tabIndex = signal(undefined); + 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 +1382,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 +1425,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 +1473,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 +1525,183 @@ 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..f3c074bb3b32 100644 --- a/src/aria/combobox/combobox.ts +++ b/src/aria/combobox/combobox.ts @@ -10,155 +10,134 @@ 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, tabIndexTransform} 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 : (tabIndex() !== undefined ? tabIndex() : 0)', + '[attr.disabled]': 'disabled() && !softDisabled() ? "" : null', + '[attr.readonly]': 'disabled() && _pattern.isEditable() ? "" : 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()); + /** The tabindex of the combobox. */ + readonly tabIndex = input(undefined, { + alias: 'tabindex', + transform: tabIndexTransform, + }); - // TODO: Maybe make expanded a signal that can be passed in? - // Or an "always expanded" option? + /** Whether the combobox is expanded. */ + readonly expanded = model(false); - /** Whether the combobox popup should always be expanded, regardless of user interaction. */ - readonly alwaysExpanded = input(false, {transform: booleanAttribute}); + /** The value of the combobox input. */ + readonly value = model(''); - /** 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/simple-combobox/testing/BUILD.bazel b/src/aria/combobox/testing/BUILD.bazel similarity index 95% rename from src/aria/simple-combobox/testing/BUILD.bazel rename to src/aria/combobox/testing/BUILD.bazel index 38540ebe2f98..b1644d156a44 100644 --- a/src/aria/simple-combobox/testing/BUILD.bazel +++ b/src/aria/combobox/testing/BUILD.bazel @@ -26,9 +26,9 @@ ng_project( deps = [ ":testing", "//:node_modules/@angular/core", + "//src/aria/combobox", "//src/aria/listbox", "//src/aria/listbox/testing", - "//src/aria/simple-combobox", "//src/cdk/overlay", "//src/cdk/testing", "//src/cdk/testing/testbed", diff --git a/src/aria/simple-combobox/testing/combobox-harness-filters.ts b/src/aria/combobox/testing/combobox-harness-filters.ts similarity index 100% rename from src/aria/simple-combobox/testing/combobox-harness-filters.ts rename to src/aria/combobox/testing/combobox-harness-filters.ts diff --git a/src/aria/simple-combobox/testing/combobox-harness.spec.ts b/src/aria/combobox/testing/combobox-harness.spec.ts similarity index 100% rename from src/aria/simple-combobox/testing/combobox-harness.spec.ts rename to src/aria/combobox/testing/combobox-harness.spec.ts diff --git a/src/aria/simple-combobox/testing/combobox-harness.ts b/src/aria/combobox/testing/combobox-harness.ts similarity index 100% rename from src/aria/simple-combobox/testing/combobox-harness.ts rename to src/aria/combobox/testing/combobox-harness.ts diff --git a/src/aria/simple-combobox/index.ts b/src/aria/combobox/testing/index.ts similarity index 100% rename from src/aria/simple-combobox/index.ts rename to src/aria/combobox/testing/index.ts diff --git a/src/aria/simple-combobox/testing/public-api.ts b/src/aria/combobox/testing/public-api.ts similarity index 100% rename from src/aria/simple-combobox/testing/public-api.ts rename to src/aria/combobox/testing/public-api.ts diff --git a/src/aria/config.bzl b/src/aria/config.bzl index 480c93fad6d5..8ee99e0c6807 100644 --- a/src/aria/config.bzl +++ b/src/aria/config.bzl @@ -2,15 +2,14 @@ ARIA_ENTRYPOINTS = [ "accordion", "accordion/testing", - "combobox", "grid", "grid/testing", "listbox", "listbox/testing", "menu", "menu/testing", - "simple-combobox", - "simple-combobox/testing", + "combobox", + "combobox/testing", "tabs", "tabs/testing", "toolbar", diff --git a/src/aria/listbox/BUILD.bazel b/src/aria/listbox/BUILD.bazel index a5cf79563a5e..303fa9829906 100644 --- a/src/aria/listbox/BUILD.bazel +++ b/src/aria/listbox/BUILD.bazel @@ -10,7 +10,6 @@ ng_project( ), deps = [ "//:node_modules/@angular/core", - "//src/aria/combobox", "//src/aria/private", "//src/aria/private/utils", "//src/cdk/a11y", diff --git a/src/aria/listbox/listbox.ts b/src/aria/listbox/listbox.ts index d237e66aa3c8..87bb2bd5eb4f 100644 --- a/src/aria/listbox/listbox.ts +++ b/src/aria/listbox/listbox.ts @@ -23,13 +23,7 @@ import { } from '@angular/core'; import {Directionality} from '@angular/cdk/bidi'; import {_IdGenerator} from '@angular/cdk/a11y'; -import { - ComboboxListboxPattern, - ListboxPattern, - SortedCollection, - tabIndexTransform, -} from '../private'; -import {ComboboxPopup} from '../combobox'; +import {ListboxPattern, SortedCollection, tabIndexTransform} from '../private'; import {Option} from './option'; import {LISTBOX} from './tokens'; @@ -73,18 +67,12 @@ import {LISTBOX} from './tokens'; '(click)': '_pattern.onClick($event)', '(focusin)': '_pattern.onFocusIn()', }, - hostDirectives: [ComboboxPopup], providers: [{provide: LISTBOX, useExisting: Listbox}], }) export class Listbox implements OnDestroy { /** A unique identifier for the listbox. */ readonly id = input(inject(_IdGenerator).getId('ng-listbox-', true)); - /** A reference to the parent combobox popup, if one exists. */ - private readonly _popup = inject>(ComboboxPopup, { - optional: true, - }); - /** A reference to the host element. */ private readonly _elementRef = inject(ElementRef); @@ -163,12 +151,9 @@ export class Listbox implements OnDestroy { activeItem: signal(undefined), textDirection: this.textDirection, element: () => this._elementRef.nativeElement, - 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()); @@ -176,10 +161,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..d601e9cbfe33 100644 --- a/src/aria/listbox/public-api.ts +++ b/src/aria/listbox/public-api.ts @@ -8,13 +8,3 @@ export {Listbox} from './listbox'; 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'; 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..f17c2daf3678 100644 --- a/src/aria/private/combobox/combobox.spec.ts +++ b/src/aria/private/combobox/combobox.spec.ts @@ -1,987 +1,274 @@ -/** - * @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 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); - 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]); + pattern.onKeydown(createKeyboardEvent('keydown', 40, 'ArrowDown')); + expect(expanded()).toBe(true); }); - 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 close on Escape when expanded', () => { + const {pattern, expanded} = setup(); + expanded.set(true); + + pattern.onKeydown(createKeyboardEvent('keydown', 27, 'Escape')); + expect(expanded()).toBe(false); }); }); - describe('Expansion', () => { - it('should open on ArrowDown', () => { - const {combobox} = getPatterns(); - expect(combobox.expanded()).toBe(false); - combobox.onKeydown(down()); - expect(combobox.expanded()).toBe(true); - }); + describe('Input handling', () => { + it('should update value and expand on input', () => { + const {pattern, element, value, expanded} = setup(); + expect(expanded()).toBe(false); - it('should open on ArrowUp', () => { - const {combobox} = getPatterns(); - expect(combobox.expanded()).toBe(false); - combobox.onKeydown(up()); - expect(combobox.expanded()).toBe(true); - }); + element.value = 'hello'; + pattern.onInput({target: element} as unknown as Event); - it('should close on Escape', () => { - const {combobox} = getPatterns(); - combobox.onKeydown(down()); - expect(combobox.expanded()).toBe(true); - combobox.onKeydown(escape()); - expect(combobox.expanded()).toBe(false); + expect(value()).toBe('hello'); + expect(expanded()).toBe(true); }); + }); - it('should close on Enter', () => { - const {combobox} = getPatterns(); - combobox.onKeydown(down()); - expect(combobox.expanded()).toBe(true); - combobox.onKeydown(enter()); - expect(combobox.expanded()).toBe(false); - }); + describe('Focus handling', () => { + it('should track focus state', () => { + const {pattern} = setup(); - 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); - }); + pattern.onFocusin(); + expect(pattern.isFocused()).toBe(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.onFocusout(new FocusEvent('focusout')); + expect(pattern.isFocused()).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()); + 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(); - expect(combobox.expanded()).toBe(true); + value.set('App'); + inlineSuggestion.set('Apple'); + expanded.set(true); + pattern.isFocused.set(true); - const event = new FocusEvent('focusout', {relatedTarget: internalElement}); - combobox.onFocusOut(event); + pattern.highlightEffect(); - expect(combobox.expanded()).toBe(true); + expect(element.value).toBe('Apple'); + expect(element.selectionStart).toBe(3); + expect(element.selectionEnd).toBe(5); }); - 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); - }); - }); + it('should not highlight when deleting text', () => { + const {pattern, element, value, expanded, inlineSuggestion} = setup(); - describe('Selection', () => { - let combobox: ComboboxPattern; - let listbox: ComboboxListboxPattern; - let inputEl: HTMLInputElement; - let options: () => TestOption[]; - let firstMatch: WritableSignalLike; + value.set('App'); + inlineSuggestion.set('Apple'); + expanded.set(true); + pattern.isFocused.set(true); - function type(text: string, opts: {backspace?: boolean} = {}) { - _type(text, inputEl, combobox, options(), listbox, firstMatch, opts.backspace); - } + const deleteEvent = new InputEvent('input', {inputType: 'deleteContentBackward'}); + Object.defineProperty(deleteEvent, 'target', {value: element}); + pattern.onInput(deleteEvent as Event); - 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'); - }); - }); + expect(pattern.isDeleting()).toBe(true); - 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.highlightEffect(); - 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); - }); + expect(element.value).not.toBe('Apple'); }); }); - 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('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); + describe('Select-only combobox behavior', () => { + function setupSelectOnly() { + const selectOnlyElement = document.createElement('div'); + const {pattern, expanded, controlTarget} = setup(); - combobox.onClick(clickOption(listbox.inputs.items(), 1)); - combobox.onClick(clickOption(listbox.inputs.items(), 2)); + // Override element to be select-only + pattern.inputs.element = signal(selectOnlyElement); - expect(listbox.inputs.value()).toEqual(['Apricot', 'Banana']); - expect(inputEl.value).toBe('Apricot, Banana'); - }); - }); - }); -}); - -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'}]}, - ]); + return {pattern, expanded, selectOnlyElement, controlTarget}; + } - (combobox.inputs.popupControls as WritableSignalLike).set(tree); + it('should toggle expansion on click', () => { + const {pattern, expanded} = setupSelectOnly(); + expect(expanded()).toBe(false); - return { - combobox, - tree, - items: items, - inputEl: inputEl()!, - containerEl: containerEl()!, - firstMatch, - inputValue, - }; - } + pattern.onClick(new PointerEvent('click')); + expect(expanded()).toBe(true); - 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'); + pattern.onClick(new PointerEvent('click')); + expect(expanded()).toBe(false); }); - it('should navigate to the last focusable item on ArrowUp', () => { - const {combobox, tree} = getPatterns(); - combobox.onKeydown(up()); - expect(tree.inputs.activeItem()?.searchTerm()).toBe('Grains'); - }); + it('should open on Enter or Space when collapsed', () => { + const {pattern, expanded} = setupSelectOnly(); - 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'); - }); + pattern.onKeydown(createKeyboardEvent('keydown', 13, 'Enter')); + expect(expanded()).toBe(true); - 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'); - }); + expanded.set(false); - 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']); + pattern.onKeydown(createKeyboardEvent('keydown', 32, ' ')); + expect(expanded()).toBe(true); }); + }); - 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'); - }); + describe('alwaysExpanded behavior', () => { + it('should stay open on Escape when alwaysExpanded is true', () => { + const {pattern, expanded} = setup({alwaysExpanded: true}); + expanded.set(true); - 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'); + pattern.onKeydown(createKeyboardEvent('keydown', 27, 'Escape')); + expect(expanded()).toBe(true); }); + }); - 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'); - }); + 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); - 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.closePopupOnBlurEffect(); + 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 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); }); }); - describe('Selection', () => { - let combobox: ComboboxPattern; - let tree: ComboboxTreePattern; - let inputEl: HTMLInputElement; - let items: () => TreeItemPattern[]; - let firstMatch: WritableSignalLike; + describe('Advanced Combo Keys Relay', () => { + it('should forward Shift + ArrowUp/ArrowDown for editable inputs', () => { + const {pattern, expanded} = setup(); + expanded.set(true); - function type(text: string, opts: {backspace?: boolean} = {}) { - _type(text, inputEl, combobox, items(), tree, firstMatch, opts.backspace); - } + const shiftUp = createKeyboardEvent('keydown', 38, 'ArrowUp'); + Object.defineProperty(shiftUp, 'shiftKey', {value: true}); + pattern.onKeydown(shiftUp); + expect(pattern.keyboardEventRelay()).toBe(shiftUp); - 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'); - }); + const shiftDown = createKeyboardEvent('keydown', 40, 'ArrowDown'); + Object.defineProperty(shiftDown, 'shiftKey', {value: true}); + pattern.onKeydown(shiftDown); + expect(pattern.keyboardEventRelay()).toBe(shiftDown); }); - 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'); - }); - }); + it('should NOT forward Ctrl+A or Shift+Home/End for editable inputs', () => { + const {pattern, expanded} = setup(); + 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); - }); - }); - }); + const ctrlA = createKeyboardEvent('keydown', 65, 'a'); + Object.defineProperty(ctrlA, 'ctrlKey', {value: true}); + pattern.onKeydown(ctrlA); + expect(pattern.keyboardEventRelay()).toBeUndefined(); - 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); + const shiftHome = createKeyboardEvent('keydown', 36, 'Home'); + Object.defineProperty(shiftHome, 'shiftKey', {value: true}); + pattern.onKeydown(shiftHome); + expect(pattern.keyboardEventRelay()).toBeUndefined(); }); - 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 forward Ctrl+A and Shift+Home/End for select-only (non-editable) comboboxes', () => { + const selectOnlyElement = document.createElement('div'); + const {pattern, expanded} = setup(); + pattern.inputs.element = signal(selectOnlyElement); + expanded.set(true); + + const ctrlA = createKeyboardEvent('keydown', 65, 'a'); + Object.defineProperty(ctrlA, 'ctrlKey', {value: true}); + pattern.onKeydown(ctrlA); + expect(pattern.keyboardEventRelay()).toBe(ctrlA); + + const shiftHome = createKeyboardEvent('keydown', 36, 'Home'); + Object.defineProperty(shiftHome, 'shiftKey', {value: true}); + pattern.onKeydown(shiftHome); + expect(pattern.keyboardEventRelay()).toBe(shiftHome); }); }); }); diff --git a/src/aria/private/combobox/combobox.ts b/src/aria/private/combobox/combobox.ts index 5fa3e70117ae..74499e913419 100644 --- a/src/aria/private/combobox/combobox.ts +++ b/src/aria/private/combobox/combobox.ts @@ -6,327 +6,172 @@ * 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, Modifier} 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. + /** Whether the combobox is soft disabled. */ + softDisabled?: SignalLike; } -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; + /** Whether the combobox is soft disabled. */ + readonly softDisabled = () => this.inputs.softDisabled?.() ?? true; - /** Toggles the expansion state of the currently active item in the popup. */ - toggleExpansion: (item?: T) => void; + /** An inline suggestion to be displayed in the input. */ + readonly inlineSuggestion = () => this.inputs.inlineSuggestion(); - /** Whether the current active item is selectable. */ - isItemSelectable: (item?: T) => boolean; -} + /** The ID of the active descendant in the popup. */ + readonly activeDescendant = computed(() => this.inputs.popup()?.activeDescendant()); -/** Controls the state of a combobox. */ -export class ComboboxPattern, V> { - /** Whether the combobox is expanded. */ - readonly expanded = signal(false); + /** The ID of the popup. */ + readonly popupId = computed(() => this.inputs.popup()?.popupId()); - /** Whether the combobox is disabled. */ - readonly disabled = () => this.inputs.disabled(); + /** The type of the popup. */ + readonly popupType = computed(() => this.inputs.popup()?.popupType()); - /** 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.isExpanded()) { + manager.on('ArrowDown', () => this.inputs.expanded.set(true)); - if (!this.expanded()) { - manager - .on('ArrowDown', () => this.open({first: true})) - .on('ArrowUp', () => this.open({last: 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)); + manager.on(' ', () => 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(Modifier.Shift, 'ArrowUp', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false}) + .on(Modifier.Shift, '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([Modifier.Ctrl, Modifier.Meta], 'a', e => this.keyboardEventRelay.set(e)) + .on([Modifier.Ctrl, Modifier.Meta], 'A', e => this.keyboardEventRelay.set(e)) + .on(Modifier.Shift, 'Home', e => this.keyboardEventRelay.set(e)) + .on(Modifier.Shift, 'End', 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}); - } - } - - const controls = this.inputs.popupControls(); + click = computed(() => { + const manager = new ClickEventManager(); - if (controls instanceof ComboboxDialogPattern) { - return; - } + if (this.isEditable()) return manager; - 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 +181,122 @@ 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(); + + if (!inlineSuggestion || !isFocused || !isExpanded || isDeleting) return; - const isHighlightable = item - .searchTerm() - .toLowerCase() - .startsWith(this.inputs.inputValue!().toLowerCase()); + 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; - } + /** Relays keyboard events to the popup. */ + keyboardEventRelayEffect() { + const event = this.keyboardEventRelay(); + if (event === undefined) return; - this.close(); + // Reset isDeleting when the user navigates, so that the highlight effect can run again. + this.isDeleting.set(false); - 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}); - } +/** Represents the required inputs for a simple combobox popup. */ +export interface ComboboxPopupInputs { + /** The type of the popup. */ + popupType: SignalLike<'listbox' | 'tree' | 'grid' | 'dialog'>; - controls?.multi() ? controls.toggle(opts.item) : controls?.select(opts.item); + /** The element that serves as the control target for the popup. */ + controlTarget: SignalLike; - if (opts.commit) { - this.commit(); - } - if (opts.close) { - this.close(); - } - } + /** The ID of the active descendant in the popup. */ + activeDescendant: 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 popup. */ + popupId: SignalLike; +} - if (!inputEl) { - return; - } +/** Controls the state of a simple combobox popup. */ +export class ComboboxPopupPattern { + /** The type of the popup. */ + readonly popupType = () => this.inputs.popupType(); - inputEl.value = selectedItems?.map(i => i.searchTerm()).join(', ') || ''; - this.inputs.inputValue?.set(inputEl.value); + /** The element that serves as the control target for the popup. */ + readonly controlTarget = () => this.inputs.controlTarget(); - if (this.inputs.filterMode() === 'highlight' && !this.readonly()) { - const length = inputEl.value.length; - inputEl.setSelectionRange(length, length); - } - } + /** The ID of the active descendant in the popup. */ + readonly activeDescendant = () => this.inputs.activeDescendant(); - /** Navigates and handles additional actions based on filter mode. */ - private _navigate(operation: () => void) { - operation(); + /** The ID of the popup. */ + readonly popupId = () => this.inputs.popupId(); - if (this.inputs.filterMode() !== 'manual') { - this.select(); - } + /** Whether the popup is focused. */ + readonly isFocused = signal(false); - 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]; + constructor(readonly inputs: ComboboxPopupInputs) {} - if (!selectedItem) { - return; - } - - 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 631c3b210125..00248757bd83 100644 --- a/src/aria/private/public-api.ts +++ b/src/aria/private/public-api.ts @@ -6,10 +6,8 @@ * found in the LICENSE file at https://angular.dev/license */ -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 +17,6 @@ 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'; @@ -29,4 +26,4 @@ export * from './utils/collection'; export * from './utils/element'; export * from './utils/element-resolver'; export * from './utils/transforms'; -export * from './simple-combobox/simple-combobox'; +export * from './combobox/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 4aea95f87edb..000000000000 --- a/src/aria/private/simple-combobox/simple-combobox.spec.ts +++ /dev/null @@ -1,274 +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); - }); - }); - - describe('Advanced Combo Keys Relay', () => { - it('should forward Shift + ArrowUp/ArrowDown for editable inputs', () => { - const {pattern, expanded} = setup(); - expanded.set(true); - - const shiftUp = createKeyboardEvent('keydown', 38, 'ArrowUp'); - Object.defineProperty(shiftUp, 'shiftKey', {value: true}); - pattern.onKeydown(shiftUp); - expect(pattern.keyboardEventRelay()).toBe(shiftUp); - - const shiftDown = createKeyboardEvent('keydown', 40, 'ArrowDown'); - Object.defineProperty(shiftDown, 'shiftKey', {value: true}); - pattern.onKeydown(shiftDown); - expect(pattern.keyboardEventRelay()).toBe(shiftDown); - }); - - it('should NOT forward Ctrl+A or Shift+Home/End for editable inputs', () => { - const {pattern, expanded} = setup(); - expanded.set(true); - - const ctrlA = createKeyboardEvent('keydown', 65, 'a'); - Object.defineProperty(ctrlA, 'ctrlKey', {value: true}); - pattern.onKeydown(ctrlA); - expect(pattern.keyboardEventRelay()).toBeUndefined(); - - const shiftHome = createKeyboardEvent('keydown', 36, 'Home'); - Object.defineProperty(shiftHome, 'shiftKey', {value: true}); - pattern.onKeydown(shiftHome); - expect(pattern.keyboardEventRelay()).toBeUndefined(); - }); - - it('should forward Ctrl+A and Shift+Home/End for select-only (non-editable) comboboxes', () => { - const selectOnlyElement = document.createElement('div'); - const {pattern, expanded} = setup(); - pattern.inputs.element = signal(selectOnlyElement); - expanded.set(true); - - const ctrlA = createKeyboardEvent('keydown', 65, 'a'); - Object.defineProperty(ctrlA, 'ctrlKey', {value: true}); - pattern.onKeydown(ctrlA); - expect(pattern.keyboardEventRelay()).toBe(ctrlA); - - const shiftHome = createKeyboardEvent('keydown', 36, 'Home'); - Object.defineProperty(shiftHome, 'shiftKey', {value: true}); - pattern.onKeydown(shiftHome); - expect(pattern.keyboardEventRelay()).toBe(shiftHome); - }); - }); -}); 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 bf73cb2717f2..000000000000 --- a/src/aria/private/simple-combobox/simple-combobox.ts +++ /dev/null @@ -1,302 +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, Modifier} 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)); - manager.on(' ', () => 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(Modifier.Shift, 'ArrowUp', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false}) - .on(Modifier.Shift, '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([Modifier.Ctrl, Modifier.Meta], 'a', e => this.keyboardEventRelay.set(e)) - .on([Modifier.Ctrl, Modifier.Meta], 'A', e => this.keyboardEventRelay.set(e)) - .on(Modifier.Shift, 'Home', e => this.keyboardEventRelay.set(e)) - .on(Modifier.Shift, 'End', 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; - - // Reset isDeleting when the user navigates, so that the highlight effect can run again. - this.isDeleting.set(false); - - 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/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 58db4c1ad846..000000000000 --- a/src/aria/simple-combobox/simple-combobox.spec.ts +++ /dev/null @@ -1,1707 +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']); - }); - - it('should resume inserting completion strings on navigation after a backspace deletion', async () => { - down(); // Open popup - - // 1. Type 'A', completion should pop up 'Alabama' - input('A'); - expect(inputElement.value).toBe('Alabama'); - - // 2. Simulate Backspace deletion (dispatch InputEvent with deleteContentBackward) - inputElement.value = ''; - inputElement.dispatchEvent( - new InputEvent('input', { - bubbles: true, - inputType: 'deleteContentBackward', - }), - ); - fixture.detectChanges(); - - // Confirm no completion gets inserted during deletion - expect(inputElement.value).toBe(''); - - // 3. Press ArrowDown key to navigate to the next option (Alaska) - down(); - - // Active descendant navigation resets `isDeleting`, so highlight/completion should successfully populate the current active match! - const options = getOptions(); - expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[1].id); - expect(inputElement.value).toBe('Alaska'); - }); - }); - }); - - 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('disabled')).toBeNull(); - expect(inputElement.getAttribute('aria-disabled')).toBe('true'); - }); - - it('should make the input read-only when disabled and softDisabled is true', () => { - fixture.componentInstance.disabled.set(true); - fixture.detectChanges(); - - expect(inputElement.getAttribute('readonly')).toBe(''); - }); - - 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('disabled')).toBe(''); - expect(inputElement.getAttribute('aria-disabled')).toBe('true'); - }); - - it('should respect user-defined tabindex when softDisabled is true', () => { - fixture.componentInstance.disabled.set(true); - fixture.componentInstance.tabIndex.set(0); - fixture.detectChanges(); - - expect(inputElement.getAttribute('tabindex')).toBe('0'); - }); - - it('should respect user-defined tabindex when not disabled', () => { - fixture.componentInstance.tabIndex.set(0); - fixture.detectChanges(); - - expect(inputElement.getAttribute('tabindex')).toBe('0'); - }); - - it('should default to tabindex 0 when not disabled', () => { - fixture.detectChanges(); - expect(inputElement.getAttribute('tabindex')).toBe('0'); - }); - - it('should force tabindex to -1 when hard-disabled, ignoring user-defined tabindex', () => { - fixture.componentInstance.disabled.set(true); - fixture.componentInstance.softDisabled.set(false); - fixture.componentInstance.tabIndex.set(0); - fixture.detectChanges(); - - expect(inputElement.getAttribute('tabindex')).toBe('-1'); - }); - }); - }); - - 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); - tabIndex = signal(undefined); - 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 f0865c2d3c7b..000000000000 --- a/src/aria/simple-combobox/simple-combobox.ts +++ /dev/null @@ -1,147 +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, - tabIndexTransform, -} 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 : (tabIndex() !== undefined ? tabIndex() : 0)', - '[attr.disabled]': 'disabled() && !softDisabled() ? "" : null', - '[attr.readonly]': 'disabled() && _pattern.isEditable() ? "" : 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}); - - /** The tabindex of the combobox. */ - readonly tabIndex = input(undefined, { - alias: 'tabindex', - transform: tabIndexTransform, - }); - - /** 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/simple-combobox/testing/index.ts b/src/aria/simple-combobox/testing/index.ts deleted file mode 100644 index 52b3c7a5156f..000000000000 --- a/src/aria/simple-combobox/testing/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/tree/BUILD.bazel b/src/aria/tree/BUILD.bazel index a07f63320f92..a9d980cf40c9 100644 --- a/src/aria/tree/BUILD.bazel +++ b/src/aria/tree/BUILD.bazel @@ -10,7 +10,6 @@ ng_project( ), deps = [ "//:node_modules/@angular/core", - "//src/aria/combobox", "//src/aria/private", "//src/cdk/a11y", "//src/cdk/bidi", diff --git a/src/aria/tree/public-api.ts b/src/aria/tree/public-api.ts index a072a42a53d9..06449fda7530 100644 --- a/src/aria/tree/public-api.ts +++ b/src/aria/tree/public-api.ts @@ -13,10 +13,3 @@ 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'; diff --git a/src/aria/tree/tree-item.ts b/src/aria/tree/tree-item.ts index f31be6495ae4..fa5acebb683b 100644 --- a/src/aria/tree/tree-item.ts +++ b/src/aria/tree/tree-item.ts @@ -19,10 +19,9 @@ import { Signal, OnInit, OnDestroy, - 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 +121,9 @@ 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 e018099d6044..a4264b9a433f 100644 --- a/src/aria/tree/tree.ts +++ b/src/aria/tree/tree.ts @@ -23,14 +23,7 @@ import { } from '@angular/core'; import {_IdGenerator} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; -import { - ComboboxTreePattern, - SortedCollection, - tabIndexTransform, - TreeItemPattern, - TreePattern, -} from '../private'; -import {ComboboxPopup} from '../combobox'; +import {SortedCollection, tabIndexTransform, TreeItemPattern, TreePattern} from '../private'; import type {TreeItem} from './tree-item'; /** @@ -84,7 +77,6 @@ import type {TreeItem} from './tree-item'; '(click)': '_pattern.onClick($event)', '(focusin)': '_pattern.onFocusIn()', }, - hostDirectives: [ComboboxPopup], }) export class Tree implements OnDestroy { /** A reference to the host element. */ @@ -93,11 +85,6 @@ export class Tree implements OnDestroy { /** A reference to the host element. */ readonly element = this._elementRef.nativeElement as HTMLElement; - /** A reference to the parent combobox popup, if one exists. */ - private readonly _popup = inject>(ComboboxPopup, { - optional: true, - }); - /** The collection of tree items. */ readonly _collection = new SortedCollection>(); @@ -174,13 +161,10 @@ export class Tree implements OnDestroy { id: this.id, items: computed(() => this._collection.orderedItems().map(item => item._pattern)), activeItem: signal | undefined>(undefined), - 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()); @@ -188,10 +172,6 @@ export class Tree implements OnDestroy { this._collection.startObserving(this.element); }); - if (this._popup?.combobox) { - this._popup?._controls?.set(this._pattern as ComboboxTreePattern); - } - // Check for any violations after the DOM has been updated. afterRenderEffect({ read: () => { @@ -217,19 +197,6 @@ export class Tree implements OnDestroy { } }, }); - - afterRenderEffect({ - write: () => { - if (!(this._pattern instanceof ComboboxTreePattern)) return; - - const items = inputs.items(); - const value = untracked(() => this.value()); - - if (items && value.some(v => !items.some(i => i.value() === v))) { - this.value.set(value.filter(v => items.some(i => i.value() === v))); - } - }, - }); } ngOnDestroy() { diff --git a/src/components-examples/aria/aria-toolbar-simple-combobox/BUILD.bazel b/src/components-examples/aria/aria-toolbar-simple-combobox/BUILD.bazel deleted file mode 100644 index c82236e772d7..000000000000 --- a/src/components-examples/aria/aria-toolbar-simple-combobox/BUILD.bazel +++ /dev/null @@ -1,33 +0,0 @@ -load("//tools:defaults.bzl", "ng_project") - -package(default_visibility = ["//visibility:public"]) - -ng_project( - name = "toolbar", - srcs = glob(["**/*.ts"]), - assets = glob([ - "**/*.html", - "**/*.css", - ]), - deps = [ - "//:node_modules/@angular/core", - "//:node_modules/@angular/forms", - "//src/aria/combobox", - "//src/aria/listbox", - "//src/aria/simple-combobox", - "//src/aria/toolbar", - "//src/cdk/a11y", - "//src/material/checkbox", - "//src/material/form-field", - "//src/material/select", - ], -) - -filegroup( - name = "source-files", - srcs = glob([ - "**/*.html", - "**/*.css", - "**/*.ts", - ]), -) diff --git a/src/components-examples/aria/aria-toolbar-simple-combobox/index.ts b/src/components-examples/aria/aria-toolbar-simple-combobox/index.ts deleted file mode 100644 index 89582a318a90..000000000000 --- a/src/components-examples/aria/aria-toolbar-simple-combobox/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export {ToolbarBasicHorizontalExample} from './toolbar-basic-horizontal/toolbar-basic-horizontal-example'; -export {ToolbarBasicVerticalExample} from './toolbar-basic-vertical/toolbar-basic-vertical-example'; -export {ToolbarConfigurableExample} from './toolbar-configurable/toolbar-configurable-example'; -export {ToolbarRtlExample} from './toolbar-rtl/toolbar-rtl-example'; -export {ToolbarHardDisabledExample} from './toolbar-hard-disabled/toolbar-hard-disabled-example'; diff --git a/src/components-examples/aria/aria-toolbar-simple-combobox/simple-toolbar.ts b/src/components-examples/aria/aria-toolbar-simple-combobox/simple-toolbar.ts deleted file mode 100644 index a1db6b645181..000000000000 --- a/src/components-examples/aria/aria-toolbar-simple-combobox/simple-toolbar.ts +++ /dev/null @@ -1,129 +0,0 @@ -import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; -import {Listbox, Option} from '@angular/aria/listbox'; -import {ToolbarWidget} from '@angular/aria/toolbar'; -import {Dir, Directionality} from '@angular/cdk/bidi'; -import {afterRenderEffect, Component, Directive, inject, 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', - class: 'example-button material-symbols-outlined', - '[aria-label]': 'widget.value()', - }, -}) -export class SimpleToolbarButton { - widget = inject(ToolbarWidget); -} - -@Directive({ - selector: 'button[toolbar-toggle-button]', - standalone: true, - hostDirectives: [{directive: ToolbarWidget, inputs: ['value']}], - host: { - type: 'button', - class: 'example-button material-symbols-outlined', - '[aria-pressed]': 'widget.selected()', - '[aria-label]': 'widget.value()', - }, -}) -export class SimpleToolbarToggleButton { - widget = inject(ToolbarWidget); -} - -@Directive({ - selector: 'button[toolbar-radio-button]', - standalone: true, - hostDirectives: [{directive: ToolbarWidget, inputs: ['value', 'disabled']}], - host: { - role: 'radio', - type: 'button', - class: 'example-button material-symbols-outlined', - '[aria-checked]': 'widget.selected()', - '[aria-label]': 'widget.value()', - }, -}) -export class SimpleToolbarRadioButton { - widget = inject(ToolbarWidget); -} - -@Component({ - selector: 'combobox', - standalone: true, - imports: [ - Dir, - Combobox, - ComboboxPopup, - 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; - 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(() => { - this.listbox()?.scrollActiveItemIntoView(); - }); - } - - onCommit() { - const selectedOption = this.selectedOption(); - if (selectedOption.length > 0) { - this.value.set(selectedOption[0]); - } - this.popupExpanded.set(false); - } -} diff --git a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-basic-horizontal/toolbar-basic-horizontal-example.html b/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-basic-horizontal/toolbar-basic-horizontal-example.html deleted file mode 100644 index 94908c0c3209..000000000000 --- a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-basic-horizontal/toolbar-basic-horizontal-example.html +++ /dev/null @@ -1,41 +0,0 @@ -
      -
      -
      - - -
      - - - -
      - - - -
      - - - - - - - -
      - - - -
      - - - -
      - - - -
      -
      -
      diff --git a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-basic-horizontal/toolbar-basic-horizontal-example.ts b/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-basic-horizontal/toolbar-basic-horizontal-example.ts deleted file mode 100644 index 8ec2ff47c8c7..000000000000 --- a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-basic-horizontal/toolbar-basic-horizontal-example.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {Component} from '@angular/core'; -import {Toolbar, ToolbarWidget, ToolbarWidgetGroup} from '@angular/aria/toolbar'; -import { - SimpleCombobox, - SimpleToolbarButton, - SimpleToolbarRadioButton, - SimpleToolbarToggleButton, -} from '../simple-toolbar'; - -/** @title Basic Horizontal Toolbar Example */ -@Component({ - selector: 'toolbar-basic-horizontal-example', - templateUrl: 'toolbar-basic-horizontal-example.html', - styleUrl: '../toolbar-common.css', - imports: [ - Toolbar, - ToolbarWidget, - ToolbarWidgetGroup, - SimpleCombobox, - SimpleToolbarButton, - SimpleToolbarRadioButton, - SimpleToolbarToggleButton, - ], -}) -export class ToolbarBasicHorizontalExample {} diff --git a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-basic-vertical/toolbar-basic-vertical-example.html b/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-basic-vertical/toolbar-basic-vertical-example.html deleted file mode 100644 index 72d7096f48f6..000000000000 --- a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-basic-vertical/toolbar-basic-vertical-example.html +++ /dev/null @@ -1,34 +0,0 @@ -
      -
      -
      - - -
      - - - -
      - - - -
      - - - -
      - - - -
      -
      -
      diff --git a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-basic-vertical/toolbar-basic-vertical-example.ts b/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-basic-vertical/toolbar-basic-vertical-example.ts deleted file mode 100644 index 62afddef2fc4..000000000000 --- a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-basic-vertical/toolbar-basic-vertical-example.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {Component} from '@angular/core'; -import {Toolbar, ToolbarWidget, ToolbarWidgetGroup} from '@angular/aria/toolbar'; -import { - SimpleToolbarButton, - SimpleToolbarRadioButton, - SimpleToolbarToggleButton, -} from '../simple-toolbar'; - -/** @title Basic Vertical Toolbar Example */ -@Component({ - selector: 'toolbar-basic-vertical-example', - templateUrl: 'toolbar-basic-vertical-example.html', - styleUrl: '../toolbar-common.css', - imports: [ - Toolbar, - ToolbarWidget, - ToolbarWidgetGroup, - SimpleToolbarButton, - SimpleToolbarRadioButton, - SimpleToolbarToggleButton, - ], -}) -export class ToolbarBasicVerticalExample {} diff --git a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-common.css b/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-common.css deleted file mode 100644 index a1a8c398c4cf..000000000000 --- a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-common.css +++ /dev/null @@ -1,205 +0,0 @@ -.example-heading { - margin: 16px 0 4px; -} - -.example-toolbar-controls { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 16px; - padding-bottom: 4px; -} - -.example-toolbar { - gap: 16px; - padding: 8px; - display: flex; - width: -webkit-fit-content; - width: -moz-fit-content; - flex-direction: row; - border-radius: var(--mat-sys-corner-small); - background-color: var(--mat-sys-surface); - border: 1px solid color-mix(in srgb, var(--mat-sys-outline) 50%, transparent); -} - -.example-toolbar[aria-orientation='vertical'], -.example-toolbar[aria-orientation='vertical'] .example-group { - flex-direction: column; -} - -.example-group { - gap: 4px; - display: flex; -} - -.example-button { - cursor: pointer; - opacity: 0.875; - font-size: 1.25rem; - padding: 6px 8px; - background-color: transparent; - border: 1px solid transparent; - border-radius: var(--mat-sys-corner-extra-small); -} - -.example-button:focus, -.example-button:hover { - background: color-mix(in srgb, var(--mat-sys-outline) 10%, transparent); -} - -.example-button:active { - background: color-mix(in srgb, var(--mat-sys-outline) 20%, transparent); -} - -.example-button[aria-pressed='true'], -.example-button[aria-checked='true'] { - color: color-mix(in srgb, var(--mat-sys-primary) 90%, black); - background: color-mix(in srgb, var(--mat-sys-primary) 15%, transparent); -} - -.example-button:focus { - border-color: var(--mat-sys-primary); - outline: 2px solid color-mix(in srgb, var(--mat-sys-primary) 50%, transparent); -} - -.example-button[aria-disabled='true'] { - cursor: default; - opacity: 0.45; -} - -.example-separator { - width: 1px; - background: color-mix(in srgb, var(--mat-sys-outline) 50%, transparent); -} - -.example-toolbar[aria-orientation='vertical'] .example-separator { - height: 1px; - width: auto; -} - -.example-combobox-container { - border-radius: var(--mat-sys-corner-extra-small); - border: 1px solid color-mix(in srgb, var(--mat-sys-outline) 50%, transparent); -} - -.example-combobox-container:focus-within { - border-color: var(--mat-sys-primary); - outline: 2px solid color-mix(in srgb, var(--mat-sys-primary) 50%, transparent); -} - -.example-combobox { - height: 100%; - width: 10rem; - display: flex; - position: relative; - flex-direction: column; -} - -.example-combobox-input-container { - display: flex; - position: relative; - align-items: center; - border-radius: var(--mat-sys-corner-extra-small); - height: 100%; -} - -.example-combobox-input { - cursor: pointer; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - height: 100%; - width: 100%; - border: none; - outline: none; - font-size: 0.8rem; - padding: 0 0.5rem; - border-radius: var(--mat-sys-corner-extra-small); - background-color: transparent; -} - -.example-combobox-input::-moz-selection { - background: transparent; -} - -.example-combobox-input::-webkit-selection { - background: transparent; -} - -.example-arrow-icon { - padding: 0 0.2rem; - position: absolute; - right: 0; - opacity: 0.7; - transition: transform 0.2s ease; - pointer-events: none; -} - -.example-combobox[dir='rtl'] .example-arrow-icon { - right: auto; - left: 0; -} - -.example-combobox-input[aria-expanded='true'] + .example-arrow-icon { - transform: rotate(180deg); -} - -.example-popover { - margin: 0; - padding: 0; - 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); -} - -.example-popup { - width: 100%; - margin-block-start: 0.25rem; - 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); - max-height: 15rem; - overflow: auto; -} - -.example-option { - cursor: pointer; - font-size: 0.8rem; - padding: 0.5rem; - display: flex; - overflow: hidden; - flex-shrink: 0; - align-items: center; - justify-content: space-between; -} - -.example-option:hover { - background: color-mix(in srgb, var(--mat-sys-outline) 10%, transparent); -} - -.example-option[data-active='true'] { - border-radius: var(--mat-sys-corner-extra-small); - outline: 1px solid var(--mat-sys-primary); - outline-offset: -1px; -} - -.example-option[aria-selected='true'] { - color: var(--mat-sys-primary); - background: color-mix(in srgb, var(--mat-sys-primary) 10%, transparent); -} - -.example-option-icon { - font-size: 1rem; -} - -.example-option[aria-selected='false'] .example-option-icon { - visibility: hidden; -} - -@media (forced-colors: active) { - .example-button[aria-pressed='true'], - .example-button[aria-checked='true'], - .example-option[aria-selected='true'] { - outline: solid 2px; - } -} diff --git a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-configurable/toolbar-configurable-example.html b/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-configurable/toolbar-configurable-example.html deleted file mode 100644 index 813a2b5ea388..000000000000 --- a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-configurable/toolbar-configurable-example.html +++ /dev/null @@ -1,110 +0,0 @@ -
      -

      Toolbar Controls

      -
      - Soft Disabled - Wrap - Disabled - - Orientation - - Vertical - Horizontal - - -
      - -

      Button

      -
      - - Disabled Buttons - - @for (widget of widgets; track widget) { - {{widget}} - } - - - - - Disabled Groups - - @for (group of groups; track group) { - {{group}} - } - - -
      -
      - - -
      -
      - - -
      - - - -
      - - - -
      - - @if (orientation === 'horizontal') { - - - } - - - -
      - - - -
      - - - -
      - - - -
      -
      - diff --git a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-configurable/toolbar-configurable-example.ts b/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-configurable/toolbar-configurable-example.ts deleted file mode 100644 index 1306d9eea92e..000000000000 --- a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-configurable/toolbar-configurable-example.ts +++ /dev/null @@ -1,63 +0,0 @@ -import {Component} from '@angular/core'; -import {MatCheckboxModule} from '@angular/material/checkbox'; -import {MatFormFieldModule} from '@angular/material/form-field'; -import {MatSelectModule} from '@angular/material/select'; -import {FormsModule, ReactiveFormsModule} from '@angular/forms'; -import {Toolbar, ToolbarWidget, ToolbarWidgetGroup} from '@angular/aria/toolbar'; -import { - SimpleCombobox, - SimpleToolbarButton, - SimpleToolbarRadioButton, - SimpleToolbarToggleButton, -} from '../simple-toolbar'; - -/** @title Configurable Aria Toolbar Example */ -@Component({ - selector: 'toolbar-configurable-example', - templateUrl: 'toolbar-configurable-example.html', - styleUrl: '../toolbar-common.css', - imports: [ - Toolbar, - ToolbarWidget, - ToolbarWidgetGroup, - SimpleCombobox, - SimpleToolbarButton, - SimpleToolbarRadioButton, - SimpleToolbarToggleButton, - MatCheckboxModule, - MatFormFieldModule, - MatSelectModule, - FormsModule, - ReactiveFormsModule, - ], -}) -export class ToolbarConfigurableExample { - wrap = true; - softDisabled = true; - toolbarDisabled = false; - orientation: 'vertical' | 'horizontal' = 'horizontal'; - - widgets = [ - 'Undo', - 'Redo', - 'Bold', - 'Italic', - 'Underline', - 'Text style', - 'Align left', - 'Align center', - 'Align right', - 'Checklist', - 'Bullet list', - 'Numbered list', - ]; - - groups = ['Alignment options', 'List options']; - - disabledGroups: string[] = []; - disabledWidgets: string[] = []; - - isDisabled(value: string) { - return this.disabledWidgets.includes(value) || this.disabledGroups.includes(value); - } -} diff --git a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-hard-disabled/toolbar-hard-disabled-example.html b/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-hard-disabled/toolbar-hard-disabled-example.html deleted file mode 100644 index 6e94bd79fae4..000000000000 --- a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-hard-disabled/toolbar-hard-disabled-example.html +++ /dev/null @@ -1,52 +0,0 @@ -
      -
      -
      - - -
      - - - -
      - - - -
      - - - - - - - -
      - - - -
      - - - -
      - - - -
      -
      -
      diff --git a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-hard-disabled/toolbar-hard-disabled-example.ts b/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-hard-disabled/toolbar-hard-disabled-example.ts deleted file mode 100644 index 2d1c27e4863c..000000000000 --- a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-hard-disabled/toolbar-hard-disabled-example.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {Component} from '@angular/core'; -import {Toolbar, ToolbarWidget, ToolbarWidgetGroup} from '@angular/aria/toolbar'; -import { - SimpleCombobox, - SimpleToolbarButton, - SimpleToolbarRadioButton, - SimpleToolbarToggleButton, -} from '../simple-toolbar'; - -/** @title Hard Disabled Toolbar Example */ -@Component({ - selector: 'toolbar-hard-disabled-example', - templateUrl: 'toolbar-hard-disabled-example.html', - styleUrl: '../toolbar-common.css', - imports: [ - Toolbar, - ToolbarWidget, - ToolbarWidgetGroup, - SimpleCombobox, - SimpleToolbarButton, - SimpleToolbarRadioButton, - SimpleToolbarToggleButton, - ], -}) -export class ToolbarHardDisabledExample {} diff --git a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-rtl/toolbar-rtl-example.html b/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-rtl/toolbar-rtl-example.html deleted file mode 100644 index 519f5e2eed1a..000000000000 --- a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-rtl/toolbar-rtl-example.html +++ /dev/null @@ -1,41 +0,0 @@ -
      -
      -
      - - -
      - - - -
      - - - -
      - - - - - - - -
      - - - -
      - - - -
      - - - -
      -
      -
      diff --git a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-rtl/toolbar-rtl-example.ts b/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-rtl/toolbar-rtl-example.ts deleted file mode 100644 index ddfca5a98528..000000000000 --- a/src/components-examples/aria/aria-toolbar-simple-combobox/toolbar-rtl/toolbar-rtl-example.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {Component} from '@angular/core'; -import {Dir} from '@angular/cdk/bidi'; -import {Toolbar, ToolbarWidget, ToolbarWidgetGroup} from '@angular/aria/toolbar'; -import { - SimpleCombobox, - SimpleToolbarButton, - SimpleToolbarRadioButton, - SimpleToolbarToggleButton, -} from '../simple-toolbar'; - -/** @title Basic RTL Toolbar Example */ -@Component({ - selector: 'toolbar-rtl-example', - templateUrl: 'toolbar-rtl-example.html', - styleUrl: '../toolbar-common.css', - imports: [ - Dir, - Toolbar, - ToolbarWidget, - ToolbarWidgetGroup, - SimpleCombobox, - SimpleToolbarButton, - SimpleToolbarRadioButton, - SimpleToolbarToggleButton, - ], -}) -export class ToolbarRtlExample {} 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..8d48bffe1760 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,14 @@ -
      +
      search
      @@ -19,26 +13,25 @@
      {{countries().length === 0 ? 'No results found for ' + query() : ''}}
      - - - + + +
      @if (countries().length === 0) { -
      No results found
      +
      No results found
      } -
      +
      @for (country of countries(); track country) { -
      - {{country}} - check -
      +
      + {{country}} + check +
      }
      -
      +
      \ No newline at end of file diff --git a/src/components-examples/aria/autocomplete/autocomplete-highlight/autocomplete-highlight-example.ts b/src/components-examples/aria/autocomplete/autocomplete-highlight/autocomplete-highlight-example.ts index fc490c2ed96b..38c570ef33a4 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,16 @@ * 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, + effect, + signal, viewChild, - viewChildren, } from '@angular/core'; import {COUNTRIES} from '../countries'; import {OverlayModule} from '@angular/cdk/overlay'; @@ -30,33 +26,21 @@ 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); + readonly listbox = viewChild(Listbox); + readonly combobox = viewChild(Combobox); - /** A reference to the ng aria combobox. */ - combobox = viewChild>(Combobox); - - /** A reference to the ng aria combobox input. */ - comboboxInput = viewChild(ComboboxInput); + popupExpanded = signal(false); + searchString = signal(''); + selectedOption = signal([]); + navigated = signal(false); /** 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 +48,37 @@ 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(); + }); + + effect(() => { + if (!this.popupExpanded()) { + this.navigated.set(false); } }); } /** 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..521518a42290 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..32cc9491e719 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 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..ffa2d0bb763c 100644 --- a/src/components-examples/aria/autocomplete/autocomplete.css +++ b/src/components-examples/aria/autocomplete/autocomplete.css @@ -18,7 +18,7 @@ position: absolute; } -[ngComboboxInput] { +input[ngCombobox] { width: 13rem; font-size: 0.9rem; border-radius: var(--mat-sys-corner-extra-small); @@ -28,15 +28,13 @@ background-color: var(--mat-sys-surface); } -[ngComboboxInput][aria-disabled='true'] { +input[ngCombobox][aria-disabled='true'], +input[ngCombobox]:disabled { cursor: default; opacity: 0.5; background-color: var(--mat-sys-surface-dim); } -[ngCombobox]:has([aria-expanded='false']) .example-popup { - display: none; -} .example-clear-button { position: absolute; @@ -118,28 +116,11 @@ font-size: 0.9rem; } -/* The ::placeholder selectors are intentionally separated. -The 'material/no-prefixes' stylelint rule flags the standard ::placeholder -when grouped with vendor prefixes. Each prefix is on its own rule to comply. -Vendor prefixes are included to ensure consistent placeholder styling -across different browsers. */ - /* stylelint-disable material/no-prefixes -- Provides all prefixes for ::placeholder */ -input::-webkit-input-placeholder { - color: var(--mat-sys-on-surface-variant); -} - -input::-moz-placeholder /* Firefox 19+ */ { - color: var(--mat-sys-on-surface-variant); -} - -input:-ms-input-placeholder /* IE 10+ */ { - color: var(--mat-sys-on-surface-variant); -} -input:-moz-placeholder /* Firefox 18- */ { - color: var(--mat-sys-on-surface-variant); -} - +input::-webkit-input-placeholder, +input::-moz-placeholder, /* Firefox 19+ */ +input:-ms-input-placeholder, /* IE 10+ */ +input:-moz-placeholder, /* Firefox 18- */ input::placeholder { color: var(--mat-sys-on-surface-variant); } diff --git a/src/components-examples/aria/autocomplete/index.ts b/src/components-examples/aria/autocomplete/index.ts index 15cb925c5e28..dcea144346df 100644 --- a/src/components-examples/aria/autocomplete/index.ts +++ b/src/components-examples/aria/autocomplete/index.ts @@ -1,4 +1,4 @@ export {AutocompleteAutoSelectExample} from './autocomplete-auto-select/autocomplete-auto-select-example'; -export {AutocompleteManualExample} from './autocomplete-manual/autocomplete-manual-example'; -export {AutocompleteHighlightExample} from './autocomplete-highlight/autocomplete-highlight-example'; export {AutocompleteDisabledExample} from './autocomplete-disabled/autocomplete-disabled-example'; +export {AutocompleteHighlightExample} from './autocomplete-highlight/autocomplete-highlight-example'; +export {AutocompleteManualExample} from './autocomplete-manual/autocomplete-manual-example'; 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/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..c16dc41a8c40 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,33 @@ -
      -
      +
      +
      search - +
      -
      - -
      +
      + {{options().length === 0 ? 'No results found for ' + searchString() : ''}} +
      + + + +
      + @if (options().length === 0) { +
      No results found
      + } +
      @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..7347ae415a9b 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 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..5c2e125c6a08 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,51 @@ -
      -
      - +
      +
      + arrow_drop_down
      - - -
      - -
      - search - -
      + + + +
      +
      +
      +
      + search + +
      - -
      - @for (option of options(); track option) { -
      - {{option}} - +
      + {{options().length === 0 ? 'No results found for ' + searchString() : ''}} +
      + + +
      + @if (options().length === 0) { +
      No results found
      + } +
      + @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..4485501cd407 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,27 @@ -
      -
      +
      +
      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..6a8e3825cb6c 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 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-editable-multiselect/simple-combobox-editable-multiselect-example.html b/src/components-examples/aria/combobox/combobox-editable-multiselect/combobox-editable-multiselect-example.html similarity index 100% rename from src/components-examples/aria/simple-combobox/simple-combobox-editable-multiselect/simple-combobox-editable-multiselect-example.html rename to src/components-examples/aria/combobox/combobox-editable-multiselect/combobox-editable-multiselect-example.html diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-editable-multiselect/simple-combobox-editable-multiselect-example.ts b/src/components-examples/aria/combobox/combobox-editable-multiselect/combobox-editable-multiselect-example.ts similarity index 91% rename from src/components-examples/aria/simple-combobox/simple-combobox-editable-multiselect/simple-combobox-editable-multiselect-example.ts rename to src/components-examples/aria/combobox/combobox-editable-multiselect/combobox-editable-multiselect-example.ts index ba50cde53047..fd359f9ba1a6 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-editable-multiselect/simple-combobox-editable-multiselect-example.ts +++ b/src/components-examples/aria/combobox/combobox-editable-multiselect/combobox-editable-multiselect-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 {Listbox, Option} from '@angular/aria/listbox'; import { afterRenderEffect, @@ -24,13 +24,13 @@ import {FormsModule} from '@angular/forms'; /** @title Editable multiselectable combobox with a dialog layout. */ @Component({ - selector: 'simple-combobox-editable-multiselect-example', - templateUrl: 'simple-combobox-editable-multiselect-example.html', - styleUrl: '../simple-combobox-example.css', + selector: 'combobox-editable-multiselect-example', + templateUrl: 'combobox-editable-multiselect-example.html', + styleUrl: '../combobox-example.css', imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SimpleComboboxEditableMultiselectExample { +export class ComboboxEditableMultiselectExample { readonly listbox = viewChild(Listbox); readonly combobox = viewChild(Combobox); readonly searchInput = viewChild>('searchInput'); 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..c9e135c8d654 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,36 @@ -
      -
      +
      +
      search - +
      -
      - -
      - @for (option of options(); track option) { -
      - {{option}} - -
      +
      + {{options().length === 0 ? 'No results found for ' + searchString() : ''}} +
      + + + +
      + @if (options().length === 0) { +
      No results found
      } +
      + @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..b65aa7ed3332 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,106 @@ * 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, effect, signal, viewChild} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; -/** @title Combobox with highlight filtering. */ +/** @title 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([]); + navigated = signal(false); 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; + effect(() => { + if (!this.popupExpanded()) { + this.navigated.set(false); + } + }); + } - 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/simple-combobox/simple-combobox-multiselect-dialog/simple-combobox-multiselect-dialog-example.html b/src/components-examples/aria/combobox/combobox-multiselect-dialog/combobox-multiselect-dialog-example.html similarity index 100% rename from src/components-examples/aria/simple-combobox/simple-combobox-multiselect-dialog/simple-combobox-multiselect-dialog-example.html rename to src/components-examples/aria/combobox/combobox-multiselect-dialog/combobox-multiselect-dialog-example.html diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-multiselect-dialog/simple-combobox-multiselect-dialog-example.ts b/src/components-examples/aria/combobox/combobox-multiselect-dialog/combobox-multiselect-dialog-example.ts similarity index 91% rename from src/components-examples/aria/simple-combobox/simple-combobox-multiselect-dialog/simple-combobox-multiselect-dialog-example.ts rename to src/components-examples/aria/combobox/combobox-multiselect-dialog/combobox-multiselect-dialog-example.ts index 950a84ca9d01..3a6391eb5d3f 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-multiselect-dialog/simple-combobox-multiselect-dialog-example.ts +++ b/src/components-examples/aria/combobox/combobox-multiselect-dialog/combobox-multiselect-dialog-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 {Listbox, Option} from '@angular/aria/listbox'; import { afterRenderEffect, @@ -24,13 +24,13 @@ import {FormsModule} from '@angular/forms'; /** @title Multiselectable combobox with a dialog layout. */ @Component({ - selector: 'simple-combobox-multiselect-dialog-example', - templateUrl: 'simple-combobox-multiselect-dialog-example.html', - styleUrl: '../simple-combobox-example.css', + selector: 'combobox-multiselect-dialog-example', + templateUrl: 'combobox-multiselect-dialog-example.html', + styleUrl: '../combobox-example.css', imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SimpleComboboxMultiselectDialogExample { +export class ComboboxMultiselectDialogExample { readonly listbox = viewChild(Listbox); readonly combobox = viewChild(Combobox); readonly searchInput = viewChild>('searchInput'); 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..4d3e970e0925 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,27 @@ -
      -
      - {{ 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..7860373e3025 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,26 @@ -
      -
      - {{ 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-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..c711666e774f 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,48 @@ -
      -
      +
      +
      search - +
      -
      - -
        - -
      -
      +
      + {{filteredGroups().length === 0 ? 'No results found for ' + searchString() : ''}}
      + + + +
      + @if (filteredGroups().length === 0) { +
      No results found
      + } +
        + +
      +
      +
      +
      @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..179940fb45fd 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,151 @@ * 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 SeasonNode { + name: string; + children?: SeasonNode[]; + 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)); + readonly dataSource = signal(SEASON_DATA); - firstMatch = computed(() => { - const flatNodes = this.flattenTreeNodes(this.nodes()); - const node = flatNodes.find(n => this.isMatch(n)); - return node?.name; - }); + constructor() { + afterRenderEffect(() => this._focusAndSelectFirstMatch()); - flattenTreeNodes(nodes: TreeNode[]): TreeNode[] { - return nodes.flatMap(node => { - return node.children ? [node, ...this.flattenTreeNodes(node.children)] : [node]; + afterRenderEffect(() => { + const active = this.tree()?._pattern.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[]); - } + // Selects the first matching child within the tree filters. + private _focusAndSelectFirstMatch() { + this.filteredGroups(); - isMatch(node: TreeNode) { - return node.name.toLowerCase().includes(this.searchString().toLowerCase()); + const option = this.firstMatchingOption(); + const treeInstance = this.tree(); + if (option && treeInstance) { + untracked(() => { + const matchedItem = treeInstance._pattern.items().find(item => item.value() === option); + if (matchedItem) { + treeInstance._pattern.treeBehavior.goto(matchedItem, {selectOne: true}); + } + }); + } } - constructor() { - afterRenderEffect(() => { - const popover = this.popover()!; - const combobox = this.combobox()!; - combobox.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); - this.tree()?.scrollActiveItemIntoView(); - }); - } + filteredData = computed(() => { + const search = this.searchString().trim().toLowerCase(); + const data = this.dataSource(); - showPopover() { - const popover = this.popover()!; - const combobox = this.combobox()!; + if (!search) { + return {groups: data, firstMatch: undefined}; + } - const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); - const popoverEl = popover.nativeElement; + let firstMatch: string | undefined = undefined; - if (comboboxRect) { - popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom + 4}px`; - popoverEl.style.left = `${comboboxRect.left - 1}px`; - } + const filterNode = (node: SeasonNode): SeasonNode | 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 SeasonNode => child !== null); + + if (matches || (children && children.length > 0)) { + return { + ...node, + children, + expanded: children && children.length > 0 ? true : node.expanded, + }; + } + + return null; + }; - popover.nativeElement.showPopover(); + const groups = data + .map(node => filterNode(node)) + .filter((node): node is SeasonNode => node !== null); + return {groups, firstMatch}; + }); + + filteredGroups = computed(() => this.filteredData().groups); + firstMatchingOption = computed(() => this.filteredData().firstMatch); + + onCommit() { + const treeInstance = this.tree(); + if (!treeInstance) return; + + const activeItem = treeInstance._pattern.activeItem(); + + if (activeItem) { + if (activeItem.selectable()) { + // Selectable child: commit value and close popup. + const selected = this.selectedValues(); + if (selected.length > 0) { + this.searchString.set(selected[0]); + this.popupExpanded.set(false); + } + } else { + // Non-selectable parent: expand and focus its first child. + const children = activeItem.children(); + if (children.length > 0) { + const firstChild = children[0]; + treeInstance._pattern.treeBehavior.goto(firstChild); + } + } + } } } + +const SEASON_DATA: SeasonNode[] = [ + {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..d646d6be833e 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,50 @@ -
      -
      +
      +
      search - +
      -
      - -
        - -
      -
      +
      + {{filteredGroups().length === 0 ? 'No results found for ' + searchString() : ''}}
      + + + +
      + @if (filteredGroups().length === 0) { +
      No results found
      + } +
        + +
      +
      +
      +
      @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..cfeec45b3c9f 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,159 @@ * 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, + effect, 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 SeasonNode { + name: string; + children?: SeasonNode[]; + 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([]); + navigated = signal(false); - nodes = computed(() => this.filterTreeNodes(TREE_NODES)); + readonly dataSource = signal(SEASON_DATA); - firstMatch = computed(() => { - const flatNodes = this.flattenTreeNodes(this.nodes()); - const node = flatNodes.find(n => this.isMatch(n)); - return node?.name; - }); + constructor() { + afterRenderEffect(() => this._focusAndSelectFirstMatch()); - flattenTreeNodes(nodes: TreeNode[]): TreeNode[] { - return nodes.flatMap(node => { - return node.children ? [node, ...this.flattenTreeNodes(node.children)] : [node]; + afterRenderEffect(() => { + const active = this.tree()?._pattern.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}); + effect(() => { + if (!this.popupExpanded()) { + this.navigated.set(false); } - return acc; - }, [] as TreeNode[]); + }); } - isMatch(node: TreeNode) { - return node.name.toLowerCase().includes(this.searchString().toLowerCase()); - } + // Selects the first matching child within the tree filters. + private _focusAndSelectFirstMatch() { + this.filteredGroups(); - constructor() { - afterRenderEffect(() => { - const popover = this.popover()!; - const combobox = this.combobox()!; - combobox.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); - this.tree()?.scrollActiveItemIntoView(); - }); + const option = this.firstMatchingOption(); + const treeInstance = this.tree(); + if (option && treeInstance) { + untracked(() => { + const matchedItem = treeInstance._pattern.items().find(item => item.value() === option); + if (matchedItem) { + treeInstance._pattern.treeBehavior.goto(matchedItem, {selectOne: true}); + } + }); + } } - showPopover() { - const popover = this.popover()!; - const combobox = this.combobox()!; - - const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); - const popoverEl = popover.nativeElement; + filteredData = computed(() => { + const search = this.searchString().trim().toLowerCase(); + const data = this.dataSource(); - if (comboboxRect) { - popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom + 4}px`; - popoverEl.style.left = `${comboboxRect.left - 1}px`; + if (!search) { + return {groups: data, firstMatch: undefined}; } - popover.nativeElement.showPopover(); + let firstMatch: string | undefined = undefined; + + const filterNode = (node: SeasonNode): SeasonNode | 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 SeasonNode => 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 SeasonNode => node !== null); + return {groups, firstMatch}; + }); + + filteredGroups = computed(() => this.filteredData().groups); + firstMatchingOption = computed(() => this.filteredData().firstMatch); + + onCommit() { + const treeInstance = this.tree(); + if (!treeInstance) return; + + const activeItem = treeInstance._pattern.activeItem(); + + if (activeItem) { + if (activeItem.selectable()) { + // Selectable child: commit value and close popup. + const selected = this.selectedValues(); + if (selected.length > 0) { + this.searchString.set(selected[0]); + this.popupExpanded.set(false); + } + } else { + // Non-selectable parent: expand and focus its first child. + const children = activeItem.children(); + if (children.length > 0) { + const firstChild = children[0]; + treeInstance._pattern.treeBehavior.goto(firstChild); + } + } + } } } + +const SEASON_DATA: SeasonNode[] = [ + {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 31bf69019e48..28645fd349fe 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 SeasonNode { /** @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 SeasonNode { 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..44a06a854d81 100644 --- a/src/components-examples/aria/combobox/index.ts +++ b/src/components-examples/aria/combobox/index.ts @@ -1,13 +1,18 @@ -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'; +export {ComboboxMultiselectDialogExample} from './combobox-multiselect-dialog/combobox-multiselect-dialog-example'; + +// Force watcher update + +export {ComboboxEditableMultiselectExample} from './combobox-editable-multiselect/combobox-editable-multiselect-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/BUILD.bazel b/src/components-examples/aria/select/BUILD.bazel deleted file mode 100644 index cf82f9bf6ef9..000000000000 --- a/src/components-examples/aria/select/BUILD.bazel +++ /dev/null @@ -1,27 +0,0 @@ -load("//tools:defaults.bzl", "ng_project") - -package(default_visibility = ["//visibility:public"]) - -ng_project( - name = "select", - srcs = glob(["**/*.ts"]), - assets = glob([ - "**/*.html", - "**/*.css", - ]), - deps = [ - "//:node_modules/@angular/core", - "//src/aria/combobox", - "//src/aria/listbox", - "//src/cdk/overlay", - ], -) - -filegroup( - name = "source-files", - srcs = glob([ - "**/*.html", - "**/*.css", - "**/*.ts", - ]), -) diff --git a/src/components-examples/aria/select/index.ts b/src/components-examples/aria/select/index.ts deleted file mode 100644 index 2ceef481d338..000000000000 --- a/src/components-examples/aria/select/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export {SelectDisabledExample} from './select-disabled/select-disabled-example'; -export {SelectMultiExample} from './select-multi/select-multi-example'; -export {SelectExample} from './select/select-example'; 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 deleted file mode 100644 index a64296a5d06d..000000000000 --- a/src/components-examples/aria/select/select-disabled/select-disabled-example.html +++ /dev/null @@ -1,28 +0,0 @@ -
      -
      -
      - Select an option -
      - - arrow_drop_down -
      - - - -
      -
      - @for (item of items; track item) { -
      - - {{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 deleted file mode 100644 index aacc40f17b4d..000000000000 --- a/src/components-examples/aria/select/select-disabled/select-disabled-example.ts +++ /dev/null @@ -1,38 +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 {ChangeDetectionStrategy, Component} from '@angular/core'; -import {OverlayModule} from '@angular/cdk/overlay'; - -/** @title Aria select disabled example. */ -@Component({ - selector: 'select-disabled-example', - templateUrl: 'select-disabled-example.html', - styleUrl: '../select.css', - imports: [ - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, - Listbox, - Option, - OverlayModule, - ], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class SelectDisabledExample { - /** The items available for selection. */ - items = ['Option 1', 'Option 2', 'Option 3']; -} 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 deleted file mode 100644 index 29e40ca53586..000000000000 --- a/src/components-examples/aria/select/select-multi/select-multi-example.html +++ /dev/null @@ -1,27 +0,0 @@ -
      -
      -
      - {{ displayValue() }} -
      - - arrow_drop_down -
      - - - -
      -
      - @for (item of items; track item) { -
      - {{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 deleted file mode 100644 index 939e0110fa7b..000000000000 --- a/src/components-examples/aria/select/select-multi/select-multi-example.ts +++ /dev/null @@ -1,74 +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, - viewChild, - viewChildren, -} from '@angular/core'; -import {OverlayModule} from '@angular/cdk/overlay'; - -/** @title Aria multiselect example. */ -@Component({ - selector: 'select-multi-example', - templateUrl: 'select-multi-example.html', - styleUrl: '../select.css', - imports: [ - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, - Listbox, - Option, - OverlayModule, - ], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class SelectMultiExample { - /** The options available in the listbox. */ - options = viewChildren>(Option); - - /** The combobox listbox popup. */ - listbox = viewChild>(Listbox); - - /** The visible label displayed to the user. */ - displayValue = computed(() => { - const value = this.listbox()?.value(); - - if (!value?.length) { - return 'Select a day'; - } - - if (value.length <= 2) { - return value.join(', '); - } - - return `${value[0]} + ${value.length - 1} more`; - }); - - /** The items available for selection. */ - items = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; - - constructor() { - // Scrolls to the active item when the active option changes. - afterRenderEffect(() => { - const option = this.options().find(opt => opt.active()); - option?.element.scrollIntoView({block: 'nearest'}); - }); - } -} diff --git a/src/components-examples/aria/select/select.css b/src/components-examples/aria/select/select.css deleted file mode 100644 index 282b043531f1..000000000000 --- a/src/components-examples/aria/select/select.css +++ /dev/null @@ -1,137 +0,0 @@ -.example-select { - position: relative; - display: flex; - align-items: center; - border: 1px solid var(--mat-sys-outline); - border-radius: var(--mat-sys-corner-extra-small); - - /* stylelint-disable-next-line material/no-prefixes -- Valid in all remotely recent browsers. */ - width: fit-content; -} - -.example-select:has([ngComboboxInput][aria-disabled='true']) { - opacity: 0.7; - background-color: var(--mat-sys-surface-dim); -} - -.example-select:focus-within { - outline-offset: -2px; - outline: 2px solid var(--mat-sys-primary); -} - -.example-arrow, -.example-select-value { - position: absolute; - pointer-events: none; -} - -.example-select-value { - display: flex; - gap: 1rem; - left: 1rem; - width: calc(100% - 4rem); -} - -.example-select-label { - text-overflow: ellipsis; - text-wrap-mode: nowrap; - overflow: hidden; -} - -.example-arrow, -.example-select-icon { - font-size: 1.25rem; - opacity: 0.875; -} - -.example-arrow { - right: 1rem; - transition: transform 0.2s ease-in-out; -} - -[ngComboboxInput] { - cursor: pointer; - padding: 0.7rem 3rem; - opacity: 0; -} - -[ngComboboxInput][aria-disabled='true'] { - cursor: default; -} - -[ngComboboxInput][aria-expanded='true'] + .example-arrow { - transform: rotate(180deg); -} - -[ngCombobox]:has([aria-expanded='false']) .example-popup { - display: none; -} - -.example-popup { - width: 100%; - margin-top: 2px; - padding: 0.1rem; - max-height: 11rem; - border-radius: var(--mat-sys-corner-extra-small); - background-color: var(--mat-sys-surface); - border: 1px solid var(--mat-sys-outline); -} - -.example-no-results { - padding: 1rem; -} - -[ngListbox] { - gap: 2px; - width: 100%; - height: 100%; - display: flex; - overflow: auto; - flex-direction: column; -} - -[ngOption] { - display: flex; - cursor: pointer; - align-items: center; - margin: 1px; - gap: 1rem; - padding: 0.7rem 1rem; - border-radius: var(--mat-sys-corner-extra-small); -} - -[ngOption][aria-disabled='true'] { - cursor: default; - opacity: 0.5; - background-color: var(--mat-sys-surface-dim); -} - -[ngOption]:hover { - background-color: color-mix(in srgb, var(--mat-sys-outline) 15%, transparent); -} - -[ngOption][data-active='true'] { - outline-offset: -2px; - outline: 2px solid var(--mat-sys-primary); -} - -[ngOption][aria-selected='true'] { - color: var(--mat-sys-primary); - background-color: color-mix(in srgb, var(--mat-sys-primary) 10%, transparent); -} - -[ngOption]:not([aria-selected='true']) .example-option-check { - display: none; -} - -.example-option-text { - flex: 1; -} - -.example-option-icon { - font-size: 1.25rem; -} - -.example-option-check { - font-size: 1rem; -} diff --git a/src/components-examples/aria/select/select/select-example.html b/src/components-examples/aria/select/select/select-example.html deleted file mode 100644 index 00ed809cfa31..000000000000 --- a/src/components-examples/aria/select/select/select-example.html +++ /dev/null @@ -1,35 +0,0 @@ -
      -
      -
      - {{ value().icon }} - {{ value().label }} -
      - - arrow_drop_down -
      - - - -
      -
      - @for (item of items; track item.label) { -
      - - {{item.label}} - -
      - } -
      -
      -
      -
      -
      diff --git a/src/components-examples/aria/select/select/select-example.ts b/src/components-examples/aria/select/select/select-example.ts deleted file mode 100644 index 29eb5513707a..000000000000 --- a/src/components-examples/aria/select/select/select-example.ts +++ /dev/null @@ -1,66 +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, - viewChild, - viewChildren, -} from '@angular/core'; -import {OverlayModule} from '@angular/cdk/overlay'; - -/** @title Aria select example. */ -@Component({ - selector: 'select-example', - templateUrl: 'select-example.html', - styleUrl: '../select.css', - imports: [ - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, - Listbox, - Option, - OverlayModule, - ], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class SelectExample { - /** The options available in the listbox. */ - options = viewChildren>(Option); - - /** The combobox listbox popup. */ - listbox = viewChild>(Listbox); - - /** The current value of the select. */ - value = computed(() => this.listbox()?.value()[0] ?? this.items[1]); - - /** The items available for selection. */ - items = [ - {label: 'Light Mode', icon: 'light_mode'}, - {label: 'Dark Mode', icon: 'dark_mode'}, - {label: 'System Default', icon: 'settings'}, - ]; - - constructor() { - // Scrolls to the active item when the active option changes. - afterRenderEffect(() => { - const option = this.options().find(opt => opt.active()); - option?.element.scrollIntoView({block: 'nearest'}); - }); - } -} 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/autocomplete.css b/src/components-examples/aria/simple-combobox/autocomplete.css deleted file mode 100644 index ffa2d0bb763c..000000000000 --- a/src/components-examples/aria/simple-combobox/autocomplete.css +++ /dev/null @@ -1,127 +0,0 @@ -.example-autocomplete { - display: flex; - position: relative; - align-items: center; - - /* stylelint-disable-next-line material/no-prefixes -- Valid in all remotely recent browsers. */ - width: fit-content; -} - -.example-search-icon, -.example-check-icon { - font-size: 1.25rem; - pointer-events: none; -} - -.example-search-icon { - left: 0.75rem; - position: absolute; -} - -input[ngCombobox] { - width: 13rem; - font-size: 0.9rem; - border-radius: var(--mat-sys-corner-extra-small); - padding: 0.7rem 2.5rem; - outline-color: var(--mat-sys-primary); - border: 1px solid var(--mat-sys-outline); - background-color: var(--mat-sys-surface); -} - -input[ngCombobox][aria-disabled='true'], -input[ngCombobox]:disabled { - cursor: default; - opacity: 0.5; - background-color: var(--mat-sys-surface-dim); -} - - -.example-clear-button { - position: absolute; - right: 0.5rem; - background-color: transparent; - border: none; - display: flex; - width: 2rem; - height: 2rem; - align-items: center; - cursor: pointer; -} - -.example-clear-icon { - font-size: 1.25rem; -} - -.example-popup { - width: 100%; - margin-top: 2px; - padding: 0.1rem; - max-height: 11rem; - border-radius: var(--mat-sys-corner-extra-small); - background-color: var(--mat-sys-surface); - border: 1px solid var(--mat-sys-outline); -} - -.example-no-results { - padding: 1rem; -} - -[ngListbox] { - gap: 2px; - height: 100%; - display: flex; - overflow: auto; - flex-direction: column; -} - -[ngOption] { - display: flex; - cursor: pointer; - align-items: center; - margin: 1px; - font-size: 0.9rem; - padding: 0.7rem; - border-radius: var(--mat-sys-corner-extra-small); -} - -[ngOption][aria-disabled='true'] { - cursor: default; - opacity: 0.5; - background-color: var(--mat-sys-surface-dim); -} - -[ngOption]:hover { - background-color: color-mix(in srgb, var(--mat-sys-primary) 5%, transparent); -} - -[ngOption][data-active='true'] { - outline-offset: -2px; - outline: 2px solid var(--mat-sys-primary); -} - -[ngOption][aria-selected='true'] { - color: var(--mat-sys-primary); - background-color: color-mix(in srgb, var(--mat-sys-primary) 10%, transparent); -} - -[ngOption]:not([aria-selected='true']) .example-check-icon { - display: none; -} - -.example-option-label { - flex: 1; -} - -.example-check-icon { - font-size: 0.9rem; -} - -/* stylelint-disable material/no-prefixes -- Provides all prefixes for ::placeholder */ -input::-webkit-input-placeholder, -input::-moz-placeholder, /* Firefox 19+ */ -input:-ms-input-placeholder, /* IE 10+ */ -input:-moz-placeholder, /* Firefox 18- */ -input::placeholder { - color: var(--mat-sys-on-surface-variant); -} -/* stylelint-enable material/no-prefixes */ 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 735dc7b86ee3..000000000000 --- a/src/components-examples/aria/simple-combobox/index.ts +++ /dev/null @@ -1,20 +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'; -export {SimpleComboboxMultiselectDialogExample} from './simple-combobox-multiselect-dialog/simple-combobox-multiselect-dialog-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 c16dc41a8c40..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-auto-select/simple-combobox-auto-select-example.html +++ /dev/null @@ -1,33 +0,0 @@ -
      -
      - search - -
      - -
      - {{options().length === 0 ? 'No results found for ' + searchString() : ''}} -
      - - - -
      - @if (options().length === 0) { -
      No results found
      - } -
      - @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 8d48bffe1760..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-auto-select/simple-combobox-autocomplete-auto-select-example.html +++ /dev/null @@ -1,46 +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 9c2980ee0a36..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 3742a03b1bac..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('Select a country'); - 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 852cc6a20d9f..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-highlight/simple-combobox-autocomplete-highlight-example.html +++ /dev/null @@ -1,37 +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 -
      - } -
      -
      -
      -
      -
      \ No newline at end of file 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 cd88394b8764..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-autocomplete-highlight/simple-combobox-autocomplete-highlight-example.ts +++ /dev/null @@ -1,85 +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, - effect, - 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([]); - navigated = signal(false); - - /** 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(); - }); - - effect(() => { - if (!this.popupExpanded()) { - this.navigated.set(false); - } - }); - } - - /** 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 521518a42290..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 5c2e125c6a08..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-dialog/simple-combobox-dialog-example.html +++ /dev/null @@ -1,51 +0,0 @@ -
      -
      - - arrow_drop_down -
      - - - - -
      -
      -
      -
      - search - -
      - -
      - {{options().length === 0 ? 'No results found for ' + searchString() : ''}} -
      - - -
      - @if (options().length === 0) { -
      No results found
      - } -
      - @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 4485501cd407..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-disabled/simple-combobox-disabled-example.html +++ /dev/null @@ -1,27 +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 c9e135c8d654..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-highlight/simple-combobox-highlight-example.html +++ /dev/null @@ -1,36 +0,0 @@ -
      -
      - search - -
      - -
      - {{options().length === 0 ? 'No results found for ' + searchString() : ''}} -
      - - - -
      - @if (options().length === 0) { -
      No results found
      - } -
      - @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 0a128a8e5691..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-highlight/simple-combobox-highlight-example.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 {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; -import {Listbox, Option} from '@angular/aria/listbox'; -import {afterRenderEffect, Component, computed, effect, 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([]); - navigated = signal(false); - - options = computed(() => - states.filter(state => state.name.toLowerCase().startsWith(this.searchString().toLowerCase())), - ); - - constructor() { - afterRenderEffect(() => { - this.listbox()?.scrollActiveItemIntoView(); - }); - - effect(() => { - if (!this.popupExpanded()) { - this.navigated.set(false); - } - }); - } - - 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 4d3e970e0925..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-readonly-disabled/simple-combobox-readonly-disabled-example.html +++ /dev/null @@ -1,27 +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 7860373e3025..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-readonly-multiselect/simple-combobox-readonly-multiselect-example.html +++ /dev/null @@ -1,26 +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-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 c711666e774f..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.html +++ /dev/null @@ -1,48 +0,0 @@ -
      -
      - search - -
      - -
      - {{filteredGroups().length === 0 ? 'No results found for ' + searchString() : ''}} -
      - - - -
      - @if (filteredGroups().length === 0) { -
      No results found
      - } -
        - -
      -
      -
      -
      -
      - - - @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 b9c7c0aea1b0..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.ts +++ /dev/null @@ -1,156 +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 SeasonNode { - name: string; - children?: SeasonNode[]; - 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(SEASON_DATA); - - constructor() { - afterRenderEffect(() => this._focusAndSelectFirstMatch()); - - afterRenderEffect(() => { - const active = this.tree()?._pattern.activeItem(); - if (active) { - untracked(() => { - active.element()?.scrollIntoView({block: 'nearest'}); - }); - } - }); - } - - // Selects the first matching child within the tree filters. - private _focusAndSelectFirstMatch() { - this.filteredGroups(); - - const option = this.firstMatchingOption(); - const treeInstance = this.tree(); - if (option && treeInstance) { - untracked(() => { - const matchedItem = treeInstance._pattern.items().find(item => item.value() === option); - if (matchedItem) { - treeInstance._pattern.treeBehavior.goto(matchedItem, {selectOne: true}); - } - }); - } - } - - 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: SeasonNode): SeasonNode | 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 SeasonNode => 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 SeasonNode => node !== null); - return {groups, firstMatch}; - }); - - filteredGroups = computed(() => this.filteredData().groups); - firstMatchingOption = computed(() => this.filteredData().firstMatch); - - onCommit() { - const treeInstance = this.tree(); - if (!treeInstance) return; - - const activeItem = treeInstance._pattern.activeItem(); - - if (activeItem) { - if (activeItem.selectable()) { - // Selectable child: commit value and close popup. - const selected = this.selectedValues(); - if (selected.length > 0) { - this.searchString.set(selected[0]); - this.popupExpanded.set(false); - } - } else { - // Non-selectable parent: expand and focus its first child. - const children = activeItem.children(); - if (children.length > 0) { - const firstChild = children[0]; - treeInstance._pattern.treeBehavior.goto(firstChild); - } - } - } - } -} - -const SEASON_DATA: SeasonNode[] = [ - {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 d646d6be833e..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.html +++ /dev/null @@ -1,50 +0,0 @@ -
      -
      - search - -
      - -
      - {{filteredGroups().length === 0 ? 'No results found for ' + searchString() : ''}} -
      - - - -
      - @if (filteredGroups().length === 0) { -
      No results found
      - } -
        - -
      -
      -
      -
      -
      - - - @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 7907131f897e..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.ts +++ /dev/null @@ -1,164 +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, - effect, - signal, - viewChild, - untracked, - ChangeDetectionStrategy, -} from '@angular/core'; -import {NgTemplateOutlet} from '@angular/common'; -import {OverlayModule} from '@angular/cdk/overlay'; - -interface SeasonNode { - name: string; - children?: SeasonNode[]; - 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([]); - navigated = signal(false); - - readonly dataSource = signal(SEASON_DATA); - - constructor() { - afterRenderEffect(() => this._focusAndSelectFirstMatch()); - - afterRenderEffect(() => { - const active = this.tree()?._pattern.activeItem(); - if (active) { - untracked(() => { - active.element()?.scrollIntoView({block: 'nearest'}); - }); - } - }); - - effect(() => { - if (!this.popupExpanded()) { - this.navigated.set(false); - } - }); - } - - // Selects the first matching child within the tree filters. - private _focusAndSelectFirstMatch() { - this.filteredGroups(); - - const option = this.firstMatchingOption(); - const treeInstance = this.tree(); - if (option && treeInstance) { - untracked(() => { - const matchedItem = treeInstance._pattern.items().find(item => item.value() === option); - if (matchedItem) { - treeInstance._pattern.treeBehavior.goto(matchedItem, {selectOne: true}); - } - }); - } - } - - 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: SeasonNode): SeasonNode | 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 SeasonNode => 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 SeasonNode => node !== null); - return {groups, firstMatch}; - }); - - filteredGroups = computed(() => this.filteredData().groups); - firstMatchingOption = computed(() => this.filteredData().firstMatch); - - onCommit() { - const treeInstance = this.tree(); - if (!treeInstance) return; - - const activeItem = treeInstance._pattern.activeItem(); - - if (activeItem) { - if (activeItem.selectable()) { - // Selectable child: commit value and close popup. - const selected = this.selectedValues(); - if (selected.length > 0) { - this.searchString.set(selected[0]); - this.popupExpanded.set(false); - } - } else { - // Non-selectable parent: expand and focus its first child. - const children = activeItem.children(); - if (children.length > 0) { - const firstChild = children[0]; - treeInstance._pattern.treeBehavior.goto(firstChild); - } - } - } - } -} - -const SEASON_DATA: SeasonNode[] = [ - {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..76094c8cb32a 100644 --- a/src/components-examples/aria/toolbar/simple-toolbar.ts +++ b/src/components-examples/aria/toolbar/simple-toolbar.ts @@ -1,24 +1,13 @@ -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'; -import { - afterRenderEffect, - Component, - Directive, - ElementRef, - inject, - signal, - viewChild, -} from '@angular/core'; +import {afterRenderEffect, Component, Directive, inject, 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 +21,7 @@ export class SimpleToolbarButton { @Directive({ selector: 'button[toolbar-toggle-button]', + standalone: true, hostDirectives: [{directive: ToolbarWidget, inputs: ['value']}], host: { type: 'button', @@ -46,6 +36,7 @@ export class SimpleToolbarToggleButton { @Directive({ selector: 'button[toolbar-radio-button]', + standalone: true, hostDirectives: [{directive: ToolbarWidget, inputs: ['value', 'disabled']}], host: { role: 'radio', @@ -61,38 +52,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 { +export class ToolbarCombobox { 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-basic-horizontal/toolbar-basic-horizontal-example.ts b/src/components-examples/aria/toolbar/toolbar-basic-horizontal/toolbar-basic-horizontal-example.ts index 8ec2ff47c8c7..993b5a8a9a2d 100644 --- a/src/components-examples/aria/toolbar/toolbar-basic-horizontal/toolbar-basic-horizontal-example.ts +++ b/src/components-examples/aria/toolbar/toolbar-basic-horizontal/toolbar-basic-horizontal-example.ts @@ -1,7 +1,7 @@ import {Component} from '@angular/core'; import {Toolbar, ToolbarWidget, ToolbarWidgetGroup} from '@angular/aria/toolbar'; import { - SimpleCombobox, + ToolbarCombobox, SimpleToolbarButton, SimpleToolbarRadioButton, SimpleToolbarToggleButton, @@ -16,7 +16,7 @@ import { Toolbar, ToolbarWidget, ToolbarWidgetGroup, - SimpleCombobox, + ToolbarCombobox, SimpleToolbarButton, SimpleToolbarRadioButton, SimpleToolbarToggleButton, diff --git a/src/components-examples/aria/toolbar/toolbar-common.css b/src/components-examples/aria/toolbar/toolbar-common.css index b3d258f780bc..a1a8c398c4cf 100644 --- a/src/components-examples/aria/toolbar/toolbar-common.css +++ b/src/components-examples/aria/toolbar/toolbar-common.css @@ -152,6 +152,16 @@ background-color: var(--mat-sys-surface); } +.example-popup { + width: 100%; + margin-block-start: 0.25rem; + 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); + max-height: 15rem; + overflow: auto; +} + .example-option { cursor: pointer; font-size: 0.8rem; diff --git a/src/components-examples/aria/toolbar/toolbar-configurable/toolbar-configurable-example.ts b/src/components-examples/aria/toolbar/toolbar-configurable/toolbar-configurable-example.ts index 1306d9eea92e..0aed3ae7f226 100644 --- a/src/components-examples/aria/toolbar/toolbar-configurable/toolbar-configurable-example.ts +++ b/src/components-examples/aria/toolbar/toolbar-configurable/toolbar-configurable-example.ts @@ -5,7 +5,7 @@ import {MatSelectModule} from '@angular/material/select'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import {Toolbar, ToolbarWidget, ToolbarWidgetGroup} from '@angular/aria/toolbar'; import { - SimpleCombobox, + ToolbarCombobox, SimpleToolbarButton, SimpleToolbarRadioButton, SimpleToolbarToggleButton, @@ -20,7 +20,7 @@ import { Toolbar, ToolbarWidget, ToolbarWidgetGroup, - SimpleCombobox, + ToolbarCombobox, SimpleToolbarButton, SimpleToolbarRadioButton, SimpleToolbarToggleButton, diff --git a/src/components-examples/aria/toolbar/toolbar-hard-disabled/toolbar-hard-disabled-example.ts b/src/components-examples/aria/toolbar/toolbar-hard-disabled/toolbar-hard-disabled-example.ts index 2d1c27e4863c..ffc1f14672ab 100644 --- a/src/components-examples/aria/toolbar/toolbar-hard-disabled/toolbar-hard-disabled-example.ts +++ b/src/components-examples/aria/toolbar/toolbar-hard-disabled/toolbar-hard-disabled-example.ts @@ -1,7 +1,7 @@ import {Component} from '@angular/core'; import {Toolbar, ToolbarWidget, ToolbarWidgetGroup} from '@angular/aria/toolbar'; import { - SimpleCombobox, + ToolbarCombobox, SimpleToolbarButton, SimpleToolbarRadioButton, SimpleToolbarToggleButton, @@ -16,7 +16,7 @@ import { Toolbar, ToolbarWidget, ToolbarWidgetGroup, - SimpleCombobox, + ToolbarCombobox, SimpleToolbarButton, SimpleToolbarRadioButton, SimpleToolbarToggleButton, diff --git a/src/components-examples/aria/toolbar/toolbar-rtl/toolbar-rtl-example.ts b/src/components-examples/aria/toolbar/toolbar-rtl/toolbar-rtl-example.ts index ddfca5a98528..b259c40b626b 100644 --- a/src/components-examples/aria/toolbar/toolbar-rtl/toolbar-rtl-example.ts +++ b/src/components-examples/aria/toolbar/toolbar-rtl/toolbar-rtl-example.ts @@ -2,7 +2,7 @@ import {Component} from '@angular/core'; import {Dir} from '@angular/cdk/bidi'; import {Toolbar, ToolbarWidget, ToolbarWidgetGroup} from '@angular/aria/toolbar'; import { - SimpleCombobox, + ToolbarCombobox, SimpleToolbarButton, SimpleToolbarRadioButton, SimpleToolbarToggleButton, @@ -18,7 +18,7 @@ import { Toolbar, ToolbarWidget, ToolbarWidgetGroup, - SimpleCombobox, + ToolbarCombobox, SimpleToolbarButton, SimpleToolbarRadioButton, SimpleToolbarToggleButton, diff --git a/src/components-examples/config.bzl b/src/components-examples/config.bzl index 435e064c5cb3..d7bc9e62c39a 100644 --- a/src/components-examples/config.bzl +++ b/src/components-examples/config.bzl @@ -37,6 +37,7 @@ ALL_EXAMPLES = [ "//src/components-examples/material/bottom-sheet", "//src/components-examples/material/badge", "//src/components-examples/material/autocomplete", + "//src/components-examples/aria/autocomplete", "//src/components-examples/material/timepicker", "//src/components-examples/material-experimental/column-resize", "//src/components-examples/material-experimental/popover-edit", diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index 59e6044fc142..faf459be208c 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -32,11 +32,8 @@ ng_project( "//src/dev-app/aria-listbox", "//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-toolbar-simple-combobox", "//src/dev-app/aria-tree", "//src/dev-app/autocomplete", "//src/dev-app/badge", diff --git a/src/dev-app/aria-autocomplete/BUILD.bazel b/src/dev-app/aria-autocomplete/BUILD.bazel index 8a28a19bfecf..571ec2743531 100644 --- a/src/dev-app/aria-autocomplete/BUILD.bazel +++ b/src/dev-app/aria-autocomplete/BUILD.bazel @@ -13,6 +13,5 @@ ng_project( "//:node_modules/@angular/core", "//:node_modules/@angular/forms", "//src/components-examples/aria/autocomplete", - "//src/components-examples/aria/simple-combobox", ], ) diff --git a/src/dev-app/aria-autocomplete/autocomplete-demo.html b/src/dev-app/aria-autocomplete/autocomplete-demo.html index 1bb48e44cdfe..a5adbec3be9c 100644 --- a/src/dev-app/aria-autocomplete/autocomplete-demo.html +++ b/src/dev-app/aria-autocomplete/autocomplete-demo.html @@ -1,4 +1,5 @@ -

      NgCombobox

      +

      Autocomplete

      +

      Auto select

      @@ -19,27 +20,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 6254ff5a02d0..4083f0414ecc 100644 --- a/src/dev-app/aria-combobox/combobox-demo.css +++ b/src/dev-app/aria-combobox/combobox-demo.css @@ -8,7 +8,7 @@ flex-direction: column; justify-content: flex-start; align-items: flex-start; - width: 350px; + min-width: 350px; padding: 20px 0; } @@ -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..2c7113e85a19 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,48 @@

      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 with Dialog Popup

      +
      +

      Editable Combobox with Multi-Select Dialog

      + +
      + +

      Combobox Grid Examples

      + +
      +
      +

      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..e0caa908a540 100644 --- a/src/dev-app/aria-combobox/combobox-demo.ts +++ b/src/dev-app/aria-combobox/combobox-demo.ts @@ -6,37 +6,43 @@ * 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, + ComboboxMultiselectDialogExample, } 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, + ComboboxMultiselectDialogExample, ], - changeDetection: ChangeDetectionStrategy.OnPush, }) export class ComboboxDemo {} diff --git a/src/dev-app/aria-select/BUILD.bazel b/src/dev-app/aria-select/BUILD.bazel deleted file mode 100644 index 8e65d8720b89..000000000000 --- a/src/dev-app/aria-select/BUILD.bazel +++ /dev/null @@ -1,16 +0,0 @@ -load("//tools:defaults.bzl", "ng_project") - -package(default_visibility = ["//visibility:public"]) - -ng_project( - name = "aria-select", - srcs = glob(["**/*.ts"]), - assets = [ - "select-demo.html", - ":select-demo.css", - ], - deps = [ - "//:node_modules/@angular/core", - "//src/components-examples/aria/select", - ], -) diff --git a/src/dev-app/aria-select/select-demo.css b/src/dev-app/aria-select/select-demo.css deleted file mode 100644 index e91c57a78bd9..000000000000 --- a/src/dev-app/aria-select/select-demo.css +++ /dev/null @@ -1,9 +0,0 @@ -:host { - display: flex; - flex-wrap: wrap; - gap: 10rem; -} - -.example-container { - width: 250px; -} diff --git a/src/dev-app/aria-select/select-demo.html b/src/dev-app/aria-select/select-demo.html deleted file mode 100644 index b3c95c298274..000000000000 --- a/src/dev-app/aria-select/select-demo.html +++ /dev/null @@ -1,14 +0,0 @@ -
      -

      Select Example

      - -
      - -
      -

      Multiselect Example

      - -
      - -
      -

      Disabled Example

      - -
      diff --git a/src/dev-app/aria-select/select-demo.ts b/src/dev-app/aria-select/select-demo.ts deleted file mode 100644 index ef1e85d8c332..000000000000 --- a/src/dev-app/aria-select/select-demo.ts +++ /dev/null @@ -1,22 +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 { - SelectDisabledExample, - SelectMultiExample, - SelectExample, -} from '@angular/components-examples/aria/select'; - -@Component({ - templateUrl: 'select-demo.html', - styleUrl: 'select-demo.css', - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [SelectDisabledExample, SelectMultiExample, SelectExample], -}) -export class SelectDemo {} 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 58f88845818a..000000000000 --- a/src/dev-app/aria-simple-combobox/simple-combobox-demo.html +++ /dev/null @@ -1,88 +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

      - -
      -
      -

      Editable Combobox with Multi-Select Dialog

      - -
      -
      - -

      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 d56a255440e8..000000000000 --- a/src/dev-app/aria-simple-combobox/simple-combobox-demo.ts +++ /dev/null @@ -1,48 +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, - SimpleComboboxMultiselectDialogExample, -} 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, - SimpleComboboxMultiselectDialogExample, - ], -}) -export class ComboboxDemo {} diff --git a/src/dev-app/aria-toolbar-simple-combobox/BUILD.bazel b/src/dev-app/aria-toolbar-simple-combobox/BUILD.bazel deleted file mode 100644 index 9a8696521aea..000000000000 --- a/src/dev-app/aria-toolbar-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-toolbar-simple-combobox", - srcs = glob(["**/*.ts"]), - assets = [ - "toolbar-demo.html", - ":toolbar-demo.css", - ], - deps = [ - "//:node_modules/@angular/core", - "//src/components-examples/aria/aria-toolbar-simple-combobox:toolbar", - ], -) diff --git a/src/dev-app/aria-toolbar-simple-combobox/toolbar-demo.css b/src/dev-app/aria-toolbar-simple-combobox/toolbar-demo.css deleted file mode 100644 index f9682baa7ca7..000000000000 --- a/src/dev-app/aria-toolbar-simple-combobox/toolbar-demo.css +++ /dev/null @@ -1,26 +0,0 @@ -.example-radio-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(100%, 1fr)); - gap: 20px; -} - -.example-radio-container { - width: 500px; - display: flex; - flex-direction: column; - justify-content: flex-start; -} - -.example-configurable-radio-container { - padding-top: 40px; -} - -h4 { - height: 36px; -} - -.example-toolbar-container { - width: 100%; -} - - diff --git a/src/dev-app/aria-toolbar-simple-combobox/toolbar-demo.html b/src/dev-app/aria-toolbar-simple-combobox/toolbar-demo.html deleted file mode 100644 index 42fee57da871..000000000000 --- a/src/dev-app/aria-toolbar-simple-combobox/toolbar-demo.html +++ /dev/null @@ -1,23 +0,0 @@ -

      Toolbar Examples with Simple Combobox

      -
      -
      -

      Toolbar Basic Horizontal

      - -
      -
      -

      Toolbar Basic Vertical

      - -
      -
      -

      Toolbar with Hard Disabled Items

      - -
      -
      -

      Toolbar RTL

      - -
      -
      -

      Configurable CDK Toolbar

      - -
      -
      diff --git a/src/dev-app/aria-toolbar-simple-combobox/toolbar-demo.ts b/src/dev-app/aria-toolbar-simple-combobox/toolbar-demo.ts deleted file mode 100644 index 2f1b5c7fde24..000000000000 --- a/src/dev-app/aria-toolbar-simple-combobox/toolbar-demo.ts +++ /dev/null @@ -1,31 +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, ViewEncapsulation} from '@angular/core'; -import { - ToolbarBasicHorizontalExample, - ToolbarBasicVerticalExample, - ToolbarConfigurableExample, - ToolbarRtlExample, - ToolbarHardDisabledExample, -} from '@angular/components-examples/aria/aria-toolbar-simple-combobox'; - -@Component({ - templateUrl: 'toolbar-demo.html', - imports: [ - ToolbarBasicHorizontalExample, - ToolbarBasicVerticalExample, - ToolbarConfigurableExample, - ToolbarRtlExample, - ToolbarHardDisabledExample, - ], - styleUrl: './toolbar-demo.css', - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ToolbarDemo {} diff --git a/src/dev-app/aria-toolbar/toolbar-demo.css b/src/dev-app/aria-toolbar/toolbar-demo.css index 7164c58accca..f9682baa7ca7 100644 --- a/src/dev-app/aria-toolbar/toolbar-demo.css +++ b/src/dev-app/aria-toolbar/toolbar-demo.css @@ -22,3 +22,5 @@ h4 { .example-toolbar-container { width: 100%; } + + diff --git a/src/dev-app/aria-toolbar/toolbar-demo.html b/src/dev-app/aria-toolbar/toolbar-demo.html index 34d4cee88762..b6e9553457de 100644 --- a/src/dev-app/aria-toolbar/toolbar-demo.html +++ b/src/dev-app/aria-toolbar/toolbar-demo.html @@ -1,25 +1,23 @@ -
      -

      Toolbar Examples with Combobox

      -
      -
      -

      Toolbar Basic Horizontal

      - -
      -
      -

      Toolbar Basic Vertical

      - -
      -
      -

      Toolbar with Hard Disabled Items

      - -
      -
      -

      Toolbar RTL

      - -
      -
      -

      Configurable CDK Toolbar

      - -
      +

      Toolbar Examples with Combobox

      +
      +
      +

      Toolbar Basic Horizontal

      + +
      +
      +

      Toolbar Basic Vertical

      + +
      +
      +

      Toolbar with Hard Disabled Items

      + +
      +
      +

      Toolbar RTL

      + +
      +
      +

      Configurable CDK Toolbar

      +
      diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index 7926e4d6518d..b7533d0026f1 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -63,17 +63,14 @@ export class DevAppLayout { {name: 'Examples', route: '/examples'}, {name: 'CDK Dialog', route: '/cdk-dialog'}, {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 Combobox', route: '/aria-combobox'}, {name: 'Aria Grid', route: '/aria-grid'}, {name: 'Aria Listbox', route: '/aria-listbox'}, {name: 'Aria Menu', route: '/aria-menu'}, {name: 'Aria Menubar', route: '/aria-menubar'}, - {name: 'Aria Select', route: '/aria-select'}, {name: 'Aria Tabs', route: '/aria-tabs'}, {name: 'Aria Toolbar', route: '/aria-toolbar'}, - {name: 'Aria Toolbar Simple Combobox', route: '/aria-toolbar-simple-combobox'}, {name: 'Aria Tree', route: '/aria-tree'}, {name: 'CDK Listbox', route: '/cdk-listbox'}, {name: 'CDK Menu', route: '/cdk-menu'}, diff --git a/src/dev-app/routes.ts b/src/dev-app/routes.ts index e0689741c3ea..03fefd5abd7c 100644 --- a/src/dev-app/routes.ts +++ b/src/dev-app/routes.ts @@ -44,15 +44,6 @@ export const DEV_APP_ROUTES: Routes = [ path: 'aria-combobox', loadComponent: () => import('./aria-combobox/combobox-demo').then(m => m.ComboboxDemo), }, - { - 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), @@ -90,11 +81,6 @@ export const DEV_APP_ROUTES: Routes = [ path: 'aria-toolbar', loadComponent: () => import('./aria-toolbar/toolbar-demo').then(m => m.ToolbarDemo), }, - { - path: 'aria-toolbar-simple-combobox', - loadComponent: () => - import('./aria-toolbar-simple-combobox/toolbar-demo').then(m => m.ToolbarDemo), - }, { path: 'cdk-dialog', loadComponent: () => import('./cdk-dialog/dialog-demo').then(m => m.DialogDemo),