From 4ab9ce6eb31aa1c144027f23aca1b64a13c28cf6 Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Tue, 9 Dec 2025 17:50:38 +0000 Subject: [PATCH 01/11] refactor(aria/combobox): alt approach WIP --- src/aria/private/BUILD.bazel | 1 + .../behaviors/list-focus/list-focus.ts | 6 +- src/aria/private/public-api.ts | 1 + src/aria/private/simple-combobox/BUILD.bazel | 18 ++ .../simple-combobox/simple-combobox.ts | 281 ++++++++++++++++++ src/aria/simple-combobox/BUILD.bazel | 16 + src/aria/simple-combobox/index.ts | 9 + src/aria/simple-combobox/simple-combobox.ts | 274 +++++++++++++++++ .../aria/simple-combobox/BUILD.bazel | 30 ++ .../aria/simple-combobox/index.ts | 4 + .../simple-combobox-examples.css | 203 +++++++++++++ ...imple-combobox-listbox-inline-example.html | 48 +++ .../simple-combobox-listbox-inline-example.ts | 114 +++++++ .../simple-combobox-listbox-example.html | 48 +++ .../simple-combobox-listbox-example.ts | 104 +++++++ .../simple-combobox-select-example.css | 94 ++++++ .../simple-combobox-select-example.html | 46 +++ .../simple-combobox-select-example.ts | 51 ++++ .../simple-combobox-tree-example.html | 70 +++++ .../simple-combobox-tree-example.ts | 111 +++++++ src/dev-app/BUILD.bazel | 1 + src/dev-app/aria-simple-combobox/BUILD.bazel | 16 + .../simple-combobox-demo.css | 22 ++ .../simple-combobox-demo.html | 33 ++ .../simple-combobox-demo.ts | 27 ++ src/dev-app/dev-app/dev-app-layout.ts | 1 + src/dev-app/routes.ts | 5 + 27 files changed, 1631 insertions(+), 3 deletions(-) create mode 100644 src/aria/private/simple-combobox/BUILD.bazel create mode 100644 src/aria/private/simple-combobox/simple-combobox.ts create mode 100644 src/aria/simple-combobox/BUILD.bazel create mode 100644 src/aria/simple-combobox/index.ts create mode 100644 src/aria/simple-combobox/simple-combobox.ts create mode 100644 src/components-examples/aria/simple-combobox/BUILD.bazel create mode 100644 src/components-examples/aria/simple-combobox/index.ts create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-examples.css create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-listbox-inline/simple-combobox-listbox-inline-example.html create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-listbox-inline/simple-combobox-listbox-inline-example.ts create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.html create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.ts create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.css create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.html create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.ts create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts create mode 100644 src/dev-app/aria-simple-combobox/BUILD.bazel create mode 100644 src/dev-app/aria-simple-combobox/simple-combobox-demo.css create mode 100644 src/dev-app/aria-simple-combobox/simple-combobox-demo.html create mode 100644 src/dev-app/aria-simple-combobox/simple-combobox-demo.ts diff --git a/src/aria/private/BUILD.bazel b/src/aria/private/BUILD.bazel index 22b2130483dc..2685cd2d34a4 100644 --- a/src/aria/private/BUILD.bazel +++ b/src/aria/private/BUILD.bazel @@ -17,6 +17,7 @@ ts_project( "//src/aria/private/grid", "//src/aria/private/listbox", "//src/aria/private/menu", + "//src/aria/private/simple-combobox", "//src/aria/private/tabs", "//src/aria/private/toolbar", "//src/aria/private/tree", diff --git a/src/aria/private/behaviors/list-focus/list-focus.ts b/src/aria/private/behaviors/list-focus/list-focus.ts index 1aba209153d0..f2fddbdfac5c 100644 --- a/src/aria/private/behaviors/list-focus/list-focus.ts +++ b/src/aria/private/behaviors/list-focus/list-focus.ts @@ -103,9 +103,9 @@ export class ListFocus { this.inputs.activeItem.set(item); if (opts?.focusElement || opts?.focusElement === undefined) { - this.inputs.focusMode() === 'roving' - ? item.element()?.focus() - : this.inputs.element()?.focus(); + if (this.inputs.focusMode() === 'roving') { + item.element()?.focus(); + } } return true; diff --git a/src/aria/private/public-api.ts b/src/aria/private/public-api.ts index 0b402dd342a6..ad2f87c2aafc 100644 --- a/src/aria/private/public-api.ts +++ b/src/aria/private/public-api.ts @@ -26,3 +26,4 @@ export * from './grid/cell'; export * from './grid/widget'; export * from './deferred-content'; export * from './utils/element'; +export * from './simple-combobox/simple-combobox'; diff --git a/src/aria/private/simple-combobox/BUILD.bazel b/src/aria/private/simple-combobox/BUILD.bazel new file mode 100644 index 000000000000..ff1162cde8f4 --- /dev/null +++ b/src/aria/private/simple-combobox/BUILD.bazel @@ -0,0 +1,18 @@ +load("//tools:defaults.bzl", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "simple-combobox", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//:node_modules/@angular/core", + "//src/aria/private/behaviors/event-manager", + "//src/aria/private/behaviors/expansion", + "//src/aria/private/behaviors/list", + "//src/aria/private/behaviors/signal-like", + ], +) diff --git a/src/aria/private/simple-combobox/simple-combobox.ts b/src/aria/private/simple-combobox/simple-combobox.ts new file mode 100644 index 000000000000..691c9ae7d026 --- /dev/null +++ b/src/aria/private/simple-combobox/simple-combobox.ts @@ -0,0 +1,281 @@ +/** + * @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, PointerEventManager} 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 { + /** 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; +} + +/** Controls the state of a simple combobox. */ +export class SimpleComboboxPattern { + /** Whether the combobox is expanded. */ + readonly expanded: WritableSignalLike; + + /** The value of the combobox. */ + readonly value: WritableSignalLike; + + /** The element that the combobox is attached to. */ + readonly element = () => this.inputs.element(); + + /** Whether the combobox is disabled. */ + readonly disabled = () => this.inputs.disabled(); + + /** 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 hasPopup = !!this.inputs.popup(); + const hasInlineSuggestion = !!this.inlineSuggestion(); + if (hasPopup && hasInlineSuggestion) { + return 'both'; + } + if (hasPopup) { + return 'list'; + } + if (hasInlineSuggestion) { + return 'inline'; + } + return 'none'; + }); + + /** A relay for keyboard events to the popup. */ + readonly keyboardEventRelay = signal(undefined); + + /** Whether the combobox is focused. */ + readonly isFocused = signal(false); + + /** Whether the most recent input event was a deletion. */ + readonly isDeleting = signal(false); + + /** Whether the combobox is editable (i.e., an input or textarea). */ + readonly isEditable = computed( + () => + this.element().tagName.toLowerCase() === 'input' || + this.element().tagName.toLowerCase() === 'textarea', + ); + + /** The keydown event manager for the combobox. */ + keydown = computed(() => { + const manager = new KeyboardEventManager(); + + if (!this.expanded()) { + manager.on('ArrowDown', () => this.expanded.set(true)); + + if (!this.isEditable()) { + manager.on(/^(Enter| )$/, () => this.expanded.set(true)); + } + + return manager; + } + + manager + .on( + 'ArrowLeft', + e => { + this.keyboardEventRelay.set(e); + }, + {preventDefault: this.popupType() !== 'listbox'}, + ) + .on( + 'ArrowRight', + e => { + this.keyboardEventRelay.set(e); + }, + {preventDefault: this.popupType() !== 'listbox'}, + ) + .on('ArrowUp', e => this.keyboardEventRelay.set(e)) + .on('ArrowDown', e => this.keyboardEventRelay.set(e)) + .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', () => this.expanded.set(false)); + + if (!this.isEditable()) { + manager + .on(' ', e => this.keyboardEventRelay.set(e)) + .on(/^.$/, e => { + this.keyboardEventRelay.set(e); + }); + } + + return manager; + }); + + /** The pointerdown event manager for the combobox. */ + pointerdown = computed(() => { + const manager = new PointerEventManager(); + + if (this.isEditable()) return manager; + + manager.on(() => this.expanded.update(v => !v)); + + return manager; + }); + + constructor(readonly inputs: SimpleComboboxInputs) { + this.expanded = inputs.expanded; + this.value = inputs.value; + } + + /** Handles keydown events for the combobox. */ + onKeydown(event: KeyboardEvent) { + if (!this.inputs.disabled()) { + this.keydown().handle(event); + } + } + + /** Handles pointerdown events for the combobox. */ + onPointerdown(event: PointerEvent) { + if (!this.disabled()) { + this.pointerdown().handle(event); + } + } + + /** Handles focus in events for the combobox. */ + onFocusin() { + this.isFocused.set(true); + } + + /** Handles focus out events for the combobox. */ + onFocusout(event: FocusEvent) { + const focusTarget = event.relatedTarget as Element | null; + if (this.element().contains(focusTarget)) return; + + this.isFocused.set(false); + } + + /** Handles input events for the combobox. */ + onInput(event: Event) { + if (!(event.target instanceof HTMLInputElement)) return; + if (this.disabled()) return; + + this.expanded.set(true); + this.value.set(event.target.value); + this.isDeleting.set(event instanceof InputEvent && !!event.inputType.match(/^delete/)); + } + + /** Highlights the currently selected item in the combobox. */ + highlightEffect() { + const value = this.value(); + const inlineSuggestion = this.inlineSuggestion(); + + const isDeleting = untracked(() => this.isDeleting()); + const isFocused = untracked(() => this.isFocused()); + const isExpanded = untracked(() => this.expanded()); + + if (!inlineSuggestion || !isFocused || !isExpanded || isDeleting) return; + + const inputEl = this.element() as HTMLInputElement; + const isHighlightable = inlineSuggestion.toLowerCase().startsWith(value.toLowerCase()); + + if (isHighlightable) { + inputEl.value = value + inlineSuggestion.slice(value.length); + inputEl.setSelectionRange(value.length, inlineSuggestion.length); + } + } + + /** Relays keyboard events to the popup. */ + keyboardEventRelayEffect() { + const event = this.keyboardEventRelay(); + if (event === undefined) return; + + const popup = untracked(() => this.inputs.popup()); + const popupExpanded = untracked(() => this.expanded()); + if (popupExpanded) { + popup?.controlTarget()?.dispatchEvent(event); + } + } + + /** Closes the popup when focus leaves the combobox and popup. */ + closePopupOnBlurEffect() { + const expanded = this.expanded(); + const comboboxFocused = this.isFocused(); + const popupFocused = !!this.inputs.popup()?.isFocused(); + if (expanded && !comboboxFocused && !popupFocused) { + this.expanded.set(false); + } + } +} + +/** Represents the required inputs for a simple combobox popup. */ +export interface SimpleComboboxPopupInputs { + /** The type of the popup. */ + popupType: SignalLike<'listbox' | 'tree' | 'grid' | 'dialog'>; + + /** The element that serves as the control target for the popup. */ + controlTarget: SignalLike; + + /** The ID of the active descendant in the popup. */ + activeDescendant: SignalLike; + + /** The ID of the popup. */ + popupId: SignalLike; +} + +/** Controls the state of a simple combobox popup. */ +export class SimpleComboboxPopupPattern { + /** The type of the popup. */ + readonly popupType = () => this.inputs.popupType(); + + /** The element that serves as the control target for the popup. */ + readonly controlTarget = () => this.inputs.controlTarget(); + + /** The ID of the active descendant in the popup. */ + readonly activeDescendant = () => this.inputs.activeDescendant(); + + /** The ID of the popup. */ + readonly popupId = () => this.inputs.popupId(); + + /** Whether the popup is focused. */ + readonly isFocused = signal(false); + + constructor(readonly inputs: SimpleComboboxPopupInputs) {} + + /** Handles focus in events for the popup. */ + onFocusin() { + this.isFocused.set(true); + } + + /** Handles focus out events for the popup. */ + onFocusout(event: FocusEvent) { + const focusTarget = event.relatedTarget as Element | null; + if (this.controlTarget()?.contains(focusTarget)) return; + + this.isFocused.set(false); + } +} diff --git a/src/aria/simple-combobox/BUILD.bazel b/src/aria/simple-combobox/BUILD.bazel new file mode 100644 index 000000000000..c872f7887fdb --- /dev/null +++ b/src/aria/simple-combobox/BUILD.bazel @@ -0,0 +1,16 @@ +load("//tools:defaults.bzl", "ng_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", + ], +) diff --git a/src/aria/simple-combobox/index.ts b/src/aria/simple-combobox/index.ts new file mode 100644 index 000000000000..2a3628897dec --- /dev/null +++ b/src/aria/simple-combobox/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export {Combobox, ComboboxPopup, ComboboxWidget} from './simple-combobox'; diff --git a/src/aria/simple-combobox/simple-combobox.ts b/src/aria/simple-combobox/simple-combobox.ts new file mode 100644 index 000000000000..fc8f41b374d2 --- /dev/null +++ b/src/aria/simple-combobox/simple-combobox.ts @@ -0,0 +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 { + afterRenderEffect, + booleanAttribute, + computed, + Directive, + ElementRef, + inject, + input, + model, + OnDestroy, + OnInit, + signal, + Renderer2, +} from '@angular/core'; +import { + DeferredContent, + DeferredContentAware, + SimpleComboboxPattern, + SimpleComboboxPopupPattern, +} from '@angular/aria/private'; + +/** + * The container element that wraps a combobox input and popup, and orchestrates its behavior. + * + * The `ngCombobox` directive is the main entry point for creating a combobox and customizing its + * behavior. It coordinates the interactions between the input and the popup. + * + * ```html + *
+ * + * + * + *
+ * + *
+ *
+ *
+ * ``` + */ +@Directive({ + selector: '[ngCombobox]', + exportAs: 'ngCombobox', + host: { + 'role': 'combobox', + '[attr.aria-autocomplete]': '_pattern.autocomplete()', + '[attr.aria-disabled]': '_pattern.disabled()', + '[attr.aria-expanded]': '_pattern.expanded()', + '[attr.aria-activedescendant]': '_pattern.activeDescendant()', + '[attr.aria-controls]': '_pattern.popupId()', + '[attr.aria-haspopup]': '_pattern.popupType()', + '(keydown)': '_pattern.onKeydown($event)', + '(focusin)': '_pattern.onFocusin()', + '(focusout)': '_pattern.onFocusout($event)', + '(pointerdown)': '_pattern.onPointerdown($event)', + '(input)': '_pattern.onInput($event)', + }, +}) +export class Combobox extends DeferredContentAware { + 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 expanded. */ + readonly expanded = model(false); + + /** The value of the combobox input. */ + readonly value = model(''); + + /** An inline suggestion to be displayed in the input. */ + readonly inlineSuggestion = input(undefined); + + /** The combobox ui pattern. */ + readonly _pattern = new SimpleComboboxPattern({ + ...this, + element: () => this.element, + expandable: () => true, + popup: computed(() => this._popup()?._pattern), + }); + + constructor() { + super(); + + afterRenderEffect(() => this._pattern.keyboardEventRelayEffect()); + afterRenderEffect(() => this._pattern.closePopupOnBlurEffect()); + afterRenderEffect(() => { + this.contentVisible.set(this._pattern.expanded()); + }); + + if (this._pattern.isEditable()) { + afterRenderEffect(() => { + this._renderer.setProperty(this.element, 'value', this.value()); + }); + afterRenderEffect(() => { + this._pattern.highlightEffect(); + }); + } + } + + /** Registers a popup with the combobox. */ + _registerPopup(popup: ComboboxPopup) { + this._popup.set(popup); + } + + /** Unregisters the popup from the combobox. */ + _unregisterPopup() { + this._popup.set(undefined); + } +} + +/** + * A structural directive that marks the `ng-template` to be used as the popup + * for a combobox. This content is conditionally rendered. + * + * The content of the popup can be any element with the `ngComboboxWidget` directive. + * + * ```html + * + *
+ * + *
+ *
+ * ``` + */ +@Directive({ + selector: 'ng-template[ngComboboxPopup]', + exportAs: 'ngComboboxPopup', + hostDirectives: [DeferredContent], +}) +export class ComboboxPopup implements OnInit, OnDestroy { + private readonly _deferredContent = inject(DeferredContent); + + /** The combobox that the popup belongs to. */ + readonly combobox = input.required(); + + /** The widget contained within the popup. */ + readonly _widget = signal(undefined); + + /** The element that serves as the control target for the popup. */ + readonly controlTarget = computed(() => this._widget()?.element); + + /** The ID of the popup. */ + readonly popupId = computed(() => this._widget()?.popupId()); + + /** The ID of the active descendant in the popup. */ + readonly activeDescendant = computed(() => this._widget()?.activeDescendant()); + + /** The type of the popup (e.g., listbox, tree, grid, dialog). */ + readonly popupType = input<'listbox' | 'tree' | 'grid' | 'dialog'>('listbox'); + + /** The popup pattern. */ + readonly _pattern = new SimpleComboboxPopupPattern({ + ...this, + }); + + ngOnInit() { + this.combobox()._registerPopup(this); + this._deferredContent.deferredContentAware.set(this.combobox()); + } + + ngOnDestroy() { + this.combobox()._unregisterPopup(); + } + + /** Registers a widget with the popup. */ + _registerWidget(widget: ComboboxWidget) { + this._widget.set(widget); + } + + /** Unregisters the widget from the popup. */ + _unregisterWidget() { + this._widget.set(undefined); + } +} + +/** + * Identifies an element as a widget within a combobox popup. + * + * This directive should be applied to the element that contains the options or content + * of the popup. It handles the communication of ID and active descendant information + * to the combobox. + */ +@Directive({ + selector: '[ngComboboxWidget]', + exportAs: 'ngComboboxWidget', + host: { + '(focusin)': 'onFocusin()', + '(focusout)': 'onFocusout($event)', + }, +}) +export class ComboboxWidget implements OnInit, OnDestroy { + /** The element that the popup widget is attached to. */ + private readonly _elementRef = inject>(ElementRef); + private readonly _popup = inject(ComboboxPopup); + + private _observer: MutationObserver | undefined; + + /** A reference to the popup widget element. */ + readonly element = this._elementRef.nativeElement; + + /** The ID of the popup widget. */ + readonly popupId = signal(undefined); + + /** The ID of the active descendant in the widget. */ + readonly activeDescendant = signal(undefined); + + constructor() { + afterRenderEffect(() => { + const controlTarget = this.element; + + this.popupId.set(controlTarget.id); + + this._observer?.disconnect(); + this._observer = new MutationObserver((mutationsList: MutationRecord[]) => { + for (const mutation of mutationsList) { + if (mutation.type === 'attributes' && mutation.attributeName) { + const attributeName = mutation.attributeName; + + if (attributeName === 'aria-activedescendant') { + const activeDescendant = controlTarget.getAttribute('aria-activedescendant'); + if (activeDescendant !== null) { + this.activeDescendant.set(activeDescendant); + } + } + + if (attributeName === 'id') { + this.popupId.set(controlTarget.id); + } + } + } + }); + this._observer.observe(controlTarget, { + attributes: true, + attributeFilter: ['id', 'aria-activedescendant'], + }); + }); + } + + ngOnInit() { + this._popup._registerWidget(this); + } + + ngOnDestroy(): void { + this._observer?.disconnect(); + this._popup._unregisterWidget(); + } + + /** Handles focus in events for the widget. */ + onFocusin() { + this._popup._pattern.onFocusin(); + } + + /** Handles focus out events for the widget. */ + onFocusout(event: FocusEvent) { + this._popup._pattern.onFocusout(event); + } +} diff --git a/src/components-examples/aria/simple-combobox/BUILD.bazel b/src/components-examples/aria/simple-combobox/BUILD.bazel new file mode 100644 index 000000000000..f6af98e8ca50 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/BUILD.bazel @@ -0,0 +1,30 @@ +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/listbox", + "//src/aria/simple-combobox", + "//src/aria/tree", + "//src/cdk/overlay", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.ts", + ]), +) diff --git a/src/components-examples/aria/simple-combobox/index.ts b/src/components-examples/aria/simple-combobox/index.ts new file mode 100644 index 000000000000..2e5c63dad8c0 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/index.ts @@ -0,0 +1,4 @@ +export {SimpleComboboxListboxExample} from './simple-combobox-listbox/simple-combobox-listbox-example'; +export {SimpleComboboxListboxInlineExample} from './simple-combobox-listbox-inline/simple-combobox-listbox-inline-example'; +export {SimpleComboboxTreeExample} from './simple-combobox-tree/simple-combobox-tree-example'; +export {SimpleComboboxSelectExample} from './simple-combobox-select/simple-combobox-select-example'; diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-examples.css b/src/components-examples/aria/simple-combobox/simple-combobox-examples.css new file mode 100644 index 000000000000..d93a0449e51e --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-examples.css @@ -0,0 +1,203 @@ +.example-combobox-container { + position: relative; + width: 100%; + display: flex; + flex-direction: column; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); + transition: + box-shadow 0.2s ease, + border-color 0.2s ease; +} + +.example-combobox-container:has([readonly='true']:not([aria-disabled='true'])) { + width: 200px; +} + +.example-combobox-input-container { + display: flex; + position: relative; + align-items: center; + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-combobox-input { + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-combobox-input[readonly='true']:not([aria-disabled='true']) { + cursor: pointer; + padding: 0.7rem 1rem; +} + +.example-combobox-container:focus-within { + border-color: var(--mat-sys-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--mat-sys-primary) 25%, transparent); +} + +.example-icon { + width: 24px; + height: 24px; + font-size: 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-popup { + width: 100%; + margin-block-start: 0.25rem; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); + background-color: var(--mat-sys-surface); +} + +.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); + transition: + background-color 0.2s ease, + color 0.2s ease; + 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-listbox-inline/simple-combobox-listbox-inline-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-listbox-inline/simple-combobox-listbox-inline-example.html new file mode 100644 index 000000000000..0d68b4da8326 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-listbox-inline/simple-combobox-listbox-inline-example.html @@ -0,0 +1,48 @@ +
+
+ search + +
+ + + +
+ @for (option of options(); track option) { +
+ {{option}} + +
+ } +
+
+
+
diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-listbox-inline/simple-combobox-listbox-inline-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-listbox-inline/simple-combobox-listbox-inline-example.ts new file mode 100644 index 000000000000..5ffa93172de3 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-listbox-inline/simple-combobox-listbox-inline-example.ts @@ -0,0 +1,114 @@ +/** + * @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, + linkedSignal, +} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; + +/** @title */ +@Component({ + selector: 'simple-combobox-listbox-inline-example', + templateUrl: 'simple-combobox-listbox-inline-example.html', + styleUrl: '../simple-combobox-examples.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], +}) +export class SimpleComboboxListboxInlineExample { + readonly listbox = viewChild(Listbox); + + popupExpanded = signal(false); + searchString = signal(''); + selectedOption = linkedSignal(() => + this.options().length > 0 ? [this.options()[0]] : [], + ); + + 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-listbox/simple-combobox-listbox-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.html new file mode 100644 index 000000000000..b7b28ea32ab3 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.html @@ -0,0 +1,48 @@ +
+
+ search + +
+ + + +
+ @for (option of options(); track option) { +
+ {{option}} + +
+ } +
+
+
+
diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.ts new file mode 100644 index 000000000000..a91bffdf164f --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import {afterRenderEffect, Component, computed, signal, viewChild, untracked} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; + +/** @title */ +@Component({ + selector: 'simple-combobox-listbox-example', + templateUrl: 'simple-combobox-listbox-example.html', + styleUrl: '../simple-combobox-examples.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], +}) +export class SimpleComboboxListboxExample { + readonly listbox = viewChild(Listbox); + + popupExpanded = signal(false); + searchString = signal(''); + selectedOption = signal([]); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + constructor() { + afterRenderEffect(() => { + this.listbox()?.scrollActiveItemIntoView(); + }); + + 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-select/simple-combobox-select-example.css b/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.css new file mode 100644 index 000000000000..6d8eadaaeefd --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.css @@ -0,0 +1,94 @@ +.example-select { + display: flex; + position: relative; + align-items: center; + color: var(--mat-sys-on-primary); + font-size: var(--mat-sys-label-large); + background-color: var(--mat-sys-primary); + border-radius: var(--mat-sys-corner-extra-large); + padding: 0 2rem; + height: 3rem; + cursor: pointer; + user-select: none; + outline: none; +} + +.example-select:hover { + background-color: color-mix(in srgb, var(--mat-sys-primary) 90%, transparent); +} + +.example-select:focus { + outline-offset: 2px; + outline: 2px solid var(--mat-sys-primary); +} + +.example-combobox-text { + width: 9rem; +} + +.example-arrow { + pointer-events: none; + transition: transform 150ms ease-in-out; +} + +[ngCombobox][aria-expanded='true'] .example-arrow { + transform: rotate(180deg); +} + +.example-popup-container { + width: 100%; + padding: 0.5rem; + margin-top: 8px; + border-radius: var(--mat-sys-corner-large); + background-color: var(--mat-sys-surface-container); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +[ngListbox] { + gap: 4px; + display: flex; + overflow: auto; + flex-direction: column; + max-height: 13rem; +} + +[ngOption] { + display: flex; + cursor: pointer; + align-items: center; + padding: 0 1rem; + min-height: 3rem; + color: var(--mat-sys-on-surface); + font-size: var(--mat-sys-label-large); + border-radius: var(--mat-sys-corner-extra-large); +} + +[ngOption]:hover { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 5%, transparent); +} + +[ngOption][data-active='true'] { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent); +} + +[ngOption][aria-selected='true'] { + color: var(--mat-sys-primary); + background-color: color-mix(in srgb, var(--mat-sys-primary) 10%, transparent); +} + +.example-option-icon { + padding-right: 1rem; +} + +.example-option-check, +.example-option-icon { + font-size: var(--mat-sys-label-large); +} + +[ngOption]:not([aria-selected='true']) .example-option-check { + display: none; +} + +.example-option-text { + flex: 1; +} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.html new file mode 100644 index 000000000000..b36b62dd0686 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.html @@ -0,0 +1,46 @@ +
+ {{value()}} + arrow_drop_down +
+ + + +
+
+ @for (option of options(); track option.value) { +
+ @if (option.icon) { + {{option.icon}} + } + {{option.value}} + @if (selectedValues().includes(option.value)) { + + } +
+ } +
+
+
+
diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.ts new file mode 100644 index 000000000000..5bf81a940e24 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Component, signal, afterRenderEffect, viewChild} from '@angular/core'; +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import {OverlayModule} from '@angular/cdk/overlay'; + +@Component({ + selector: 'simple-combobox-select-example', + templateUrl: 'simple-combobox-select-example.html', + styleUrl: 'simple-combobox-select-example.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], +}) +export class SimpleComboboxSelectExample { + readonly listbox = viewChild(Listbox); + + readonly options = signal([ + {value: 'Select a label', icon: ''}, + {value: 'Important', icon: 'label'}, + {value: 'Starred', icon: 'star'}, + {value: 'Work', icon: 'work'}, + {value: 'Personal', icon: 'person'}, + {value: 'To Do', icon: 'checklist'}, + {value: 'Later', icon: 'schedule'}, + {value: 'Read', icon: 'menu_book'}, + {value: 'Travel', icon: 'flight'}, + ]); + readonly value = signal('Select a label'); + readonly selectedValues = signal(['Select a label']); + readonly popupExpanded = signal(false); + + constructor() { + afterRenderEffect(() => { + this.listbox()?.scrollActiveItemIntoView(); + }); + } + + onCommit() { + const values = this.selectedValues(); + if (values.length) { + this.value.set(values[0]); + this.popupExpanded.set(false); + } + } +} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html new file mode 100644 index 000000000000..a398911c78b4 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html @@ -0,0 +1,70 @@ +
+
+ search + +
+ + + +
    + +
+
+
+
+ + + @for (node of nodes; track node.name) { +
  • + + {{ node.name }} + +
  • + @if (node.children) { +
      + + + +
    + } + } +
    diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts new file mode 100644 index 000000000000..4678828be53d --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts @@ -0,0 +1,111 @@ +/** + * @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, computed, signal, viewChild} from '@angular/core'; +import {NgTemplateOutlet} from '@angular/common'; +import {OverlayModule} from '@angular/cdk/overlay'; + +interface FoodNode { + name: string; + children?: FoodNode[]; + expanded?: boolean; +} + +/** @title */ +@Component({ + selector: 'simple-combobox-tree-example', + templateUrl: 'simple-combobox-tree-example.html', + styleUrl: '../simple-combobox-examples.css', + imports: [ + Combobox, + ComboboxPopup, + ComboboxWidget, + NgTemplateOutlet, + Tree, + TreeItem, + TreeItemGroup, + OverlayModule, + ], +}) +export class SimpleComboboxTreeExample { + readonly tree = viewChild(Tree); + + popupExpanded = signal(false); + searchString = signal(''); + selectedValues = signal([]); + + readonly dataSource = signal(FOOD_DATA); + + filteredGroups = computed(() => { + const search = this.searchString().toLowerCase(); + const data = this.dataSource(); + + if (!search) { + return data; + } + + const filterNode = (node: FoodNode): FoodNode | null => { + const matches = node.name.toLowerCase().includes(search); + const children = node.children + ?.map(child => filterNode(child)) + .filter((child): child is FoodNode => child !== null); + + if (matches || (children && children.length > 0)) { + return { + ...node, + children, + expanded: children && children.length > 0, + }; + } + + return null; + }; + + return data.map(node => filterNode(node)).filter((node): node is FoodNode => node !== null); + }); + + onCommit() { + const selected = this.selectedValues(); + if (selected.length > 0) { + const value = selected[0]; + this.searchString.set(value.name); + this.popupExpanded.set(false); + } + } +} + +const FOOD_DATA: FoodNode[] = [ + { + name: 'Fruits', + children: [ + {name: 'Apples'}, + {name: 'Bananas'}, + { + name: 'Berries', + children: [{name: 'Strawberry'}, {name: 'Blueberry'}, {name: 'Raspberry'}], + }, + {name: 'Oranges'}, + ], + }, + { + name: 'Vegetables', + children: [ + { + name: 'Green', + children: [{name: 'Broccoli'}, {name: 'Brussels sprouts'}], + }, + { + name: 'Orange', + children: [{name: 'Pumpkins'}, {name: 'Carrots'}], + }, + {name: 'Onions'}, + ], + }, +]; diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index b18b11e22ea1..f4b2184f1ba3 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -33,6 +33,7 @@ ng_project( "//src/dev-app/aria-menu", "//src/dev-app/aria-menubar", "//src/dev-app/aria-select", + "//src/dev-app/aria-simple-combobox", "//src/dev-app/aria-tabs", "//src/dev-app/aria-toolbar", "//src/dev-app/aria-tree", diff --git a/src/dev-app/aria-simple-combobox/BUILD.bazel b/src/dev-app/aria-simple-combobox/BUILD.bazel new file mode 100644 index 000000000000..0226eb758e65 --- /dev/null +++ b/src/dev-app/aria-simple-combobox/BUILD.bazel @@ -0,0 +1,16 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "aria-simple-combobox", + srcs = glob(["**/*.ts"]), + assets = [ + "simple-combobox-demo.html", + "simple-combobox-demo.css", + ], + deps = [ + "//:node_modules/@angular/core", + "//src/components-examples/aria/simple-combobox", + ], +) diff --git a/src/dev-app/aria-simple-combobox/simple-combobox-demo.css b/src/dev-app/aria-simple-combobox/simple-combobox-demo.css new file mode 100644 index 000000000000..607c068d07ef --- /dev/null +++ b/src/dev-app/aria-simple-combobox/simple-combobox-demo.css @@ -0,0 +1,22 @@ +.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; +} diff --git a/src/dev-app/aria-simple-combobox/simple-combobox-demo.html b/src/dev-app/aria-simple-combobox/simple-combobox-demo.html new file mode 100644 index 000000000000..7571103ed822 --- /dev/null +++ b/src/dev-app/aria-simple-combobox/simple-combobox-demo.html @@ -0,0 +1,33 @@ +
    +

    Listbox autocomplete examples

    + +
    +
    +

    Combobox with manual filtering

    + +
    + +
    +

    Combobox with inline suggestion

    + +
    +
    + +

    Tree autocomplete examples

    + +
    +
    +

    Combobox with tree

    + +
    +
    + +

    Combobox select examples

    + +
    +
    +

    Combobox with select

    + +
    +
    +
    diff --git a/src/dev-app/aria-simple-combobox/simple-combobox-demo.ts b/src/dev-app/aria-simple-combobox/simple-combobox-demo.ts new file mode 100644 index 000000000000..1e686dd0a9bf --- /dev/null +++ b/src/dev-app/aria-simple-combobox/simple-combobox-demo.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Component} from '@angular/core'; +import { + SimpleComboboxListboxExample, + SimpleComboboxListboxInlineExample, + SimpleComboboxTreeExample, + SimpleComboboxSelectExample, +} from '@angular/components-examples/aria/simple-combobox'; + +@Component({ + templateUrl: 'simple-combobox-demo.html', + styleUrl: 'simple-combobox-demo.css', + imports: [ + SimpleComboboxListboxExample, + SimpleComboboxListboxInlineExample, + SimpleComboboxTreeExample, + SimpleComboboxSelectExample, + ], +}) +export class ComboboxDemo {} diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index 8e552a3d0727..7fb023a4d0cc 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -65,6 +65,7 @@ export class DevAppLayout { {name: 'Aria Accordion', route: '/aria-accordion'}, {name: 'Aria Combobox', route: '/aria-combobox'}, {name: 'Aria Autocomplete', route: '/aria-autocomplete'}, + {name: 'Aria Simple Combobox', route: '/aria-simple-combobox'}, {name: 'Aria Grid', route: '/aria-grid'}, {name: 'Aria Listbox', route: '/aria-listbox'}, {name: 'Aria Menu', route: '/aria-menu'}, diff --git a/src/dev-app/routes.ts b/src/dev-app/routes.ts index 2fab9c4af821..2f2daeb6d542 100644 --- a/src/dev-app/routes.ts +++ b/src/dev-app/routes.ts @@ -48,6 +48,11 @@ export const DEV_APP_ROUTES: Routes = [ path: 'aria-select', loadComponent: () => import('./aria-select/select-demo').then(m => m.SelectDemo), }, + { + path: 'aria-simple-combobox', + loadComponent: () => + import('./aria-simple-combobox/simple-combobox-demo').then(m => m.ComboboxDemo), + }, { path: 'aria-grid', loadComponent: () => import('./aria-grid/grid-demo').then(m => m.GridDemo), From 85645e2833fadf3a08a9992705e896d76d632ebe Mon Sep 17 00:00:00 2001 From: tjshiu <35056071+tjshiu@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:54:33 -0800 Subject: [PATCH 02/11] refactor(aria/combobox): add grid and datepicker simple-combobox examples --- src/aria/grid/grid.ts | 11 ++ src/aria/private/behaviors/grid/grid-focus.ts | 8 + src/aria/private/behaviors/grid/grid.ts | 31 +++- src/aria/private/grid/grid.spec.ts | 10 ++ src/aria/private/grid/grid.ts | 2 - src/aria/private/grid/widget.ts | 5 + .../simple-combobox/simple-combobox.ts | 8 +- .../aria/simple-combobox/BUILD.bazel | 6 + .../aria/simple-combobox/index.ts | 2 + .../simple-combobox-datepicker-example.css | 108 +++++++++++ .../simple-combobox-datepicker-example.html | 67 +++++++ .../simple-combobox-datepicker-example.ts | 168 ++++++++++++++++++ .../simple-combobox-examples.css | 118 +++++++++++- .../simple-combobox-grid-example.html | 31 ++++ .../simple-combobox-grid-example.ts | 82 +++++++++ .../simple-combobox-demo.css | 3 + .../simple-combobox-demo.html | 16 +- .../simple-combobox-demo.ts | 4 + 18 files changed, 666 insertions(+), 14 deletions(-) create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.css create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.html create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.ts create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.html create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.ts diff --git a/src/aria/grid/grid.ts b/src/aria/grid/grid.ts index bb6bb34ab44b..3523b566bbd4 100644 --- a/src/aria/grid/grid.ts +++ b/src/aria/grid/grid.ts @@ -120,6 +120,12 @@ export class Grid { */ readonly selectionMode = input<'follow' | 'explicit'>('follow'); + /** Whether enable range selections (with modifier keys or dragging). */ + readonly enableRangeSelection = input(false, {transform: booleanAttribute}); + + /** Overrides the default tab index of the grid. */ + readonly tabIndex = input(undefined); + /** The UI pattern for the grid. */ readonly _pattern = new GridPattern({ ...this, @@ -136,6 +142,11 @@ export class Grid { afterRenderEffect(() => this._pattern.focusEffect()); } + /** Scrolls the active cell into view. */ + scrollActiveCellIntoView(options: ScrollIntoViewOptions = {block: 'nearest'}) { + this._pattern.activeCell()?.element().scrollIntoView(options); + } + /** Gets the cell pattern for a given element. */ private _getCell(element: Element | null | undefined): GridCellPattern | undefined { let target = element; diff --git a/src/aria/private/behaviors/grid/grid-focus.ts b/src/aria/private/behaviors/grid/grid-focus.ts index 31f0502e80f7..61dbfd11e0b3 100644 --- a/src/aria/private/behaviors/grid/grid-focus.ts +++ b/src/aria/private/behaviors/grid/grid-focus.ts @@ -31,6 +31,9 @@ export interface GridFocusInputs { /** Whether disabled cells in the grid should be focusable. */ softDisabled: SignalLike; + + /** Overrides the default tab index of the grid. */ + tabIndex?: SignalLike; } /** Dependencies for the `GridFocus` class. */ @@ -96,6 +99,11 @@ export class GridFocus { /** The tab index for the grid container. */ readonly gridTabIndex = computed<-1 | 0>(() => { + const tabIndexOverride = this.inputs.tabIndex?.(); + if (tabIndexOverride !== undefined && tabIndexOverride !== null) { + return (tabIndexOverride === -1 ? -1 : 0) as -1 | 0; + } + if (this.gridDisabled()) { return 0; } diff --git a/src/aria/private/behaviors/grid/grid.ts b/src/aria/private/behaviors/grid/grid.ts index 6d0316395d38..3597974fb7ed 100644 --- a/src/aria/private/behaviors/grid/grid.ts +++ b/src/aria/private/behaviors/grid/grid.ts @@ -318,20 +318,45 @@ export class Grid { } if (this.focusBehavior.stateStale()) { + const activeCell = this.focusBehavior.activeCell(); + const activeCoords = this.focusBehavior.activeCoords(); + // Try focus on the same active cell after if a reordering happened. - if (this.focusBehavior.focusCell(this.focusBehavior.activeCell()!)) { + if (activeCell && this.focusBehavior.focusCell(activeCell)) { return true; } // If the active cell is no longer exist, focus on the coordinates instead. - if (this.focusBehavior.focusCoordinates(this.focusBehavior.activeCoords())) { + if (this.focusBehavior.focusCoordinates(activeCoords)) { return true; } + // If the coordinates are no longer valid (e.g. because the row was deleted at the end), + // try to focus on the previous row focusing on the same column. + const maxRow = this.data.maxRowCount() - 1; + const targetRow = Math.min(activeCoords.row, maxRow); + + if (targetRow >= 0) { + // Try same column in the clamped row. + if (this.focusBehavior.focusCoordinates({row: targetRow, col: activeCoords.col})) { + return true; + } + + // If that fails, try to find ANY cell in that row. + const firstInRow = this.navigationBehavior.peekFirst(targetRow); + if (firstInRow !== undefined && this.focusBehavior.focusCoordinates(firstInRow)) { + return true; + } + } + // If the coordinates no longer valid, go back to the first available cell. - if (this.focusBehavior.focusCoordinates(this.navigationBehavior.peekFirst()!)) { + const firstAvailable = this.navigationBehavior.peekFirst(); + if (firstAvailable !== undefined && this.focusBehavior.focusCoordinates(firstAvailable)) { return true; } + + this.focusBehavior.activeCell.set(undefined); + this.focusBehavior.activeCoords.set({row: -1, col: -1}); } return false; diff --git a/src/aria/private/grid/grid.spec.ts b/src/aria/private/grid/grid.spec.ts index 5cd9dbe0a404..7653e5e4f588 100644 --- a/src/aria/private/grid/grid.spec.ts +++ b/src/aria/private/grid/grid.spec.ts @@ -274,6 +274,16 @@ describe('Grid', () => { expect(widget.isActivated()).toBe(true); }); + it('should trigger click on Enter for simple widget', () => { + const {grid} = createGrid([{cells: [{widgets: [{widgetType: 'simple'}]}]}], gridInputs); + const widget = grid.cells()[0][0].inputs.widgets()[0]; + const element = widget.element(); + spyOn(element, 'click'); + + widget.onKeydown(enter()); + expect(element.click).toHaveBeenCalled(); + }); + it('should not activate if disabled', () => { const {grid} = createGrid( [{cells: [{widget: {widgetType: 'complex', disabled: true}}]}], diff --git a/src/aria/private/grid/grid.ts b/src/aria/private/grid/grid.ts index 2f557b2e383c..eb2d95ed6991 100644 --- a/src/aria/private/grid/grid.ts +++ b/src/aria/private/grid/grid.ts @@ -256,8 +256,6 @@ export class GridPattern { /** Sets the default active state of the grid before receiving focus the first time. */ setDefaultStateEffect(): void { - if (this.hasBeenInteracted()) return; - this.gridBehavior.setDefaultState(); } diff --git a/src/aria/private/grid/widget.ts b/src/aria/private/grid/widget.ts index 779e03640b73..d43ebdd696df 100644 --- a/src/aria/private/grid/widget.ts +++ b/src/aria/private/grid/widget.ts @@ -72,7 +72,11 @@ export class GridCellWidgetPattern { const manager = new KeyboardEventManager(); // Simple widget does not need to pause default grid behaviors. + // However, it does need to capture Enter key and trigger a click on the host element + // since the browser won't do it for us in activedescendant mode. if (this.inputs.widgetType() === 'simple') { + console.log('simple widget keydown'); + manager.on('Enter', () => this.element().click()); return manager; } @@ -110,6 +114,7 @@ export class GridCellWidgetPattern { /** Handles keydown events for the widget. */ onKeydown(event: KeyboardEvent): void { if (this.disabled()) return; + console.log('keydown of widget.ts'); this.keydown().handle(event); } diff --git a/src/aria/private/simple-combobox/simple-combobox.ts b/src/aria/private/simple-combobox/simple-combobox.ts index 691c9ae7d026..248ae540e3f1 100644 --- a/src/aria/private/simple-combobox/simple-combobox.ts +++ b/src/aria/private/simple-combobox/simple-combobox.ts @@ -107,17 +107,17 @@ export class SimpleComboboxPattern { e => { this.keyboardEventRelay.set(e); }, - {preventDefault: this.popupType() !== 'listbox'}, + {preventDefault: this.popupType() !== 'listbox', ignoreRepeat: false}, ) .on( 'ArrowRight', e => { this.keyboardEventRelay.set(e); }, - {preventDefault: this.popupType() !== 'listbox'}, + {preventDefault: this.popupType() !== 'listbox', ignoreRepeat: false}, ) - .on('ArrowUp', e => this.keyboardEventRelay.set(e)) - .on('ArrowDown', e => this.keyboardEventRelay.set(e)) + .on('ArrowUp', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false}) + .on('ArrowDown', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false}) .on('Home', e => this.keyboardEventRelay.set(e)) .on('End', e => this.keyboardEventRelay.set(e)) .on('Enter', e => this.keyboardEventRelay.set(e)) diff --git a/src/components-examples/aria/simple-combobox/BUILD.bazel b/src/components-examples/aria/simple-combobox/BUILD.bazel index f6af98e8ca50..331dcf7e195d 100644 --- a/src/components-examples/aria/simple-combobox/BUILD.bazel +++ b/src/components-examples/aria/simple-combobox/BUILD.bazel @@ -13,10 +13,16 @@ ng_project( "//: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", ], ) diff --git a/src/components-examples/aria/simple-combobox/index.ts b/src/components-examples/aria/simple-combobox/index.ts index 2e5c63dad8c0..096cbefef68c 100644 --- a/src/components-examples/aria/simple-combobox/index.ts +++ b/src/components-examples/aria/simple-combobox/index.ts @@ -2,3 +2,5 @@ export {SimpleComboboxListboxExample} from './simple-combobox-listbox/simple-com export {SimpleComboboxListboxInlineExample} from './simple-combobox-listbox-inline/simple-combobox-listbox-inline-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'; diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.css b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.css new file mode 100644 index 000000000000..c4f54e2e8638 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.css @@ -0,0 +1,108 @@ +.example-datepicker-popup { + padding: 16px; + width: 320px; + max-height: none; + overflow: visible; + background-color: var(--mat-sys-surface); + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); + box-shadow: var(--mat-sys-level2-shadow); +} + +.example-datepicker-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 12px; + border-bottom: 1px solid var(--mat-sys-outline-variant); + margin-bottom: 12px; +} + +.example-datepicker-title { + font-weight: 600; + font-size: 0.9rem; + color: var(--mat-sys-on-surface); +} + +.example-datepicker-nav-button { + background-color: transparent; + border: none; + border-radius: 50%; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--mat-sys-on-surface); + transition: background-color 0.2s ease; +} + +.example-datepicker-nav-button:hover { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent); +} + +.example-datepicker-grid { + width: 100%; + border-collapse: collapse; +} + +.example-datepicker-cell { + width: 40px; + height: 40px; + text-align: center; + vertical-align: middle; + padding: 0; +} + +.example-datepicker-weekday { + font-size: 0.75rem; + font-weight: 500; + color: var(--mat-sys-on-surface-variant); + padding-bottom: 8px; +} + +.example-datepicker-empty { + color: color-mix(in srgb, var(--mat-sys-on-surface) 30%, transparent); + font-size: 0.8rem; +} + +.example-datepicker-day-button { + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background-color: transparent; + cursor: pointer; + font-size: 0.85rem; + color: var(--mat-sys-on-surface); + transition: + background-color 0.2s ease, + color 0.2s ease; +} + +.example-datepicker-cell:hover .example-datepicker-day-button { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent); +} + +/* Show circular focus ring on the day button when active using box-shadow */ +/* Subdued grey by default when navigating from the input */ +.example-datepicker-cell[data-active='true'] .example-datepicker-day-button { + box-shadow: 0 0 0 2px var(--mat-sys-outline); +} + +/* Highlight circle with primary color when the grid has actual focus */ +.example-datepicker-grid:focus .example-datepicker-cell[data-active='true'] .example-datepicker-day-button, +.example-datepicker-grid:focus-within .example-datepicker-cell[data-active='true'] .example-datepicker-day-button { + box-shadow: 0 0 0 2px var(--mat-sys-primary); +} + +/* Hide all grid focus indicators when focus is in the header navigation */ +.example-datepicker-header:focus-within~.example-datepicker-grid .example-datepicker-cell[data-active='true'] .example-datepicker-day-button { + box-shadow: none; +} + +.example-datepicker-cell[aria-selected='true'] .example-datepicker-day-button { + background-color: var(--mat-sys-primary); + color: var(--mat-sys-on-primary); +} \ No newline at end of file diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.html new file mode 100644 index 000000000000..2f11fbf37fff --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.html @@ -0,0 +1,67 @@ +
    +
    + calendar_month + +
    + + + +
    +
    + +
    {{ monthYearLabel() }}
    + +
    + + + + + @for (day of weekdays(); track day.long) { + + } + + + + + @for (week of weeks(); track week) { + + @if ($first) { + @for (day of daysFromPrevMonth(); track day) { + + } + } + + @for (day of week; track day) { + + } + + @if ($last && week.length < 7) { @for (day of [].constructor(7 - week.length); track $index) { + } + } + + } + +
    + {{ day.long }} + +
    {{ day }} + + {{ $index + 1 }}
    +
    +
    +
    +
    \ No newline at end of file diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.ts new file mode 100644 index 000000000000..00cf838be38c --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + inject, + Component, + WritableSignal, + signal, + Signal, + computed, + untracked, + viewChild, +} from '@angular/core'; +import {DateAdapter, MAT_DATE_FORMATS, MatDateFormats} from '@angular/material/core'; +import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid'; +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {OverlayModule} from '@angular/cdk/overlay'; +import {A11yModule} from '@angular/cdk/a11y'; + +const DAYS_PER_WEEK = 7; + +interface CalendarCell { + displayName: string; + ariaLabel: string; + date: D; + selected: WritableSignal; +} + +/** @title Combobox with Datepicker Grid. */ +@Component({ + selector: 'simple-combobox-datepicker-example', + templateUrl: 'simple-combobox-datepicker-example.html', + styleUrls: ['../simple-combobox-examples.css', 'simple-combobox-datepicker-example.css'], + imports: [ + Grid, + GridRow, + GridCell, + GridCellWidget, + Combobox, + ComboboxPopup, + ComboboxWidget, + OverlayModule, + A11yModule, + ], +}) +export class SimpleComboboxDatepickerExample { + private readonly _dateAdapter = inject>(DateAdapter, {optional: true})!; + private readonly _dateFormats = inject(MAT_DATE_FORMATS, {optional: true})!; + + /** The grid instance used in the popup. */ + readonly grid = viewChild(Grid); + + readonly selection = signal(''); + readonly popupExpanded = signal(true); + + private readonly _firstWeekOffset: Signal = computed(() => { + const firstOfMonth = this._dateAdapter.createDate( + this._dateAdapter.getYear(this.viewMonth()), + this._dateAdapter.getMonth(this.viewMonth()), + 1, + ); + + return ( + (DAYS_PER_WEEK + + this._dateAdapter.getDayOfWeek(firstOfMonth) - + this._dateAdapter.getFirstDayOfWeek()) % + DAYS_PER_WEEK + ); + }); + + private readonly _activeDate: WritableSignal = signal(this._dateAdapter.today()); + + readonly monthYearLabel: Signal = computed(() => + this._dateAdapter + .format(this.viewMonth(), this._dateFormats.display.monthYearLabel) + .toLocaleUpperCase(), + ); + readonly prevMonthNumDays: Signal = computed(() => + this._dateAdapter.getNumDaysInMonth(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1)), + ); + readonly daysFromPrevMonth: Signal = computed(() => { + const days: number[] = []; + for (let i = this._firstWeekOffset() - 1; i >= 0; i--) { + days.push(this.prevMonthNumDays() - i); + } + return days; + }); + readonly viewMonth: WritableSignal = signal(this._dateAdapter.today()); + readonly weekdays: Signal<{long: string; narrow: string}[]> = computed(() => { + const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek(); + const narrowWeekdays = this._dateAdapter.getDayOfWeekNames('narrow'); + const longWeekdays = this._dateAdapter.getDayOfWeekNames('long'); + + const weekdays = longWeekdays.map((long, i) => { + return {long, narrow: narrowWeekdays[i]}; + }); + return weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek)); + }); + readonly weeks: Signal[][]> = computed(() => + this._createWeekCells(this.viewMonth()), + ); + + nextMonth(): void { + this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), 1)); + } + + prevMonth(): void { + this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1)); + } + + selectDate(cell: CalendarCell): void { + const formatted = this._dateAdapter.format(cell.date, this._dateFormats.display.dateInput); + this.selection.set(formatted); + this._activeDate.set(cell.date); + this.popupExpanded.set(false); + } + + /** Handles keydown events on the widget container. */ + handleWidgetKeydown(event: KeyboardEvent) { + // Only forward to the grid if the event targets the container itself + // (e.g. events relayed from the combobox input). + if (event.target === event.currentTarget) { + this.grid()?._pattern.onKeydown(event); + } + } + + /** Handles keydown events on navigation buttons. */ + handleButtonKeydown(event: KeyboardEvent) { + // Prevent button keydowns from bubbling to the grid pattern. + event.stopPropagation(); + } + + private _createWeekCells(viewMonth: D): CalendarCell[][] { + const daysInMonth = this._dateAdapter.getNumDaysInMonth(viewMonth); + const dateNames = this._dateAdapter.getDateNames(); + const weeks: CalendarCell[][] = [[]]; + for (let i = 0, cell = this._firstWeekOffset(); i < daysInMonth; i++, cell++) { + if (cell == DAYS_PER_WEEK) { + weeks.push([]); + cell = 0; + } + const date = this._dateAdapter.createDate( + this._dateAdapter.getYear(viewMonth), + this._dateAdapter.getMonth(viewMonth), + i + 1, + ); + const ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel); + + weeks[weeks.length - 1].push({ + displayName: dateNames[i], + ariaLabel, + date, + selected: signal( + this._dateAdapter.compareDate( + date, + untracked(() => this._activeDate()), + ) === 0, + ), + }); + } + return weeks; + } +} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-examples.css b/src/components-examples/aria/simple-combobox/simple-combobox-examples.css index d93a0449e51e..2b8e0cac9535 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-examples.css +++ b/src/components-examples/aria/simple-combobox/simple-combobox-examples.css @@ -58,7 +58,7 @@ transition: transform 0.2s ease; } -.example-combobox-input[aria-expanded='true'] + .example-arrow-icon { +.example-combobox-input[aria-expanded='true']+.example-arrow-icon { transform: rotate(180deg); } @@ -85,6 +85,8 @@ border: 1px solid var(--mat-sys-outline); border-radius: var(--mat-sys-corner-extra-small); background-color: var(--mat-sys-surface); + max-height: 15rem; + overflow: auto; } .example-listbox { @@ -147,7 +149,7 @@ background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent); } -.example-combobox-container:focus-within [data-active='true'] { +.example-combobox-container:focus-within [data-active='true']:not(button) { outline: 2px solid color-mix(in srgb, var(--mat-sys-primary) 80%, transparent); } @@ -166,7 +168,7 @@ padding: 0.3rem 1rem; } -li[aria-expanded='false'] + ul[role='group'] { +li[aria-expanded='false']+ul[role='group'] { display: none; } @@ -201,3 +203,113 @@ ul[role='group'] { opacity: 0.4; cursor: default; } + +.example-grid-row { + display: flex; + align-items: center; + gap: 12px; + padding: 4px 8px; + border-radius: var(--mat-sys-corner-extra-small); + transition: background-color 0.2s ease; +} + +.example-grid-row.selectable { + cursor: pointer; +} + +.example-grid-row.selectable:hover { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 5%, transparent); +} + +.example-grid-row.selectable:active { + background-color: color-mix(in srgb, var(--mat-sys-primary) 20%, transparent); +} + +.example-grid-row[data-active='true'] { + background-color: color-mix(in srgb, var(--mat-sys-primary) 10%, transparent); + outline: 2px solid var(--mat-sys-primary); +} + +.example-grid-header-row { + display: flex; + gap: 12px; + padding: 8px; + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 5%, transparent); + border-bottom: 1px solid var(--mat-sys-outline); + font-weight: 600; + font-size: 0.85rem; +} + +.example-cell { + flex: 1; + display: flex; + align-items: center; + min-height: 40px; +} + +.example-cell-header { + flex: 1; + display: flex; + align-items: center; +} + +.example-cell-label { + flex: 2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.example-cell-checkbox, +.example-cell-button { + flex: 0 0 auto; + justify-content: center; +} + +.example-cell-input { + flex: 1; +} + +.example-button { + cursor: pointer; + opacity: 0.6; + padding: 0; + margin: 0; + height: 100%; + width: 40px; + border: none; + background: transparent; + display: grid; + place-items: center; + color: var(--mat-sys-on-surface); + transition: opacity 0.2s, color 0.2s, transform 0.2s; +} + +.example-button mat-icon { + font-size: 20px; + width: 20px; + height: 20px; +} + +.example-button:focus, +.example-button:hover, +.example-button[data-active='true'] { + opacity: 1; + color: var(--mat-sys-primary); + outline: none; + transform: scale(1.15); +} + +.example-button:active { + transform: scale(0.95); +} + +.example-button[aria-pressed='true'], +.example-button[aria-checked='true'] { + color: var(--mat-sys-primary); +} + +.example-button[aria-disabled='true'] { + cursor: default; + opacity: 0.45; +} \ No newline at end of file diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.html new file mode 100644 index 000000000000..f5390245da5c --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.html @@ -0,0 +1,31 @@ +
    +
    + search + +
    + + + +
    + @for (item of filteredItems(); track item.label; let i = $index) { +
    +
    + {{item.label}} +
    +
    + +
    +
    + } +
    +
    +
    +
    \ No newline at end of file diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.ts new file mode 100644 index 000000000000..385aac244dc9 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; +import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid'; +import {MatIconModule} from '@angular/material/icon'; + +/** @title */ +@Component({ + selector: 'simple-combobox-grid-example', + templateUrl: 'simple-combobox-grid-example.html', + styleUrl: '../simple-combobox-examples.css', + imports: [ + Combobox, + ComboboxPopup, + ComboboxWidget, + OverlayModule, + Grid, + GridRow, + GridCell, + GridCellWidget, + MatIconModule, + ], +}) +export class SimpleComboboxGridExample { + readonly grid = viewChild(Grid); + + popupExpanded = signal(true); + searchString = signal(''); + + constructor() { + afterRenderEffect(() => { + this.grid()?.scrollActiveCellIntoView({block: 'nearest'}); + }); + } + + readonly items = signal([ + {label: 'Antelope'}, + {label: 'Bird'}, + {label: 'Cat'}, + {label: 'Dog'}, + {label: 'Elephant'}, + {label: 'Fox'}, + {label: 'Giraffe'}, + {label: 'Hamster'}, + {label: 'Hippo'}, + {label: 'Iguana'}, + {label: 'Jaguar'}, + {label: 'Koala'}, + {label: 'Lion'}, + {label: 'Monkey'}, + {label: 'Nightingale'}, + {label: 'Owl'}, + {label: 'Panda'}, + {label: 'Quokka'}, + {label: 'Rabbit'}, + {label: 'Snake'}, + {label: 'Tiger'}, + {label: 'Umbrella Bird'}, + {label: 'Vulture'}, + {label: 'Whale'}, + {label: 'X-ray Tetra'}, + {label: 'Yak'}, + {label: 'Zebra'}, + ]); + + readonly filteredItems = computed(() => { + const search = this.searchString().toLowerCase(); + return [...this.items()].filter(item => item.label.toLowerCase().includes(search)); + }); + + removeItem(itemToRemove: {label: string}) { + this.items.update(items => items.filter(item => item !== itemToRemove)); + } +} diff --git a/src/dev-app/aria-simple-combobox/simple-combobox-demo.css b/src/dev-app/aria-simple-combobox/simple-combobox-demo.css index 607c068d07ef..a3b9cc5650f1 100644 --- a/src/dev-app/aria-simple-combobox/simple-combobox-demo.css +++ b/src/dev-app/aria-simple-combobox/simple-combobox-demo.css @@ -20,3 +20,6 @@ h2 { h3 { font-size: 1rem; } +.simple-combobox-demo { + 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 index 7571103ed822..a9372253e7c7 100644 --- a/src/dev-app/aria-simple-combobox/simple-combobox-demo.html +++ b/src/dev-app/aria-simple-combobox/simple-combobox-demo.html @@ -1,4 +1,4 @@ -
    +

    Listbox autocomplete examples

    @@ -30,4 +30,16 @@

    Combobox with select

    -
    +

    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 index 1e686dd0a9bf..cf70b2ef62f4 100644 --- a/src/dev-app/aria-simple-combobox/simple-combobox-demo.ts +++ b/src/dev-app/aria-simple-combobox/simple-combobox-demo.ts @@ -12,6 +12,8 @@ import { SimpleComboboxListboxInlineExample, SimpleComboboxTreeExample, SimpleComboboxSelectExample, + SimpleComboboxGridExample, + SimpleComboboxDatepickerExample, } from '@angular/components-examples/aria/simple-combobox'; @Component({ @@ -22,6 +24,8 @@ import { SimpleComboboxListboxInlineExample, SimpleComboboxTreeExample, SimpleComboboxSelectExample, + SimpleComboboxGridExample, + SimpleComboboxDatepickerExample, ], }) export class ComboboxDemo {} From 24447cd0fb7fbee12a393e656c80c4bef4968f6f Mon Sep 17 00:00:00 2001 From: tjshiu <35056071+tjshiu@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:33:09 -0800 Subject: [PATCH 03/11] refactor(aria/combobox): change grid tabIndex input to tabbable boolean --- src/aria/grid/grid.ts | 4 ++-- src/aria/private/behaviors/grid/grid-focus.ts | 15 +++++++++------ src/aria/private/behaviors/grid/grid.ts | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/aria/grid/grid.ts b/src/aria/grid/grid.ts index 3523b566bbd4..03d4334a3c61 100644 --- a/src/aria/grid/grid.ts +++ b/src/aria/grid/grid.ts @@ -123,8 +123,8 @@ export class Grid { /** Whether enable range selections (with modifier keys or dragging). */ readonly enableRangeSelection = input(false, {transform: booleanAttribute}); - /** Overrides the default tab index of the grid. */ - readonly tabIndex = input(undefined); + /** Whether the grid is tabbable. */ + readonly tabbable = input(undefined); /** The UI pattern for the grid. */ readonly _pattern = new GridPattern({ diff --git a/src/aria/private/behaviors/grid/grid-focus.ts b/src/aria/private/behaviors/grid/grid-focus.ts index 61dbfd11e0b3..236905786d28 100644 --- a/src/aria/private/behaviors/grid/grid-focus.ts +++ b/src/aria/private/behaviors/grid/grid-focus.ts @@ -32,8 +32,8 @@ export interface GridFocusInputs { /** Whether disabled cells in the grid should be focusable. */ softDisabled: SignalLike; - /** Overrides the default tab index of the grid. */ - tabIndex?: SignalLike; + /** Whether the grid is tabbable. */ + tabbable?: SignalLike; } /** Dependencies for the `GridFocus` class. */ @@ -98,10 +98,13 @@ export class GridFocus { }); /** The tab index for the grid container. */ - readonly gridTabIndex = computed<-1 | 0>(() => { - const tabIndexOverride = this.inputs.tabIndex?.(); - if (tabIndexOverride !== undefined && tabIndexOverride !== null) { - return (tabIndexOverride === -1 ? -1 : 0) as -1 | 0; + readonly gridTabIndex = computed<-1 | 0 | null>(() => { + const isTabbable = this.inputs.tabbable?.(); + if (isTabbable === false) { + return -1; + } + if (isTabbable === true) { + return 0; } if (this.gridDisabled()) { diff --git a/src/aria/private/behaviors/grid/grid.ts b/src/aria/private/behaviors/grid/grid.ts index 3597974fb7ed..c499e66c8171 100644 --- a/src/aria/private/behaviors/grid/grid.ts +++ b/src/aria/private/behaviors/grid/grid.ts @@ -83,7 +83,7 @@ export class Grid { ); /** The tab index for the grid container. */ - readonly gridTabIndex: SignalLike<-1 | 0> = () => this.focusBehavior.gridTabIndex(); + readonly gridTabIndex: SignalLike<-1 | 0 | null> = () => this.focusBehavior.gridTabIndex(); /** Whether the grid is in a disabled state. */ readonly gridDisabled: SignalLike = () => this.focusBehavior.gridDisabled(); From 9c65e2a635a469154a524519ce01a9e4142b1671 Mon Sep 17 00:00:00 2001 From: tjshiu <35056071+tjshiu@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:49:59 -0800 Subject: [PATCH 04/11] refactor(aria/combobox): refine grid state reset logic with dual-axis clamping and add tests --- src/aria/private/behaviors/grid/grid.spec.ts | 6 +++--- src/aria/private/behaviors/grid/grid.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/aria/private/behaviors/grid/grid.spec.ts b/src/aria/private/behaviors/grid/grid.spec.ts index 8db7756e7728..4bd80939aea2 100644 --- a/src/aria/private/behaviors/grid/grid.spec.ts +++ b/src/aria/private/behaviors/grid/grid.spec.ts @@ -395,7 +395,7 @@ describe('Grid', () => { expect(grid.focusBehavior.activeCoords()).toEqual({row: 1, col: 1}); }); - it('should focus the first cell if active cell and coords are no longer valid', () => { + it('should focus the row above when the last row is deleted', () => { const cellsSignal = signal(createTestGrid(createGridA)); const grid = setupGrid(cellsSignal); grid.gotoCell(cellsSignal()[2][2]); @@ -416,8 +416,8 @@ describe('Grid', () => { expect(grid.focusBehavior.stateStale()).toBe(true); const result = grid.resetState(); expect(result).toBe(true); - expect(grid.focusBehavior.activeCell()).toBe(newCells[0][0]); - expect(grid.focusBehavior.activeCoords()).toEqual({row: 0, col: 0}); + expect(grid.focusBehavior.activeCell()).toBe(newCells[1][1]); + expect(grid.focusBehavior.activeCoords()).toEqual({row: 1, col: 1}); }); }); }); diff --git a/src/aria/private/behaviors/grid/grid.ts b/src/aria/private/behaviors/grid/grid.ts index c499e66c8171..d015840b0b07 100644 --- a/src/aria/private/behaviors/grid/grid.ts +++ b/src/aria/private/behaviors/grid/grid.ts @@ -342,6 +342,18 @@ export class Grid { return true; } + // Try clamping the column as well. + const colCount = this.data.getColCount(targetRow); + if (colCount !== undefined) { + const targetCol = Math.min(activeCoords.col, colCount - 1); + if ( + targetCol >= 0 && + this.focusBehavior.focusCoordinates({row: targetRow, col: targetCol}) + ) { + return true; + } + } + // If that fails, try to find ANY cell in that row. const firstInRow = this.navigationBehavior.peekFirst(targetRow); if (firstInRow !== undefined && this.focusBehavior.focusCoordinates(firstInRow)) { From 4f33cb92433c0d42a50eaa6bf1e0450fe0221918 Mon Sep 17 00:00:00 2001 From: tjshiu <35056071+tjshiu@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:29:09 -0700 Subject: [PATCH 05/11] refactor(aria/tree): slight improvements to tree example --- .../simple-combobox/simple-combobox-examples.css | 6 ++++++ .../simple-combobox-tree-example.html | 2 +- .../simple-combobox-tree-example.ts | 14 +++++++++++--- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-examples.css b/src/components-examples/aria/simple-combobox/simple-combobox-examples.css index 2b8e0cac9535..5c5dde3bdebd 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-examples.css +++ b/src/components-examples/aria/simple-combobox/simple-combobox-examples.css @@ -166,6 +166,12 @@ align-items: center; gap: 1rem; padding: 0.3rem 1rem; + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-tree-item[data-active='true'] { + background-color: color-mix(in srgb, var(--mat-sys-primary) 10%, transparent); + outline: 2px solid var(--mat-sys-primary); } li[aria-expanded='false']+ul[role='group'] { diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html index a398911c78b4..616cb9ef2c92 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html @@ -13,7 +13,7 @@ diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts index 4678828be53d..7d72459849b9 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts @@ -8,7 +8,7 @@ import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree'; -import {Component, computed, signal, viewChild} from '@angular/core'; +import {Component, afterRenderEffect, computed, signal, viewChild} from '@angular/core'; import {NgTemplateOutlet} from '@angular/common'; import {OverlayModule} from '@angular/cdk/overlay'; @@ -43,8 +43,16 @@ export class SimpleComboboxTreeExample { readonly dataSource = signal(FOOD_DATA); + constructor() { + afterRenderEffect(() => { + if (this.popupExpanded()) { + this.tree()?.scrollActiveItemIntoView(); + } + }); + } + filteredGroups = computed(() => { - const search = this.searchString().toLowerCase(); + const search = this.searchString().trim().toLowerCase(); const data = this.dataSource(); if (!search) { @@ -61,7 +69,7 @@ export class SimpleComboboxTreeExample { return { ...node, children, - expanded: children && children.length > 0, + expanded: children && children.length > 0 ? true : node.expanded, }; } From 5b57d0d4b50238ab532b33481b0773ef3cf404cc Mon Sep 17 00:00:00 2001 From: tjshiu <35056071+tjshiu@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:08:42 -0700 Subject: [PATCH 06/11] refactor(multiple): add comprehensive simple-combobox examples and refine behavior - Adds new examples for simple-combobox: auto-select, highlight, disabled, readonly, and dialog popups. - Refactors existing datepicker and grid examples for better interaction patterns. - Introduces alwaysExpanded input to SimpleCombobox for persistent popup states. - Refines selection behavior in ListboxPattern when followFocus is enabled. - Cleans up unused inline-suggestion examples and console logs. --- src/aria/private/grid/widget.ts | 2 - src/aria/private/listbox/listbox.ts | 3 + .../simple-combobox/simple-combobox.ts | 19 ++- src/aria/simple-combobox/simple-combobox.ts | 3 + .../aria/simple-combobox/index.ts | 10 +- .../simple-combobox-auto-select-example.html | 47 +++++++ .../simple-combobox-auto-select-example.ts | 98 +++++++++++++ .../simple-combobox-datepicker-example.css | 26 ++-- .../simple-combobox-datepicker-example.html | 107 +++++++------- .../simple-combobox-datepicker-example.ts | 45 ++++-- .../simple-combobox-dialog-example.html | 39 +++++ .../simple-combobox-dialog-example.ts | 133 ++++++++++++++++++ .../simple-combobox-disabled-example.html | 51 +++++++ .../simple-combobox-disabled-example.ts} | 22 +-- .../simple-combobox-examples.css | 63 ++++++--- .../simple-combobox-grid-example.html | 11 +- .../simple-combobox-grid-example.ts | 17 +++ .../simple-combobox-highlight-example.html | 48 +++++++ .../simple-combobox-highlight-example.ts | 100 +++++++++++++ ...imple-combobox-listbox-inline-example.html | 48 ------- .../simple-combobox-listbox-example.html | 2 +- .../simple-combobox-listbox-example.ts | 8 +- ...le-combobox-readonly-disabled-example.html | 47 +++++++ ...mple-combobox-readonly-disabled-example.ts | 60 ++++++++ ...combobox-readonly-multiselect-example.html | 43 ++++++ ...e-combobox-readonly-multiselect-example.ts | 61 ++++++++ .../simple-combobox-select-example.css | 8 ++ .../simple-combobox-select-example.html | 3 +- ...ple-combobox-tree-auto-select-example.html | 73 ++++++++++ ...imple-combobox-tree-auto-select-example.ts | 108 ++++++++++++++ ...imple-combobox-tree-highlight-example.html | 76 ++++++++++ .../simple-combobox-tree-highlight-example.ts | 126 +++++++++++++++++ .../simple-combobox-tree-example.html | 14 +- .../simple-combobox-tree-example.ts | 43 ++---- .../simple-combobox-demo.html | 49 ++++++- .../simple-combobox-demo.ts | 18 ++- 36 files changed, 1408 insertions(+), 223 deletions(-) create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-auto-select/simple-combobox-auto-select-example.html create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-auto-select/simple-combobox-auto-select-example.ts create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-dialog/simple-combobox-dialog-example.html create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-dialog/simple-combobox-dialog-example.ts create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-disabled/simple-combobox-disabled-example.html rename src/components-examples/aria/simple-combobox/{simple-combobox-listbox-inline/simple-combobox-listbox-inline-example.ts => simple-combobox-disabled/simple-combobox-disabled-example.ts} (82%) create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-highlight/simple-combobox-highlight-example.html create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-highlight/simple-combobox-highlight-example.ts delete mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-listbox-inline/simple-combobox-listbox-inline-example.html create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-readonly-disabled/simple-combobox-readonly-disabled-example.html create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-readonly-disabled/simple-combobox-readonly-disabled-example.ts create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-readonly-multiselect/simple-combobox-readonly-multiselect-example.html create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-readonly-multiselect/simple-combobox-readonly-multiselect-example.ts create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.html create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.ts create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.html create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.ts diff --git a/src/aria/private/grid/widget.ts b/src/aria/private/grid/widget.ts index d43ebdd696df..0725e70661d6 100644 --- a/src/aria/private/grid/widget.ts +++ b/src/aria/private/grid/widget.ts @@ -75,7 +75,6 @@ export class GridCellWidgetPattern { // However, it does need to capture Enter key and trigger a click on the host element // since the browser won't do it for us in activedescendant mode. if (this.inputs.widgetType() === 'simple') { - console.log('simple widget keydown'); manager.on('Enter', () => this.element().click()); return manager; } @@ -114,7 +113,6 @@ export class GridCellWidgetPattern { /** Handles keydown events for the widget. */ onKeydown(event: KeyboardEvent): void { if (this.disabled()) return; - console.log('keydown of widget.ts'); this.keydown().handle(event); } diff --git a/src/aria/private/listbox/listbox.ts b/src/aria/private/listbox/listbox.ts index a97b1b224560..b71445b6e0c1 100644 --- a/src/aria/private/listbox/listbox.ts +++ b/src/aria/private/listbox/listbox.ts @@ -264,6 +264,9 @@ export class ListboxPattern { if (firstItem) { this.inputs.activeItem.set(firstItem); + if (this.followFocus()) { + this.listBehavior.select(); + } } } diff --git a/src/aria/private/simple-combobox/simple-combobox.ts b/src/aria/private/simple-combobox/simple-combobox.ts index 248ae540e3f1..843cd9411709 100644 --- a/src/aria/private/simple-combobox/simple-combobox.ts +++ b/src/aria/private/simple-combobox/simple-combobox.ts @@ -7,12 +7,15 @@ */ import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager'; -import {computed, signal, untracked} from '@angular/core'; +import {afterRenderEffect, 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; @@ -123,7 +126,11 @@ export class SimpleComboboxPattern { .on('Enter', e => this.keyboardEventRelay.set(e)) .on('PageUp', e => this.keyboardEventRelay.set(e)) .on('PageDown', e => this.keyboardEventRelay.set(e)) - .on('Escape', () => this.expanded.set(false)); + .on('Escape', () => { + if (!this.inputs.alwaysExpanded()) { + this.expanded.set(false); + } + }); if (!this.isEditable()) { manager @@ -150,6 +157,12 @@ export class SimpleComboboxPattern { constructor(readonly inputs: SimpleComboboxInputs) { this.expanded = inputs.expanded; this.value = inputs.value; + + afterRenderEffect(() => { + if (this.inputs.alwaysExpanded()) { + this.expanded.set(true); + } + }); } /** Handles keydown events for the combobox. */ @@ -226,7 +239,7 @@ export class SimpleComboboxPattern { const expanded = this.expanded(); const comboboxFocused = this.isFocused(); const popupFocused = !!this.inputs.popup()?.isFocused(); - if (expanded && !comboboxFocused && !popupFocused) { + if (expanded && !this.inputs.alwaysExpanded() && !comboboxFocused && !popupFocused) { this.expanded.set(false); } } diff --git a/src/aria/simple-combobox/simple-combobox.ts b/src/aria/simple-combobox/simple-combobox.ts index fc8f41b374d2..af9b02e57db2 100644 --- a/src/aria/simple-combobox/simple-combobox.ts +++ b/src/aria/simple-combobox/simple-combobox.ts @@ -78,6 +78,9 @@ export class Combobox extends DeferredContentAware { /** Whether the combobox is disabled. */ readonly disabled = input(false, {transform: booleanAttribute}); + /** Whether the combobox should always remain expanded. */ + readonly alwaysExpanded = input(false, {transform: booleanAttribute}); + /** Whether the combobox is expanded. */ readonly expanded = model(false); diff --git a/src/components-examples/aria/simple-combobox/index.ts b/src/components-examples/aria/simple-combobox/index.ts index 096cbefef68c..e42d699eefae 100644 --- a/src/components-examples/aria/simple-combobox/index.ts +++ b/src/components-examples/aria/simple-combobox/index.ts @@ -1,6 +1,14 @@ export {SimpleComboboxListboxExample} from './simple-combobox-listbox/simple-combobox-listbox-example'; -export {SimpleComboboxListboxInlineExample} from './simple-combobox-listbox-inline/simple-combobox-listbox-inline-example'; export {SimpleComboboxTreeExample} from './simple-combobox-tree/simple-combobox-tree-example'; export {SimpleComboboxSelectExample} from './simple-combobox-select/simple-combobox-select-example'; export {SimpleComboboxGridExample} from './simple-combobox-grid/simple-combobox-grid-example'; export {SimpleComboboxDatepickerExample} from './simple-combobox-datepicker/simple-combobox-datepicker-example'; +export {SimpleComboboxAutoSelectExample} from './simple-combobox-auto-select/simple-combobox-auto-select-example'; +export {SimpleComboboxHighlightExample} from './simple-combobox-highlight/simple-combobox-highlight-example'; +export {SimpleComboboxDisabledExample} from './simple-combobox-disabled/simple-combobox-disabled-example'; +export {SimpleComboboxReadonlyDisabledExample} from './simple-combobox-readonly-disabled/simple-combobox-readonly-disabled-example'; +export {SimpleComboboxReadonlyMultiselectExample} from './simple-combobox-readonly-multiselect/simple-combobox-readonly-multiselect-example'; +export {SimpleComboboxDialogExample} from './simple-combobox-dialog/simple-combobox-dialog-example'; +export {SimpleComboboxTreeAutoSelectExample} from './simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example'; +export {SimpleComboboxTreeHighlightExample} from './simple-combobox-tree-highlight/simple-combobox-tree-highlight-example'; +// Force watcher update diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-auto-select/simple-combobox-auto-select-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-auto-select/simple-combobox-auto-select-example.html new file mode 100644 index 000000000000..7b575fc50b43 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-auto-select/simple-combobox-auto-select-example.html @@ -0,0 +1,47 @@ +
    +
    + search + +
    + + + +
    + @for (option of options(); track option) { +
    + {{option}} + +
    + } +
    +
    +
    +
    diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-auto-select/simple-combobox-auto-select-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-auto-select/simple-combobox-auto-select-example.ts new file mode 100644 index 000000000000..052b2d4c3ced --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-auto-select/simple-combobox-auto-select-example.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import {afterRenderEffect, Component, computed, signal, viewChild, untracked} 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-examples.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], +}) +export class SimpleComboboxAutoSelectExample { + readonly listbox = viewChild(Listbox); + + popupExpanded = signal(false); + searchString = signal(''); + selectedOption = signal([]); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + constructor() { + afterRenderEffect(() => { + this.listbox()?.scrollActiveItemIntoView(); + }); + } + + onCommit() { + const selectedOption = this.selectedOption(); + if (selectedOption.length > 0) { + this.searchString.set(selectedOption[0]); + } + this.popupExpanded.set(false); + } +} + +const states = [ + 'Alabama', + 'Alaska', + 'Arizona', + 'Arkansas', + 'California', + 'Colorado', + 'Connecticut', + 'Delaware', + 'Florida', + 'Georgia', + 'Hawaii', + 'Idaho', + 'Illinois', + 'Indiana', + 'Iowa', + 'Kansas', + 'Kentucky', + 'Louisiana', + 'Maine', + 'Maryland', + 'Massachusetts', + 'Michigan', + 'Minnesota', + 'Mississippi', + 'Missouri', + 'Montana', + 'Nebraska', + 'Nevada', + 'New Hampshire', + 'New Jersey', + 'New Mexico', + 'New York', + 'North Carolina', + 'North Dakota', + 'Ohio', + 'Oklahoma', + 'Oregon', + 'Pennsylvania', + 'Rhode Island', + 'South Carolina', + 'South Dakota', + 'Tennessee', + 'Texas', + 'Utah', + 'Vermont', + 'Virginia', + 'Washington', + 'West Virginia', + 'Wisconsin', + 'Wyoming', +]; diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.css b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.css index c4f54e2e8638..942d3636b229 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.css +++ b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.css @@ -85,24 +85,22 @@ background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent); } -/* Show circular focus ring on the day button when active using box-shadow */ -/* Subdued grey by default when navigating from the input */ -.example-datepicker-cell[data-active='true'] .example-datepicker-day-button { - box-shadow: 0 0 0 2px var(--mat-sys-outline); +.example-datepicker-cell:focus-within { + outline: 2px solid var(--mat-sys-primary); + outline-offset: -2px; } -/* Highlight circle with primary color when the grid has actual focus */ -.example-datepicker-grid:focus .example-datepicker-cell[data-active='true'] .example-datepicker-day-button, -.example-datepicker-grid:focus-within .example-datepicker-cell[data-active='true'] .example-datepicker-day-button { - box-shadow: 0 0 0 2px var(--mat-sys-primary); -} - -/* Hide all grid focus indicators when focus is in the header navigation */ -.example-datepicker-header:focus-within~.example-datepicker-grid .example-datepicker-cell[data-active='true'] .example-datepicker-day-button { - box-shadow: none; +.example-datepicker-day-button:focus { + outline: none; } .example-datepicker-cell[aria-selected='true'] .example-datepicker-day-button { background-color: var(--mat-sys-primary); color: var(--mat-sys-on-primary); -} \ No newline at end of file +} + +.example-combobox-hint { + font-size: 0.75rem; + color: var(--mat-sys-on-surface-variant); + margin-top: 4px; +} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.html index 2f11fbf37fff..756eaf9b6954 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.html +++ b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.html @@ -1,67 +1,68 @@ -
    +
    calendar_month - +
    -
    -
    - -
    {{ monthYearLabel() }}
    - -
    - - - - - @for (day of weekdays(); track day.long) { - - } - - +
    +
    +
    + +
    {{ monthYearLabel() }}
    + +
    -
    - @for (week of weeks(); track week) { - - @if ($first) { - @for (day of daysFromPrevMonth(); track day) { - - } - } - - @for (day of week; track day) { - - } +
    - {{ day.long }} - -
    {{ day }} - -
    + + + @for (day of weekdays(); track day.long) { + + } + + - @if ($last && week.length < 7) { @for (day of [].constructor(7 - week.length); track $index) { + + @for (week of weeks(); track $index) { + + @if ($first) { + @for (day of daysFromPrevMonth(); track $index) { + + } } + + @for (day of week; track $index) { + } - - } - -
    + {{ day.long }} + +
    {{ $index + 1 }}
    {{ day }} + +
    + + @if ($last && week.length < 7) { @for (day of [].constructor(7 - week.length); track $index) { {{ $index + 1 }} + + } + } + + } + + +
    -
    \ No newline at end of file + +
    Format: MM/DD/YYYY
    \ No newline at end of file diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.ts index 00cf838be38c..2f4a82a34267 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.ts +++ b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.ts @@ -15,6 +15,7 @@ import { computed, untracked, viewChild, + ElementRef, } from '@angular/core'; import {DateAdapter, MAT_DATE_FORMATS, MatDateFormats} from '@angular/material/core'; import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid'; @@ -52,11 +53,11 @@ export class SimpleComboboxDatepickerExample { private readonly _dateAdapter = inject>(DateAdapter, {optional: true})!; private readonly _dateFormats = inject(MAT_DATE_FORMATS, {optional: true})!; - /** The grid instance used in the popup. */ readonly grid = viewChild(Grid); + readonly gridTable = viewChild>('gridTable'); readonly selection = signal(''); - readonly popupExpanded = signal(true); + readonly popupExpanded = signal(false); private readonly _firstWeekOffset: Signal = computed(() => { const firstOfMonth = this._dateAdapter.createDate( @@ -113,28 +114,52 @@ export class SimpleComboboxDatepickerExample { this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1)); } + readonly comboboxInput = viewChild>('comboboxInput'); + selectDate(cell: CalendarCell): void { const formatted = this._dateAdapter.format(cell.date, this._dateFormats.display.dateInput); this.selection.set(formatted); this._activeDate.set(cell.date); this.popupExpanded.set(false); + this.comboboxInput()?.nativeElement.focus(); + } + + onInputKeydown(event: KeyboardEvent) { + if (event.key === 'Enter') { + const value = this.selection(); + const parsedDate = this._dateAdapter.parse(value, this._dateFormats.display.dateInput); + if (parsedDate && this._dateAdapter.isValid(parsedDate)) { + this._activeDate.set(parsedDate); + this.viewMonth.set(parsedDate); + this.popupExpanded.set(false); + event.stopPropagation(); + } + } else if (event.key === 'ArrowDown' && this.popupExpanded()) { + setTimeout(() => { + const tableEl = this.gridTable()?.nativeElement; + if (tableEl) { + const tabbable = tableEl.querySelector('[tabindex="0"]') as HTMLElement; + (tabbable || tableEl).focus(); + } + }); + } } /** Handles keydown events on the widget container. */ handleWidgetKeydown(event: KeyboardEvent) { - // Only forward to the grid if the event targets the container itself - // (e.g. events relayed from the combobox input). + if (event.key === 'Escape') { + this.popupExpanded.set(false); + this.comboboxInput()?.nativeElement.focus(); + event.preventDefault(); + event.stopPropagation(); + return; + } + if (event.target === event.currentTarget) { this.grid()?._pattern.onKeydown(event); } } - /** Handles keydown events on navigation buttons. */ - handleButtonKeydown(event: KeyboardEvent) { - // Prevent button keydowns from bubbling to the grid pattern. - event.stopPropagation(); - } - private _createWeekCells(viewMonth: D): CalendarCell[][] { const daysInMonth = this._dateAdapter.getNumDaysInMonth(viewMonth); const dateNames = this._dateAdapter.getDateNames(); diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-dialog/simple-combobox-dialog-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-dialog/simple-combobox-dialog-example.html new file mode 100644 index 000000000000..f03b05da98de --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-dialog/simple-combobox-dialog-example.html @@ -0,0 +1,39 @@ +
    +
    + + arrow_drop_down +
    + + + + +
    +
    +
    +
    + search + +
    + +
    + @for (option of options(); track option) { +
    + {{option}} + +
    + } +
    +
    +
    +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-dialog/simple-combobox-dialog-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-dialog/simple-combobox-dialog-example.ts new file mode 100644 index 000000000000..15a8fc34358b --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-dialog/simple-combobox-dialog-example.ts @@ -0,0 +1,133 @@ +/** + * @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, + linkedSignal, + 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-examples.css'], + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SimpleComboboxDialogExample { + listbox = viewChild>(Listbox); + combobox = viewChild(Combobox); + searchInput = viewChild>('searchInput'); + + value = signal(''); + searchString = signal(''); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + selectedStates = signal([]); + popupExpanded = signal(false); + + constructor() { + afterRenderEffect(() => { + if (this.popupExpanded()) { + untracked(() => { + setTimeout(() => { + this.searchInput()?.nativeElement.focus(); + }); + }); + } + }); + + afterRenderEffect(() => { + if (this.popupExpanded()) { + this.listbox()?.scrollActiveItemIntoView(); + } + }); + } + + onCommit() { + const selected = this.selectedStates(); + if (selected.length > 0) { + this.value.set(selected[0]); + this.searchString.set(''); + this.popupExpanded.set(false); + this.combobox()?.element.focus(); + } + } + + onSearchEscape(event: Event) { + this.popupExpanded.set(false); + this.combobox()?.element.focus(); // Focus back to main trigger! + } +} + +const states = [ + 'Alabama', + 'Alaska', + 'Arizona', + 'Arkansas', + 'California', + 'Colorado', + 'Connecticut', + 'Delaware', + 'Florida', + 'Georgia', + 'Hawaii', + 'Idaho', + 'Illinois', + 'Indiana', + 'Iowa', + 'Kansas', + 'Kentucky', + 'Louisiana', + 'Maine', + 'Maryland', + 'Massachusetts', + 'Michigan', + 'Minnesota', + 'Mississippi', + 'Missouri', + 'Montana', + 'Nebraska', + 'Nevada', + 'New Hampshire', + 'New Jersey', + 'New Mexico', + 'New York', + 'North Carolina', + 'North Dakota', + 'Ohio', + 'Oklahoma', + 'Oregon', + 'Pennsylvania', + 'Rhode Island', + 'South Carolina', + 'South Dakota', + 'Tennessee', + 'Texas', + 'Utah', + 'Vermont', + 'Virginia', + 'Washington', + 'West Virginia', + 'Wisconsin', + 'Wyoming', +]; diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-disabled/simple-combobox-disabled-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-disabled/simple-combobox-disabled-example.html new file mode 100644 index 000000000000..695d8d0da9a0 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-disabled/simple-combobox-disabled-example.html @@ -0,0 +1,51 @@ +
    +
    + search + +
    + + + + +
    +
    + @for (option of options(); track option) { +
    + {{option}} + +
    + } +
    +
    +
    +
    +
    diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-listbox-inline/simple-combobox-listbox-inline-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-disabled/simple-combobox-disabled-example.ts similarity index 82% rename from src/components-examples/aria/simple-combobox/simple-combobox-listbox-inline/simple-combobox-listbox-inline-example.ts rename to src/components-examples/aria/simple-combobox/simple-combobox-disabled/simple-combobox-disabled-example.ts index 5ffa93172de3..7c35a906fd6e 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-listbox-inline/simple-combobox-listbox-inline-example.ts +++ b/src/components-examples/aria/simple-combobox/simple-combobox-disabled/simple-combobox-disabled-example.ts @@ -8,32 +8,22 @@ import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; import {Listbox, Option} from '@angular/aria/listbox'; -import { - afterRenderEffect, - Component, - computed, - signal, - viewChild, - untracked, - linkedSignal, -} from '@angular/core'; +import {afterRenderEffect, Component, computed, signal, viewChild, untracked} from '@angular/core'; import {OverlayModule} from '@angular/cdk/overlay'; -/** @title */ +/** @title Simple Combobox Disabled */ @Component({ - selector: 'simple-combobox-listbox-inline-example', - templateUrl: 'simple-combobox-listbox-inline-example.html', + selector: 'simple-combobox-disabled-example', + templateUrl: 'simple-combobox-disabled-example.html', styleUrl: '../simple-combobox-examples.css', imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], }) -export class SimpleComboboxListboxInlineExample { +export class SimpleComboboxDisabledExample { readonly listbox = viewChild(Listbox); popupExpanded = signal(false); searchString = signal(''); - selectedOption = linkedSignal(() => - this.options().length > 0 ? [this.options()[0]] : [], - ); + selectedOption = signal([]); options = computed(() => states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-examples.css b/src/components-examples/aria/simple-combobox/simple-combobox-examples.css index 5c5dde3bdebd..6ee8be5da669 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-examples.css +++ b/src/components-examples/aria/simple-combobox/simple-combobox-examples.css @@ -38,7 +38,9 @@ .example-icon { width: 24px; height: 24px; - font-size: 20px; + font-size: 24px; + color: var(--mat-sys-on-surface-variant); + user-select: none; display: grid; place-items: center; pointer-events: none; @@ -96,15 +98,14 @@ max-height: 10rem; padding: 0.5rem; gap: 4px; + outline: none; } +/* --- 2. LISTBOX & OPTIONS --- */ .example-option { cursor: pointer; padding: 0.3rem 1rem; border-radius: var(--mat-sys-corner-extra-small); - transition: - background-color 0.2s ease, - color 0.2s ease; display: flex; overflow: hidden; flex-shrink: 0; @@ -149,13 +150,23 @@ background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent); } -.example-combobox-container:focus-within [data-active='true']:not(button) { +.example-combobox-container:not(.no-active-outline):focus-within [data-active='true']:not(.no-active-outline) { outline: 2px solid color-mix(in srgb, var(--mat-sys-primary) 80%, transparent); + outline-offset: -2px; } +.example-dialog .example-combobox-input-container { + border-bottom: 1px solid var(--mat-sys-outline); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + + .example-tree { padding: 10px; overflow-x: scroll; + width: 100%; + box-sizing: border-box; } .example-tree-item { @@ -166,12 +177,6 @@ align-items: center; gap: 1rem; padding: 0.3rem 1rem; - border-radius: var(--mat-sys-corner-extra-small); -} - -.example-tree-item[data-active='true'] { - background-color: color-mix(in srgb, var(--mat-sys-primary) 10%, transparent); - outline: 2px solid var(--mat-sys-primary); } li[aria-expanded='false']+ul[role='group'] { @@ -205,7 +210,7 @@ ul[role='group'] { visibility: visible; } -.example-combobox-container:has([aria-disabled='true']) { +.example-combobox-container:has(.example-combobox-input[aria-disabled='true']) { opacity: 0.4; cursor: default; } @@ -213,8 +218,6 @@ ul[role='group'] { .example-grid-row { display: flex; align-items: center; - gap: 12px; - padding: 4px 8px; border-radius: var(--mat-sys-corner-extra-small); transition: background-color 0.2s ease; } @@ -236,6 +239,11 @@ ul[role='group'] { outline: 2px solid var(--mat-sys-primary); } +.example-grid-row[aria-selected='true'] { + color: var(--mat-sys-primary); + background-color: color-mix(in srgb, var(--mat-sys-primary) 10%, transparent); +} + .example-grid-header-row { display: flex; gap: 12px; @@ -260,10 +268,12 @@ ul[role='group'] { } .example-cell-label { + border-radius: var(--mat-sys-corner-extra-small); flex: 2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + padding: 0 12px; } .example-cell-checkbox, @@ -303,7 +313,6 @@ ul[role='group'] { opacity: 1; color: var(--mat-sys-primary); outline: none; - transform: scale(1.15); } .example-button:active { @@ -318,4 +327,26 @@ ul[role='group'] { .example-button[aria-disabled='true'] { cursor: default; opacity: 0.45; -} \ No newline at end of file +} + +.example-label-button { + background: transparent; + border: none; + padding: 0; + margin: 0; + font: inherit; + color: inherit; + cursor: pointer; + text-align: left; + width: 100%; + height: 100%; + outline: none; +} + +.example-popup .example-button[data-active='true'] { + color: inherit; +} + +.example-grid-row[aria-selected='true'] .example-selected-icon { + visibility: visible; +} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.html index f5390245da5c..c20b72656531 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.html +++ b/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.html @@ -2,7 +2,7 @@
    search + [(value)]="searchString" [(expanded)]="popupExpanded" (blur)="onBlur()" />
    @for (item of filteredItems(); track item.label; let i = $index) { -
    +
    - {{item.label}} + + check
    - diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.ts index 385aac244dc9..c181a704a4a7 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.ts +++ b/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.ts @@ -34,6 +34,7 @@ export class SimpleComboboxGridExample { popupExpanded = signal(true); searchString = signal(''); + readonly selectedItem = signal<{label: string} | null>(null); constructor() { afterRenderEffect(() => { @@ -79,4 +80,20 @@ export class SimpleComboboxGridExample { removeItem(itemToRemove: {label: string}) { this.items.update(items => items.filter(item => item !== itemToRemove)); } + + selectItem(item: {label: string}) { + this.selectedItem.set(item); + this.searchString.set(item.label); + this.popupExpanded.set(false); + } + + onBlur() { + const selectedItem = this.selectedItem(); + if ( + this.searchString() === '' || + (selectedItem !== null && this.searchString() === selectedItem.label) + ) { + this.selectedItem.set(null); + } + } } diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-highlight/simple-combobox-highlight-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-highlight/simple-combobox-highlight-example.html new file mode 100644 index 000000000000..8f6b2bac67b2 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-highlight/simple-combobox-highlight-example.html @@ -0,0 +1,48 @@ +
    +
    + search + +
    + + + +
    + @for (option of options(); track option) { +
    + {{option}} + +
    + } +
    +
    +
    +
    diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-highlight/simple-combobox-highlight-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-highlight/simple-combobox-highlight-example.ts new file mode 100644 index 000000000000..4b809c6a04c9 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-highlight/simple-combobox-highlight-example.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {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 Highlight */ +@Component({ + selector: 'simple-combobox-highlight-example', + templateUrl: 'simple-combobox-highlight-example.html', + styleUrl: '../simple-combobox-examples.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], +}) +export class SimpleComboboxHighlightExample { + readonly listbox = viewChild(Listbox); + + popupExpanded = signal(false); + searchString = signal(''); + selectedOption = signal([]); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + constructor() { + afterRenderEffect(() => { + this.listbox()?.scrollActiveItemIntoView(); + }); + } + + onCommit() { + const selectedOption = this.selectedOption(); + if (selectedOption.length > 0) { + this.searchString.set(selectedOption[0]); + } else { + this.searchString.set(''); + } + 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-listbox-inline/simple-combobox-listbox-inline-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-listbox-inline/simple-combobox-listbox-inline-example.html deleted file mode 100644 index 0d68b4da8326..000000000000 --- a/src/components-examples/aria/simple-combobox/simple-combobox-listbox-inline/simple-combobox-listbox-inline-example.html +++ /dev/null @@ -1,48 +0,0 @@ -
    -
    - search - -
    - - - -
    - @for (option of options(); track option) { -
    - {{option}} - -
    - } -
    -
    -
    -
    diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.html index b7b28ea32ab3..92c1df9cef27 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.html +++ b/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.html @@ -23,7 +23,7 @@ class="example-listbox example-popup" focusMode="activedescendant" selectionMode="explicit" - [(values)]="selectedOption" + [(value)]="selectedOption" (click)="onCommit()" (keydown.enter)="onCommit()" > diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.ts index a91bffdf164f..ad1d72b5f672 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.ts +++ b/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.ts @@ -33,20 +33,14 @@ export class SimpleComboboxListboxExample { 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); } + this.popupExpanded.set(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 new file mode 100644 index 000000000000..077b56774fd6 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-readonly-disabled/simple-combobox-readonly-disabled-example.html @@ -0,0 +1,47 @@ +
    + {{value()}} + arrow_drop_down +
    + + + +
    +
    + @for (option of options(); track option.value) { +
    + @if (option.icon) { + {{option.icon}} + } + {{option.value}} + @if (selectedValues().includes(option.value)) { + + } +
    + } +
    +
    +
    +
    diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-readonly-disabled/simple-combobox-readonly-disabled-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-readonly-disabled/simple-combobox-readonly-disabled-example.ts new file mode 100644 index 000000000000..73d2e8df552b --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-readonly-disabled/simple-combobox-readonly-disabled-example.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + signal, + viewChild, + viewChildren, +} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; + +/** @title Disabled readonly combobox. */ +@Component({ + selector: 'simple-combobox-readonly-disabled-example', + templateUrl: 'simple-combobox-readonly-disabled-example.html', + styleUrl: '../simple-combobox-select/simple-combobox-select-example.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SimpleComboboxReadonlyDisabledExample { + readonly listbox = viewChild(Listbox); + + readonly options = signal([ + {value: 'Select a label', icon: ''}, + {value: 'Important', icon: 'label'}, + {value: 'Starred', icon: 'star'}, + {value: 'Work', icon: 'work'}, + {value: 'Personal', icon: 'person'}, + {value: 'To Do', icon: 'checklist'}, + {value: 'Later', icon: 'schedule'}, + {value: 'Read', icon: 'menu_book'}, + {value: 'Travel', icon: 'flight'}, + ]); + readonly value = signal('Select a label'); + readonly selectedValues = signal(['Select a label']); + readonly popupExpanded = signal(false); + + constructor() { + afterRenderEffect(() => { + this.listbox()?.scrollActiveItemIntoView(); + }); + } + + onCommit() { + const values = this.selectedValues(); + if (values.length) { + this.value.set(values[0]); + this.popupExpanded.set(false); + } + } +} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-readonly-multiselect/simple-combobox-readonly-multiselect-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-readonly-multiselect/simple-combobox-readonly-multiselect-example.html new file mode 100644 index 000000000000..6a85a30fe31a --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-readonly-multiselect/simple-combobox-readonly-multiselect-example.html @@ -0,0 +1,43 @@ +
    + {{value()}} + arrow_drop_down +
    + + + +
    +
    + @for (option of options(); track option.value) { +
    + @if (option.icon) { + {{option.icon}} + } + {{option.value}} + @if (selectedValues().includes(option.value)) { + + } +
    + } +
    +
    +
    +
    diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-readonly-multiselect/simple-combobox-readonly-multiselect-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-readonly-multiselect/simple-combobox-readonly-multiselect-example.ts new file mode 100644 index 000000000000..7206815275fb --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-readonly-multiselect/simple-combobox-readonly-multiselect-example.ts @@ -0,0 +1,61 @@ +/** + * @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, + viewChildren, +} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; + +/** @title Readonly multiselectable combobox. */ +@Component({ + selector: 'simple-combobox-readonly-multiselect-example', + templateUrl: 'simple-combobox-readonly-multiselect-example.html', + styleUrl: '../simple-combobox-select/simple-combobox-select-example.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SimpleComboboxReadonlyMultiselectExample { + readonly listbox = viewChild(Listbox); + + readonly options = signal([ + {value: 'Important', icon: 'label'}, + {value: 'Starred', icon: 'star'}, + {value: 'Work', icon: 'work'}, + {value: 'Personal', icon: 'person'}, + {value: 'To Do', icon: 'checklist'}, + {value: 'Later', icon: 'schedule'}, + {value: 'Read', icon: 'menu_book'}, + {value: 'Travel', icon: 'flight'}, + ]); + readonly selectedValues = signal([]); + readonly value = computed(() => { + const values = this.selectedValues(); + if (values.length === 0) { + return 'Select a label'; + } else if (values.length === 1) { + return values[0]; + } else { + return `${values[0]} + ${values.length - 1} more`; + } + }); + readonly popupExpanded = signal(false); + + constructor() { + afterRenderEffect(() => { + this.listbox()?.scrollActiveItemIntoView(); + }); + } +} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.css b/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.css index 6d8eadaaeefd..bdff7dd05f3e 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.css +++ b/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.css @@ -22,6 +22,12 @@ outline: 2px solid var(--mat-sys-primary); } +.example-select[aria-disabled='true'] { + opacity: 0.4; + cursor: default; + pointer-events: none; +} + .example-combobox-text { width: 9rem; } @@ -69,6 +75,8 @@ [ngOption][data-active='true'] { background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent); + outline: 2px solid var(--mat-sys-on-surface); + outline-offset: -2px; } [ngOption][aria-selected='true'] { diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.html index b36b62dd0686..ad4154e96dc0 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.html +++ b/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.html @@ -19,10 +19,9 @@
    +
    + search + +
    + + + +
      + +
    +
    +
    +
    + + + @for (node of nodes; track node.name) { +
  • + + {{ node.name }} + +
  • + @if (node.children) { +
      + + + +
    + } + } +
    diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.ts new file mode 100644 index 000000000000..866c7d24ee75 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree'; +import { + Component, + afterRenderEffect, + computed, + signal, + viewChild, + untracked, + ChangeDetectionStrategy, +} from '@angular/core'; +import {NgTemplateOutlet} from '@angular/common'; +import {OverlayModule} from '@angular/cdk/overlay'; + +interface FoodNode { + name: string; + children?: FoodNode[]; + expanded?: boolean; +} + +/** @title Combobox with tree popup and auto-select filtering. */ +@Component({ + selector: 'simple-combobox-tree-auto-select-example', + templateUrl: 'simple-combobox-tree-auto-select-example.html', + styleUrl: '../simple-combobox-examples.css', + imports: [ + Combobox, + ComboboxPopup, + ComboboxWidget, + NgTemplateOutlet, + Tree, + TreeItem, + TreeItemGroup, + OverlayModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SimpleComboboxTreeAutoSelectExample { + readonly tree = viewChild(Tree); + + popupExpanded = signal(false); + searchString = signal(''); + selectedValues = signal([]); + + readonly dataSource = signal(FOOD_DATA); + + constructor() { + afterRenderEffect(() => { + const active = this.tree()?._pattern.inputs.activeItem(); + if (active) { + untracked(() => { + active.element()?.scrollIntoView({block: 'nearest'}); + }); + } + }); + } + + filteredGroups = computed(() => { + const search = this.searchString().trim().toLowerCase(); + const data = this.dataSource(); + + if (!search) { + return data; + } + + const filterNode = (node: FoodNode): FoodNode | null => { + const matches = node.name.toLowerCase().includes(search); + const children = node.children + ?.map(child => filterNode(child)) + .filter((child): child is FoodNode => child !== null); + + if (matches || (children && children.length > 0)) { + return { + ...node, + children, + expanded: children && children.length > 0 ? true : node.expanded, + }; + } + + return null; + }; + + return data.map(node => filterNode(node)).filter((node): node is FoodNode => node !== null); + }); + + onCommit() { + const selected = this.selectedValues(); + if (selected.length > 0) { + this.searchString.set(selected[0]); + this.popupExpanded.set(false); + } + } +} + +const FOOD_DATA: FoodNode[] = [ + {name: 'Winter', children: [{name: 'December'}, {name: 'January'}, {name: 'February'}]}, + {name: 'Spring', children: [{name: 'March'}, {name: 'April'}, {name: 'May'}]}, + {name: 'Summer', children: [{name: 'June'}, {name: 'July'}, {name: 'August'}]}, + {name: 'Fall', children: [{name: 'September'}, {name: 'October'}, {name: 'November'}]}, +]; diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.html new file mode 100644 index 000000000000..bca3f88c43dd --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.html @@ -0,0 +1,76 @@ +
    +
    + search + +
    + + + +
    +
      + +
    +
    +
    +
    +
    + + + @for (node of nodes; track node.name) { +
  • + + {{ node.name }} + +
  • + @if (node.children) { +
      + + + +
    + } + } +
    diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.ts new file mode 100644 index 000000000000..7c83cf56ec32 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree'; +import { + Component, + afterRenderEffect, + computed, + signal, + viewChild, + untracked, + ChangeDetectionStrategy, +} from '@angular/core'; +import {NgTemplateOutlet} from '@angular/common'; +import {OverlayModule} from '@angular/cdk/overlay'; + +interface FoodNode { + name: string; + children?: FoodNode[]; + expanded?: boolean; +} + +/** @title Combobox with tree popup and highlight filtering. */ +@Component({ + selector: 'simple-combobox-tree-highlight-example', + templateUrl: 'simple-combobox-tree-highlight-example.html', + styleUrl: '../simple-combobox-examples.css', + imports: [ + Combobox, + ComboboxPopup, + ComboboxWidget, + NgTemplateOutlet, + Tree, + TreeItem, + TreeItemGroup, + OverlayModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SimpleComboboxTreeHighlightExample { + readonly tree = viewChild(Tree); + + popupExpanded = signal(false); + searchString = signal(''); + selectedValues = signal([]); + + readonly dataSource = signal(FOOD_DATA); + + constructor() { + // Highlight mode focus update + afterRenderEffect(() => { + this.filteredGroups(); + }); + + afterRenderEffect(() => { + const active = this.tree()?._pattern.inputs.activeItem(); + if (active) { + untracked(() => { + active.element()?.scrollIntoView({block: 'nearest'}); + }); + } + }); + } + + filteredData = computed(() => { + const search = this.searchString().trim().toLowerCase(); + const data = this.dataSource(); + + if (!search) { + return {groups: data, firstMatch: undefined}; + } + + let firstMatch: string | undefined = undefined; + + const filterNode = (node: FoodNode): FoodNode | null => { + // Find the first leaf node that starts with the search string + if (!firstMatch && !node.children && node.name.toLowerCase().startsWith(search)) { + firstMatch = node.name; + } + + const matches = node.name.toLowerCase().includes(search); + const children = node.children + ?.map(child => filterNode(child)) + .filter((child): child is FoodNode => child !== null); + + if (matches || (children && children.length > 0)) { + return { + ...node, + children, + expanded: children && children.length > 0 ? true : node.expanded, + }; + } + + return null; + }; + + const groups = data + .map(node => filterNode(node)) + .filter((node): node is FoodNode => node !== null); + return {groups, firstMatch}; + }); + + filteredGroups = computed(() => this.filteredData().groups); + firstMatchingOption = computed(() => this.filteredData().firstMatch); + + onCommit() { + const selected = this.selectedValues(); + if (selected.length > 0) { + this.searchString.set(selected[0]); + this.popupExpanded.set(false); + } + } +} + +const FOOD_DATA: FoodNode[] = [ + {name: 'Winter', children: [{name: 'December'}, {name: 'January'}, {name: 'February'}]}, + {name: 'Spring', children: [{name: 'March'}, {name: 'April'}, {name: 'May'}]}, + {name: 'Summer', children: [{name: 'June'}, {name: 'July'}, {name: 'August'}]}, + {name: 'Fall', children: [{name: 'September'}, {name: 'October'}, {name: 'November'}]}, +]; diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html index 616cb9ef2c92..f4b6061b3bd3 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html @@ -23,7 +23,7 @@ class="example-tree example-popup" focusMode="activedescendant" selectionMode="explicit" - [(values)]="selectedValues" + [(value)]="selectedValues" (click)="onCommit()" (keydown.enter)="onCommit()" #tree="ngTree" @@ -42,19 +42,17 @@
  • - + {{ node.name }} - +
  • @if (node.children) {
      diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts index 7d72459849b9..0e7a5eeb374b 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts @@ -8,7 +8,7 @@ import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree'; -import {Component, afterRenderEffect, computed, signal, viewChild} from '@angular/core'; +import {Component, afterRenderEffect, computed, signal, viewChild, untracked} from '@angular/core'; import {NgTemplateOutlet} from '@angular/common'; import {OverlayModule} from '@angular/cdk/overlay'; @@ -39,14 +39,17 @@ export class SimpleComboboxTreeExample { popupExpanded = signal(false); searchString = signal(''); - selectedValues = signal([]); + selectedValues = signal([]); readonly dataSource = signal(FOOD_DATA); constructor() { afterRenderEffect(() => { - if (this.popupExpanded()) { - this.tree()?.scrollActiveItemIntoView(); + const active = this.tree()?._pattern.inputs.activeItem(); + if (active) { + untracked(() => { + active.element()?.scrollIntoView({block: 'nearest'}); + }); } }); } @@ -83,37 +86,15 @@ export class SimpleComboboxTreeExample { const selected = this.selectedValues(); if (selected.length > 0) { const value = selected[0]; - this.searchString.set(value.name); + this.searchString.set(value); this.popupExpanded.set(false); } } } const FOOD_DATA: FoodNode[] = [ - { - name: 'Fruits', - children: [ - {name: 'Apples'}, - {name: 'Bananas'}, - { - name: 'Berries', - children: [{name: 'Strawberry'}, {name: 'Blueberry'}, {name: 'Raspberry'}], - }, - {name: 'Oranges'}, - ], - }, - { - name: 'Vegetables', - children: [ - { - name: 'Green', - children: [{name: 'Broccoli'}, {name: 'Brussels sprouts'}], - }, - { - name: 'Orange', - children: [{name: 'Pumpkins'}, {name: 'Carrots'}], - }, - {name: 'Onions'}, - ], - }, + {name: 'Winter', children: [{name: 'December'}, {name: 'January'}, {name: 'February'}]}, + {name: 'Spring', children: [{name: 'March'}, {name: 'April'}, {name: 'May'}]}, + {name: 'Summer', children: [{name: 'June'}, {name: 'July'}, {name: 'August'}]}, + {name: 'Fall', children: [{name: 'September'}, {name: 'October'}, {name: 'November'}]}, ]; diff --git a/src/dev-app/aria-simple-combobox/simple-combobox-demo.html b/src/dev-app/aria-simple-combobox/simple-combobox-demo.html index a9372253e7c7..5d57e7a1eab1 100644 --- a/src/dev-app/aria-simple-combobox/simple-combobox-demo.html +++ b/src/dev-app/aria-simple-combobox/simple-combobox-demo.html @@ -6,10 +6,19 @@

      Listbox autocomplete examples

      Combobox with manual filtering

    + +
    +

    Combobox with auto-select

    + +
    +
    +

    Combobox with highlight

    + +
    -

    Combobox with inline suggestion

    - +

    Combobox with disabled

    +
    @@ -17,9 +26,19 @@

    Tree autocomplete examples

    -

    Combobox with tree

    +

    Combobox with tree popup and manual filtering

    + +
    +

    Combobox with tree popup and auto-select

    + +
    + +
    +

    Combobox with tree popup and highlight filtering

    + +

    Combobox select examples

    @@ -29,7 +48,27 @@

    Combobox select examples

    Combobox with select

    + +
    +

    Combobox with Multi-Select

    + +
    + +
    +

    Combobox with Readonly + Disabled

    + +
    + +

    Combobox with Dialog Popup

    + +
    +
    +

    Combobox with Dialog Popup

    + +
    +
    +

    Combobox Grid Examples

    @@ -41,5 +80,5 @@

    Combobox with Grid

    Combobox with Datepicker Grid

    - - \ No newline at end of file + + \ 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 index cf70b2ef62f4..34f29baff5a5 100644 --- a/src/dev-app/aria-simple-combobox/simple-combobox-demo.ts +++ b/src/dev-app/aria-simple-combobox/simple-combobox-demo.ts @@ -9,11 +9,18 @@ import {Component} from '@angular/core'; import { SimpleComboboxListboxExample, - SimpleComboboxListboxInlineExample, SimpleComboboxTreeExample, SimpleComboboxSelectExample, SimpleComboboxGridExample, SimpleComboboxDatepickerExample, + SimpleComboboxAutoSelectExample, + SimpleComboboxHighlightExample, + SimpleComboboxDisabledExample, + SimpleComboboxReadonlyDisabledExample, + SimpleComboboxReadonlyMultiselectExample, + SimpleComboboxDialogExample, + SimpleComboboxTreeAutoSelectExample, + SimpleComboboxTreeHighlightExample, } from '@angular/components-examples/aria/simple-combobox'; @Component({ @@ -21,11 +28,18 @@ import { styleUrl: 'simple-combobox-demo.css', imports: [ SimpleComboboxListboxExample, - SimpleComboboxListboxInlineExample, SimpleComboboxTreeExample, SimpleComboboxSelectExample, SimpleComboboxGridExample, SimpleComboboxDatepickerExample, + SimpleComboboxAutoSelectExample, + SimpleComboboxHighlightExample, + SimpleComboboxDisabledExample, + SimpleComboboxReadonlyDisabledExample, + SimpleComboboxReadonlyMultiselectExample, + SimpleComboboxDialogExample, + SimpleComboboxTreeAutoSelectExample, + SimpleComboboxTreeHighlightExample, ], }) export class ComboboxDemo {} From 2d8fc55e844b85d7f960ee2943990dd6536d3e7c Mon Sep 17 00:00:00 2001 From: tjshiu <35056071+tjshiu@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:59:40 -0700 Subject: [PATCH 07/11] refactor(multiple): add tabbable input to listbox and tree patterns - Introduces a 'tabbable' input to Listbox and Tree to control whether the widget or its items are in the tab order. - Updates ListFocus and Tree behaviors to respect the 'tabbable' signal, defaulting tabIndex to -1 when false. - Updates simple-combobox examples to set [tabbable]="false" on internal widgets to ensure correct focus behavior. - Includes unit tests for the new tabbable behavior in ListFocus and Tree. --- src/aria/listbox/listbox.ts | 4 + .../behaviors/list-focus/list-focus.spec.ts | 20 +++++ .../behaviors/list-focus/list-focus.ts | 9 ++ src/aria/private/behaviors/tree/tree.spec.ts | 7 ++ src/aria/tree/tree.ts | 3 + .../simple-combobox-auto-select-example.html | 51 +++-------- .../simple-combobox-dialog-example.html | 2 +- .../simple-combobox-disabled-example.html | 48 +++-------- .../simple-combobox-grid-example.html | 2 +- .../simple-combobox-highlight-example.html | 52 +++--------- .../simple-combobox-listbox-example.html | 49 +++-------- ...le-combobox-readonly-disabled-example.html | 1 + ...combobox-readonly-multiselect-example.html | 1 + .../simple-combobox-select-example.html | 1 + ...ple-combobox-tree-auto-select-example.html | 85 ++++++------------- ...imple-combobox-tree-highlight-example.html | 85 ++++++------------- .../simple-combobox-tree-example.html | 1 + 17 files changed, 148 insertions(+), 273 deletions(-) diff --git a/src/aria/listbox/listbox.ts b/src/aria/listbox/listbox.ts index c599ed8e9257..29b2ddbbedde 100644 --- a/src/aria/listbox/listbox.ts +++ b/src/aria/listbox/listbox.ts @@ -133,6 +133,9 @@ export class Listbox { /** Whether the listbox is readonly. */ readonly readonly = input(false, {transform: booleanAttribute}); + /** Whether the list is tabbable. */ + tabbable = input(true, {transform: booleanAttribute}); + /** The values of the currently selected items. */ readonly value = model([]); @@ -146,6 +149,7 @@ export class Listbox { items: this.items, activeItem: signal(undefined), textDirection: this.textDirection, + tabbable: this.tabbable, element: () => this._elementRef.nativeElement, combobox: () => this._popup?.combobox?._pattern, }; diff --git a/src/aria/private/behaviors/list-focus/list-focus.spec.ts b/src/aria/private/behaviors/list-focus/list-focus.spec.ts index c37c1e40f629..ab4b1fbdd3b8 100644 --- a/src/aria/private/behaviors/list-focus/list-focus.spec.ts +++ b/src/aria/private/behaviors/list-focus/list-focus.spec.ts @@ -107,6 +107,26 @@ describe('List Focus', () => { }); }); + describe('tabbable', () => { + it('should override getListTabIndex to -1 when tabbable is explicitly false', () => { + const focusManager = getListFocus({ + focusMode: signal('activedescendant'), + tabbable: signal(false), + }); + expect(focusManager.getListTabIndex()).toBe(-1); + }); + + it('should override getItemTabIndex to -1 when tabbable is explicitly false', () => { + const focusManager = getListFocus({ + focusMode: signal('roving'), + tabbable: signal(false), + }); + const items = focusManager.inputs.items(); + focusManager.inputs.activeItem.set(items[0]); + expect(focusManager.getItemTabIndex(items[0])).toBe(-1); + }); + }); + describe('#isFocusable', () => { it('should return true for enabled items', () => { const focusManager = getListFocus({softDisabled: signal(false)}); diff --git a/src/aria/private/behaviors/list-focus/list-focus.ts b/src/aria/private/behaviors/list-focus/list-focus.ts index f2fddbdfac5c..03033e3b56cb 100644 --- a/src/aria/private/behaviors/list-focus/list-focus.ts +++ b/src/aria/private/behaviors/list-focus/list-focus.ts @@ -39,6 +39,9 @@ export interface ListFocusInputs { /** The html element that should receive focus. */ element: SignalLike; + + /** Whether the list is tabbable. */ + tabbable?: SignalLike; } /** Controls focus for a list of items. */ @@ -76,6 +79,9 @@ export class ListFocus { /** The tab index for the list. */ getListTabIndex(): -1 | 0 { + if (this.inputs.tabbable !== undefined && !this.inputs.tabbable()) { + return -1; + } if (this.isListDisabled()) { return 0; } @@ -84,6 +90,9 @@ export class ListFocus { /** Returns the tab index for the given item. */ getItemTabIndex(item: T): -1 | 0 { + if (this.inputs.tabbable !== undefined && !this.inputs.tabbable()) { + return -1; + } if (this.isListDisabled()) { return -1; } diff --git a/src/aria/private/behaviors/tree/tree.spec.ts b/src/aria/private/behaviors/tree/tree.spec.ts index 721d4cca4750..6791d6f17050 100644 --- a/src/aria/private/behaviors/tree/tree.spec.ts +++ b/src/aria/private/behaviors/tree/tree.spec.ts @@ -118,6 +118,13 @@ describe('Tree Behavior', () => { }); }); + describe('with tabbable: false', () => { + it('should override tree container tabIndex to -1', () => { + const {tree} = getDefaultPatterns({tabbable: signal(false)}); + expect(tree.tabIndex()).toBe(-1); + }); + }); + describe('with focusMode: "roving"', () => { it('should set the list tab index to -1', () => { const {tree} = getDefaultPatterns({focusMode: signal('roving')}); diff --git a/src/aria/tree/tree.ts b/src/aria/tree/tree.ts index 5ab3285bc2ba..7b0f6cc3bb8a 100644 --- a/src/aria/tree/tree.ts +++ b/src/aria/tree/tree.ts @@ -130,6 +130,9 @@ export class Tree { /** The delay in seconds before the typeahead search is reset. */ readonly typeaheadDelay = input(500); + /** Whether the tree is tabbable. */ + readonly tabbable = input(true, {transform: booleanAttribute}); + /** The values of the currently selected items. */ readonly value = model([]); diff --git a/src/components-examples/aria/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 index 7b575fc50b43..994d1778939c 100644 --- 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 @@ -1,47 +1,22 @@
    search - +
    - + -
    - @for (option of options(); track option) { -
    - {{option}} - -
    - } +
    + @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.html b/src/components-examples/aria/simple-combobox/simple-combobox-dialog/simple-combobox-dialog-example.html index f03b05da98de..572614d47187 100644 --- 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 @@ -19,7 +19,7 @@ (keydown.escape)="onSearchEscape($event)" /> -
    @for (option of options(); track option) {
    search - +
    - +
    -
    +
    @for (option of options(); track option) { -
    - {{option}} - -
    +
    + {{option}} + +
    }
    -
    +
    \ No newline at end of file diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.html index c20b72656531..d0633934620d 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.html +++ b/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.html @@ -8,7 +8,7 @@ -
    @for (item of filteredItems(); track item.label; let i = $index) {
    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 index 8f6b2bac67b2..0a2b9f28370a 100644 --- 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 @@ -1,48 +1,22 @@
    search - +
    - + -
    - @for (option of options(); track option) { -
    - {{option}} - -
    - } +
    + @for (option of options(); track option) { +
    + {{option}} +
    + } +
    -
    +
    \ No newline at end of file diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.html index 92c1df9cef27..ad36d94e4142 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.html +++ b/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.html @@ -1,48 +1,23 @@
    search - +
    - + -
    +
    @for (option of options(); track option) { -
    - {{option}} - -
    +
    + {{option}} + +
    }
    -
    +
    \ No newline at end of file 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 index 077b56774fd6..2c2c3bd96168 100644 --- 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 @@ -22,6 +22,7 @@ tabIndex="-1" ngComboboxWidget focusMode="activedescendant" + [tabbable]="false" selectionMode="explicit" [(value)]="selectedValues" (click)="onCommit()" 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 index 6a85a30fe31a..3b3df47de863 100644 --- 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 @@ -21,6 +21,7 @@ [multi]="true" ngComboboxWidget focusMode="activedescendant" + [tabbable]="false" selectionMode="explicit" [(value)]="selectedValues" > diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.html index ad4154e96dc0..9c1221efd572 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.html +++ b/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.html @@ -20,6 +20,7 @@ ngListbox ngComboboxWidget focusMode="activedescendant" + [tabbable]="false" selectionMode="explicit" [(value)]="selectedValues" (click)="onCommit()" 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 index 75b24d235cf3..ef19038f94cc 100644 --- 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 @@ -1,73 +1,38 @@
    search - +
    - + -
      - -
    +
      + +
    @for (node of nodes; track node.name) { -
  • - - {{ node.name }} - -
  • - @if (node.children) { -
      - - - -
    - } +
  • + + {{ node.name }} + +
  • + @if (node.children) { +
      + + + +
    + } } -
    + \ No newline at end of file diff --git a/src/components-examples/aria/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 index bca3f88c43dd..6f7535c71ff7 100644 --- 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 @@ -1,39 +1,20 @@
    search - +
    - +
    -
      - +
        +
    @@ -42,35 +23,19 @@ @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/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html index f4b6061b3bd3..3169aee5d382 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html @@ -22,6 +22,7 @@ ngComboboxWidget class="example-tree example-popup" focusMode="activedescendant" + [tabbable]="false" selectionMode="explicit" [(value)]="selectedValues" (click)="onCommit()" From d47eeb4ce5023e88776c77ffe5c3329711fbc1ba Mon Sep 17 00:00:00 2001 From: tjshiu <35056071+tjshiu@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:14:35 -0700 Subject: [PATCH 08/11] refactor(multiple): add simple-combobox unit tests and refine interaction logic - Adds extensive unit tests for SimpleCombobox across Listbox, Tree, and Grid implementations. - Refines aria-autocomplete calculation to exclude dialog-type popups, as they do not support autocomplete behavior. - Switches SimpleCombobox from pointerdown to click for popup triggering to improve interaction consistency. - Fixes ListFocus to properly focus the host element when using activedescendant mode. - Updates GridPattern to prevent resetting its default active state once a user has already interacted with it. - Moves alwaysExpanded initialization to ngOnInit in the public Combobox component for better lifecycle management. - Improves simple-combobox-highlight example to handle disabled states. --- src/aria/listbox/listbox.spec.ts | 8 +- src/aria/private/behaviors/grid/grid-focus.ts | 2 +- src/aria/private/behaviors/grid/grid.ts | 2 +- .../behaviors/list-focus/list-focus.ts | 2 + src/aria/private/grid/grid.spec.ts | 4 +- src/aria/private/grid/grid.ts | 1 + src/aria/private/simple-combobox/BUILD.bazel | 22 +- .../simple-combobox/simple-combobox.spec.ts | 225 +++ .../simple-combobox/simple-combobox.ts | 34 +- src/aria/simple-combobox/BUILD.bazel | 26 +- .../simple-combobox/simple-combobox.spec.ts | 1630 +++++++++++++++++ src/aria/simple-combobox/simple-combobox.ts | 10 +- .../simple-combobox-examples.css | 15 +- .../simple-combobox-highlight-example.html | 10 +- .../simple-combobox-highlight-example.ts | 116 +- 15 files changed, 2014 insertions(+), 93 deletions(-) create mode 100644 src/aria/private/simple-combobox/simple-combobox.spec.ts create mode 100644 src/aria/simple-combobox/simple-combobox.spec.ts diff --git a/src/aria/listbox/listbox.spec.ts b/src/aria/listbox/listbox.spec.ts index 34f41a56ad73..543f2d4321a7 100644 --- a/src/aria/listbox/listbox.spec.ts +++ b/src/aria/listbox/listbox.spec.ts @@ -148,10 +148,10 @@ describe('Listbox', () => { expect(listboxElement.getAttribute('aria-multiselectable')).toBe('false'); }); - it('should set aria-selected to "false" for all options by default', () => { - optionElements.forEach(optionElement => { - expect(optionElement.getAttribute('aria-selected')).toBe('false'); - }); + it('should set aria-selected to "true" for the first option and "false" for others by default', () => { + expect(optionElements[0].getAttribute('aria-selected')).toBe('true'); + expect(optionElements[1].getAttribute('aria-selected')).toBe('false'); + expect(optionElements[2].getAttribute('aria-selected')).toBe('false'); }); }); diff --git a/src/aria/private/behaviors/grid/grid-focus.ts b/src/aria/private/behaviors/grid/grid-focus.ts index 236905786d28..f947c54318a0 100644 --- a/src/aria/private/behaviors/grid/grid-focus.ts +++ b/src/aria/private/behaviors/grid/grid-focus.ts @@ -98,7 +98,7 @@ export class GridFocus { }); /** The tab index for the grid container. */ - readonly gridTabIndex = computed<-1 | 0 | null>(() => { + readonly gridTabIndex = computed<-1 | 0>(() => { const isTabbable = this.inputs.tabbable?.(); if (isTabbable === false) { return -1; diff --git a/src/aria/private/behaviors/grid/grid.ts b/src/aria/private/behaviors/grid/grid.ts index d015840b0b07..37c6ec15def6 100644 --- a/src/aria/private/behaviors/grid/grid.ts +++ b/src/aria/private/behaviors/grid/grid.ts @@ -83,7 +83,7 @@ export class Grid { ); /** The tab index for the grid container. */ - readonly gridTabIndex: SignalLike<-1 | 0 | null> = () => this.focusBehavior.gridTabIndex(); + readonly gridTabIndex: SignalLike<-1 | 0> = () => this.focusBehavior.gridTabIndex(); /** Whether the grid is in a disabled state. */ readonly gridDisabled: SignalLike = () => this.focusBehavior.gridDisabled(); diff --git a/src/aria/private/behaviors/list-focus/list-focus.ts b/src/aria/private/behaviors/list-focus/list-focus.ts index 03033e3b56cb..8ed8031b6dea 100644 --- a/src/aria/private/behaviors/list-focus/list-focus.ts +++ b/src/aria/private/behaviors/list-focus/list-focus.ts @@ -114,6 +114,8 @@ export class ListFocus { if (opts?.focusElement || opts?.focusElement === undefined) { if (this.inputs.focusMode() === 'roving') { item.element()?.focus(); + } else if (this.inputs.focusMode() === 'activedescendant') { + this.inputs.element()?.focus(); } } diff --git a/src/aria/private/grid/grid.spec.ts b/src/aria/private/grid/grid.spec.ts index 7653e5e4f588..5f2d1b99ab69 100644 --- a/src/aria/private/grid/grid.spec.ts +++ b/src/aria/private/grid/grid.spec.ts @@ -275,8 +275,8 @@ describe('Grid', () => { }); it('should trigger click on Enter for simple widget', () => { - const {grid} = createGrid([{cells: [{widgets: [{widgetType: 'simple'}]}]}], gridInputs); - const widget = grid.cells()[0][0].inputs.widgets()[0]; + const {grid} = createGrid([{cells: [{widget: {widgetType: 'simple'}}]}], gridInputs); + const widget = grid.cells()[0][0].inputs.widget()!; const element = widget.element(); spyOn(element, 'click'); diff --git a/src/aria/private/grid/grid.ts b/src/aria/private/grid/grid.ts index eb2d95ed6991..28cb1a2cd2e5 100644 --- a/src/aria/private/grid/grid.ts +++ b/src/aria/private/grid/grid.ts @@ -256,6 +256,7 @@ export class GridPattern { /** Sets the default active state of the grid before receiving focus the first time. */ setDefaultStateEffect(): void { + if (this.hasBeenInteracted()) return; this.gridBehavior.setDefaultState(); } diff --git a/src/aria/private/simple-combobox/BUILD.bazel b/src/aria/private/simple-combobox/BUILD.bazel index ff1162cde8f4..ed2c6582fc03 100644 --- a/src/aria/private/simple-combobox/BUILD.bazel +++ b/src/aria/private/simple-combobox/BUILD.bazel @@ -1,4 +1,4 @@ -load("//tools:defaults.bzl", "ts_project") +load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project") package(default_visibility = ["//visibility:public"]) @@ -16,3 +16,23 @@ ts_project( "//src/aria/private/behaviors/signal-like", ], ) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":simple-combobox", + "//:node_modules/@angular/core", + "//src/aria/private/behaviors/signal-like", + "//src/aria/private/listbox", + "//src/aria/private/tree", + "//src/cdk/keycodes", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/aria/private/simple-combobox/simple-combobox.spec.ts b/src/aria/private/simple-combobox/simple-combobox.spec.ts new file mode 100644 index 000000000000..ef318061e04e --- /dev/null +++ b/src/aria/private/simple-combobox/simple-combobox.spec.ts @@ -0,0 +1,225 @@ +import {SimpleComboboxPattern, SimpleComboboxPopupPattern} from './simple-combobox'; +import {signal} from '../behaviors/signal-like/signal-like'; +import {createKeyboardEvent} from '@angular/cdk/testing/private'; + +describe('SimpleComboboxPattern', () => { + function setup( + inputs: Partial<{ + disabled: boolean; + alwaysExpanded: boolean; + inlineSuggestion: string; + popupType: 'listbox' | 'tree' | 'grid' | 'dialog'; + }> = {}, + ) { + const element = document.createElement('input'); + const value = signal(''); + const expanded = signal(false); + const alwaysExpanded = signal(inputs.alwaysExpanded ?? false); + const disabled = signal(inputs.disabled ?? false); + const inlineSuggestion = signal(inputs.inlineSuggestion); + + // Mock a generic popup pattern + const popupId = signal('popup-1'); + const activeDescendant = signal('item-1'); + const controlTarget = document.createElement('div'); + const popupType = signal<'listbox' | 'tree' | 'grid' | 'dialog'>(inputs.popupType ?? 'listbox'); + + const popup = new SimpleComboboxPopupPattern({ + popupType, + controlTarget: signal(controlTarget), + activeDescendant, + popupId, + }); + + const pattern = new SimpleComboboxPattern({ + alwaysExpanded, + value, + element: signal(element), + popup: signal(popup), + inlineSuggestion, + disabled, + expanded, + expandable: signal(true), + }); + + return { + pattern, + element, + value, + expanded, + alwaysExpanded, + inlineSuggestion, + disabled, + popup, + controlTarget, + }; + } + + describe('Aria-autocomplete calculation', () => { + it('should return "list" when only popup is present', () => { + const {pattern} = setup(); + expect(pattern.autocomplete()).toBe('list'); + }); + + it('should return "both" when popup and inline suggestion are present', () => { + const {pattern} = setup({inlineSuggestion: 'suggestion'}); + expect(pattern.autocomplete()).toBe('both'); + }); + + it('should return "none" when only dialog popup is present', () => { + const {pattern} = setup({popupType: 'dialog'}); + expect(pattern.autocomplete()).toBe('none'); + }); + + it('should return "inline" when dialog popup and inline suggestion are present', () => { + const {pattern} = setup({popupType: 'dialog', inlineSuggestion: 'suggestion'}); + expect(pattern.autocomplete()).toBe('inline'); + }); + }); + + describe('Expansion via Keyboard', () => { + it('should open on ArrowDown when collapsed', () => { + const {pattern, expanded} = setup(); + expect(expanded()).toBe(false); + + pattern.onKeydown(createKeyboardEvent('keydown', 40, 'ArrowDown')); + expect(expanded()).toBe(true); + }); + + it('should close on Escape when expanded', () => { + const {pattern, expanded} = setup(); + expanded.set(true); + + pattern.onKeydown(createKeyboardEvent('keydown', 27, 'Escape')); + expect(expanded()).toBe(false); + }); + }); + + describe('Input handling', () => { + it('should update value and expand on input', () => { + const {pattern, element, value, expanded} = setup(); + expect(expanded()).toBe(false); + + element.value = 'hello'; + pattern.onInput({target: element} as unknown as Event); + + expect(value()).toBe('hello'); + expect(expanded()).toBe(true); + }); + }); + + describe('Focus handling', () => { + it('should track focus state', () => { + const {pattern} = setup(); + + pattern.onFocusin(); + expect(pattern.isFocused()).toBe(true); + + pattern.onFocusout(new FocusEvent('focusout')); + expect(pattern.isFocused()).toBe(false); + }); + }); + + describe('Inline Suggestion / Highlighting', () => { + it('should insert the inline suggestion into the input and select the remaining text', () => { + const {pattern, element, value, expanded, inlineSuggestion} = setup(); + + value.set('App'); + inlineSuggestion.set('Apple'); + expanded.set(true); + pattern.isFocused.set(true); + + pattern.highlightEffect(); + + expect(element.value).toBe('Apple'); + expect(element.selectionStart).toBe(3); + expect(element.selectionEnd).toBe(5); + }); + + it('should not highlight when deleting text', () => { + const {pattern, element, value, expanded, inlineSuggestion} = setup(); + + value.set('App'); + inlineSuggestion.set('Apple'); + expanded.set(true); + pattern.isFocused.set(true); + + const deleteEvent = new InputEvent('input', {inputType: 'deleteContentBackward'}); + Object.defineProperty(deleteEvent, 'target', {value: element}); + pattern.onInput(deleteEvent as Event); + + expect(pattern.isDeleting()).toBe(true); + + pattern.highlightEffect(); + + expect(element.value).not.toBe('Apple'); + }); + }); + + describe('Select-only combobox behavior', () => { + function setupSelectOnly() { + const selectOnlyElement = document.createElement('div'); + const {pattern, expanded, controlTarget} = setup(); + + // Override element to be select-only + pattern.inputs.element = signal(selectOnlyElement); + + return {pattern, expanded, selectOnlyElement, controlTarget}; + } + + it('should toggle expansion on click', () => { + const {pattern, expanded} = setupSelectOnly(); + expect(expanded()).toBe(false); + + pattern.onClick(new PointerEvent('click')); + expect(expanded()).toBe(true); + + pattern.onClick(new PointerEvent('click')); + expect(expanded()).toBe(false); + }); + + it('should open on Enter or Space when collapsed', () => { + const {pattern, expanded} = setupSelectOnly(); + + pattern.onKeydown(createKeyboardEvent('keydown', 13, 'Enter')); + expect(expanded()).toBe(true); + + expanded.set(false); + + pattern.onKeydown(createKeyboardEvent('keydown', 32, ' ')); + expect(expanded()).toBe(true); + }); + }); + + describe('alwaysExpanded behavior', () => { + it('should stay open on Escape when alwaysExpanded is true', () => { + const {pattern, expanded} = setup({alwaysExpanded: true}); + expanded.set(true); + + pattern.onKeydown(createKeyboardEvent('keydown', 27, 'Escape')); + expect(expanded()).toBe(true); + }); + }); + + describe('Blur behavior', () => { + it('should close when focus leaves both combobox and popup', () => { + const {pattern, expanded} = setup(); + expanded.set(true); + pattern.isFocused.set(false); + pattern.inputs.popup()!.isFocused.set(false); + + pattern.closePopupOnBlurEffect(); + expect(expanded()).toBe(false); + }); + + it('should remain open if popup is focused', () => { + const {pattern, expanded} = setup(); + expanded.set(true); + pattern.isFocused.set(false); + pattern.inputs.popup()!.isFocused.set(true); + + pattern.closePopupOnBlurEffect(); + expect(expanded()).toBe(true); + }); + }); +}); diff --git a/src/aria/private/simple-combobox/simple-combobox.ts b/src/aria/private/simple-combobox/simple-combobox.ts index 843cd9411709..017e12a3521e 100644 --- a/src/aria/private/simple-combobox/simple-combobox.ts +++ b/src/aria/private/simple-combobox/simple-combobox.ts @@ -6,8 +6,8 @@ * found in the LICENSE file at https://angular.dev/license */ -import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager'; -import {afterRenderEffect, computed, signal, untracked} from '@angular/core'; +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'; @@ -60,12 +60,13 @@ export class SimpleComboboxPattern { /** The autocomplete behavior of the combobox. */ readonly autocomplete = computed<'none' | 'inline' | 'list' | 'both'>(() => { - const hasPopup = !!this.inputs.popup(); + const popupType = this.popupType(); + const hasAutocompletePopup = !!this.inputs.popup() && popupType !== 'dialog'; const hasInlineSuggestion = !!this.inlineSuggestion(); - if (hasPopup && hasInlineSuggestion) { + if (hasAutocompletePopup && hasInlineSuggestion) { return 'both'; } - if (hasPopup) { + if (hasAutocompletePopup) { return 'list'; } if (hasInlineSuggestion) { @@ -143,9 +144,9 @@ export class SimpleComboboxPattern { return manager; }); - /** The pointerdown event manager for the combobox. */ - pointerdown = computed(() => { - const manager = new PointerEventManager(); + /** The click event manager for the combobox. */ + click = computed(() => { + const manager = new ClickEventManager(); if (this.isEditable()) return manager; @@ -157,12 +158,6 @@ export class SimpleComboboxPattern { constructor(readonly inputs: SimpleComboboxInputs) { this.expanded = inputs.expanded; this.value = inputs.value; - - afterRenderEffect(() => { - if (this.inputs.alwaysExpanded()) { - this.expanded.set(true); - } - }); } /** Handles keydown events for the combobox. */ @@ -172,10 +167,10 @@ export class SimpleComboboxPattern { } } - /** Handles pointerdown events for the combobox. */ - onPointerdown(event: PointerEvent) { + /** Handles click events for the combobox. */ + onClick(event: PointerEvent) { if (!this.disabled()) { - this.pointerdown().handle(event); + this.click().handle(event); } } @@ -186,9 +181,6 @@ export class SimpleComboboxPattern { /** Handles focus out events for the combobox. */ onFocusout(event: FocusEvent) { - const focusTarget = event.relatedTarget as Element | null; - if (this.element().contains(focusTarget)) return; - this.isFocused.set(false); } @@ -209,7 +201,7 @@ export class SimpleComboboxPattern { const isDeleting = untracked(() => this.isDeleting()); const isFocused = untracked(() => this.isFocused()); - const isExpanded = untracked(() => this.expanded()); + const isExpanded = this.expanded(); if (!inlineSuggestion || !isFocused || !isExpanded || isDeleting) return; diff --git a/src/aria/simple-combobox/BUILD.bazel b/src/aria/simple-combobox/BUILD.bazel index c872f7887fdb..c75084791e61 100644 --- a/src/aria/simple-combobox/BUILD.bazel +++ b/src/aria/simple-combobox/BUILD.bazel @@ -1,4 +1,4 @@ -load("//tools:defaults.bzl", "ng_project") +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") package(default_visibility = ["//visibility:public"]) @@ -14,3 +14,27 @@ ng_project( "//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/simple-combobox.spec.ts b/src/aria/simple-combobox/simple-combobox.spec.ts new file mode 100644 index 000000000000..b185f4e7fc2c --- /dev/null +++ b/src/aria/simple-combobox/simple-combobox.spec.ts @@ -0,0 +1,1630 @@ +import { + Component, + computed, + DebugElement, + signal, + ChangeDetectionStrategy, + effect, + untracked, + viewChild, + afterRenderEffect, +} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {Combobox, ComboboxPopup, ComboboxWidget} from './simple-combobox'; +import {Listbox, Option} from '../listbox'; +import {runAccessibilityChecks} from '@angular/cdk/testing/private'; +import {Tree, TreeItem, TreeItemGroup} from '../tree'; +import {NgTemplateOutlet} from '@angular/common'; +import {Grid, GridRow, GridCell, GridCellWidget} from '../grid'; + +describe('Combobox', () => { + const waitForMicrotasks = (ms = 10) => new Promise(resolve => setTimeout(resolve, ms)); + + 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')!; + + await waitForMicrotasks(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(option.id); + }); + }); + + describe('Navigation', () => { + beforeEach(() => setupCombobox()); + + it('should navigate to the first item on ArrowDown', async () => { + down(); + const options = getOptions(); + + await waitForMicrotasks(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[0].id); + }); + + it('should navigate to the last item on ArrowUp', async () => { + down(); // Opens the focus on Alabama + up(); + const options = getOptions(); + + await waitForMicrotasks(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe( + options[options.length - 1].id, + ); + }); + + it('should navigate to the next item on ArrowDown when open', async () => { + down(); // Open popup + down(); // Move to next item + const options = getOptions(); + await waitForMicrotasks(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[1].id); + }); + + it('should navigate to the previous item on ArrowUp when open', async () => { + down(); // Open + down(); // Move to next item + up(); // Move back to first item + const options = getOptions(); + await waitForMicrotasks(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[0].id); + }); + + it('should navigate to the first item on Home when open', async () => { + down(); // Open + down(); // Move to next item + keydown('Home'); + const options = getOptions(); + await waitForMicrotasks(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[0].id); + }); + + it('should navigate to the last item on End when open', async () => { + down(); // Open + keydown('End'); + const options = getOptions(); + await waitForMicrotasks(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe( + options[options.length - 1].id, + ); + }); + }); + + describe('Expansion', () => { + beforeEach(() => setupCombobox()); + + it('should open on ArrowDown', () => { + focus(); + keydown('ArrowDown'); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should close on Escape', () => { + down(); + escape(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on focusout', () => { + focus(); + blur(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on escape and maintain the current input value', async () => { + setupCombobox(ComboboxListboxHighlightExample); + + down(); // Use down() instead of focus() + input('Ala'); + expect(inputElement.value).toBe('Alabama'); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + + escape(); + expect(inputElement.value).toBe('Alabama'); + expect(inputElement.selectionEnd).toBe(7); + expect(inputElement.selectionStart).toBe(3); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on enter', () => { + down(); + enter(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on click to select an item', () => { + down(); + const fruitItem = getOption('Alabama')!; + click(fruitItem); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + }); + describe('Selection', () => { + describe('with manual filtering', () => { + beforeEach(() => setupCombobox(ComboboxListboxExample)); + + it('should select and commit on click', async () => { + down(); // Use down() to open + + const options = getOptions(); + click(options[0]); + + expect(fixture.componentInstance.value()).toEqual(['Alabama']); + expect(inputElement.value).toBe('Alabama'); + }); + + it('should select and commit to input on Enter', async () => { + focus(); + down(); + + enter(); + + expect(fixture.componentInstance.value()).toEqual(['Alabama']); + expect(inputElement.value).toBe('Alabama'); + }); + + it('should not select on navigation', () => { + down(); + down(); + + expect(fixture.componentInstance.value()).toEqual([]); + }); + + it('should select on focusout if the input text exactly matches an item', () => { + focus(); + input('Alabama'); + blur(); + + expect(fixture.componentInstance.value()).toEqual(['Alabama']); + }); + + it('should not select on focusout if the input text does not match an item', () => { + focus(); + input('Appl'); + blur(); + + expect(fixture.componentInstance.value()).toEqual([]); + expect(inputElement.value).toBe('Appl'); + }); + }); + + describe('with auto-select behavior', () => { + beforeEach(() => setupCombobox(ComboboxListboxAutoSelectExample)); + + it('should select and commit on click', async () => { + down(); // Use down() to open + + const options = getOptions(); + click(options[1]); + + expect(fixture.componentInstance.value()).toEqual(['Alaska']); + expect(inputElement.value).toBe('Alaska'); + }); + + it('should select and commit on Enter', () => { + down(); + down(); + enter(); + + expect(fixture.componentInstance.value()).toEqual(['Alaska']); + expect(inputElement.value).toBe('Alaska'); + }); + + it('should select on navigation in auto-select', async () => { + down(); + + expect(fixture.componentInstance.value()).toEqual(['Alabama']); + + down(); + + expect(fixture.componentInstance.value()).toEqual(['Alaska']); + + down(); + + expect(fixture.componentInstance.value()).toEqual(['Arizona']); + }); + it('should select the first option on input', () => { + focus(); + input('W'); + + expect(fixture.componentInstance.value()).toEqual(['Washington']); + }); + + it('should commit the selected option on focusout', () => { + focus(); + input('G'); + blur(); + + expect(inputElement.value).toBe('Georgia'); + expect(fixture.componentInstance.value()).toEqual(['Georgia']); + }); + }); + + describe('with highlight behavior', () => { + beforeEach(() => setupCombobox(ComboboxListboxHighlightExample)); + + it('should select and commit on click', async () => { + down(); // Use down() to open + + const options = getOptions(); + click(options[2]); + + expect(fixture.componentInstance.value()).toEqual(['Arizona']); + expect(inputElement.value).toBe('Arizona'); + }); + + it('should select and commit on Enter', async () => { + down(); + + down(); + down(); + enter(); + + expect(fixture.componentInstance.value()).toEqual(['Arizona']); + expect(inputElement.value).toBe('Arizona'); + }); + + it('should select on navigation', async () => { + down(); + + // Should auto-select the first option on open + expect(fixture.componentInstance.value()).toEqual(['Alabama']); + + down(); + + // Should update selection on navigation + expect(fixture.componentInstance.value()).toEqual(['Alaska']); + }); + + it('should update input value on navigation', async () => { + down(); + + expect(inputElement.value).toBe('Alabama'); + + down(); + + expect(inputElement.value).toBe('Alaska'); + }); + + it('should select the first option on input', async () => { + down(); // Use down() instead of focus() + + input('Cali'); + + expect(fixture.componentInstance.value()).toEqual(['California']); + }); + + it('should insert a highlighted completion string on input', async () => { + down(); // Use down() instead of focus() + + input('A'); + + expect(inputElement.value).toBe('Alabama'); + expect(inputElement.selectionStart).toBe(1); + expect(inputElement.selectionEnd).toBe(7); + }); + + it('should not insert a completion string on backspace', async () => { + down(); // Use down() instead of focus() + + input('New'); + + expect(inputElement.value).toBe('New Hampshire'); + expect(inputElement.selectionStart).toBe(3); + expect(inputElement.selectionEnd).toBe(13); + }); + + it('should insert a completion string even if the items are not changed', async () => { + down(); // Use down() instead of focus() + + input('New'); + await fixture.whenStable(); + fixture.detectChanges(); + + input('New '); + + expect(inputElement.value).toBe('New Hampshire'); + expect(inputElement.selectionStart).toBe(4); + expect(inputElement.selectionEnd).toBe(13); + }); + + it('should commit the selected option on focusout', async () => { + down(); // Use down() instead of focus() + + input('Cali'); + + blur(); + + expect(inputElement.value).toBe('California'); + expect(fixture.componentInstance.value()).toEqual(['California']); + }); + }); + }); + + describe('Filtering', () => { + it('should lazily render options', async () => { + setupCombobox(); + expect(getOptions().length).toBe(0); + + down(); + + expect(getOptions().length).toBe(50); + }); + + it('should filter the options based on the input value', () => { + setupCombobox(); + focus(); + input('New'); + + const options = getOptions(); + expect(options.length).toBe(4); + expect(options[0].textContent?.trim()).toBe('New Hampshire'); + expect(options[1].textContent?.trim()).toBe('New Jersey'); + expect(options[2].textContent?.trim()).toBe('New Mexico'); + expect(options[3].textContent?.trim()).toBe('New York'); + }); + + it('should show no options if nothing matches', () => { + setupCombobox(); + focus(); + input('xyz'); + const options = getOptions(); + expect(options.length).toBe(0); + }); + + it('should show all options when the input is cleared', () => { + setupCombobox(); + focus(); + input('Alabama'); + expect(getOptions().length).toBe(1); + + input(''); + expect(getOptions().length).toBe(50); + }); + }); + + describe('Readonly', () => { + beforeEach(() => setupCombobox(ComboboxListboxExample, {readonly: true})); + + it('should close on selection', () => { + focus(); + down(); + click(getOption('Alabama')!); + expect(inputElement.value).toBe('Alabama'); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on escape', () => { + focus(); + down(); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + escape(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + }); + + describe('Always Expanded', () => { + beforeEach(() => setupCombobox()); + + it('should not close on escape when alwaysExpanded is true', () => { + fixture.componentInstance.alwaysExpanded.set(true); + fixture.detectChanges(); + + focus(); + // Manually open since alwaysExpanded was set after init + fixture.componentInstance.popupExpanded.set(true); + fixture.detectChanges(); + + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + + escape(); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + }); + }); + }); + + describe('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(); + await waitForMicrotasks(20); + const item = getTreeItem('Winter')!; + expect(item.getAttribute('aria-expanded')).toBe('false'); + + right(); // Opens Winter + await waitForMicrotasks(20); + expect(item.getAttribute('aria-expanded')).toBe('true'); + + left(); // Closes Winter + await waitForMicrotasks(20); + expect(item.getAttribute('aria-expanded')).toBe('false'); + }); + }); + + describe('Navigation', () => { + beforeEach(() => setupCombobox()); + + it('should navigate to the first focusable item on ArrowDown', async () => { + down(); // Winter + await waitForMicrotasks(10); + const item = getTreeItem('Winter')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); + }); + + it('should navigate to the last focusable item on ArrowUp', async () => { + down(); // Winter + up(); // Fall + await waitForMicrotasks(10); + const item = getTreeItem('Fall')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); + }); + + it('should navigate to the next focusable item on ArrowDown when open', async () => { + down(); // Winter + down(); // Spring + await waitForMicrotasks(10); + const item = getTreeItem('Spring')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); + }); + + it('should navigate to the previous item on ArrowUp when open', async () => { + down(); // Winter + down(); // Spring + down(); // Summer + down(); // Fall + up(); // Summer + await waitForMicrotasks(10); + const item = getTreeItem('Summer')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); + }); + + it('should expand a closed node on ArrowRight', async () => { + down(); // Winter + expect(getVisibleTreeItems().length).toBe(4); + right(); // Expand Winter + expect(getVisibleTreeItems().length).toBe(7); + expect(getTreeItem('January')).not.toBeNull(); + }); + + it('should navigate to the next item on ArrowRight when already expanded', async () => { + down(); // Winter + right(); // Expand Winter + right(); // December + + const item = getTreeItem('December')!; + await waitForMicrotasks(10); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); + }); + + it('should collapse an open node on ArrowLeft', async () => { + down(); // Winter + right(); // Winter Expanded + expect(getVisibleTreeItems().length).toBe(7); + left(); // Winter Collapsed + expect(getVisibleTreeItems().length).toBe(4); + await waitForMicrotasks(10); + const item = getTreeItem('Winter')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); + }); + + it('should navigate to the parent node on ArrowLeft when in a child node', async () => { + down(); // Winter + right(); // Expand Winter + right(); // December + await waitForMicrotasks(10); + + const item1 = getTreeItem('December')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item1.id); + + left(); + await waitForMicrotasks(10); + + const item2 = getTreeItem('Winter')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item2.id); + }); + + it('should navigate to the first focusable item on Home when open', async () => { + down(); + down(); + keydown('Home'); + await waitForMicrotasks(10); + + const item = getTreeItem('Winter')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); + }); + + it('should navigate to the last focusable item on End when open', async () => { + down(); + down(); + keydown('End'); + await waitForMicrotasks(10); + + const grainsItem = getTreeItem('Fall')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(grainsItem.id); + }); + }); + + describe('Expansion', () => { + beforeEach(() => setupCombobox()); + + it('should open on ArrowDown', () => { + focus(); + keydown('ArrowDown'); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should close on Escape', () => { + down(); + escape(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on focusout', () => { + focus(); + blur(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on escape', () => { + focus(); + input('Mar'); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + escape(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on enter', () => { + down(); + enter(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on click to select an item', () => { + down(); + click(getTreeItem('Spring')!); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + }); + + describe('Selection', () => { + describe('with manual filtering', () => { + beforeEach(() => setupCombobox()); + + it('should select and commit on click', () => { + click(inputElement); + + // Iterate to the parent node and expand it so the child is visible + down(); // Winter + down(); // Spring + right(); // Expand Spring + + const item = getTreeItem('April')!; + click(item); + + expect(fixture.componentInstance.value()).toEqual(['April']); + expect(inputElement.value).toBe('April'); + }); + + it('should select and commit to input on Enter', () => { + down(); + enter(); + + expect(fixture.componentInstance.value()).toEqual(['Winter']); + expect(inputElement.value).toBe('Winter'); + }); + + it('should select on focusout if the input text exactly matches an item', () => { + focus(); + input('November'); + blur(); + + expect(fixture.componentInstance.value()).toEqual(['November']); + }); + + it('should not select on navigation', () => { + down(); + down(); + + expect(fixture.componentInstance.value()).toEqual([]); + }); + + it('should not select on focusout if the input text does not match an item', () => { + focus(); + input('Appl'); + blur(); + + expect(fixture.componentInstance.value()).toEqual([]); + expect(inputElement.value).toBe('Appl'); + }); + }); + }); + + describe('Filtering', () => { + beforeEach(() => setupCombobox()); + + it('should lazily render options', async () => { + expect(getTreeItems().length).toBe(0); + + focus(); + down(); + // Mutate dataSource to expand all + fixture.componentInstance.dataSource().forEach(node => (node.expanded = true)); + + // Force computed signal to re-evaluate by updating dataSource reference + fixture.componentInstance.dataSource.set([...fixture.componentInstance.dataSource()]); + fixture.detectChanges(); + await waitForMicrotasks(); + 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 + await waitForMicrotasks(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-label'); + }); + }); + + it('should navigate up and down with grid navigation', async () => { + focus(); + down(); // Open popup + + down(); // Navigate down to 'Bird-label' + await waitForMicrotasks(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Bird-label'); + + up(); // Navigate back up to 'Antelope-label' + await waitForMicrotasks(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-label'); + }); + + it('should navigate left and right with grid navigation', async () => { + focus(); + down(); // Open popup + + right(); // Move right to 'Antelope-delete' + await waitForMicrotasks(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-delete'); + + left(); // Move back left to 'Antelope-label' + await waitForMicrotasks(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-label'); + }); + + it('should navigate to the start of the row on Home', async () => { + focus(); + down(); // Open popup + + right(); // Move right to 'Antelope-delete' + await waitForMicrotasks(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-delete'); + + home(); // Move back to 'Antelope-label' + await waitForMicrotasks(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-label'); + }); + + it('should navigate to the end of the row on End', async () => { + focus(); + down(); // Open popup + + end(); // Move to end of row ('Antelope-delete') + await waitForMicrotasks(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-delete'); + }); + + it('should update aria-activedescendant with grid navigation', async () => { + focus(); + down(); // Open popup + + down(); // Navigate down + await waitForMicrotasks(20); + + // The active item is 'Bird' because we navigated down once more + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Bird-label'); + + right(); // Move right to delete button + await waitForMicrotasks(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Bird-delete'); + + down(); // Move down to next row + await waitForMicrotasks(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Cat-delete'); + }); + + it('should remove an item when delete is pressed in the delete cell', async () => { + down(); // On Antelope + right(); // Move right to delete button + enter(); // Click delete button + expect(fixture.componentInstance.items()).not.toContain('Antelope'); + }); + + it('should filter items and maintain selection', async () => { + down(); // Antelope + enter(); // Select active item + await waitForMicrotasks(20); + + expect(fixture.componentInstance.searchString()).toBe('Antelope'); + + inputElement.value = ''; + inputElement.dispatchEvent(new Event('input', {bubbles: true})); + fixture.detectChanges(); + + expect(fixture.componentInstance.searchString()).toBe(''); + + down(); // Go to BirdLabel + await waitForMicrotasks(20); + expect(inputElement.getAttribute('aria-activedescendant')).toBe('Bird-label'); + }); + + describe('Expansion', () => { + beforeEach(() => setupCombobox()); + + it('should close on Escape', () => { + down(); + escape(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on focusout', () => { + focus(); + blur(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on enter', () => { + down(); + enter(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + }); + + describe('Selection', () => { + beforeEach(() => setupCombobox()); + + it('should select and commit on click', async () => { + focus(); + down(); // Open popup + + const gridCells = fixture.nativeElement.querySelectorAll('[ngGridCellWidget]'); + gridCells[0].dispatchEvent(new PointerEvent('click', {bubbles: true})); + fixture.detectChanges(); + await waitForMicrotasks(20); + + expect(fixture.componentInstance.selectedItem()).toBe('Antelope'); + expect(inputElement.value).toBe('Antelope'); + }); + + it('should not select on navigation', async () => { + focus(); + down(); // Open popup + + down(); // Move row down + await waitForMicrotasks(20); + + expect(fixture.componentInstance.selectedItem()).toBeNull(); + }); + }); + }); +}); + +@Component({ + template: ` +
    + + + +
    + @for (option of options(); track option) { +
    + {{option}} +
    + } +
    +
    +
    + `, + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class ComboboxListboxExample { + readonly = signal(false); + alwaysExpanded = signal(false); + popupExpanded = signal(false); + searchString = signal(''); + value = signal([]); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + onCommit() { + const val = this.value(); + if (val.length > 0) { + this.searchString.set(val[0]); + } + this.popupExpanded.set(false); + } + + onBlur() { + const search = this.searchString().trim().toLowerCase(); + if (!search) return; + + const match = states.find(state => state.toLowerCase().startsWith(search)); + if (match) { + this.value.set([match]); + this.searchString.set(match); + } + } +} + +interface TreeNode { + name: string; + children?: TreeNode[]; + expanded?: boolean; +} + +function getTreeNodes(): TreeNode[] { + return [ + { + name: 'Winter', + expanded: false, + children: [{name: 'December'}, {name: 'January'}, {name: 'February'}], + }, + { + name: 'Spring', + expanded: false, + children: [{name: 'March'}, {name: 'April'}, {name: 'May'}], + }, + { + name: 'Summer', + expanded: false, + children: [{name: 'June'}, {name: 'July'}, {name: 'August'}], + }, + { + name: 'Fall', + expanded: false, + children: [{name: 'September'}, {name: 'October'}, {name: 'November'}], + }, + ]; +} + +@Component({ + template: ` +
    + + + +
      + +
    +
    +
    + + + @for (node of nodes; track node.name) { +
  • + {{ node.name }} +
  • + + @if (node.children) { +
      + + + +
    + } + } +
    + `, + imports: [ + Combobox, + ComboboxPopup, + ComboboxWidget, + Tree, + TreeItem, + TreeItemGroup, + NgTemplateOutlet, + ], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class ComboboxTreeExample { + readonly tree = viewChild(Tree); + + readonly = signal(false); + popupExpanded = signal(false); + searchString = signal(''); + value = signal([]); + readonly dataSource = signal(getTreeNodes()); + nodes = computed(() => { + const res = this.filterTreeNodes(this.dataSource()); + return res; + }); + + onCommit() { + const selected = this.value(); + if (selected.length > 0) { + this.searchString.set(selected[0]); + } + this.popupExpanded.set(false); + } + + onBlur() { + const flatNodes = this.flattenTreeNodes(this.dataSource()); + const match = flatNodes.find(n => n.name.toLowerCase() === this.searchString().toLowerCase()); + if (match) { + this.value.set([match.name]); + } + } + + firstMatch = computed(() => { + const flatNodes = this.flattenTreeNodes(this.nodes()); + const node = flatNodes.find(n => this.isMatch(n)); + return node?.name; + }); + + constructor() { + afterRenderEffect(() => { + const active = this.tree()?._pattern.inputs.activeItem(); + if (active) { + untracked(() => { + active.element()?.scrollIntoView({block: 'nearest'}); + }); + } + }); + } + + flattenTreeNodes(nodes: TreeNode[]): TreeNode[] { + return nodes.flatMap(node => { + return node.children ? [node, ...this.flattenTreeNodes(node.children)] : [node]; + }); + } + + deepCopyNodes(nodes: TreeNode[]): TreeNode[] { + return nodes.map(node => ({ + ...node, + children: node.children ? this.deepCopyNodes(node.children) : undefined, + })); + } + + filterTreeNodes(nodes: TreeNode[]): TreeNode[] { + const search = this.searchString().trim().toLowerCase(); + if (!search) { + return nodes; + } + + return nodes.reduce((acc, node) => { + const children = node.children ? this.filterTreeNodes(node.children) : undefined; + if (this.isMatch(node) || (children && children.length > 0)) { + acc.push({ + ...node, + children, + expanded: children && children.length > 0 ? true : node.expanded, + }); + } + return acc; + }, [] as TreeNode[]); + } + + isMatch(node: TreeNode) { + return node.name.toLowerCase().includes(this.searchString().toLowerCase()); + } +} + +const states = [ + 'Alabama', + 'Alaska', + 'Arizona', + 'Arkansas', + 'California', + 'Colorado', + 'Connecticut', + 'Delaware', + 'Florida', + 'Georgia', + 'Hawaii', + 'Idaho', + 'Illinois', + 'Indiana', + 'Iowa', + 'Kansas', + 'Kentucky', + 'Louisiana', + 'Maine', + 'Maryland', + 'Massachusetts', + 'Michigan', + 'Minnesota', + 'Mississippi', + 'Missouri', + 'Montana', + 'Nebraska', + 'Nevada', + 'New Hampshire', + 'New Jersey', + 'New Mexico', + 'New York', + 'North Carolina', + 'North Dakota', + 'Ohio', + 'Oklahoma', + 'Oregon', + 'Pennsylvania', + 'Rhode Island', + 'South Carolina', + 'South Dakota', + 'Tennessee', + 'Texas', + 'Utah', + 'Vermont', + 'Virginia', + 'Washington', + 'West Virginia', + 'Wisconsin', + 'Wyoming', +]; + +@Component({ + template: ` +
    + + + +
    + @for (item of filteredItems(); track item; let i = $index) { +
    +
    + +
    +
    + +
    +
    + } +
    +
    +
    + `, + imports: [Combobox, ComboboxPopup, ComboboxWidget, Grid, GridRow, GridCell, GridCellWidget], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class ComboboxGridExample { + popupExpanded = signal(false); + searchString = signal(''); + selectedItem = signal(null); + + items = signal(['Antelope', 'Bird', 'Cat', 'Dog']); + + filteredItems = computed(() => { + const search = this.searchString().toLowerCase(); + return this.items().filter(item => item.toLowerCase().includes(search)); + }); + + selectItem(item: string) { + this.selectedItem.set(item); + this.searchString.set(item); + this.popupExpanded.set(false); + } + + removeItem(itemToRemove: string) { + this.items.update(items => items.filter(item => item !== itemToRemove)); + } +} + +@Component({ + template: ` +
    + + + +
    + @for (option of options(); track option) { +
    + {{option}} +
    + } +
    +
    +
    + `, + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class ComboboxListboxAutoSelectExample { + readonly = signal(false); + popupExpanded = signal(false); + searchString = signal(''); + value = signal([]); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + onInput() { + const filtered = this.options(); + if (filtered.length > 0) { + this.value.set([filtered[0]]); + } + } + + onCommit() { + const val = this.value(); + if (val.length > 0) { + this.searchString.set(val[0]); + } + this.popupExpanded.set(false); + } + + onBlur() { + const search = this.searchString().trim().toLowerCase(); + if (!search) return; + + const match = states.find(state => state.toLowerCase().startsWith(search)); + if (match) { + this.value.set([match]); + this.searchString.set(match); + } + } +} + +@Component({ + template: ` +
    + + + +
    + @for (option of options(); track option) { +
    + {{option}} +
    + } +
    +
    +
    + `, + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class ComboboxListboxHighlightExample { + readonly combobox = viewChild(Combobox); + readonly = signal(false); + popupExpanded = signal(false); + searchString = signal(''); + value = signal([]); + readonly activeDescendantValue = signal(undefined); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + constructor() { + afterRenderEffect(() => { + const id = this.combobox()?._pattern.activeDescendant(); + if (id) { + const el = document.getElementById(id); + this.activeDescendantValue.set(el?.textContent?.trim()); + } else { + this.activeDescendantValue.set(undefined); + } + }); + } + + onCommit() { + const val = this.value(); + if (val.length > 0) { + this.searchString.set(val[0]); + } + this.popupExpanded.set(false); + } +} diff --git a/src/aria/simple-combobox/simple-combobox.ts b/src/aria/simple-combobox/simple-combobox.ts index af9b02e57db2..09536717b9c1 100644 --- a/src/aria/simple-combobox/simple-combobox.ts +++ b/src/aria/simple-combobox/simple-combobox.ts @@ -59,11 +59,11 @@ import { '(keydown)': '_pattern.onKeydown($event)', '(focusin)': '_pattern.onFocusin()', '(focusout)': '_pattern.onFocusout($event)', - '(pointerdown)': '_pattern.onPointerdown($event)', + '(click)': '_pattern.onClick($event)', '(input)': '_pattern.onInput($event)', }, }) -export class Combobox extends DeferredContentAware { +export class Combobox extends DeferredContentAware implements OnInit { private readonly _renderer = inject(Renderer2); /** The element that the combobox is attached to. */ @@ -117,6 +117,12 @@ export class Combobox extends DeferredContentAware { } } + ngOnInit() { + if (this.alwaysExpanded()) { + this.expanded.set(true); + } + } + /** Registers a popup with the combobox. */ _registerPopup(popup: ComboboxPopup) { this._popup.set(popup); diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-examples.css b/src/components-examples/aria/simple-combobox/simple-combobox-examples.css index 6ee8be5da669..4def504bc834 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-examples.css +++ b/src/components-examples/aria/simple-combobox/simple-combobox-examples.css @@ -39,8 +39,8 @@ width: 24px; height: 24px; font-size: 24px; - color: var(--mat-sys-on-surface-variant); - user-select: none; + color: var(--mat-sys-on-surface-variant); + user-select: none; display: grid; place-items: center; pointer-events: none; @@ -114,6 +114,13 @@ gap: 1rem; } +.example-option[aria-disabled="true"] { + cursor: not-allowed; + opacity: 0.5; + color: var(--mat-sys-on-surface-variant); + pointer-events: none; +} + .example-option-text { flex: 1; } @@ -166,7 +173,7 @@ padding: 10px; overflow-x: scroll; width: 100%; - box-sizing: border-box; + box-sizing: border-box; } .example-tree-item { @@ -349,4 +356,4 @@ ul[role='group'] { .example-grid-row[aria-selected='true'] .example-selected-icon { visibility: visible; -} +} \ No newline at end of file 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 index 0a2b9f28370a..4719ba14616d 100644 --- 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 @@ -2,7 +2,8 @@
    search + [(value)]="searchString" [(expanded)]="popupExpanded" + [inlineSuggestion]="selectedOption()[0] || options()[0]?.name" />
    - @for (option of options(); track option) { -
    - {{option}} + @for (option of options(); track option.name) { +
    + {{option.name}}
    } 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 index 4b809c6a04c9..f7912f5f9ee1 100644 --- 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 @@ -8,7 +8,15 @@ 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 { + afterRenderEffect, + Component, + computed, + signal, + viewChild, + untracked, + effect, +} from '@angular/core'; import {OverlayModule} from '@angular/cdk/overlay'; /** @title Simple Combobox Highlight */ @@ -26,7 +34,7 @@ export class SimpleComboboxHighlightExample { selectedOption = signal([]); options = computed(() => - states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + states.filter(state => state.name.toLowerCase().startsWith(this.searchString().toLowerCase())), ); constructor() { @@ -38,6 +46,10 @@ export class SimpleComboboxHighlightExample { 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(''); @@ -47,54 +59,54 @@ export class SimpleComboboxHighlightExample { } 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}, ]; From 4efc3bf40b6cc2bbba555e189ef3e9087c3f98f1 Mon Sep 17 00:00:00 2001 From: tjshiu <35056071+tjshiu@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:51:35 -0700 Subject: [PATCH 09/11] build(multiple): update goldens --- goldens/aria/grid/index.api.md | 5 +- goldens/aria/listbox/index.api.md | 3 +- goldens/aria/private/index.api.md | 62 +++++++++++++++ goldens/aria/simple-combobox/index.api.md | 77 +++++++++++++++++++ goldens/aria/tree/index.api.md | 3 +- src/aria/config.bzl | 1 + .../simple-combobox-auto-select-example.ts | 2 +- .../simple-combobox-datepicker-example.ts | 2 +- .../simple-combobox-dialog-example.ts | 2 +- .../simple-combobox-disabled-example.ts | 2 +- ...amples.css => simple-combobox-example.css} | 19 +++-- .../simple-combobox-grid-example.ts | 2 +- .../simple-combobox-highlight-example.ts | 2 +- .../simple-combobox-listbox-example.ts | 2 +- ...imple-combobox-tree-auto-select-example.ts | 2 +- .../simple-combobox-tree-highlight-example.ts | 2 +- .../simple-combobox-tree-example.ts | 2 +- .../simple-combobox-demo.css | 2 +- .../simple-combobox-demo.html | 2 +- 19 files changed, 171 insertions(+), 23 deletions(-) create mode 100644 goldens/aria/simple-combobox/index.api.md rename src/components-examples/aria/simple-combobox/{simple-combobox-examples.css => simple-combobox-example.css} (93%) diff --git a/goldens/aria/grid/index.api.md b/goldens/aria/grid/index.api.md index 9692ebfa562b..9d2ab2e6643d 100644 --- a/goldens/aria/grid/index.api.md +++ b/goldens/aria/grid/index.api.md @@ -15,16 +15,19 @@ export class Grid { readonly colWrap: _angular_core.InputSignal<"continuous" | "loop" | "nowrap">; readonly disabled: _angular_core.InputSignalWithTransform; readonly element: HTMLElement; + readonly enableRangeSelection: _angular_core.InputSignalWithTransform; readonly enableSelection: _angular_core.InputSignalWithTransform; readonly focusMode: _angular_core.InputSignal<"roving" | "activedescendant">; readonly multi: _angular_core.InputSignalWithTransform; readonly _pattern: GridPattern; readonly rowWrap: _angular_core.InputSignal<"continuous" | "loop" | "nowrap">; + scrollActiveCellIntoView(options?: ScrollIntoViewOptions): void; readonly selectionMode: _angular_core.InputSignal<"follow" | "explicit">; readonly softDisabled: _angular_core.InputSignalWithTransform; + readonly tabbable: _angular_core.InputSignal; readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } diff --git a/goldens/aria/listbox/index.api.md b/goldens/aria/listbox/index.api.md index 9bab3e7021fd..00c0979196a5 100644 --- a/goldens/aria/listbox/index.api.md +++ b/goldens/aria/listbox/index.api.md @@ -24,12 +24,13 @@ export class Listbox { scrollActiveItemIntoView(options?: ScrollIntoViewOptions): void; readonly selectionMode: _angular_core.InputSignal<"follow" | "explicit">; readonly softDisabled: _angular_core.InputSignalWithTransform; + tabbable: _angular_core.InputSignalWithTransform; protected readonly textDirection: _angular_core.Signal<_angular_cdk_bidi.Direction>; readonly typeaheadDelay: _angular_core.InputSignal; readonly value: _angular_core.ModelSignal; readonly wrap: _angular_core.InputSignalWithTransform; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration, "[ngListbox]", ["ngListbox"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "orientation": { "alias": "orientation"; "required": false; "isSignal": true; }; "multi": { "alias": "multi"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "focusMode": { "alias": "focusMode"; "required": false; "isSignal": true; }; "selectionMode": { "alias": "selectionMode"; "required": false; "isSignal": true; }; "typeaheadDelay": { "alias": "typeaheadDelay"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "readonly": { "alias": "readonly"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; }, ["_options"], never, true, [{ directive: typeof ComboboxPopup; inputs: {}; outputs: {}; }]>; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration, "[ngListbox]", ["ngListbox"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "orientation": { "alias": "orientation"; "required": false; "isSignal": true; }; "multi": { "alias": "multi"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "focusMode": { "alias": "focusMode"; "required": false; "isSignal": true; }; "selectionMode": { "alias": "selectionMode"; "required": false; "isSignal": true; }; "typeaheadDelay": { "alias": "typeaheadDelay"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "readonly": { "alias": "readonly"; "required": false; "isSignal": true; }; "tabbable": { "alias": "tabbable"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; }, ["_options"], never, true, [{ directive: typeof ComboboxPopup; inputs: {}; outputs: {}; }]>; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration, never>; } diff --git a/goldens/aria/private/index.api.md b/goldens/aria/private/index.api.md index b6c1d72b8fa7..23d1c60dc335 100644 --- a/goldens/aria/private/index.api.md +++ b/goldens/aria/private/index.api.md @@ -656,6 +656,68 @@ 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; + value: WritableSignalLike; +} + +// @public +export class SimpleComboboxPattern { + constructor(inputs: SimpleComboboxInputs); + readonly activeDescendant: _angular_core.Signal; + readonly autocomplete: _angular_core.Signal<"none" | "inline" | "list" | "both">; + click: _angular_core.Signal>; + closePopupOnBlurEffect(): void; + readonly disabled: () => boolean; + readonly element: () => HTMLElement; + readonly expanded: WritableSignalLike; + highlightEffect(): void; + readonly inlineSuggestion: () => string | undefined; + // (undocumented) + readonly inputs: SimpleComboboxInputs; + readonly isDeleting: _angular_core.WritableSignal; + readonly isEditable: _angular_core.Signal; + readonly isFocused: _angular_core.WritableSignal; + readonly keyboardEventRelay: _angular_core.WritableSignal; + keyboardEventRelayEffect(): void; + keydown: _angular_core.Signal>; + onClick(event: PointerEvent): void; + onFocusin(): void; + onFocusout(event: FocusEvent): void; + onInput(event: Event): void; + onKeydown(event: KeyboardEvent): void; + readonly popupId: _angular_core.Signal; + readonly popupType: _angular_core.Signal<"listbox" | "tree" | "grid" | "dialog" | undefined>; + readonly value: WritableSignalLike; +} + +// @public +export interface SimpleComboboxPopupInputs { + activeDescendant: SignalLike; + controlTarget: SignalLike; + popupId: SignalLike; + popupType: SignalLike<'listbox' | 'tree' | 'grid' | 'dialog'>; +} + +// @public +export class SimpleComboboxPopupPattern { + constructor(inputs: SimpleComboboxPopupInputs); + readonly activeDescendant: () => string | undefined; + readonly controlTarget: () => HTMLElement | undefined; + // (undocumented) + readonly inputs: SimpleComboboxPopupInputs; + readonly isFocused: _angular_core.WritableSignal; + onFocusin(): void; + onFocusout(event: FocusEvent): void; + readonly popupId: () => string | undefined; + readonly popupType: () => "listbox" | "tree" | "grid" | "dialog"; +} + // @public export function sortDirectives(a: HasElement, b: HasElement): 1 | -1; diff --git a/goldens/aria/simple-combobox/index.api.md b/goldens/aria/simple-combobox/index.api.md new file mode 100644 index 000000000000..3ea77c42b985 --- /dev/null +++ b/goldens/aria/simple-combobox/index.api.md @@ -0,0 +1,77 @@ +## 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; + _unregisterPopup(): void; + readonly value: _angular_core.ModelSignal; + // (undocumented) + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + +// @public +export class ComboboxPopup implements OnInit, OnDestroy { + readonly activeDescendant: _angular_core.Signal; + readonly combobox: _angular_core.InputSignal; + readonly controlTarget: _angular_core.Signal; + // (undocumented) + ngOnDestroy(): void; + // (undocumented) + ngOnInit(): void; + readonly _pattern: SimpleComboboxPopupPattern; + readonly popupId: _angular_core.Signal; + readonly popupType: _angular_core.InputSignal<"listbox" | "tree" | "grid" | "dialog">; + _registerWidget(widget: ComboboxWidget): void; + _unregisterWidget(): void; + readonly _widget: _angular_core.WritableSignal; + // (undocumented) + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + +// @public +export class ComboboxWidget implements OnInit, OnDestroy { + constructor(); + readonly activeDescendant: _angular_core.WritableSignal; + readonly element: HTMLElement; + // (undocumented) + ngOnDestroy(): void; + // (undocumented) + ngOnInit(): void; + onFocusin(): void; + onFocusout(event: FocusEvent): void; + readonly popupId: _angular_core.WritableSignal; + // (undocumented) + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/aria/tree/index.api.md b/goldens/aria/tree/index.api.md index 5502e0dfa0dd..028d29f05c3c 100644 --- a/goldens/aria/tree/index.api.md +++ b/goldens/aria/tree/index.api.md @@ -28,6 +28,7 @@ export class Tree { scrollActiveItemIntoView(options?: ScrollIntoViewOptions): void; readonly selectionMode: _angular_core.InputSignal<"follow" | "explicit">; readonly softDisabled: _angular_core.InputSignalWithTransform; + readonly tabbable: _angular_core.InputSignalWithTransform; readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>; readonly typeaheadDelay: _angular_core.InputSignal; // (undocumented) @@ -35,7 +36,7 @@ export class Tree { readonly value: _angular_core.ModelSignal; readonly wrap: _angular_core.InputSignalWithTransform; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration, "[ngTree]", ["ngTree"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "orientation": { "alias": "orientation"; "required": false; "isSignal": true; }; "multi": { "alias": "multi"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "selectionMode": { "alias": "selectionMode"; "required": false; "isSignal": true; }; "focusMode": { "alias": "focusMode"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "typeaheadDelay": { "alias": "typeaheadDelay"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; "nav": { "alias": "nav"; "required": false; "isSignal": true; }; "currentType": { "alias": "currentType"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; }, never, never, true, [{ directive: typeof ComboboxPopup; inputs: {}; outputs: {}; }]>; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration, "[ngTree]", ["ngTree"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "orientation": { "alias": "orientation"; "required": false; "isSignal": true; }; "multi": { "alias": "multi"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "selectionMode": { "alias": "selectionMode"; "required": false; "isSignal": true; }; "focusMode": { "alias": "focusMode"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "typeaheadDelay": { "alias": "typeaheadDelay"; "required": false; "isSignal": true; }; "tabbable": { "alias": "tabbable"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; "nav": { "alias": "nav"; "required": false; "isSignal": true; }; "currentType": { "alias": "currentType"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; }, never, never, true, [{ directive: typeof ComboboxPopup; inputs: {}; outputs: {}; }]>; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration, never>; } diff --git a/src/aria/config.bzl b/src/aria/config.bzl index adba36ba70e9..7526a7d7ce61 100644 --- a/src/aria/config.bzl +++ b/src/aria/config.bzl @@ -8,6 +8,7 @@ ARIA_ENTRYPOINTS = [ "listbox/testing", "menu", "menu/testing", + "simple-combobox", "tabs", "tabs/testing", "toolbar", diff --git a/src/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 index 052b2d4c3ced..0927b08a94f9 100644 --- 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 @@ -15,7 +15,7 @@ import {OverlayModule} from '@angular/cdk/overlay'; @Component({ selector: 'simple-combobox-auto-select-example', templateUrl: 'simple-combobox-auto-select-example.html', - styleUrl: '../simple-combobox-examples.css', + styleUrl: '../simple-combobox-example.css', imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], }) export class SimpleComboboxAutoSelectExample { diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.ts index 2f4a82a34267..c79d8c240a4f 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.ts +++ b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.ts @@ -36,7 +36,7 @@ interface CalendarCell { @Component({ selector: 'simple-combobox-datepicker-example', templateUrl: 'simple-combobox-datepicker-example.html', - styleUrls: ['../simple-combobox-examples.css', 'simple-combobox-datepicker-example.css'], + styleUrls: ['../simple-combobox-example.css', 'simple-combobox-datepicker-example.css'], imports: [ Grid, GridRow, 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 index 15a8fc34358b..fa3018d7bb6f 100644 --- 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 @@ -26,7 +26,7 @@ import {FormsModule} from '@angular/forms'; @Component({ selector: 'simple-combobox-dialog-example', templateUrl: 'simple-combobox-dialog-example.html', - styleUrls: ['../simple-combobox-examples.css'], + styleUrls: ['../simple-combobox-example.css'], imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule], changeDetection: ChangeDetectionStrategy.OnPush, }) 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 index 7c35a906fd6e..0e7fe2b7dadb 100644 --- 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 @@ -15,7 +15,7 @@ import {OverlayModule} from '@angular/cdk/overlay'; @Component({ selector: 'simple-combobox-disabled-example', templateUrl: 'simple-combobox-disabled-example.html', - styleUrl: '../simple-combobox-examples.css', + styleUrl: '../simple-combobox-example.css', imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], }) export class SimpleComboboxDisabledExample { diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-examples.css b/src/components-examples/aria/simple-combobox/simple-combobox-example.css similarity index 93% rename from src/components-examples/aria/simple-combobox/simple-combobox-examples.css rename to src/components-examples/aria/simple-combobox/simple-combobox-example.css index 4def504bc834..6f239afd12e3 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-examples.css +++ b/src/components-examples/aria/simple-combobox/simple-combobox-example.css @@ -40,6 +40,8 @@ height: 24px; font-size: 24px; color: var(--mat-sys-on-surface-variant); + -webkit-user-select: none; + -moz-user-select: none; user-select: none; display: grid; place-items: center; @@ -60,7 +62,7 @@ transition: transform 0.2s ease; } -.example-combobox-input[aria-expanded='true']+.example-arrow-icon { +.example-combobox-input[aria-expanded='true'] + .example-arrow-icon { transform: rotate(180deg); } @@ -114,7 +116,7 @@ gap: 1rem; } -.example-option[aria-disabled="true"] { +.example-option[aria-disabled='true'] { cursor: not-allowed; opacity: 0.5; color: var(--mat-sys-on-surface-variant); @@ -157,7 +159,8 @@ background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent); } -.example-combobox-container:not(.no-active-outline):focus-within [data-active='true']:not(.no-active-outline) { +.example-combobox-container:not(.example-no-active-outline):focus-within + [data-active='true']:not(.example-no-active-outline) { outline: 2px solid color-mix(in srgb, var(--mat-sys-primary) 80%, transparent); outline-offset: -2px; } @@ -186,7 +189,7 @@ padding: 0.3rem 1rem; } -li[aria-expanded='false']+ul[role='group'] { +li[aria-expanded='false'] + ul[role='group'] { display: none; } @@ -229,15 +232,15 @@ ul[role='group'] { transition: background-color 0.2s ease; } -.example-grid-row.selectable { +.example-grid-row.example-selectable { cursor: pointer; } -.example-grid-row.selectable:hover { +.example-grid-row.example-selectable:hover { background-color: color-mix(in srgb, var(--mat-sys-on-surface) 5%, transparent); } -.example-grid-row.selectable:active { +.example-grid-row.example-selectable:active { background-color: color-mix(in srgb, var(--mat-sys-primary) 20%, transparent); } @@ -356,4 +359,4 @@ ul[role='group'] { .example-grid-row[aria-selected='true'] .example-selected-icon { visibility: visible; -} \ No newline at end of file +} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.ts index c181a704a4a7..01fa4011fe49 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.ts +++ b/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.ts @@ -16,7 +16,7 @@ import {MatIconModule} from '@angular/material/icon'; @Component({ selector: 'simple-combobox-grid-example', templateUrl: 'simple-combobox-grid-example.html', - styleUrl: '../simple-combobox-examples.css', + styleUrl: '../simple-combobox-example.css', imports: [ Combobox, ComboboxPopup, 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 index f7912f5f9ee1..c0c062fe9626 100644 --- 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 @@ -23,7 +23,7 @@ import {OverlayModule} from '@angular/cdk/overlay'; @Component({ selector: 'simple-combobox-highlight-example', templateUrl: 'simple-combobox-highlight-example.html', - styleUrl: '../simple-combobox-examples.css', + styleUrl: '../simple-combobox-example.css', imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], }) export class SimpleComboboxHighlightExample { diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.ts index ad1d72b5f672..c820800bae48 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.ts +++ b/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-example.ts @@ -15,7 +15,7 @@ import {OverlayModule} from '@angular/cdk/overlay'; @Component({ selector: 'simple-combobox-listbox-example', templateUrl: 'simple-combobox-listbox-example.html', - styleUrl: '../simple-combobox-examples.css', + styleUrl: '../simple-combobox-example.css', imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], }) export class SimpleComboboxListboxExample { 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 index 866c7d24ee75..c362d3aa3d7a 100644 --- 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 @@ -30,7 +30,7 @@ interface FoodNode { @Component({ selector: 'simple-combobox-tree-auto-select-example', templateUrl: 'simple-combobox-tree-auto-select-example.html', - styleUrl: '../simple-combobox-examples.css', + styleUrl: '../simple-combobox-example.css', imports: [ Combobox, ComboboxPopup, 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 index 7c83cf56ec32..6de3da7b55a4 100644 --- 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 @@ -30,7 +30,7 @@ interface FoodNode { @Component({ selector: 'simple-combobox-tree-highlight-example', templateUrl: 'simple-combobox-tree-highlight-example.html', - styleUrl: '../simple-combobox-examples.css', + styleUrl: '../simple-combobox-example.css', imports: [ Combobox, ComboboxPopup, diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts index 0e7a5eeb374b..c1faeb5bf87c 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts @@ -22,7 +22,7 @@ interface FoodNode { @Component({ selector: 'simple-combobox-tree-example', templateUrl: 'simple-combobox-tree-example.html', - styleUrl: '../simple-combobox-examples.css', + styleUrl: '../simple-combobox-example.css', imports: [ Combobox, ComboboxPopup, diff --git a/src/dev-app/aria-simple-combobox/simple-combobox-demo.css b/src/dev-app/aria-simple-combobox/simple-combobox-demo.css index a3b9cc5650f1..78b87b236202 100644 --- a/src/dev-app/aria-simple-combobox/simple-combobox-demo.css +++ b/src/dev-app/aria-simple-combobox/simple-combobox-demo.css @@ -20,6 +20,6 @@ h2 { h3 { font-size: 1rem; } -.simple-combobox-demo { +.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 index 5d57e7a1eab1..d651ab1aa070 100644 --- a/src/dev-app/aria-simple-combobox/simple-combobox-demo.html +++ b/src/dev-app/aria-simple-combobox/simple-combobox-demo.html @@ -1,4 +1,4 @@ -
    +

    Listbox autocomplete examples

    From d7d670947d81e542a2f0dc7c90c88ae97c2be3f7 Mon Sep 17 00:00:00 2001 From: tjshiu <35056071+tjshiu@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:22:26 -0700 Subject: [PATCH 10/11] test: use mutation observer for more reliable simple-combobox tests --- GEMINI.md | 13 ++ .../simple-combobox/simple-combobox.ts | 2 +- .../simple-combobox/simple-combobox.spec.ts | 118 ++++++++++++------ .../simple-combobox-auto-select-example.ts | 2 +- .../simple-combobox-dialog-example.ts | 1 - .../simple-combobox-highlight-example.ts | 10 +- .../simple-combobox-listbox-example.ts | 2 +- ...mple-combobox-readonly-disabled-example.ts | 1 - ...e-combobox-readonly-multiselect-example.ts | 1 - .../simple-combobox-demo.ts | 3 +- 10 files changed, 98 insertions(+), 55 deletions(-) create mode 100644 GEMINI.md diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 000000000000..b19269c3c2a7 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,13 @@ +# Jetski Global Instructions & Context + + ## Rules + - **Skip Tests by Default**: Do not run unit or integration tests automatically after making code changes. + - **Run Tests on Demand**: Only run tests when explicitly prompted by the user (e.g., "run tests", "verify with tests"). + - **Always allowed to run `git status`**: You can run `git status` at any time to check the state of the repository. + - **Git Commits**: Follow Angular's commit message format (`(): `). Use imperative mood, lowercase subject, and no trailing period. Valid scopes are in `.ng-dev/commit-message.mts`. Scopes can be omitted for `test` or refactoring types. Use `multiple` for cross-component changes. Do not use `aria` as a standalone scope. + - **Git Pushing**: Always push branches to the `tjshiu` remote (e.g., `git push tjshiu `), as `origin` is not configured. + + ## Aliases + - **start-fresh**: Run `git stash push --include-untracked -m "work-in-progress"`, checkout `main`, pull `origin main`, checkout a new branch, and stash pop. + - **update-main**: Sync local main with remote using `git checkout main && git pull upstream main && git checkout -`. + \ No newline at end of file diff --git a/src/aria/private/simple-combobox/simple-combobox.ts b/src/aria/private/simple-combobox/simple-combobox.ts index 017e12a3521e..04e9e1cfb7e8 100644 --- a/src/aria/private/simple-combobox/simple-combobox.ts +++ b/src/aria/private/simple-combobox/simple-combobox.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {KeyboardEventManager, ClickEventManager, Modifier} from '../behaviors/event-manager'; +import {KeyboardEventManager, ClickEventManager} from '../behaviors/event-manager'; import {computed, signal, untracked} from '@angular/core'; import {SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like'; import {ExpansionItem} from '../behaviors/expansion/expansion'; diff --git a/src/aria/simple-combobox/simple-combobox.spec.ts b/src/aria/simple-combobox/simple-combobox.spec.ts index b185f4e7fc2c..84319630efb9 100644 --- a/src/aria/simple-combobox/simple-combobox.spec.ts +++ b/src/aria/simple-combobox/simple-combobox.spec.ts @@ -4,7 +4,6 @@ import { DebugElement, signal, ChangeDetectionStrategy, - effect, untracked, viewChild, afterRenderEffect, @@ -17,9 +16,44 @@ import {runAccessibilityChecks} from '@angular/cdk/testing/private'; import {Tree, TreeItem, TreeItemGroup} from '../tree'; import {NgTemplateOutlet} from '@angular/common'; import {Grid, GridRow, GridCell, GridCellWidget} from '../grid'; +import {MutationObserverFactory} from '@angular/cdk/observers'; describe('Combobox', () => { - const waitForMicrotasks = (ms = 10) => new Promise(resolve => setTimeout(resolve, ms)); + let currentFixture: ComponentFixture | null = null; + + const resetMutationState = () => { + // No-op, kept to avoid changing setup helpers + }; + + const waitForMutation = (ms = 50) => { + const factory = TestBed.inject(MutationObserverFactory); + return new Promise(resolve => { + let resolved = false; + const timeoutId = setTimeout(() => { + if (!resolved) { + resolved = true; + observer?.disconnect(); + currentFixture?.detectChanges(); + resolve(); + } + }, ms); + + const observer = factory.create(() => { + if (!resolved) { + resolved = true; + clearTimeout(timeoutId); + observer?.disconnect(); + currentFixture?.detectChanges(); + resolve(); + } + }); + observer?.observe(document.body, { + attributes: true, + childList: true, + subtree: true, + }); + }); + }; describe('with Listbox', () => { let fixture: ComponentFixture; @@ -78,6 +112,8 @@ describe('Combobox', () => { fixture.detectChanges(); defineTestVariables(); + currentFixture = fixture; + resetMutationState(); } function defineTestVariables() { @@ -149,7 +185,7 @@ describe('Combobox', () => { down(); const option = getOption('Alabama')!; - await waitForMicrotasks(); + await waitForMutation(); expect(inputElement.getAttribute('aria-activedescendant')).toBe(option.id); }); }); @@ -161,7 +197,7 @@ describe('Combobox', () => { down(); const options = getOptions(); - await waitForMicrotasks(); + await waitForMutation(); expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[0].id); }); @@ -170,7 +206,7 @@ describe('Combobox', () => { up(); const options = getOptions(); - await waitForMicrotasks(); + await waitForMutation(); expect(inputElement.getAttribute('aria-activedescendant')).toBe( options[options.length - 1].id, ); @@ -180,7 +216,7 @@ describe('Combobox', () => { down(); // Open popup down(); // Move to next item const options = getOptions(); - await waitForMicrotasks(); + await waitForMutation(); expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[1].id); }); @@ -189,7 +225,7 @@ describe('Combobox', () => { down(); // Move to next item up(); // Move back to first item const options = getOptions(); - await waitForMicrotasks(); + await waitForMutation(); expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[0].id); }); @@ -198,7 +234,7 @@ describe('Combobox', () => { down(); // Move to next item keydown('Home'); const options = getOptions(); - await waitForMicrotasks(); + await waitForMutation(); expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[0].id); }); @@ -206,7 +242,7 @@ describe('Combobox', () => { down(); // Open keydown('End'); const options = getOptions(); - await waitForMicrotasks(); + await waitForMutation(); expect(inputElement.getAttribute('aria-activedescendant')).toBe( options[options.length - 1].id, ); @@ -602,6 +638,8 @@ describe('Combobox', () => { fixture.detectChanges(); defineTestVariables(); + currentFixture = fixture; + resetMutationState(); } function defineTestVariables() { @@ -661,16 +699,16 @@ describe('Combobox', () => { it('should toggle aria-expanded on parent nodes', async () => { down(); - await waitForMicrotasks(20); + await waitForMutation(20); const item = getTreeItem('Winter')!; expect(item.getAttribute('aria-expanded')).toBe('false'); right(); // Opens Winter - await waitForMicrotasks(20); + await waitForMutation(20); expect(item.getAttribute('aria-expanded')).toBe('true'); left(); // Closes Winter - await waitForMicrotasks(20); + await waitForMutation(20); expect(item.getAttribute('aria-expanded')).toBe('false'); }); }); @@ -680,7 +718,7 @@ describe('Combobox', () => { it('should navigate to the first focusable item on ArrowDown', async () => { down(); // Winter - await waitForMicrotasks(10); + await waitForMutation(10); const item = getTreeItem('Winter')!; expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); }); @@ -688,7 +726,7 @@ describe('Combobox', () => { it('should navigate to the last focusable item on ArrowUp', async () => { down(); // Winter up(); // Fall - await waitForMicrotasks(10); + await waitForMutation(10); const item = getTreeItem('Fall')!; expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); }); @@ -696,7 +734,7 @@ describe('Combobox', () => { it('should navigate to the next focusable item on ArrowDown when open', async () => { down(); // Winter down(); // Spring - await waitForMicrotasks(10); + await waitForMutation(10); const item = getTreeItem('Spring')!; expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); }); @@ -707,7 +745,7 @@ describe('Combobox', () => { down(); // Summer down(); // Fall up(); // Summer - await waitForMicrotasks(10); + await waitForMutation(10); const item = getTreeItem('Summer')!; expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); }); @@ -726,7 +764,7 @@ describe('Combobox', () => { right(); // December const item = getTreeItem('December')!; - await waitForMicrotasks(10); + await waitForMutation(10); expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); }); @@ -736,7 +774,7 @@ describe('Combobox', () => { expect(getVisibleTreeItems().length).toBe(7); left(); // Winter Collapsed expect(getVisibleTreeItems().length).toBe(4); - await waitForMicrotasks(10); + await waitForMutation(10); const item = getTreeItem('Winter')!; expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); }); @@ -745,13 +783,13 @@ describe('Combobox', () => { down(); // Winter right(); // Expand Winter right(); // December - await waitForMicrotasks(10); + await waitForMutation(10); const item1 = getTreeItem('December')!; expect(inputElement.getAttribute('aria-activedescendant')).toBe(item1.id); left(); - await waitForMicrotasks(10); + await waitForMutation(10); const item2 = getTreeItem('Winter')!; expect(inputElement.getAttribute('aria-activedescendant')).toBe(item2.id); @@ -761,7 +799,7 @@ describe('Combobox', () => { down(); down(); keydown('Home'); - await waitForMicrotasks(10); + await waitForMutation(10); const item = getTreeItem('Winter')!; expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); @@ -771,7 +809,7 @@ describe('Combobox', () => { down(); down(); keydown('End'); - await waitForMicrotasks(10); + await waitForMutation(10); const grainsItem = getTreeItem('Fall')!; expect(inputElement.getAttribute('aria-activedescendant')).toBe(grainsItem.id); @@ -887,7 +925,7 @@ describe('Combobox', () => { // Force computed signal to re-evaluate by updating dataSource reference fixture.componentInstance.dataSource.set([...fixture.componentInstance.dataSource()]); fixture.detectChanges(); - await waitForMicrotasks(); + await waitForMutation(); expect(getTreeItems().length).toBe(16); }); @@ -979,6 +1017,8 @@ describe('Combobox', () => { fixture.detectChanges(); const inputDebugElement = fixture.debugElement.query(By.directive(Combobox)); inputElement = inputDebugElement.nativeElement as HTMLInputElement; + currentFixture = fixture; + resetMutationState(); } beforeEach(() => setupCombobox()); @@ -1011,7 +1051,7 @@ describe('Combobox', () => { it('should set aria-activedescendant to the active grid cell id', async () => { focus(); down(); // Open popup - await waitForMicrotasks(20); + await waitForMutation(20); expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-label'); }); }); @@ -1021,11 +1061,11 @@ describe('Combobox', () => { down(); // Open popup down(); // Navigate down to 'Bird-label' - await waitForMicrotasks(20); + await waitForMutation(20); expect(inputElement.getAttribute('aria-activedescendant')).toBe('Bird-label'); up(); // Navigate back up to 'Antelope-label' - await waitForMicrotasks(20); + await waitForMutation(20); expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-label'); }); @@ -1034,11 +1074,11 @@ describe('Combobox', () => { down(); // Open popup right(); // Move right to 'Antelope-delete' - await waitForMicrotasks(20); + await waitForMutation(20); expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-delete'); left(); // Move back left to 'Antelope-label' - await waitForMicrotasks(20); + await waitForMutation(20); expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-label'); }); @@ -1047,11 +1087,11 @@ describe('Combobox', () => { down(); // Open popup right(); // Move right to 'Antelope-delete' - await waitForMicrotasks(20); + await waitForMutation(20); expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-delete'); home(); // Move back to 'Antelope-label' - await waitForMicrotasks(20); + await waitForMutation(20); expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-label'); }); @@ -1060,7 +1100,7 @@ describe('Combobox', () => { down(); // Open popup end(); // Move to end of row ('Antelope-delete') - await waitForMicrotasks(20); + await waitForMutation(20); expect(inputElement.getAttribute('aria-activedescendant')).toBe('Antelope-delete'); }); @@ -1069,17 +1109,17 @@ describe('Combobox', () => { down(); // Open popup down(); // Navigate down - await waitForMicrotasks(20); + await waitForMutation(20); // The active item is 'Bird' because we navigated down once more expect(inputElement.getAttribute('aria-activedescendant')).toBe('Bird-label'); right(); // Move right to delete button - await waitForMicrotasks(20); + await waitForMutation(20); expect(inputElement.getAttribute('aria-activedescendant')).toBe('Bird-delete'); down(); // Move down to next row - await waitForMicrotasks(20); + await waitForMutation(20); expect(inputElement.getAttribute('aria-activedescendant')).toBe('Cat-delete'); }); @@ -1093,7 +1133,7 @@ describe('Combobox', () => { it('should filter items and maintain selection', async () => { down(); // Antelope enter(); // Select active item - await waitForMicrotasks(20); + await waitForMutation(20); expect(fixture.componentInstance.searchString()).toBe('Antelope'); @@ -1104,7 +1144,7 @@ describe('Combobox', () => { expect(fixture.componentInstance.searchString()).toBe(''); down(); // Go to BirdLabel - await waitForMicrotasks(20); + await waitForMutation(20); expect(inputElement.getAttribute('aria-activedescendant')).toBe('Bird-label'); }); @@ -1140,7 +1180,7 @@ describe('Combobox', () => { const gridCells = fixture.nativeElement.querySelectorAll('[ngGridCellWidget]'); gridCells[0].dispatchEvent(new PointerEvent('click', {bubbles: true})); fixture.detectChanges(); - await waitForMicrotasks(20); + await waitForMutation(20); expect(fixture.componentInstance.selectedItem()).toBe('Antelope'); expect(inputElement.value).toBe('Antelope'); @@ -1151,7 +1191,7 @@ describe('Combobox', () => { down(); // Open popup down(); // Move row down - await waitForMicrotasks(20); + await waitForMutation(20); expect(fixture.componentInstance.selectedItem()).toBeNull(); }); @@ -1265,7 +1305,7 @@ function getTreeNodes(): TreeNode[] { [disabled]="readonly()" (focusout)="onBlur()" /> - +
      Date: Thu, 23 Apr 2026 16:24:46 -0700 Subject: [PATCH 11/11] refactor(aria/combobox): update autocomplete examples to simple-combobox and add softDisabled --- goldens/aria/private/index.api.md | 2 + goldens/aria/simple-combobox/index.api.md | 3 +- .../simple-combobox/simple-combobox.ts | 6 ++ .../simple-combobox/simple-combobox.spec.ts | 34 +++++++++++ src/aria/simple-combobox/simple-combobox.ts | 5 ++ .../aria/autocomplete/BUILD.bazel | 2 +- .../autocomplete-auto-select-example.html | 16 +++--- .../autocomplete-auto-select-example.ts | 57 +++++++------------ .../autocomplete-disabled-example.html | 18 +++--- .../autocomplete-disabled-example.ts | 37 ++++-------- .../autocomplete-highlight-example.html | 19 ++++--- .../autocomplete-highlight-example.ts | 57 +++++++------------ .../autocomplete-manual-example.html | 39 +++++-------- .../autocomplete-manual-example.ts | 57 +++++++------------ .../aria/autocomplete/autocomplete.css | 8 +-- 15 files changed, 172 insertions(+), 188 deletions(-) diff --git a/goldens/aria/private/index.api.md b/goldens/aria/private/index.api.md index 23d1c60dc335..31c775d8e969 100644 --- a/goldens/aria/private/index.api.md +++ b/goldens/aria/private/index.api.md @@ -663,6 +663,7 @@ export interface SimpleComboboxInputs extends ExpansionItem { element: SignalLike; inlineSuggestion: SignalLike; popup: SignalLike; + softDisabled: SignalLike; value: WritableSignalLike; } @@ -693,6 +694,7 @@ export class SimpleComboboxPattern { 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; } diff --git a/goldens/aria/simple-combobox/index.api.md b/goldens/aria/simple-combobox/index.api.md index 3ea77c42b985..2a4f3d9c88a0 100644 --- a/goldens/aria/simple-combobox/index.api.md +++ b/goldens/aria/simple-combobox/index.api.md @@ -25,10 +25,11 @@ export class Combobox extends DeferredContentAware implements OnInit { readonly _pattern: SimpleComboboxPattern; readonly _popup: _angular_core.WritableSignal; _registerPopup(popup: ComboboxPopup): void; + readonly softDisabled: _angular_core.InputSignalWithTransform; _unregisterPopup(): void; readonly value: _angular_core.ModelSignal; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } diff --git a/src/aria/private/simple-combobox/simple-combobox.ts b/src/aria/private/simple-combobox/simple-combobox.ts index 04e9e1cfb7e8..01daa1fe1bfd 100644 --- a/src/aria/private/simple-combobox/simple-combobox.ts +++ b/src/aria/private/simple-combobox/simple-combobox.ts @@ -30,6 +30,9 @@ export interface SimpleComboboxInputs extends ExpansionItem { /** Whether the combobox is disabled. */ disabled: SignalLike; + + /** Whether the combobox is soft disabled. */ + softDisabled: SignalLike; } /** Controls the state of a simple combobox. */ @@ -46,6 +49,9 @@ export class SimpleComboboxPattern { /** Whether the combobox is disabled. */ readonly disabled = () => this.inputs.disabled(); + /** Whether the combobox is soft disabled. */ + readonly softDisabled = () => this.inputs.softDisabled(); + /** An inline suggestion to be displayed in the input. */ readonly inlineSuggestion = () => this.inputs.inlineSuggestion(); diff --git a/src/aria/simple-combobox/simple-combobox.spec.ts b/src/aria/simple-combobox/simple-combobox.spec.ts index 84319630efb9..5a9afcd3e9e4 100644 --- a/src/aria/simple-combobox/simple-combobox.spec.ts +++ b/src/aria/simple-combobox/simple-combobox.spec.ts @@ -580,6 +580,36 @@ describe('Combobox', () => { expect(inputElement.getAttribute('aria-expanded')).toBe('true'); }); }); + + describe('Disabled', () => { + beforeEach(() => setupCombobox()); + + it('should keep the input focusable by default when disabled', () => { + fixture.componentInstance.disabled.set(true); + fixture.detectChanges(); + + expect(inputElement.disabled).toBe(false); + expect(inputElement.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should block interactions when disabled', () => { + fixture.componentInstance.disabled.set(true); + fixture.detectChanges(); + + focus(); + keydown('ArrowDown'); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should make the input unfocusable when softDisabled is false', () => { + fixture.componentInstance.disabled.set(true); + fixture.componentInstance.softDisabled.set(false); + fixture.detectChanges(); + + expect(inputElement.disabled).toBe(true); + expect(inputElement.getAttribute('aria-disabled')).toBe('true'); + }); + }); }); describe('with Tree', () => { @@ -1209,6 +1239,8 @@ describe('Combobox', () => { [(value)]="searchString" [(expanded)]="popupExpanded" [readonly]="readonly()" + [disabled]="disabled()" + [softDisabled]="softDisabled()" [alwaysExpanded]="alwaysExpanded()" (focusout)="onBlur()" /> @@ -1233,6 +1265,8 @@ describe('Combobox', () => { }) class ComboboxListboxExample { readonly = signal(false); + disabled = signal(false); + softDisabled = signal(true); alwaysExpanded = signal(false); popupExpanded = signal(false); searchString = signal(''); diff --git a/src/aria/simple-combobox/simple-combobox.ts b/src/aria/simple-combobox/simple-combobox.ts index 09536717b9c1..93a422f707d8 100644 --- a/src/aria/simple-combobox/simple-combobox.ts +++ b/src/aria/simple-combobox/simple-combobox.ts @@ -56,6 +56,8 @@ import { '[attr.aria-activedescendant]': '_pattern.activeDescendant()', '[attr.aria-controls]': '_pattern.popupId()', '[attr.aria-haspopup]': '_pattern.popupType()', + '[attr.tabindex]': 'disabled() && !softDisabled() ? -1 : null', + '[attr.disabled]': 'disabled() && !softDisabled() ? "" : null', '(keydown)': '_pattern.onKeydown($event)', '(focusin)': '_pattern.onFocusin()', '(focusout)': '_pattern.onFocusout($event)', @@ -78,6 +80,9 @@ export class Combobox extends DeferredContentAware implements OnInit { /** 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}); diff --git a/src/components-examples/aria/autocomplete/BUILD.bazel b/src/components-examples/aria/autocomplete/BUILD.bazel index 7b5c57c7ef81..329cbfd90b7b 100644 --- a/src/components-examples/aria/autocomplete/BUILD.bazel +++ b/src/components-examples/aria/autocomplete/BUILD.bazel @@ -13,8 +13,8 @@ ng_project( "//:node_modules/@angular/common", "//:node_modules/@angular/core", "//:node_modules/@angular/forms", - "//src/aria/combobox", "//src/aria/listbox", + "//src/aria/simple-combobox", "//src/cdk/overlay", ], ) diff --git a/src/components-examples/aria/autocomplete/autocomplete-auto-select/autocomplete-auto-select-example.html b/src/components-examples/aria/autocomplete/autocomplete-auto-select/autocomplete-auto-select-example.html index b7ec4065b427..c31b8ba1147a 100644 --- a/src/components-examples/aria/autocomplete/autocomplete-auto-select/autocomplete-auto-select-example.html +++ b/src/components-examples/aria/autocomplete/autocomplete-auto-select/autocomplete-auto-select-example.html @@ -1,10 +1,13 @@ -
      +
      search
      - - - + + +
      @if (countries().length === 0) {
      No results found
      } -
      +
      @for (country of countries(); track country) {
      {{country}} diff --git a/src/components-examples/aria/autocomplete/autocomplete-highlight/autocomplete-highlight-example.ts b/src/components-examples/aria/autocomplete/autocomplete-highlight/autocomplete-highlight-example.ts index fc490c2ed96b..160bb3f30535 100644 --- a/src/components-examples/aria/autocomplete/autocomplete-highlight/autocomplete-highlight-example.ts +++ b/src/components-examples/aria/autocomplete/autocomplete-highlight/autocomplete-highlight-example.ts @@ -6,20 +6,15 @@ * found in the LICENSE file at https://angular.dev/license */ -import { - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, -} from '@angular/aria/combobox'; +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; import {Listbox, Option} from '@angular/aria/listbox'; import { afterRenderEffect, ChangeDetectionStrategy, Component, computed, + signal, viewChild, - viewChildren, } from '@angular/core'; import {COUNTRIES} from '../countries'; import {OverlayModule} from '@angular/cdk/overlay'; @@ -30,33 +25,20 @@ import {FormsModule} from '@angular/forms'; selector: 'autocomplete-highlight-example', templateUrl: 'autocomplete-highlight-example.html', styleUrl: '../autocomplete.css', - imports: [ - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, - Listbox, - Option, - OverlayModule, - FormsModule, - ], + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule], changeDetection: ChangeDetectionStrategy.OnPush, }) export class AutocompleteHighlightExample { /** The selected value of the combobox. */ - listbox = viewChild>(Listbox); - - /** The options available in the listbox. */ - options = viewChildren>(Option); - - /** A reference to the ng aria combobox. */ - combobox = viewChild>(Combobox); + readonly listbox = viewChild(Listbox); + readonly combobox = viewChild(Combobox); - /** A reference to the ng aria combobox input. */ - comboboxInput = viewChild(ComboboxInput); + popupExpanded = signal(false); + searchString = signal(''); + selectedOption = signal([]); /** The query string used to filter the list of countries. */ - query = computed(() => this.comboboxInput()?.value() || ''); + query = computed(() => this.searchString()); /** The list of countries filtered by the query. */ countries = computed(() => @@ -64,26 +46,31 @@ export class AutocompleteHighlightExample { ); constructor() { - // Scrolls to the active item when the active option changes. afterRenderEffect(() => { - if (this.combobox()?.expanded()) { - const option = this.options().find(opt => opt.active()); - option?.element.scrollIntoView({block: 'nearest'}); - } + this.listbox()?.scrollActiveItemIntoView(); }); } /** Clears the query and the listbox value. */ clear(): void { - this.comboboxInput()?.value.set(''); - this.listbox?.()?.value.set([]); + this.searchString.set(''); + this.selectedOption.set([]); + } + + onCommit() { + const selectedOption = this.selectedOption(); + if (selectedOption.length > 0) { + this.searchString.set(selectedOption[0]); + } + this.popupExpanded.set(false); + this.combobox()?.element.focus(); } /** Handles keydown events on the clear button. */ onKeydown(event: KeyboardEvent): void { if (event.key === 'Enter') { this.clear(); - this.combobox?.()?.close(); + this.popupExpanded.set(false); event.stopPropagation(); } } diff --git a/src/components-examples/aria/autocomplete/autocomplete-manual/autocomplete-manual-example.html b/src/components-examples/aria/autocomplete/autocomplete-manual/autocomplete-manual-example.html index 3571019382ec..b033cbaf96fa 100644 --- a/src/components-examples/aria/autocomplete/autocomplete-manual/autocomplete-manual-example.html +++ b/src/components-examples/aria/autocomplete/autocomplete-manual/autocomplete-manual-example.html @@ -1,17 +1,9 @@ -
      +
      search - -
      @@ -20,25 +12,24 @@ {{countries().length === 0 ? 'No results found for ' + query() : ''}}
      - - + +
      @if (countries().length === 0) { -
      No results found
      +
      No results found
      } -
      +
      @for (country of countries(); track country) { -
      - {{country}} - check -
      +
      + {{country}} + check +
      }
      -
      +
      \ No newline at end of file diff --git a/src/components-examples/aria/autocomplete/autocomplete-manual/autocomplete-manual-example.ts b/src/components-examples/aria/autocomplete/autocomplete-manual/autocomplete-manual-example.ts index 7d1a725ad324..376208860da4 100644 --- a/src/components-examples/aria/autocomplete/autocomplete-manual/autocomplete-manual-example.ts +++ b/src/components-examples/aria/autocomplete/autocomplete-manual/autocomplete-manual-example.ts @@ -6,20 +6,15 @@ * found in the LICENSE file at https://angular.dev/license */ -import { - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, -} from '@angular/aria/combobox'; +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; import {Listbox, Option} from '@angular/aria/listbox'; import { afterRenderEffect, ChangeDetectionStrategy, Component, computed, + signal, viewChild, - viewChildren, } from '@angular/core'; import {COUNTRIES} from '../countries'; import {OverlayModule} from '@angular/cdk/overlay'; @@ -30,33 +25,20 @@ import {FormsModule} from '@angular/forms'; selector: 'autocomplete-manual-example', templateUrl: 'autocomplete-manual-example.html', styleUrl: '../autocomplete.css', - imports: [ - Combobox, - ComboboxInput, - ComboboxPopup, - ComboboxPopupContainer, - Listbox, - Option, - OverlayModule, - FormsModule, - ], + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule], changeDetection: ChangeDetectionStrategy.OnPush, }) export class AutocompleteManualExample { /** The selected value of the combobox. */ - listbox = viewChild>(Listbox); - - /** The options available in the listbox. */ - options = viewChildren>(Option); - - /** A reference to the ng aria combobox. */ - combobox = viewChild>(Combobox); + readonly listbox = viewChild(Listbox); + readonly combobox = viewChild(Combobox); - /** A reference to the ng aria combobox input. */ - comboboxInput = viewChild(ComboboxInput); + popupExpanded = signal(false); + searchString = signal(''); + selectedOption = signal([]); /** The query string used to filter the list of countries. */ - query = computed(() => this.comboboxInput()?.value() || ''); + query = computed(() => this.searchString()); /** The list of countries filtered by the query. */ countries = computed(() => @@ -64,26 +46,31 @@ export class AutocompleteManualExample { ); constructor() { - // Scrolls to the active item when the active option changes. afterRenderEffect(() => { - if (this.combobox()?.expanded()) { - const option = this.options().find(opt => opt.active()); - option?.element.scrollIntoView({block: 'nearest'}); - } + this.listbox()?.scrollActiveItemIntoView(); }); } /** Clears the query and the listbox value. */ clear(): void { - this.comboboxInput()?.value.set(''); - this.listbox?.()?.value.set([]); + this.searchString.set(''); + this.selectedOption.set([]); + } + + onCommit() { + const selectedOption = this.selectedOption(); + if (selectedOption.length > 0) { + this.searchString.set(selectedOption[0]); + } + this.popupExpanded.set(false); + this.combobox()?.element.focus(); } /** Handles keydown events on the clear button. */ onKeydown(event: KeyboardEvent): void { if (event.key === 'Enter') { this.clear(); - this.combobox?.()?.close(); + this.popupExpanded.set(false); event.stopPropagation(); } } diff --git a/src/components-examples/aria/autocomplete/autocomplete.css b/src/components-examples/aria/autocomplete/autocomplete.css index d0a57a1f7def..ffa2d0bb763c 100644 --- a/src/components-examples/aria/autocomplete/autocomplete.css +++ b/src/components-examples/aria/autocomplete/autocomplete.css @@ -18,7 +18,7 @@ position: absolute; } -[ngComboboxInput] { +input[ngCombobox] { width: 13rem; font-size: 0.9rem; border-radius: var(--mat-sys-corner-extra-small); @@ -28,15 +28,13 @@ background-color: var(--mat-sys-surface); } -[ngComboboxInput][aria-disabled='true'] { +input[ngCombobox][aria-disabled='true'], +input[ngCombobox]:disabled { cursor: default; opacity: 0.5; background-color: var(--mat-sys-surface-dim); } -[ngCombobox]:has([aria-expanded='false']) .example-popup { - display: none; -} .example-clear-button { position: absolute;