diff --git a/src/aria/private/BUILD.bazel b/src/aria/private/BUILD.bazel index f688ab1b20e1..add8cfebb129 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 ac0f43ce952b..83c86b6597ca 100644 --- a/src/aria/private/behaviors/list-focus/list-focus.ts +++ b/src/aria/private/behaviors/list-focus/list-focus.ts @@ -106,9 +106,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 ed8716c7b67b..685cc465f5f8 100644 --- a/src/aria/private/public-api.ts +++ b/src/aria/private/public-api.ts @@ -25,3 +25,4 @@ export * from './grid/row'; export * from './grid/cell'; export * from './grid/widget'; export * from './deferred-content'; +export * from './simple-combobox/simple-combobox'; 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 51fc01080c97..5596cf49111f 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -31,6 +31,7 @@ ng_project( "//src/dev-app/aria-listbox", "//src/dev-app/aria-menu", "//src/dev-app/aria-menubar", + "//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 f3b4d991773f..da5c9fab52e3 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -64,6 +64,7 @@ export class DevAppLayout { {name: 'CDK Dialog', route: '/cdk-dialog'}, {name: 'Aria Accordion', route: '/aria-accordion'}, {name: 'Aria Combobox', route: '/aria-combobox'}, + {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 f719de30b300..46756ef94140 100644 --- a/src/dev-app/routes.ts +++ b/src/dev-app/routes.ts @@ -44,6 +44,11 @@ export const DEV_APP_ROUTES: Routes = [ path: 'aria-combobox', loadComponent: () => import('./aria-combobox/combobox-demo').then(m => m.ComboboxDemo), }, + { + 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),