diff --git a/src/aria/combobox/combobox-popup.ts b/src/aria/combobox/combobox-popup.ts index 3ecc0d227732..178ff854f6ae 100644 --- a/src/aria/combobox/combobox-popup.ts +++ b/src/aria/combobox/combobox-popup.ts @@ -7,7 +7,12 @@ */ import {Directive, inject, signal} from '@angular/core'; -import {ComboboxListboxControls, ComboboxTreeControls, ComboboxDialogPattern} from '../private'; +import { + ComboboxListboxControls, + ComboboxTreeControls, + ComboboxDialogPattern, + ComboboxNavigation, +} from '../private'; import type {Combobox} from './combobox'; import {COMBOBOX} from './combobox-tokens'; @@ -41,4 +46,12 @@ export class ComboboxPopup { | ComboboxDialogPattern | undefined >(undefined); + + /** The navigation state to apply when the popup expands. */ + readonly pendingNavigation = signal(undefined); + + /** Sets the navigation state to be applied when the popup is ready. */ + focusOnReady(nav: ComboboxNavigation) { + this.pendingNavigation.set(nav); + } } diff --git a/src/aria/grid/grid.ts b/src/aria/grid/grid.ts index 7799f2defb40..cd34c657d333 100644 --- a/src/aria/grid/grid.ts +++ b/src/aria/grid/grid.ts @@ -125,6 +125,9 @@ export class Grid { /** Whether enable range selections (with modifier keys or dragging). */ readonly enableRangeSelection = input(false, {transform: booleanAttribute}); + /** Whether the grid is tabbable. */ + readonly tabbable = input(undefined); + /** The UI pattern for the grid. */ readonly _pattern = new GridPattern({ ...this, @@ -157,6 +160,11 @@ export class Grid { afterRenderEffect(() => this._pattern.focusEffect()); } + /** Scrolls the active cell into view. */ + scrollActiveCellIntoView(options: ScrollIntoViewOptions = {block: 'nearest'}) { + this._pattern.activeCell()?.element().scrollIntoView(options); + } + /** Gets the cell pattern for a given element. */ private _getCell(element: Element | null | undefined): GridCellPattern | undefined { let target = element; diff --git a/src/aria/listbox/listbox.ts b/src/aria/listbox/listbox.ts index 4576d88b3d57..d6cae9909dc3 100644 --- a/src/aria/listbox/listbox.ts +++ b/src/aria/listbox/listbox.ts @@ -24,7 +24,7 @@ import {_IdGenerator} from '@angular/cdk/a11y'; import {ComboboxListboxPattern, ListboxPattern, OptionPattern} from '../private'; import {ComboboxPopup} from '../combobox'; import {Option} from './option'; -import {LISTBOX} from './tokens'; +import {COMBOBOX_WIDGET, LISTBOX} from './tokens'; /** * Represents a container used to display a list of items for a user to select from. @@ -78,6 +78,8 @@ export class Listbox { optional: true, }); + private readonly _widget = inject(COMBOBOX_WIDGET, {optional: true}); + /** A reference to the host element. */ private readonly _elementRef = inject(ElementRef); @@ -151,6 +153,7 @@ export class Listbox { textDirection: this.textDirection, element: () => this._elementRef.nativeElement, combobox: () => this._popup?.combobox?._pattern, + hasPopup: () => !!this._popup?.combobox || !!this._widget, }; this._pattern = this._popup?.combobox @@ -171,8 +174,14 @@ export class Listbox { }); afterRenderEffect(() => { - if (!this._hasFocused()) { - this._pattern.setDefaultState(); + const active = this._pattern.inputs.activeItem(); + + if (!this._widget || (this._widget as any).filterMode() !== 'manual') { + untracked(() => this._pattern.listBehavior.select()); + } + + if (this._widget) { + untracked(() => (this._widget as any).activeValue.set(active?.value())); } }); @@ -192,7 +201,12 @@ export class Listbox { const items = inputs.items(); const values = untracked(() => this.values()); - if (items && values.some(v => !items.some(i => i.value() === v))) { + // If using simple combobx, the combobox should handle the value. + if (this._popup?.combobox || this._widget) { + return; + } + + if (items.length > 0 && values.some(v => !items.some(i => i.value() === v))) { this.values.set(values.filter(v => items.some(i => i.value() === v))); } }); diff --git a/src/aria/listbox/tokens.ts b/src/aria/listbox/tokens.ts index 0caf8ad6d1aa..2a9475b09f2e 100644 --- a/src/aria/listbox/tokens.ts +++ b/src/aria/listbox/tokens.ts @@ -10,3 +10,5 @@ import {InjectionToken} from '@angular/core'; import type {Listbox} from './listbox'; export const LISTBOX = new InjectionToken>('LISTBOX'); + +export const COMBOBOX_WIDGET = new InjectionToken('COMBOBOX_WIDGET'); 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/grid/grid-focus.ts b/src/aria/private/behaviors/grid/grid-focus.ts index 31f0502e80f7..236905786d28 100644 --- a/src/aria/private/behaviors/grid/grid-focus.ts +++ b/src/aria/private/behaviors/grid/grid-focus.ts @@ -31,6 +31,9 @@ export interface GridFocusInputs { /** Whether disabled cells in the grid should be focusable. */ softDisabled: SignalLike; + + /** Whether the grid is tabbable. */ + tabbable?: SignalLike; } /** Dependencies for the `GridFocus` class. */ @@ -95,7 +98,15 @@ export class GridFocus { }); /** The tab index for the grid container. */ - readonly gridTabIndex = computed<-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()) { return 0; } diff --git a/src/aria/private/behaviors/grid/grid.spec.ts b/src/aria/private/behaviors/grid/grid.spec.ts index 8db7756e7728..4bd80939aea2 100644 --- a/src/aria/private/behaviors/grid/grid.spec.ts +++ b/src/aria/private/behaviors/grid/grid.spec.ts @@ -395,7 +395,7 @@ describe('Grid', () => { expect(grid.focusBehavior.activeCoords()).toEqual({row: 1, col: 1}); }); - it('should focus the first cell if active cell and coords are no longer valid', () => { + it('should focus the row above when the last row is deleted', () => { const cellsSignal = signal(createTestGrid(createGridA)); const grid = setupGrid(cellsSignal); grid.gotoCell(cellsSignal()[2][2]); @@ -416,8 +416,8 @@ describe('Grid', () => { expect(grid.focusBehavior.stateStale()).toBe(true); const result = grid.resetState(); expect(result).toBe(true); - expect(grid.focusBehavior.activeCell()).toBe(newCells[0][0]); - expect(grid.focusBehavior.activeCoords()).toEqual({row: 0, col: 0}); + expect(grid.focusBehavior.activeCell()).toBe(newCells[1][1]); + expect(grid.focusBehavior.activeCoords()).toEqual({row: 1, col: 1}); }); }); }); diff --git a/src/aria/private/behaviors/grid/grid.ts b/src/aria/private/behaviors/grid/grid.ts index 6d0316395d38..d015840b0b07 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(); @@ -318,20 +318,57 @@ export class Grid { } if (this.focusBehavior.stateStale()) { + const activeCell = this.focusBehavior.activeCell(); + const activeCoords = this.focusBehavior.activeCoords(); + // Try focus on the same active cell after if a reordering happened. - if (this.focusBehavior.focusCell(this.focusBehavior.activeCell()!)) { + if (activeCell && this.focusBehavior.focusCell(activeCell)) { return true; } // If the active cell is no longer exist, focus on the coordinates instead. - if (this.focusBehavior.focusCoordinates(this.focusBehavior.activeCoords())) { + if (this.focusBehavior.focusCoordinates(activeCoords)) { return true; } + // If the coordinates are no longer valid (e.g. because the row was deleted at the end), + // try to focus on the previous row focusing on the same column. + const maxRow = this.data.maxRowCount() - 1; + const targetRow = Math.min(activeCoords.row, maxRow); + + if (targetRow >= 0) { + // Try same column in the clamped row. + if (this.focusBehavior.focusCoordinates({row: targetRow, col: activeCoords.col})) { + return true; + } + + // Try clamping the column as well. + const colCount = this.data.getColCount(targetRow); + if (colCount !== undefined) { + const targetCol = Math.min(activeCoords.col, colCount - 1); + if ( + targetCol >= 0 && + this.focusBehavior.focusCoordinates({row: targetRow, col: targetCol}) + ) { + return true; + } + } + + // If that fails, try to find ANY cell in that row. + const firstInRow = this.navigationBehavior.peekFirst(targetRow); + if (firstInRow !== undefined && this.focusBehavior.focusCoordinates(firstInRow)) { + return true; + } + } + // If the coordinates no longer valid, go back to the first available cell. - if (this.focusBehavior.focusCoordinates(this.navigationBehavior.peekFirst()!)) { + const firstAvailable = this.navigationBehavior.peekFirst(); + if (firstAvailable !== undefined && this.focusBehavior.focusCoordinates(firstAvailable)) { return true; } + + this.focusBehavior.activeCell.set(undefined); + this.focusBehavior.activeCoords.set({row: -1, col: -1}); } return false; diff --git a/src/aria/private/behaviors/list-focus/list-focus.ts b/src/aria/private/behaviors/list-focus/list-focus.ts index bfb96e1d3806..ccdae4087463 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/combobox/combobox.ts b/src/aria/private/combobox/combobox.ts index fd93cc1ccc4a..d4580c6391d2 100644 --- a/src/aria/private/combobox/combobox.ts +++ b/src/aria/private/combobox/combobox.ts @@ -139,7 +139,7 @@ export interface ComboboxTreeControls, V> extends Combobox } /** Controls the state of a combobox. */ -export class ComboboxPattern, V> { +export class ComboboxPattern, V> implements ComboboxLike { /** Whether the combobox is expanded. */ expanded = signal(false); @@ -570,7 +570,7 @@ export class ComboboxPattern, V> { } /** Opens the combobox. */ - open(nav?: {first?: boolean; last?: boolean; selected?: boolean}) { + open(nav?: ComboboxNavigation) { this.expanded.set(true); const popupControls = this.inputs.popupControls(); @@ -737,3 +737,15 @@ export class ComboboxDialogPattern { } } } + +export interface ComboboxNavigation { + first?: boolean; + last?: boolean; + selected?: boolean; +} + +export interface ComboboxLike { + expanded: WritableSignalLike; + highlightedItem: WritableSignalLike; + open(nav?: ComboboxNavigation): void; +} diff --git a/src/aria/private/grid/grid.spec.ts b/src/aria/private/grid/grid.spec.ts index be17f2a6040d..c882a543cde9 100644 --- a/src/aria/private/grid/grid.spec.ts +++ b/src/aria/private/grid/grid.spec.ts @@ -298,6 +298,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: [{widgets: [{widgetType: 'complex', disabled: true}]}]}], diff --git a/src/aria/private/grid/grid.ts b/src/aria/private/grid/grid.ts index 6f5bcf03dd99..9f8520e7e7e0 100644 --- a/src/aria/private/grid/grid.ts +++ b/src/aria/private/grid/grid.ts @@ -252,6 +252,7 @@ export class GridPattern { onKeydown(event: KeyboardEvent) { if (this.disabled()) return; + this.hasBeenFocused.set(true); this.activeCell()?.onKeydown(event); this.keydown().handle(event); } @@ -328,7 +329,10 @@ export class GridPattern { /** Sets the default active state of the grid before receiving focus the first time. */ setDefaultStateEffect(): void { - if (this.hasBeenFocused()) return; + if (this.hasBeenFocused() || !this.gridBehavior.focusBehavior.stateEmpty()) { + this.hasBeenFocused.set(true); + return; + } this.gridBehavior.setDefaultState(); } diff --git a/src/aria/private/grid/widget.ts b/src/aria/private/grid/widget.ts index f51278638e71..655008f05c65 100644 --- a/src/aria/private/grid/widget.ts +++ b/src/aria/private/grid/widget.ts @@ -78,7 +78,11 @@ export class GridCellWidgetPattern implements ListNavigationItem { 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; } @@ -116,6 +120,7 @@ export class GridCellWidgetPattern implements ListNavigationItem { /** 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 0c927989f2c1..e8cf3d4320f1 100644 --- a/src/aria/private/listbox/listbox.ts +++ b/src/aria/private/listbox/listbox.ts @@ -18,6 +18,9 @@ export type ListboxInputs = ListInputs, V> & { /** Whether the listbox is readonly. */ readonly: SignalLike; + + /** Whether the listbox is in a popup or widget context. */ + hasPopup?: SignalLike; }; /** Controls the state of a listbox. */ @@ -135,8 +138,12 @@ export class ListboxPattern { } if (!this.followFocus() && !this.inputs.multi()) { - manager.on(this.dynamicSpaceKey, () => this.listBehavior.toggleOne()); - manager.on('Enter', () => this.listBehavior.toggleOne()); + manager.on(this.dynamicSpaceKey, () => + this.inputs.hasPopup?.() ? this.listBehavior.selectOne() : this.listBehavior.toggleOne(), + ); + manager.on('Enter', () => + this.inputs.hasPopup?.() ? this.listBehavior.selectOne() : this.listBehavior.toggleOne(), + ); } if (this.inputs.multi() && this.followFocus()) { 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..3c04c9228654 --- /dev/null +++ b/src/aria/private/simple-combobox/BUILD.bazel @@ -0,0 +1,19 @@ +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", + "//src/aria/private/combobox", + ], +) 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..66d0d3ea65b0 --- /dev/null +++ b/src/aria/private/simple-combobox/simple-combobox.ts @@ -0,0 +1,333 @@ +/** + * @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 {ComboboxLike, ComboboxNavigation} from '../combobox/combobox'; +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; + + /** The active value in the popup (the option's value). */ + activeValue: SignalLike; + + /** Whether the combobox is disabled. */ + disabled: SignalLike; + + /** The filtering mode for the combobox. */ + filterMode: SignalLike<'manual' | 'auto-select' | 'highlight'>; +} + +/** 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 ID of the currently highlighted item in the popup. */ + readonly highlightedItem = signal(undefined); + + /** 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', ignoreRepeat: false}, + ) + .on( + 'ArrowRight', + e => { + this.keyboardEventRelay.set(e); + }, + {preventDefault: this.popupType() !== 'listbox', ignoreRepeat: false}, + ) + .on('ArrowUp', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false}) + .on('ArrowDown', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false}) + .on('Home', e => this.keyboardEventRelay.set(e)) + .on('End', e => this.keyboardEventRelay.set(e)) + .on('Enter', e => this.keyboardEventRelay.set(e)) + .on('PageUp', e => this.keyboardEventRelay.set(e)) + .on('PageDown', e => this.keyboardEventRelay.set(e)) + .on('Escape', () => 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) { + const activeValue = untracked(() => this.inputs.activeValue?.()); + + // Auto-commit highlighted item on blur for non-manual modes (auto-select and highlight). + // The Listbox pushes its highlighted value here, letting the headless directive sync it. + if (activeValue && this.inputs.filterMode() !== 'manual') { + this.value.set(activeValue as any); // Type assertion if needed + } + 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; + + /** Navigates to the first item in the popup. */ + first: () => void; + + /** Navigates to the last item in the popup. */ + last: () => void; + + /** Focuses the currently selected item in the popup. */ + focusSelected: () => void; + + /** Focuses the popup with the given navigation instruction when ready. */ + focusOnReady: (nav: ComboboxNavigation) => void; +} + +/** 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); + + /** Navigates to the first item in the popup. */ + first() { + this.inputs.first(); + } + + /** Navigates to the last item in the popup. */ + last() { + this.inputs.last(); + } + + /** Focuses the currently selected item in the popup. */ + focusSelected() { + this.inputs.focusSelected(); + } + + /** + * Focuses the popup with the given navigation instruction when ready. + * This is used for lazy rendering (when the popup isn't in the DOM yet synchronously). + */ + focusOnReady(nav: ComboboxNavigation) { + this.inputs.focusOnReady(nav); + } + + constructor(readonly inputs: SimpleComboboxPopupInputs) {} + + /** Handles focus in events for the popup. */ + onFocusin() { + this.isFocused.set(true); + } + + /** Handles focus out events for the popup. */ + onFocusout(event: FocusEvent) { + const focusTarget = event.relatedTarget as Element | null; + if (this.controlTarget()?.contains(focusTarget)) return; + + this.isFocused.set(false); + } +} diff --git a/src/aria/private/tree/combobox-tree.ts b/src/aria/private/tree/combobox-tree.ts index fccec240ea6e..df13ffeeb103 100644 --- a/src/aria/private/tree/combobox-tree.ts +++ b/src/aria/private/tree/combobox-tree.ts @@ -56,9 +56,6 @@ export class ComboboxTreePattern /** Noop. The combobox handles pointerdown events. */ override onPointerdown(_: PointerEvent): void {} - /** Noop. The combobox controls the open state. */ - override setDefaultState(): void {} - /** Navigates to the specified item in the tree. */ focus = (item: TreeItemPattern) => this.treeBehavior.goto(item); @@ -75,7 +72,9 @@ export class ComboboxTreePattern first = () => this.treeBehavior.first(); /** Unfocuses the currently focused item in the tree. */ - unfocus = () => this.treeBehavior.unfocus(); + unfocus = () => { + this.treeBehavior.unfocus(); + }; // TODO: handle non-selectable parent nodes. /** Selects the specified item in the tree or the current active item if not provided. */ diff --git a/src/aria/simple-combobox/BUILD.bazel b/src/aria/simple-combobox/BUILD.bazel new file mode 100644 index 000000000000..2fd85fdf65d5 --- /dev/null +++ b/src/aria/simple-combobox/BUILD.bazel @@ -0,0 +1,40 @@ +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "simple-combobox", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//:node_modules/@angular/core", + "//src/aria/listbox", + "//src/aria/private", + "//src/cdk/bidi", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = glob( + ["**/*.spec.ts"], + ), + deps = [ + ":simple-combobox", + "//:node_modules/@angular/common", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//src/aria/listbox", + "//src/aria/tree", + "//src/cdk/overlay", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/aria/simple-combobox/index.ts b/src/aria/simple-combobox/index.ts new file mode 100644 index 000000000000..2a3628897dec --- /dev/null +++ b/src/aria/simple-combobox/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export {Combobox, ComboboxPopup, ComboboxWidget} from './simple-combobox'; diff --git a/src/aria/simple-combobox/simple-combobox.spec.ts b/src/aria/simple-combobox/simple-combobox.spec.ts new file mode 100644 index 000000000000..92b67ef2be93 --- /dev/null +++ b/src/aria/simple-combobox/simple-combobox.spec.ts @@ -0,0 +1,458 @@ +import {Component, computed, DebugElement, signal} from '@angular/core'; +import {ComponentFixture, TestBed, flush, fakeAsync} 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 {OverlayModule} from '@angular/cdk/overlay'; + +describe('SimpleCombobox', () => { + 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( + opts: {readonly?: boolean; filterMode?: 'manual' | 'auto-select' | 'highlight'} = {}, + ) { + fixture = TestBed.createComponent(SimpleComboboxListboxExample); + const testComponent = fixture.componentInstance; + + if (opts.filterMode) { + testComponent.filterMode.set(opts.filterMode); + } + if (opts.readonly) { + testComponent.readonly.set(true); + } + + fixture.detectChanges(); + defineTestVariables(); + } + + function defineTestVariables() { + // In Simple Combobox, ngCombobox is on the input itself! + const inputDebugElement = fixture.debugElement.query(By.directive(Combobox)); + inputElement = inputDebugElement.nativeElement as HTMLInputElement; + } + + function getOption(text: string): HTMLElement | null { + const options = fixture.debugElement + .queryAll(By.directive(Option)) + .map((debugEl: DebugElement) => debugEl.nativeElement as HTMLElement); + return options.find(option => option.textContent?.trim() === text) || null; + } + + function getOptions(): HTMLElement[] { + return fixture.debugElement + .queryAll(By.directive(Option)) + .map((debugEl: DebugElement) => debugEl.nativeElement as HTMLElement); + } + + 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', () => { + // Toggle expanded to render overlay + fixture.componentInstance.popupExpanded.set(true); + fixture.detectChanges(); + + const listbox = fixture.debugElement.query(By.directive(Listbox)).nativeElement; + expect(inputElement.getAttribute('aria-controls')).toBe(listbox.id); + }); + + it('should set aria-autocomplete to list for manual mode', () => { + expect(inputElement.getAttribute('aria-autocomplete')).toBe('list'); + }); + + it('should set aria-autocomplete to list for auto-select mode', () => { + fixture.componentInstance.filterMode.set('auto-select'); + fixture.detectChanges(); + expect(inputElement.getAttribute('aria-autocomplete')).toBe('list'); + }); + + it('should set aria-autocomplete to both for highlight mode', () => { + fixture.componentInstance.filterMode.set('highlight'); + fixture.detectChanges(); + expect(inputElement.getAttribute('aria-autocomplete')).toBe('both'); + }); + + it('should set aria-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'); + }); + }); + + describe('Navigation', () => { + beforeEach(() => setupCombobox()); + + it('should navigate to the first item on ArrowDown', () => { + down(); + const options = getOptions(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[0].id); + }); + + it('should navigate to the last item on ArrowUp', () => { + up(); + const options = getOptions(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe( + options[options.length - 1].id, + ); + }); + }); + + describe('Selection', () => { + describe('when filterMode is "manual"', () => { + beforeEach(() => setupCombobox({filterMode: 'manual'})); + + it('should select on focusout if the input text exactly matches an item', () => { + focus(); + input('Alabama'); + blur(); + + expect(fixture.componentInstance.values()).toEqual(['Alabama']); + }); + }); + }); + }); + + 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 right = (modifierKeys?: {}) => keydown('ArrowRight', modifierKeys); + const left = (modifierKeys?: {}) => keydown('ArrowLeft', modifierKeys); + + function setupCombobox( + opts: {readonly?: boolean; filterMode?: 'manual' | 'auto-select' | 'highlight'} = {}, + ) { + fixture = TestBed.createComponent(SimpleComboboxTreeExample); + const testComponent = fixture.componentInstance; + + if (opts.filterMode) { + testComponent.filterMode.set(opts.filterMode); + } + 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 = fixture.debugElement + .queryAll(By.directive(TreeItem)) + .map((debugEl: DebugElement) => debugEl.nativeElement as HTMLElement); + return items.find(item => item.textContent?.trim() === text) || null; + } + + function getTreeItems(): HTMLElement[] { + return fixture.debugElement + .queryAll(By.directive(TreeItem)) + .map((debugEl: DebugElement) => debugEl.nativeElement as HTMLElement); + } + + 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 toggle aria-expanded on parent nodes', () => { + down(); + const item = getTreeItem('Winter')!; + expect(item.getAttribute('aria-expanded')).toBe('false'); + + right(); + expect(item.getAttribute('aria-expanded')).toBe('true'); + + left(); + expect(item.getAttribute('aria-expanded')).toBe('false'); + }); + }); + }); +}); + +@Component({ + template: ` +
+
+ +
+ + + +
+ @for (option of options(); track option) { +
+ {{option}} +
+ } +
+
+
+
+ `, + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], +}) +class SimpleComboboxListboxExample { + readonly = signal(false); + searchString = ''; + popupExpanded = signal(false); + values = signal([]); + filterMode = signal<'manual' | 'auto-select' | 'highlight'>('manual'); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString.toLowerCase())), + ); +} + +@Component({ + template: ` +
+
+ +
+ + + +
    + +
+
+
+
+ + + @for (node of nodes; track node.name) { +
  • + {{ node.name }} +
  • + + @if (node.children) { +
      + + + +
    + } + } +
    + `, + imports: [ + Combobox, + ComboboxPopup, + ComboboxWidget, + Tree, + TreeItem, + TreeItemGroup, + NgTemplateOutlet, + OverlayModule, + ], +}) +class SimpleComboboxTreeExample { + readonly = signal(false); + searchString = ''; + popupExpanded = signal(false); + values = signal([]); + nodes = computed(() => this.filterTreeNodes(TREE_NODES)); + filterMode = signal<'manual' | 'auto-select' | 'highlight'>('manual'); + + flattenTreeNodes(nodes: TreeNode[]): TreeNode[] { + return nodes.flatMap(node => { + return node.children ? [node, ...this.flattenTreeNodes(node.children)] : [node]; + }); + } + + filterTreeNodes(nodes: TreeNode[]): TreeNode[] { + return nodes.reduce((acc, node) => { + const children = node.children ? this.filterTreeNodes(node.children) : undefined; + if (this.isMatch(node) || (children && children.length > 0)) { + acc.push({...node, children}); + } + return acc; + }, [] as TreeNode[]); + } + + isMatch(node: TreeNode) { + return node.name.toLowerCase().includes(this.searchString.toLowerCase()); + } +} + +interface TreeNode { + name: string; + children?: TreeNode[]; +} + +const TREE_NODES = [ + {name: 'Winter', children: [{name: 'December'}, {name: 'January'}, {name: 'February'}]}, + {name: 'Spring', children: [{name: 'March'}, {name: 'April'}, {name: 'May'}]}, + {name: 'Summer', children: [{name: 'June'}, {name: 'July'}, {name: 'August'}]}, + {name: 'Fall', children: [{name: 'September'}, {name: 'October'}, {name: 'November'}]}, +]; + +const states = [ + 'Alabama', + 'Alaska', + 'Arizona', + 'Arkansas', + 'California', + 'Colorado', + 'Connecticut', +]; diff --git a/src/aria/simple-combobox/simple-combobox.ts b/src/aria/simple-combobox/simple-combobox.ts new file mode 100644 index 000000000000..8a457eca6dbb --- /dev/null +++ b/src/aria/simple-combobox/simple-combobox.ts @@ -0,0 +1,340 @@ +/** + * @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, + ComboboxNavigation, +} from '@angular/aria/private'; + +import {COMBOBOX_WIDGET} from '../listbox/tokens'; + +/** + * 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); + + /** The active value of the popup. */ + readonly activeValue = computed(() => this._popup()?.activeValue()); + + /** 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 filtering mode for the combobox. */ + readonly filterMode = input<'manual' | 'auto-select' | 'highlight'>('manual'); + + /** 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>(undefined); + + /** The navigation state to apply when the popup expands. */ + readonly pendingNavigation = signal(undefined); + + /** Sets the navigation state to be applied when the popup is ready. */ + focusOnReady(nav: ComboboxNavigation) { + this.pendingNavigation.set(nav); + } + + /** 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 active value of the popup widget. */ + readonly activeValue = computed(() => this._widget()?.activeValue()); + + /** The type of the popup (e.g., listbox, tree, grid, dialog). */ + readonly popupType = input<'listbox' | 'tree' | 'grid' | 'dialog'>('listbox'); + + /** Navigates to the first item in the popup. */ + first() { + this._widget()?.gotoFirst(); + } + + /** Navigates to the last item in the popup. */ + last() { + this._widget()?.gotoLast(); + } + + /** Focuses the currently selected item in the popup. */ + focusSelected() { + this._widget()?.focusSelected(); + } + + /** The popup pattern. */ + readonly _pattern = new SimpleComboboxPopupPattern({ + ...this, + focusOnReady: nav => this.focusOnReady(nav), + }); + + 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)', + }, + providers: [{provide: COMBOBOX_WIDGET, useExisting: ComboboxWidget}], +}) +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); + + /** + * The active value of the option. + * This acts as the bridge to pass the currently highlighted option value back to + * the headless directive for automated commits on blur! + */ + readonly activeValue = signal(undefined); + + /** The filter mode of the combobox. */ + readonly filterMode = computed(() => this._popup.combobox().filterMode()); + + constructor() { + afterRenderEffect(() => { + const controlTarget = this.element; + + this.popupId.set(controlTarget.id); + this.activeDescendant.set(controlTarget.getAttribute('aria-activedescendant') ?? undefined); + + 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); + } + + /** Navigates to the first item in the widget. */ + gotoFirst() { + const target = this._popup.controlTarget() as any; + if (target?.gotoFirst) { + target.gotoFirst(); + } + } + + /** Navigates to the last item in the widget. */ + gotoLast() { + // TODO: implement this in Tree and Listbox if needed, or use behaviors directly. + } + + /** Focuses the currently selected item in the widget. */ + focusSelected() { + // The widget handles initial focus via afterRenderEffect in Tree/Listbox. + } +} diff --git a/src/aria/tree/tree.ts b/src/aria/tree/tree.ts index d08cc870cbc0..63b08fe38b52 100644 --- a/src/aria/tree/tree.ts +++ b/src/aria/tree/tree.ts @@ -184,8 +184,34 @@ export class Tree { }); afterRenderEffect(() => { - if (!this._hasFocused()) { - this._pattern.setDefaultState(); + const isExpanded = this._popup?.combobox?.expanded() ?? true; + const nav = this._popup?.pendingNavigation(); + + if (!this._hasFocused() && isExpanded) { + const items = inputs.items(); + const activeItem = untracked(() => inputs.activeItem()); + const selectedItem = items.find(i => i.selected()); + + if (items.length === 0) return; + + if (nav) { + if (nav.first) { + if (activeItem !== items[0]) { + this._pattern.treeBehavior.first(); + } + } else if (nav.last) { + const lastItem = items[items.length - 1]; + if (activeItem !== lastItem) { + this._pattern.treeBehavior.last(); + } + } else if (nav.selected) { + this._pattern.setDefaultState(); + } + + untracked(() => this._popup?.pendingNavigation.set(undefined)); + } else if (!activeItem || (selectedItem && activeItem !== selectedItem)) { + this._pattern.setDefaultState(); + } } }); @@ -227,4 +253,9 @@ export class Tree { scrollActiveItemIntoView(options: ScrollIntoViewOptions = {block: 'nearest'}) { this._pattern.inputs.activeItem()?.element()?.scrollIntoView(options); } + + /** Navigates to the first item in the tree. */ + gotoFirst() { + this._pattern.treeBehavior.first(); + } } diff --git a/src/components-examples/aria/simple-combobox/BUILD.bazel b/src/components-examples/aria/simple-combobox/BUILD.bazel new file mode 100644 index 000000000000..331dcf7e195d --- /dev/null +++ b/src/components-examples/aria/simple-combobox/BUILD.bazel @@ -0,0 +1,36 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "simple-combobox", + srcs = glob(["**/*.ts"]), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + deps = [ + "//:node_modules/@angular/common", + "//:node_modules/@angular/core", + "//:node_modules/@angular/forms", + "//src/aria/grid", + "//src/aria/listbox", + "//src/aria/simple-combobox", + "//src/aria/tree", + "//src/cdk/a11y", + "//src/cdk/overlay", + "//src/material/checkbox", + "//src/material/core", + "//src/material/icon", + "//src/material/tooltip", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.ts", + ]), +) diff --git a/src/components-examples/aria/simple-combobox/index.ts b/src/components-examples/aria/simple-combobox/index.ts new file mode 100644 index 000000000000..b6b0f53925d9 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/index.ts @@ -0,0 +1,15 @@ +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..18b27bfa4bc5 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-auto-select/simple-combobox-auto-select-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-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..3b4b2ba947cc --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-auto-select/simple-combobox-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 {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([]); + private _lastSelected: string[] = []; + + 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._lastSelected = selectedOption; + this.searchString.set(selectedOption[0]); + } else { + this.selectedOption.set(this._lastSelected); + } + this.popupExpanded.set(false); + } +} + +const states = [ + 'Alabama', + 'Alaska', + 'Arizona', + 'Arkansas', + 'California', + 'Colorado', + 'Connecticut', + 'Delaware', + 'Florida', + 'Georgia', + 'Hawaii', + 'Idaho', + 'Illinois', + 'Indiana', + 'Iowa', + 'Kansas', + 'Kentucky', + 'Louisiana', + 'Maine', + 'Maryland', + 'Massachusetts', + 'Michigan', + 'Minnesota', + 'Mississippi', + 'Missouri', + 'Montana', + 'Nebraska', + 'Nevada', + 'New Hampshire', + 'New Jersey', + 'New Mexico', + 'New York', + 'North Carolina', + 'North Dakota', + 'Ohio', + 'Oklahoma', + 'Oregon', + 'Pennsylvania', + 'Rhode Island', + 'South Carolina', + 'South Dakota', + 'Tennessee', + 'Texas', + 'Utah', + 'Vermont', + 'Virginia', + 'Washington', + 'West Virginia', + 'Wisconsin', + 'Wyoming', +]; diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.css b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.css new file mode 100644 index 000000000000..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..8683c01af9ec --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-datepicker/simple-combobox-datepicker-example.html @@ -0,0 +1,69 @@ +
    +
    + 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-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..e17d21eb072d --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-dialog/simple-combobox-dialog-example.html @@ -0,0 +1,61 @@ +
    +
    + + arrow_drop_down +
    + + + +
    +
    +
    + search + +
    + +
    + @for (option of options(); track option) { +
    + {{option}} + +
    + } +
    +
    +
    +
    +
    +
    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..8f2cf818c512 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-dialog/simple-combobox-dialog-example.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + computed, + signal, + viewChild, + untracked, +} 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', + styleUrl: '../simple-combobox-examples.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SimpleComboboxDialogExample { + listbox = viewChild>(Listbox); + combobox = viewChild(Combobox); + + 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.listbox()?.gotoFirst())); + } + }); + + afterRenderEffect(() => { + const selected = this.selectedStates(); + if (selected.length > 0) { + this.value.set(selected[0]); + this.searchString.set(''); + this.popupExpanded.set(false); + } + }); + } + + onCommit() { + // Triggered on list item click/enter + } +} + +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', +]; +// Force watcher update 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..7ace319d1c2d --- /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-disabled/simple-combobox-disabled-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-disabled/simple-combobox-disabled-example.ts new file mode 100644 index 000000000000..7c35a906fd6e --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-disabled/simple-combobox-disabled-example.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import {afterRenderEffect, Component, computed, signal, viewChild, untracked} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; + +/** @title Simple Combobox Disabled */ +@Component({ + selector: 'simple-combobox-disabled-example', + templateUrl: 'simple-combobox-disabled-example.html', + styleUrl: '../simple-combobox-examples.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], +}) +export class SimpleComboboxDisabledExample { + readonly listbox = viewChild(Listbox); + + popupExpanded = signal(false); + searchString = signal(''); + selectedOption = signal([]); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + constructor() { + afterRenderEffect(() => { + this.listbox()?.scrollActiveItemIntoView(); + }); + + afterRenderEffect(() => { + if (this.popupExpanded()) { + untracked(() => setTimeout(() => this.listbox()?.gotoFirst())); + } + }); + } + + onCommit() { + const selectedOption = this.selectedOption(); + if (selectedOption.length > 0) { + this.searchString.set(selectedOption[0]); + this.popupExpanded.set(false); + } + } +} + +const states = [ + 'Alabama', + 'Alaska', + 'Arizona', + 'Arkansas', + 'California', + 'Colorado', + 'Connecticut', + 'Delaware', + 'Florida', + 'Georgia', + 'Hawaii', + 'Idaho', + 'Illinois', + 'Indiana', + 'Iowa', + 'Kansas', + 'Kentucky', + 'Louisiana', + 'Maine', + 'Maryland', + 'Massachusetts', + 'Michigan', + 'Minnesota', + 'Mississippi', + 'Missouri', + 'Montana', + 'Nebraska', + 'Nevada', + 'New Hampshire', + 'New Jersey', + 'New Mexico', + 'New York', + 'North Carolina', + 'North Dakota', + 'Ohio', + 'Oklahoma', + 'Oregon', + 'Pennsylvania', + 'Rhode Island', + 'South Carolina', + 'South Dakota', + 'Tennessee', + 'Texas', + 'Utah', + 'Vermont', + 'Virginia', + 'Washington', + 'West Virginia', + 'Wisconsin', + 'Wyoming', +]; diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-examples.css b/src/components-examples/aria/simple-combobox/simple-combobox-examples.css new file mode 100644 index 000000000000..23cfc70235b3 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-examples.css @@ -0,0 +1,316 @@ +.example-combobox-container { + position: relative; + width: 100%; + display: flex; + flex-direction: column; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); + transition: + box-shadow 0.2s ease, + border-color 0.2s ease; +} + +.example-combobox-container:has([readonly='true']:not([aria-disabled='true'])) { + width: 200px; +} + +.example-combobox-input-container { + display: flex; + position: relative; + align-items: center; + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-combobox-input { + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-combobox-input[readonly='true']:not([aria-disabled='true']) { + cursor: pointer; + padding: 0.7rem 1rem; +} + +.example-combobox-container:focus-within { + border-color: var(--mat-sys-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--mat-sys-primary) 25%, transparent); +} + +.example-icon { + width: 24px; + height: 24px; + font-size: 24px; + color: var(--mat-sys-on-surface-variant); + user-select: none; + display: grid; + place-items: center; + pointer-events: none; +} + +.example-search-icon { + padding: 0 0.5rem; + position: absolute; + opacity: 0.8; +} + +.example-arrow-icon { + padding: 0 0.5rem; + position: absolute; + right: 0; + opacity: 0.8; + transition: transform 0.2s ease; +} + +.example-combobox-input[aria-expanded='true']+.example-arrow-icon { + transform: rotate(180deg); +} + +.example-combobox-input { + width: 100%; + border: none; + outline: none; + font-size: 1rem; + padding: 0.7rem 1rem 0.7rem 2.5rem; + background-color: var(--mat-sys-surface); +} + +.example-popover { + margin: 0; + padding: 0; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); + background-color: var(--mat-sys-surface); +} + +.example-popup { + width: 100%; + margin-block-start: 0.25rem; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); + background-color: var(--mat-sys-surface); + max-height: 15rem; + overflow: auto; +} + +.example-listbox { + display: flex; + flex-direction: column; + overflow: auto; + max-height: 10rem; + padding: 0.5rem; + gap: 4px; +} + +.example-option { + cursor: pointer; + padding: 0.3rem 1rem; + border-radius: var(--mat-sys-corner-extra-small); + display: flex; + overflow: hidden; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.example-option-text { + flex: 1; +} + +.example-checkbox-blank-icon, +.example-option[aria-selected='true'] .example-checkbox-filled-icon { + display: flex; + align-items: center; +} + +.example-checkbox-filled-icon, +.example-option[aria-selected='true'] .example-checkbox-blank-icon { + display: none; +} + +.example-checkbox-blank-icon { + opacity: 0.6; +} + +.example-selected-icon { + visibility: hidden; +} + +.example-option[aria-selected='true'] .example-selected-icon { + visibility: visible; +} + +.example-option[aria-selected='true'] { + color: var(--mat-sys-primary); + background-color: color-mix(in srgb, var(--mat-sys-primary) 10%, transparent); +} + +.example-option:hover { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent); +} + +.example-combobox-container:focus-within [data-active='true'] { + outline: 2px solid color-mix(in srgb, var(--mat-sys-primary) 80%, transparent); +} + +.example-tree { + padding: 10px; + overflow-x: scroll; + width: 100%; + box-sizing: border-box; +} + +.example-tree-item { + cursor: pointer; + list-style: none; + text-decoration: none; + display: flex; + align-items: center; + gap: 1rem; + padding: 0.3rem 1rem; +} + +li[aria-expanded='false']+ul[role='group'] { + display: none; +} + +ul[role='group'] { + padding-inline-start: 1rem; +} + +.example-icon { + margin: 0; + width: 24px; +} + +.example-parent-icon { + transition: transform 0.2s ease; +} + +.example-tree-item[aria-expanded='true'] .example-parent-icon { + transform: rotate(90deg); +} + +.example-selected-icon { + visibility: hidden; + margin-left: auto; +} + +.example-tree-item[aria-current] .example-selected-icon, +.example-tree-item[aria-selected='true'] .example-selected-icon { + visibility: visible; +} + +.example-combobox-container:has([aria-disabled='true']) { + 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..7a8234730426 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.html @@ -0,0 +1,33 @@ +
    +
    + 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/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..3e0376835f1d --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-highlight/simple-combobox-highlight-example.html @@ -0,0 +1,50 @@ +
    +
    + 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..d2a607a37636 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-highlight/simple-combobox-highlight-example.ts @@ -0,0 +1,105 @@ +/** + * @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(); + }); + + afterRenderEffect(() => { + this.searchString(); // Make effect run when search text changes + 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-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..89eff4fb70b4 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-listbox-inline/simple-combobox-listbox-inline-example.html @@ -0,0 +1,49 @@ +
    +
    + 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..5031a3d1d2e7 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-listbox-inline/simple-combobox-listbox-inline-example.ts @@ -0,0 +1,115 @@ +/** + * @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(() => { + this.searchString(); // Make effect run when search text changes + 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..69a60f96798f --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-listbox/simple-combobox-listbox-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-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-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..ab4270dbf97e --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-readonly-disabled/simple-combobox-readonly-disabled-example.html @@ -0,0 +1,52 @@ +
    +
    + + arrow_drop_down +
    + + + +
    +
    + @for (label of labels; track label.value) { +
    + {{label.icon}} + {{label.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..6a0f7f3f36c2 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-readonly-disabled/simple-combobox-readonly-disabled-example.ts @@ -0,0 +1,84 @@ +/** + * @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-examples.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SimpleComboboxReadonlyDisabledExample { + /** The string that is displayed in the combobox. */ + displayValue = signal(''); + + /** The combobox listbox popup. */ + listbox = viewChild>(Listbox); + + /** The options available in the listbox. */ + optionsList = viewChildren>(Option); + + popupExpanded = signal(false); + + /** The labels that are available for selection. */ + labels = [ + {value: 'Important', icon: 'label'}, + {value: 'Starred', icon: 'star'}, + {value: 'Work', icon: 'work'}, + {value: 'Personal', icon: 'person'}, + {value: 'To Do', icon: 'checklist'}, + {value: 'Later', icon: 'schedule'}, + {value: 'Read', icon: 'menu_book'}, + {value: 'Travel', icon: 'flight'}, + ]; + + selectedStates = signal([]); + + constructor() { + // Updates the display value when the listbox values change. + afterRenderEffect(() => { + const values = this.selectedStates(); + if (values.length === 0) { + this.displayValue.set('Select a label'); + } else if (values.length === 1) { + this.displayValue.set(values[0]); + } else { + this.displayValue.set(`${values[0]} + ${values.length - 1} more`); + } + }); + + // Scrolls to the active item when the active option changes. + afterRenderEffect(() => { + const option = this.optionsList().find(opt => opt.active()); + if (option) { + setTimeout(() => option.element.scrollIntoView({block: 'nearest'}), 50); + } + }); + } + + onCommit() { + const values = this.selectedStates(); + if (values.length > 0) { + this.displayValue.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..3bd2ed8e4177 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-readonly-multiselect/simple-combobox-readonly-multiselect-example.html @@ -0,0 +1,50 @@ +
    +
    + + arrow_drop_down +
    + + + +
    + @for (label of labels; track label.value) { +
    + {{label.icon}} + {{label.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..68b98ab0ca5a --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-readonly-multiselect/simple-combobox-readonly-multiselect-example.ts @@ -0,0 +1,81 @@ +/** + * @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 Readonly multiselectable combobox. */ +@Component({ + selector: 'simple-combobox-readonly-multiselect-example', + templateUrl: 'simple-combobox-readonly-multiselect-example.html', + styleUrl: '../simple-combobox-examples.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SimpleComboboxReadonlyMultiselectExample { + /** The string that is displayed in the combobox. */ + displayValue = signal(''); + + /** The combobox listbox popup. */ + listbox = viewChild>(Listbox); + + /** The options available in the listbox. */ + optionsList = viewChildren>(Option); + + popupExpanded = signal(false); + + /** The labels that are available for selection. */ + labels = [ + {value: 'Important', icon: 'label'}, + {value: 'Starred', icon: 'star'}, + {value: 'Work', icon: 'work'}, + {value: 'Personal', icon: 'person'}, + {value: 'To Do', icon: 'checklist'}, + {value: 'Later', icon: 'schedule'}, + {value: 'Read', icon: 'menu_book'}, + {value: 'Travel', icon: 'flight'}, + ]; + + selectedStates = signal([]); + + constructor() { + // Updates the display value when the listbox values change. + afterRenderEffect(() => { + const values = this.selectedStates(); + if (values.length === 0) { + this.displayValue.set('Select a label'); + } else if (values.length === 1) { + this.displayValue.set(values[0]); + } else { + this.displayValue.set(`${values[0]} + ${values.length - 1} more`); + } + }); + + // Scrolls to the active item when the active option changes. + afterRenderEffect(() => { + const option = this.optionsList().find(opt => opt.active()); + if (option) { + setTimeout(() => option.element.scrollIntoView({block: 'nearest'}), 50); + } + }); + } + + onCommit() { + // For multiselect, we usually keep the popup open or close on explicit action. + // In Full-combobox example, clicking options just toggles. + } +} 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..8d90948db8d4 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.html @@ -0,0 +1,44 @@ +
    + {{value()}} + arrow_drop_down +
    + + + +
    + @for (option of options(); track option.value) { +
    + @if (option.icon) { + {{option.icon}} + } + {{option.value}} + @if (selectedValues().includes(option.value)) { + + } +
    + } +
    +
    +
    diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.ts new file mode 100644 index 000000000000..5bf81a940e24 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-select/simple-combobox-select-example.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Component, signal, afterRenderEffect, viewChild} from '@angular/core'; +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import {OverlayModule} from '@angular/cdk/overlay'; + +@Component({ + selector: 'simple-combobox-select-example', + templateUrl: 'simple-combobox-select-example.html', + styleUrl: 'simple-combobox-select-example.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], +}) +export class SimpleComboboxSelectExample { + readonly listbox = viewChild(Listbox); + + readonly options = signal([ + {value: 'Select a label', icon: ''}, + {value: 'Important', icon: 'label'}, + {value: 'Starred', icon: 'star'}, + {value: 'Work', icon: 'work'}, + {value: 'Personal', icon: 'person'}, + {value: 'To Do', icon: 'checklist'}, + {value: 'Later', icon: 'schedule'}, + {value: 'Read', icon: 'menu_book'}, + {value: 'Travel', icon: 'flight'}, + ]); + readonly value = signal('Select a label'); + readonly selectedValues = signal(['Select a label']); + readonly popupExpanded = signal(false); + + constructor() { + afterRenderEffect(() => { + this.listbox()?.scrollActiveItemIntoView(); + }); + } + + onCommit() { + const values = this.selectedValues(); + if (values.length) { + this.value.set(values[0]); + this.popupExpanded.set(false); + } + } +} diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.html new file mode 100644 index 000000000000..35b4ce850e0e --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.html @@ -0,0 +1,74 @@ +
    +
    + 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..f4b7730cce6c --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.ts @@ -0,0 +1,119 @@ +/** + * @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(() => { + this.filteredGroups(); + if (this.popupExpanded()) { + untracked(() => + setTimeout(() => { + this.tree()?.gotoFirst(); + }), + ); + } + }); + + 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..032d93936392 --- /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..0f0bcf7f5cea --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.ts @@ -0,0 +1,119 @@ +/** + * @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() { + afterRenderEffect(() => { + if (this.popupExpanded()) { + untracked(() => setTimeout(() => this.tree()?.gotoFirst())); + } + }); + + // Highlight mode focus update + afterRenderEffect(() => { + this.filteredGroups(); + }); + + 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/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..3fb86a2898f7 --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.html @@ -0,0 +1,68 @@ +
    +
    + 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..0e7a5eeb374b --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree'; +import {Component, afterRenderEffect, computed, signal, viewChild, untracked} from '@angular/core'; +import {NgTemplateOutlet} from '@angular/common'; +import {OverlayModule} from '@angular/cdk/overlay'; + +interface FoodNode { + name: string; + children?: FoodNode[]; + expanded?: boolean; +} + +/** @title */ +@Component({ + selector: 'simple-combobox-tree-example', + templateUrl: 'simple-combobox-tree-example.html', + styleUrl: '../simple-combobox-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); + + constructor() { + afterRenderEffect(() => { + const active = this.tree()?._pattern.inputs.activeItem(); + if (active) { + untracked(() => { + active.element()?.scrollIntoView({block: 'nearest'}); + }); + } + }); + } + + filteredGroups = computed(() => { + const search = this.searchString().trim().toLowerCase(); + const data = this.dataSource(); + + if (!search) { + return data; + } + + const filterNode = (node: FoodNode): FoodNode | null => { + const matches = node.name.toLowerCase().includes(search); + const children = node.children + ?.map(child => filterNode(child)) + .filter((child): child is FoodNode => child !== null); + + if (matches || (children && children.length > 0)) { + return { + ...node, + children, + expanded: children && children.length > 0 ? true : node.expanded, + }; + } + + return null; + }; + + return data.map(node => filterNode(node)).filter((node): node is FoodNode => node !== null); + }); + + onCommit() { + const selected = this.selectedValues(); + if (selected.length > 0) { + const value = selected[0]; + this.searchString.set(value); + this.popupExpanded.set(false); + } + } +} + +const FOOD_DATA: FoodNode[] = [ + {name: 'Winter', children: [{name: 'December'}, {name: 'January'}, {name: 'February'}]}, + {name: 'Spring', children: [{name: 'March'}, {name: 'April'}, {name: 'May'}]}, + {name: 'Summer', children: [{name: 'June'}, {name: 'July'}, {name: 'August'}]}, + {name: 'Fall', children: [{name: 'September'}, {name: 'October'}, {name: 'November'}]}, +]; diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index b18b11e22ea1..f4b2184f1ba3 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -33,6 +33,7 @@ ng_project( "//src/dev-app/aria-menu", "//src/dev-app/aria-menubar", "//src/dev-app/aria-select", + "//src/dev-app/aria-simple-combobox", "//src/dev-app/aria-tabs", "//src/dev-app/aria-toolbar", "//src/dev-app/aria-tree", diff --git a/src/dev-app/aria-simple-combobox/BUILD.bazel b/src/dev-app/aria-simple-combobox/BUILD.bazel new file mode 100644 index 000000000000..0226eb758e65 --- /dev/null +++ b/src/dev-app/aria-simple-combobox/BUILD.bazel @@ -0,0 +1,16 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "aria-simple-combobox", + srcs = glob(["**/*.ts"]), + assets = [ + "simple-combobox-demo.html", + "simple-combobox-demo.css", + ], + deps = [ + "//:node_modules/@angular/core", + "//src/components-examples/aria/simple-combobox", + ], +) diff --git a/src/dev-app/aria-simple-combobox/simple-combobox-demo.css b/src/dev-app/aria-simple-combobox/simple-combobox-demo.css new file mode 100644 index 000000000000..a3b9cc5650f1 --- /dev/null +++ b/src/dev-app/aria-simple-combobox/simple-combobox-demo.css @@ -0,0 +1,25 @@ +.example-combobox-row { + display: flex; + gap: 20px; +} + +.example-combobox-container { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + min-width: 350px; + padding: 20px 0; +} + +h2 { + font-size: 1.5rem; + padding-top: 20px; +} + +h3 { + font-size: 1rem; +} +.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 new file mode 100644 index 000000000000..bc4df5170d76 --- /dev/null +++ b/src/dev-app/aria-simple-combobox/simple-combobox-demo.html @@ -0,0 +1,91 @@ +
    +

    Listbox autocomplete examples

    + +
    +
    +

    Combobox with manual filtering

    + +
    + +
    +

    Combobox with inline suggestion

    + +
    + +
    +

    Combobox with auto-select

    + +
    +
    +

    Combobox with highlight

    + +
    +
    + +
    +
    +

    Combobox with disabled

    + +
    +
    + +

    Tree autocomplete examples

    + +
    +
    +

    Combobox with tree popup and manual filtering

    + +
    + +
    +

    Combobox with tree popup and auto-select

    + +
    + +
    +

    Combobox with tree popup and highlight filtering

    + +
    +
    + +

    Combobox select examples

    + +
    +
    +

    Combobox with select

    + +
    + +
    +

    Combobox with Multi-Select

    + +
    + +
    +

    Combobox with Readonly + Disabled

    + +
    +
    + +

    Combobox with Dialog Popup

    + +
    +
    +

    Combobox with Dialog Popup

    + +
    +
    + +

    Combobox Grid Examples

    + +
    +
    +

    Combobox with Grid

    + +
    +
    +

    Combobox with Datepicker Grid

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