diff --git a/src/aria/combobox/combobox.ts b/src/aria/combobox/combobox.ts index 49ba07f0ca10..8601227cbf8f 100644 --- a/src/aria/combobox/combobox.ts +++ b/src/aria/combobox/combobox.ts @@ -25,6 +25,7 @@ import { ComboboxPattern, ComboboxListboxControls, ComboboxTreeControls, + ComboboxDialogPattern, } from '@angular/aria/private'; import {Directionality} from '@angular/cdk/bidi'; import {toSignal} from '@angular/core/rxjs-interop'; @@ -84,7 +85,12 @@ export class Combobox { readonly firstMatch = input(undefined); /** Whether the combobox is expanded. */ - readonly expanded = computed(() => this._pattern.expanded()); + readonly expanded = computed(() => this.alwaysExpanded() || this._pattern.expanded()); + + // TODO: Maybe make expanded a signal that can be passed in? + // Or an "always expanded" option? + + readonly alwaysExpanded = input(false); /** Input element connected to the combobox, if any. */ readonly inputElement = computed(() => this._pattern.inputs.inputEl()); @@ -103,7 +109,16 @@ export class Combobox { constructor() { afterRenderEffect(() => { - if (!this._deferredContentAware?.contentVisible() && this._pattern.isFocused()) { + if (this.alwaysExpanded()) { + this._pattern.expanded.set(true); + } + }); + + afterRenderEffect(() => { + if ( + !this._deferredContentAware?.contentVisible() && + (this._pattern.isFocused() || this.alwaysExpanded()) + ) { this._deferredContentAware?.contentVisible.set(true); } }); @@ -146,10 +161,15 @@ export class ComboboxInput { ); this.combobox._pattern.inputs.inputValue = this.value; + const controls = this.combobox.popup()?.controls(); + if (controls instanceof ComboboxDialogPattern) { + return; + } + /** Focuses & selects the first item in the combobox if the user changes the input value. */ afterRenderEffect(() => { this.value(); - this.combobox.popup()?.controls()?.items(); + controls?.items(); untracked(() => this.combobox._pattern.onFilter()); }); } @@ -172,6 +192,58 @@ export class ComboboxPopup { /** The controls the popup exposes to the combobox. */ readonly controls = signal< - ComboboxListboxControls | ComboboxTreeControls | undefined + | ComboboxListboxControls + | ComboboxTreeControls + | ComboboxDialogPattern + | undefined >(undefined); } + +@Directive({ + selector: 'dialog[ngComboboxDialog]', + exportAs: 'ngComboboxDialog', + host: { + '[attr.data-open]': 'combobox._pattern.expanded()', + '(keydown)': '_pattern.onKeydown($event)', + '(click)': '_pattern.onClick($event)', + }, + hostDirectives: [ComboboxPopup], +}) +export class ComboboxDialog { + /** The dialog element. */ + readonly element = inject(ElementRef); + + /** The combobox that the dialog belongs to. */ + readonly combobox = inject(Combobox); + + /** A reference to the parent combobox popup, if one exists. */ + private readonly _popup = inject>(ComboboxPopup, { + optional: true, + }); + + _pattern: ComboboxDialogPattern; + + constructor() { + this._pattern = new ComboboxDialogPattern({ + id: () => '', + element: () => this.element.nativeElement, + combobox: this.combobox._pattern, + }); + + if (this._popup) { + this._popup.controls.set(this._pattern); + } + + afterRenderEffect(() => { + if (this.element) { + this.combobox._pattern.expanded() + ? this.element.nativeElement.showModal() + : this.element.nativeElement.close(); + } + }); + } + + close() { + this._popup?.combobox?._pattern.close(); + } +} diff --git a/src/aria/combobox/index.ts b/src/aria/combobox/index.ts index c859f6cc01de..70513ea9f2ec 100644 --- a/src/aria/combobox/index.ts +++ b/src/aria/combobox/index.ts @@ -6,4 +6,10 @@ * found in the LICENSE file at https://angular.dev/license */ -export {Combobox, ComboboxInput, ComboboxPopup, ComboboxPopupContainer} from './combobox'; +export { + Combobox, + ComboboxDialog, + ComboboxInput, + ComboboxPopup, + ComboboxPopupContainer, +} from './combobox'; diff --git a/src/aria/listbox/listbox.ts b/src/aria/listbox/listbox.ts index 9fc95ed45f74..7cb36191f4a2 100644 --- a/src/aria/listbox/listbox.ts +++ b/src/aria/listbox/listbox.ts @@ -56,7 +56,7 @@ import {ComboboxPopup} from '../combobox'; '(pointerdown)': '_pattern.onPointerdown($event)', '(focusin)': 'onFocus()', }, - hostDirectives: [{directive: ComboboxPopup}], + hostDirectives: [ComboboxPopup], }) export class Listbox { /** A unique identifier for the listbox. */ @@ -187,6 +187,11 @@ export class Listbox { scrollActiveItemIntoView(options: ScrollIntoViewOptions = {block: 'nearest'}) { this._pattern.inputs.activeItem()?.element()?.scrollIntoView(options); } + + /** Navigates to the first item in the listbox. */ + gotoFirst() { + this._pattern.listBehavior.first(); + } } /** A selectable option in a Listbox. */ diff --git a/src/aria/private/combobox/combobox.spec.ts b/src/aria/private/combobox/combobox.spec.ts index 7c10480eb608..d97876882b38 100644 --- a/src/aria/private/combobox/combobox.spec.ts +++ b/src/aria/private/combobox/combobox.spec.ts @@ -106,6 +106,7 @@ function getComboboxPattern( filterMode: signal(inputs.filterMode ?? 'manual'), firstMatch, inputValue, + alwaysExpanded: signal(false), }); return {combobox, inputEl, containerEl, firstMatch, inputValue}; @@ -395,14 +396,14 @@ describe('Combobox with Listbox Pattern', () => { expect(listbox.inputs.value()).toEqual(['Apple']); }); - it('should deselect on backspace', () => { + it('should deselect on close if the input text does not match any options', () => { combobox.onKeydown(down()); combobox.onKeydown(enter()); + expect(listbox.inputs.value()).toEqual(['Apple']); type('Appl', {backspace: true}); - combobox.onInput(new InputEvent('input', {inputType: 'deleteContentBackward'})); - - expect(listbox.getSelectedItems().length).toBe(0); + expect(listbox.inputs.value()).toEqual(['Apple']); + combobox.onKeydown(escape()); expect(listbox.inputs.value()).toEqual([]); }); @@ -759,13 +760,14 @@ describe('Combobox with Tree Pattern', () => { expect(tree.inputs.value()).toEqual(['Apple']); }); - it('should deselect on backspace', () => { + it('should deselect on close if the input text does not match any options', () => { combobox.onKeydown(down()); combobox.onKeydown(enter()); - type('Appl', {backspace: true}); - - expect(tree.getSelectedItems().length).toBe(0); + expect(tree.inputs.value()).toEqual(['Fruit']); + type('Frui', {backspace: true}); + expect(tree.inputs.value()).toEqual(['Fruit']); + combobox.onKeydown(escape()); expect(tree.inputs.value()).toEqual([]); }); diff --git a/src/aria/private/combobox/combobox.ts b/src/aria/private/combobox/combobox.ts index 1246b7398e59..2dfd672ec352 100644 --- a/src/aria/private/combobox/combobox.ts +++ b/src/aria/private/combobox/combobox.ts @@ -14,7 +14,9 @@ import {ListItem} from '../behaviors/list/list'; /** Represents the required inputs for a combobox. */ export interface ComboboxInputs, V> { /** The controls for the popup associated with the combobox. */ - popupControls: SignalLike | ComboboxTreeControls | undefined>; + popupControls: SignalLike< + ComboboxListboxControls | ComboboxTreeControls | ComboboxDialogPattern | undefined + >; /** The HTML input element that serves as the combobox input. */ inputEl: SignalLike; @@ -39,6 +41,9 @@ export interface ComboboxInputs, V> { /** Whether the combobox is in a right-to-left context. */ textDirection: SignalLike<'rtl' | 'ltr'>; + + /** Whether the combobox is always expanded. */ + alwaysExpanded: SignalLike; } /** An interface that allows combobox popups to expose the necessary controls for the combobox. */ @@ -90,6 +95,9 @@ export interface ComboboxListboxControls, V> { /** Returns the item corresponding to the given event. */ getItem: (e: PointerEvent) => T | undefined; + /** Returns the currently active (focused) item in the popup. */ + getActiveItem: () => T | undefined; + /** Returns the currently selected items in the popup. */ getSelectedItems: () => T[]; @@ -130,7 +138,14 @@ export class ComboboxPattern, V> { expanded = signal(false); /** The ID of the active item in the combobox. */ - activeDescendant = computed(() => this.inputs.popupControls()?.activeId() ?? null); + activeDescendant = computed(() => { + const popupControls = this.inputs.popupControls(); + if (popupControls instanceof ComboboxDialogPattern) { + return null; + } + + return popupControls?.activeId() ?? null; + }); /** The currently highlighted item in the combobox. */ highlightedItem = signal(undefined); @@ -141,6 +156,9 @@ export class ComboboxPattern, V> { /** Whether the combobox is focused. */ isFocused = signal(false); + /** Whether the combobox has ever been focused. */ + hasBeenFocused = signal(false); + /** The key used to navigate to the previous item in the list. */ expandKey = computed(() => (this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight')); @@ -161,13 +179,57 @@ export class ComboboxPattern, V> { /** Whether the combobox is read-only. */ readonly = computed(() => this.inputs.readonly() || null); + /** Returns the listbox controls for the combobox. */ + listControls = () => { + const popupControls = this.inputs.popupControls(); + + if (popupControls instanceof ComboboxDialogPattern) { + return null; + } + + return popupControls; + }; + + /** Returns the tree controls for the combobox. */ + treeControls = () => { + const popupControls = this.inputs.popupControls(); + + if (popupControls?.role() === 'tree') { + return popupControls as ComboboxTreeControls; + } + + return null; + }; + /** The keydown event manager for the combobox. */ keydown = computed(() => { + const manager = new KeyboardEventManager(); + const popupControls = this.inputs.popupControls(); + + if (!popupControls) { + return manager; + } + + if (popupControls instanceof ComboboxDialogPattern) { + if (!this.expanded()) { + manager.on('ArrowUp', () => this.open()).on('ArrowDown', () => this.open()); + + if (this.readonly()) { + manager.on('Enter', () => this.open()).on(' ', () => this.open()); + } + } + + return manager; + } + + if (!this.inputs.alwaysExpanded()) { + manager.on('Escape', () => this.close({reset: !this.readonly()})); + } + if (!this.expanded()) { - const manager = new KeyboardEventManager() + manager .on('ArrowDown', () => this.open({first: true})) - .on('ArrowUp', () => this.open({last: true})) - .on('Escape', () => this.close({reset: !this.readonly()})); + .on('ArrowUp', () => this.open({last: true})); if (this.readonly()) { manager @@ -178,56 +240,65 @@ export class ComboboxPattern, V> { return manager; } - const popupControls = this.inputs.popupControls(); - - if (!popupControls) { - return new KeyboardEventManager(); - } - - const manager = new KeyboardEventManager() + manager .on('ArrowDown', () => this.next()) .on('ArrowUp', () => this.prev()) .on('Home', () => this.first()) - .on('End', () => this.last()) - .on('Escape', () => this.close({reset: !this.readonly()})); + .on('End', () => this.last()); if (this.readonly()) { manager.on(' ', () => this.select({commit: true, close: !popupControls.multi()})); } if (popupControls.role() === 'listbox') { - manager.on('Enter', () => this.select({commit: true, close: !popupControls.multi()})); + manager.on('Enter', () => { + this.select({commit: true, close: !popupControls.multi()}); + }); } - if (popupControls.role() === 'tree') { - const treeControls = popupControls as ComboboxTreeControls; + const treeControls = this.treeControls(); - if (treeControls.isItemSelectable()) { - manager.on('Enter', () => this.select({commit: true, close: true})); - } else if (treeControls.isItemExpandable()) { - manager.on('Enter', () => this.expandItem()); - } + if (treeControls?.isItemSelectable()) { + manager.on('Enter', () => this.select({commit: true, close: true})); + } - if (treeControls.isItemExpandable() || treeControls.isItemCollapsible()) { - manager.on(this.collapseKey(), () => this.collapseItem()); - } + if (treeControls?.isItemExpandable()) { + manager + .on(this.expandKey(), () => this.expandItem()) + .on(this.collapseKey(), () => this.collapseItem()); - if (treeControls.isItemExpandable()) { - manager.on(this.expandKey(), () => this.expandItem()); + if (!treeControls.isItemSelectable()) { + manager.on('Enter', () => this.expandItem()); } } + if (treeControls?.isItemCollapsible()) { + manager.on(this.collapseKey(), () => this.collapseItem()); + } + return manager; }); /** The pointerup event manager for the combobox. */ pointerup = computed(() => new PointerEventManager().on(e => { - const item = this.inputs.popupControls()?.getItem(e); + if (e.target === this.inputs.inputEl()) { + if (this.readonly()) { + this.expanded() ? this.close() : this.open({selected: true}); + } + } + + const controls = this.inputs.popupControls(); + + if (controls instanceof ComboboxDialogPattern) { + return; + } + + const item = controls?.getItem(e); if (item) { - if (this.inputs.popupControls()?.role() === 'tree') { - const treeControls = this.inputs.popupControls() as ComboboxTreeControls; + if (controls?.role() === 'tree') { + const treeControls = controls as ComboboxTreeControls; if (treeControls.isItemExpandable(item) && !treeControls.isItemSelectable(item)) { treeControls.toggleExpansion(item); @@ -236,15 +307,9 @@ export class ComboboxPattern, V> { } } - this.select({item, commit: true, close: !this.inputs.popupControls()?.multi()}); + this.select({item, commit: true, close: !controls?.multi()}); this.inputs.inputEl()?.focus(); // Return focus to the input after selecting. } - - if (e.target === this.inputs.inputEl()) { - if (this.readonly()) { - this.expanded() ? this.close() : this.open({selected: true}); - } - } }), ); @@ -276,19 +341,16 @@ export class ComboboxPattern, V> { return; } + const popupControls = this.inputs.popupControls(); + + if (popupControls instanceof ComboboxDialogPattern) { + return; + } + this.open(); this.inputs.inputValue?.set(inputEl.value); this.isDeleting = event instanceof InputEvent && !!event.inputType.match(/^delete/); - if (this.inputs.filterMode() === 'manual') { - const selectedItems = this.inputs.popupControls()?.getSelectedItems(); - const searchTerm = selectedItems?.[0]?.searchTerm(); - - if (searchTerm && this.inputs.inputValue!() !== searchTerm) { - this.inputs.popupControls()?.clearSelection(); - } - } - if (this.inputs.filterMode() === 'highlight' && !this.isDeleting) { this.highlight(); } @@ -296,7 +358,13 @@ export class ComboboxPattern, V> { /** Handles focus in events for the combobox. */ onFocusIn() { + if (this.inputs.alwaysExpanded() && !this.hasBeenFocused()) { + const firstSelectedItem = this.listControls()?.getSelectedItems()[0]; + firstSelectedItem ? this.listControls()?.focus(firstSelectedItem) : this.first(); + } + this.isFocused.set(true); + this.hasBeenFocused.set(true); } /** Handles focus out events for the combobox. */ @@ -305,6 +373,12 @@ export class ComboboxPattern, V> { return; } + const popupControls = this.inputs.popupControls(); + + if (popupControls instanceof ComboboxDialogPattern) { + return; + } + if ( !(event.relatedTarget instanceof HTMLElement) || !this.inputs.containerEl()?.contains(event.relatedTarget) @@ -319,8 +393,7 @@ export class ComboboxPattern, V> { if (this.inputs.filterMode() !== 'manual') { this.commit(); } else { - const item = this.inputs - .popupControls() + const item = popupControls ?.items() .find(i => i.searchTerm() === this.inputs.inputEl()?.value); @@ -338,12 +411,11 @@ export class ComboboxPattern, V> { // TODO(wagnermaciel): Consider whether we should not provide this default behavior for the // listbox. Instead, we may want to allow users to have no match so that typing does not focus // any option. - if (this.inputs.popupControls()?.role() === 'listbox') { - return this.inputs.popupControls()?.items()[0]; + if (this.listControls()?.role() === 'listbox') { + return this.listControls()?.items()[0]; } - return this.inputs - .popupControls() + return this.listControls() ?.items() .find(i => i.value() === this.inputs.firstMatch()); }); @@ -354,6 +426,12 @@ export class ComboboxPattern, V> { return; } + const popupControls = this.inputs.popupControls(); + + if (popupControls instanceof ComboboxDialogPattern) { + return; + } + // TODO(wagnermaciel) // When the user first interacts with the combobox, the popup will lazily render for the first // time. This is a simple way to detect this and avoid auto-focus & selection logic, but this @@ -377,12 +455,12 @@ export class ComboboxPattern, V> { const item = this.firstMatch(); if (!item) { - this.inputs.popupControls()?.clearSelection(); - this.inputs.popupControls()?.unfocus(); + popupControls?.clearSelection(); + popupControls?.unfocus(); return; } - this.inputs.popupControls()?.focus(item); + popupControls?.focus(item); if (this.inputs.filterMode() !== 'manual') { this.select({item}); @@ -396,7 +474,7 @@ export class ComboboxPattern, V> { /** Highlights the currently selected item in the combobox. */ highlight() { const inputEl = this.inputs.inputEl(); - const selectedItems = this.inputs.popupControls()?.getSelectedItems(); + const selectedItems = this.listControls()?.getSelectedItems(); const item = selectedItems?.[0]; if (!inputEl || !item) { @@ -418,13 +496,32 @@ export class ComboboxPattern, V> { /** Closes the combobox. */ close(opts?: {reset: boolean}) { - if (!opts?.reset) { + const popupControls = this.inputs.popupControls(); + + if (this.inputs.alwaysExpanded()) { + return; + } + + if (popupControls instanceof ComboboxDialogPattern) { this.expanded.set(false); - this.inputs.popupControls()?.unfocus(); return; } - const popupControls = this.inputs.popupControls(); + if (!opts?.reset) { + if (this.inputs.filterMode() === 'manual') { + if ( + !this.listControls() + ?.items() + .some(i => i.searchTerm() === this.inputs.inputEl()?.value) + ) { + this.listControls()?.clearSelection(); + } + } + + this.expanded.set(false); + popupControls?.unfocus(); + return; + } if (!this.expanded()) { this.inputs.inputValue?.set(''); @@ -448,13 +545,19 @@ export class ComboboxPattern, V> { this.close(); if (!this.readonly()) { - this.inputs.popupControls()?.clearSelection(); + popupControls?.clearSelection(); } } /** Opens the combobox. */ open(nav?: {first?: boolean; last?: boolean; selected?: boolean}) { this.expanded.set(true); + const popupControls = this.inputs.popupControls(); + + if (popupControls instanceof ComboboxDialogPattern) { + return; + } + const inputEl = this.inputs.inputEl(); if (inputEl && this.inputs.filterMode() === 'highlight') { @@ -472,32 +575,31 @@ export class ComboboxPattern, V> { this.last(); } if (nav?.selected) { - const selectedItem = this.inputs - .popupControls() + const selectedItem = popupControls ?.items() - .find(i => this.inputs.popupControls()?.getSelectedItems().includes(i)); - selectedItem ? this.inputs.popupControls()?.focus(selectedItem) : this.first(); + .find(i => popupControls?.getSelectedItems().includes(i)); + selectedItem ? popupControls?.focus(selectedItem) : this.first(); } } /** Navigates to the next focusable item in the combobox popup. */ next() { - this._navigate(() => this.inputs.popupControls()?.next()); + this._navigate(() => this.listControls()?.next()); } /** Navigates to the previous focusable item in the combobox popup. */ prev() { - this._navigate(() => this.inputs.popupControls()?.prev()); + this._navigate(() => this.listControls()?.prev()); } /** Navigates to the first focusable item in the combobox popup. */ first() { - this._navigate(() => this.inputs.popupControls()?.first()); + this._navigate(() => this.listControls()?.first()); } /** Navigates to the last focusable item in the combobox popup. */ last() { - this._navigate(() => this.inputs.popupControls()?.last()); + this._navigate(() => this.listControls()?.last()); } /** Collapses the currently focused item in the combobox. */ @@ -514,7 +616,7 @@ export class ComboboxPattern, V> { /** Selects an item in the combobox popup. */ select(opts: {item?: T; commit?: boolean; close?: boolean} = {}) { - const controls = this.inputs.popupControls(); + const controls = this.listControls(); if (opts.item) { controls?.focus(opts.item, {focusElement: false}); @@ -533,7 +635,7 @@ export class ComboboxPattern, V> { /** Updates the value of the input based on the currently selected item. */ commit() { const inputEl = this.inputs.inputEl(); - const selectedItems = this.inputs.popupControls()?.getSelectedItems(); + const selectedItems = this.listControls()?.getSelectedItems(); if (!inputEl) { return; @@ -559,7 +661,7 @@ export class ComboboxPattern, V> { if (this.inputs.filterMode() === 'highlight') { // This is to handle when the user navigates back to the originally highlighted item. // E.g. User types "Al", highlights "Alice", then navigates down and back up to "Alice". - const selectedItem = this.inputs.popupControls()?.getSelectedItems()[0]; + const selectedItem = this.listControls()?.getSelectedItems()[0]; if (!selectedItem) { return; @@ -574,3 +676,32 @@ export class ComboboxPattern, V> { } } } + +export class ComboboxDialogPattern { + id = () => this.inputs.id(); + + role = () => 'dialog' as const; + + keydown = computed(() => { + return new KeyboardEventManager().on('Escape', () => this.inputs.combobox.close()); + }); + + constructor( + readonly inputs: { + combobox: ComboboxPattern; + element: SignalLike; + id: SignalLike; + }, + ) {} + + onKeydown(event: KeyboardEvent) { + this.keydown().handle(event); + } + + onClick(event: MouseEvent) { + // The "click" event fires on the dialog when the user clicks outside of the dialog content. + if (event.target === this.inputs.element()) { + this.inputs.combobox.close(); + } + } +} diff --git a/src/aria/private/listbox/combobox-listbox.ts b/src/aria/private/listbox/combobox-listbox.ts index 518e56014541..02735c6c4aa6 100644 --- a/src/aria/private/listbox/combobox-listbox.ts +++ b/src/aria/private/listbox/combobox-listbox.ts @@ -64,6 +64,9 @@ export class ComboboxListboxPattern this.listBehavior.goto(item, opts); }; + /** Navigates to the previous focusable item in the listbox. */ + getActiveItem = () => this.inputs.activeItem(); + /** Navigates to the next focusable item in the listbox. */ next = () => this.listBehavior.next(); diff --git a/src/aria/private/tree/combobox-tree.ts b/src/aria/private/tree/combobox-tree.ts index 71005978af7e..3f64ae752938 100644 --- a/src/aria/private/tree/combobox-tree.ts +++ b/src/aria/private/tree/combobox-tree.ts @@ -29,6 +29,9 @@ export class ComboboxTreePattern /* The id of the active (focused) item in the tree. */ activeId = computed(() => this.listBehavior.activeDescendant()); + /** Returns the currently active (focused) item in the tree. */ + getActiveItem = () => this.inputs.activeItem(); + /** The list of items in the tree. */ items = computed(() => this.inputs.allItems()); diff --git a/src/aria/tree/tree.ts b/src/aria/tree/tree.ts index f42eb3e1696a..6b876da5263c 100644 --- a/src/aria/tree/tree.ts +++ b/src/aria/tree/tree.ts @@ -83,7 +83,7 @@ function sortDirectives(a: HasElement, b: HasElement) { '(pointerdown)': '_pattern.onPointerdown($event)', '(focusin)': 'onFocus()', }, - hostDirectives: [{directive: ComboboxPopup}], + hostDirectives: [ComboboxPopup], }) export class Tree { /** A unique identifier for the tree. */ diff --git a/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.css b/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.css new file mode 100644 index 000000000000..68d4cf47c377 --- /dev/null +++ b/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.css @@ -0,0 +1,222 @@ +.example-combobox-container { + position: relative; + width: 100%; + display: flex; + flex-direction: column; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-combobox-container:has([readonly='true']) { + width: 225px; +} + +.example-combobox-input-container { + display: flex; + position: relative; + align-items: center; + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-combobox-input { + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-combobox-input[readonly='true'] { + cursor: pointer; + padding: 0.7rem 1rem; +} + +.example-combobox-container:focus-within .example-combobox-input { + outline: 1.5px solid var(--mat-sys-primary); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--mat-sys-primary) 25%, transparent); +} + +.example-icon { + width: 24px; + height: 24px; + font-size: 20px; + display: grid; + place-items: center; + pointer-events: none; +} + +.example-search-icon { + padding: 0 0.5rem; + position: absolute; + opacity: 0.8; +} + +.example-arrow-icon { + padding: 0 0.5rem; + position: absolute; + right: 0; + opacity: 0.8; + transition: transform 0.2s ease; +} + +.example-combobox-input[aria-expanded='true'] + .example-arrow-icon { + transform: rotate(180deg); +} + +.example-combobox-input { + width: 100%; + border: none; + outline: none; + font-size: 1rem; + padding: 0.7rem 1rem 0.7rem 2.5rem; + background-color: var(--mat-sys-surface); +} + +.example-popover { + margin: 0; + padding: 0; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); + background-color: var(--mat-sys-surface); +} + +.example-listbox { + display: flex; + flex-direction: column; + overflow: auto; + max-height: 10rem; + padding: 0.5rem; + gap: 4px; +} + +.example-option { + cursor: pointer; + padding: 0.3rem 1rem; + border-radius: var(--mat-sys-corner-extra-small); + display: flex; + overflow: hidden; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.example-option-text { + flex: 1; +} + +.example-checkbox-blank-icon, +.example-option[aria-selected='true'] .example-checkbox-filled-icon { + display: flex; + align-items: center; +} + +.example-checkbox-filled-icon, +.example-option[aria-selected='true'] .example-checkbox-blank-icon { + display: none; +} + +.example-checkbox-blank-icon { + opacity: 0.6; +} + +.example-selected-icon { + visibility: hidden; +} + +.example-option[aria-selected='true'] .example-selected-icon { + visibility: visible; +} + +.example-option[aria-selected='true'] { + color: var(--mat-sys-primary); + background-color: color-mix(in srgb, var(--mat-sys-primary) 10%, transparent); +} + +.example-option:hover { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent); +} + +.example-combobox-container:focus-within [data-active='true'] { + outline: 2px solid color-mix(in srgb, var(--mat-sys-primary) 80%, transparent); +} + +.example-tree { + padding: 10px; + overflow-x: scroll; +} + +.example-tree-item { + cursor: pointer; + list-style: none; + text-decoration: none; + display: flex; + align-items: center; + gap: 1rem; + padding: 0.3rem 1rem; +} + +li[aria-expanded='false'] + ul[role='group'] { + display: none; +} + +ul[role='group'] { + padding-inline-start: 1rem; +} + +.example-icon { + margin: 0; + width: 24px; +} + +.example-parent-icon { + transition: transform 0.2s ease; +} + +.example-tree-item[aria-expanded='true'] .example-parent-icon { + transform: rotate(90deg); +} + +.example-selected-icon { + visibility: hidden; + margin-left: auto; +} + +.example-tree-item[aria-current] .example-selected-icon, +.example-tree-item[aria-selected='true'] .example-selected-icon { + visibility: visible; +} + +.example-dialog { + position: absolute; + left: auto; + right: auto; + top: auto; + bottom: auto; + padding: 0; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-dialog .example-combobox-input-container { + border-radius: 0; +} + +.example-dialog .example-combobox-container, +.example-dialog .example-combobox-input-container { + border: none; +} + +.example-dialog .example-combobox-input { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.example-dialog .example-combobox-container:focus-within .example-combobox-input { + outline: none; + box-shadow: none; +} + +.example-dialog .example-combobox-input-container { + border-bottom: 1px solid var(--mat-sys-outline); +} + +.example-dialog::backdrop { + opacity: 0; +} diff --git a/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.html b/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.html new file mode 100644 index 000000000000..4a8d62f95840 --- /dev/null +++ b/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.html @@ -0,0 +1,49 @@ +
+
+ + arrow_drop_down +
+ + + +
+ +
+ search + +
+ + +
+ @for (option of options(); track option) { +
+ {{option}} + +
+ } +
+
+
+
+
+
diff --git a/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.ts b/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.ts new file mode 100644 index 000000000000..db6660a0fba8 --- /dev/null +++ b/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.ts @@ -0,0 +1,146 @@ +/** + * @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, + ComboboxDialog, + ComboboxInput, + ComboboxPopupContainer, +} from '@angular/aria/combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + computed, + signal, + untracked, + viewChild, +} from '@angular/core'; +import {FormsModule} from '@angular/forms'; + +/** @title Combobox with a dialog popup. */ +@Component({ + selector: 'combobox-dialog-example', + templateUrl: 'combobox-dialog-example.html', + styleUrl: 'combobox-dialog-example.css', + imports: [ + ComboboxDialog, + Combobox, + ComboboxInput, + ComboboxPopupContainer, + Listbox, + Option, + FormsModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ComboboxDialogExample { + dialog = viewChild(ComboboxDialog); + listbox = viewChild>(Listbox); + combobox = viewChild>(Combobox); + + value = signal(''); + searchString = signal(''); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + selectedStates = signal([]); + + constructor() { + afterRenderEffect(() => { + if (this.dialog() && this.combobox()?.expanded()) { + untracked(() => this.listbox()?.gotoFirst()); + this.positionDialog(); + } + }); + + afterRenderEffect(() => { + if (this.selectedStates().length > 0) { + untracked(() => this.dialog()?.close()); + this.value.set(this.selectedStates()[0]); + this.searchString.set(''); + } + }); + + afterRenderEffect(() => this.listbox()?.scrollActiveItemIntoView()); + } + + // TODO(wagnermaciel): Switch to using the CDK for positioning. + + positionDialog() { + const dialog = this.dialog()!; + const combobox = this.combobox()!; + + const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); + const dialogEl = dialog.element.nativeElement; + + const scrollY = window.scrollY; + + if (comboboxRect) { + dialogEl.style.width = `${comboboxRect.width}px`; + dialogEl.style.top = `${comboboxRect.bottom + scrollY + 4}px`; + dialogEl.style.left = `${comboboxRect.left - 1}px`; + } + } +} + +const states = [ + 'Alabama', + 'Alaska', + 'Arizona', + 'Arkansas', + 'California', + 'Colorado', + 'Connecticut', + 'Delaware', + 'Florida', + 'Georgia', + 'Hawaii', + 'Idaho', + 'Illinois', + 'Indiana', + 'Iowa', + 'Kansas', + 'Kentucky', + 'Louisiana', + 'Maine', + 'Maryland', + 'Massachusetts', + 'Michigan', + 'Minnesota', + 'Mississippi', + 'Missouri', + 'Montana', + 'Nebraska', + 'Nevada', + 'New Hampshire', + 'New Jersey', + 'New Mexico', + 'New York', + 'North Carolina', + 'North Dakota', + 'Ohio', + 'Oklahoma', + 'Oregon', + 'Pennsylvania', + 'Rhode Island', + 'South Carolina', + 'South Dakota', + 'Tennessee', + 'Texas', + 'Utah', + 'Vermont', + 'Virginia', + 'Washington', + 'West Virginia', + 'Wisconsin', + 'Wyoming', +]; diff --git a/src/components-examples/aria/combobox/index.ts b/src/components-examples/aria/combobox/index.ts index 05fda59a78b2..40ba42080d32 100644 --- a/src/components-examples/aria/combobox/index.ts +++ b/src/components-examples/aria/combobox/index.ts @@ -1,3 +1,4 @@ +export {ComboboxDialogExample} from './combobox-dialog/combobox-dialog-example'; export {ComboboxManualExample} from './combobox-manual/combobox-manual-example'; export {ComboboxAutoSelectExample} from './combobox-auto-select/combobox-auto-select-example'; export {ComboboxHighlightExample} from './combobox-highlight/combobox-highlight-example'; diff --git a/src/dev-app/aria-combobox/combobox-demo.html b/src/dev-app/aria-combobox/combobox-demo.html index 52678600ac8b..27a664f488e0 100644 --- a/src/dev-app/aria-combobox/combobox-demo.html +++ b/src/dev-app/aria-combobox/combobox-demo.html @@ -50,4 +50,13 @@

Readonly Multiselect Combobox

+ +

Combobox with dialog popup

+ +
+
+

Combobox with dialog popup

+ +
+
diff --git a/src/dev-app/aria-combobox/combobox-demo.ts b/src/dev-app/aria-combobox/combobox-demo.ts index 1ee06edce0f5..279ed650baae 100644 --- a/src/dev-app/aria-combobox/combobox-demo.ts +++ b/src/dev-app/aria-combobox/combobox-demo.ts @@ -7,6 +7,7 @@ */ import { + ComboboxDialogExample, ComboboxAutoSelectExample, ComboboxHighlightExample, ComboboxManualExample, @@ -22,6 +23,7 @@ import {ChangeDetectionStrategy, Component} from '@angular/core'; templateUrl: 'combobox-demo.html', styleUrl: 'combobox-demo.css', imports: [ + ComboboxDialogExample, ComboboxManualExample, ComboboxAutoSelectExample, ComboboxHighlightExample,