From 0a5903aa83e424b8cc22be6d8f5c81fba31dbcf7 Mon Sep 17 00:00:00 2001 From: tjshiu <35056071+tjshiu@users.noreply.github.com> Date: Mon, 4 May 2026 21:10:22 -0700 Subject: [PATCH] refactor(aria/combobox): allow custom popup triggering logic (#32493) - Adds `trigger` input to support custom trigger elements (like dropdown buttons). - Updates blur check logic to protect focus within custom popup triggering zones. - Introduces a new custom trigger button component example. --- goldens/aria/private/index.api.md | 2 + goldens/aria/simple-combobox/index.api.md | 4 +- .../simple-combobox/simple-combobox.spec.ts | 2 + .../simple-combobox/simple-combobox.ts | 19 ++- .../simple-combobox/simple-combobox.spec.ts | 147 ++++++++++++++++++ src/aria/simple-combobox/simple-combobox.ts | 8 + .../aria/simple-combobox/index.ts | 1 + ...imple-combobox-custom-trigger-example.html | 36 +++++ .../simple-combobox-custom-trigger-example.ts | 106 +++++++++++++ .../simple-combobox-example.css | 31 ++++ .../simple-combobox-demo.html | 16 +- .../simple-combobox-demo.ts | 2 + 12 files changed, 364 insertions(+), 10 deletions(-) create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-custom-trigger/simple-combobox-custom-trigger-example.html create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-custom-trigger/simple-combobox-custom-trigger-example.ts diff --git a/goldens/aria/private/index.api.md b/goldens/aria/private/index.api.md index 6f48bc4ee15e..b2139cdaf4b7 100644 --- a/goldens/aria/private/index.api.md +++ b/goldens/aria/private/index.api.md @@ -671,8 +671,10 @@ export interface SimpleComboboxInputs extends ExpansionItem { disabled: SignalLike; element: SignalLike; inlineSuggestion: SignalLike; + openOnInput: SignalLike; popup: SignalLike; softDisabled?: SignalLike; + trigger?: SignalLike; value: WritableSignalLike; } diff --git a/goldens/aria/simple-combobox/index.api.md b/goldens/aria/simple-combobox/index.api.md index 706075dce478..321c6341ad12 100644 --- a/goldens/aria/simple-combobox/index.api.md +++ b/goldens/aria/simple-combobox/index.api.md @@ -22,15 +22,17 @@ export class Combobox extends DeferredContentAware implements OnInit { readonly inlineSuggestion: _angular_core.InputSignal; // (undocumented) ngOnInit(): void; + readonly openOnInput: _angular_core.InputSignalWithTransform; readonly _pattern: SimpleComboboxPattern; readonly _popup: _angular_core.WritableSignal; _registerPopup(popup: ComboboxPopup): void; readonly softDisabled: _angular_core.InputSignalWithTransform; readonly tabIndex: _angular_core.InputSignalWithTransform; + readonly trigger: _angular_core.InputSignal; _unregisterPopup(): void; readonly value: _angular_core.ModelSignal; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } diff --git a/src/aria/private/simple-combobox/simple-combobox.spec.ts b/src/aria/private/simple-combobox/simple-combobox.spec.ts index 4aea95f87edb..515da623554b 100644 --- a/src/aria/private/simple-combobox/simple-combobox.spec.ts +++ b/src/aria/private/simple-combobox/simple-combobox.spec.ts @@ -40,6 +40,8 @@ describe('SimpleComboboxPattern', () => { disabled, expanded, expandable: signal(true), + openOnInput: signal(true), + trigger: signal(undefined), }); return { diff --git a/src/aria/private/simple-combobox/simple-combobox.ts b/src/aria/private/simple-combobox/simple-combobox.ts index bf73cb2717f2..eb8bcd979f57 100644 --- a/src/aria/private/simple-combobox/simple-combobox.ts +++ b/src/aria/private/simple-combobox/simple-combobox.ts @@ -33,6 +33,12 @@ export interface SimpleComboboxInputs extends ExpansionItem { /** Whether the combobox is soft disabled. */ softDisabled?: SignalLike; + + /** Whether the combobox opens automatically on text input. */ + openOnInput: SignalLike; + + /** Optional trigger element associated with the combobox. */ + trigger?: SignalLike; } /** Controls the state of a simple combobox. */ @@ -98,7 +104,6 @@ export class SimpleComboboxPattern { ); /** The keydown event manager for the combobox. */ - // TODO(tjshiu): Allow combo keys in combobox (#33101). keydown = computed(() => { const manager = new KeyboardEventManager(); @@ -202,7 +207,9 @@ export class SimpleComboboxPattern { if (!(event.target instanceof HTMLInputElement)) return; if (this.disabled()) return; - this.inputs.expanded.set(true); + if (this.inputs.openOnInput()) { + this.inputs.expanded.set(true); + } this.value.set(event.target.value); this.isDeleting.set(event instanceof InputEvent && !!event.inputType.match(/^delete/)); } @@ -244,10 +251,14 @@ export class SimpleComboboxPattern { /** Closes the popup when focus leaves the combobox and popup. */ closePopupOnBlurEffect() { - const expanded = this.isExpanded(); + if (!this.isExpanded() || this.inputs.alwaysExpanded()) return; + const comboboxFocused = this.isFocused(); const popupFocused = !!this.inputs.popup()?.isFocused(); - if (expanded && !this.inputs.alwaysExpanded() && !comboboxFocused && !popupFocused) { + const triggerEl = this.inputs.trigger?.(); + const triggerFocused = !!triggerEl?.contains(document.activeElement); + + if (!comboboxFocused && !popupFocused && !triggerFocused) { this.inputs.expanded.set(false); } } diff --git a/src/aria/simple-combobox/simple-combobox.spec.ts b/src/aria/simple-combobox/simple-combobox.spec.ts index c0228769aaf3..eacc74495cde 100644 --- a/src/aria/simple-combobox/simple-combobox.spec.ts +++ b/src/aria/simple-combobox/simple-combobox.spec.ts @@ -252,6 +252,82 @@ describe('Combobox', () => { expect(inputElement.getAttribute('aria-expanded')).toBe('false'); }); }); + + describe('Custom triggering via openOnInput', () => { + it('should automatically open on text input by default', () => { + setupCombobox(ComboboxListboxCustomTriggerExample); + focus(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + input('A'); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should not open on text input when openOnInput is false', () => { + setupCombobox(ComboboxListboxCustomTriggerExample); + (fixture.componentInstance as any).openOnInput.set(false); + fixture.detectChanges(); + + focus(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + input('A'); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + }); + + describe('Trigger element focus zone and toggling', () => { + it('should open the popup when clicking the trigger element from a closed state', () => { + setupCombobox(ComboboxListboxTriggerZoneExample); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + + const btn = fixture.nativeElement.querySelector('#test-trigger-btn'); + btn.focus(); + btn.click(); + fixture.detectChanges(); + + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should close the popup when clicking the trigger element from an open state', () => { + setupCombobox(ComboboxListboxTriggerZoneExample); + + const btn = fixture.nativeElement.querySelector('#test-trigger-btn'); + btn.focus(); + btn.click(); + fixture.detectChanges(); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + + btn.click(); + fixture.detectChanges(); + + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should not close the popup on focusout when focus moves to the bound trigger element', () => { + setupCombobox(ComboboxListboxTriggerZoneExample); + + down(); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + + const btn = fixture.nativeElement.querySelector('#test-trigger-btn'); + btn.focus(); + blur(btn); + + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should close the popup on focusout if focus leaves the combobox to an unrelated element', () => { + setupCombobox(ComboboxListboxTriggerZoneExample); + + down(); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + + const unrelatedElement = document.createElement('div'); + blur(unrelatedElement); + + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + }); + describe('Selection', () => { describe('with manual filtering', () => { beforeEach(() => setupCombobox(ComboboxListboxExample)); @@ -1705,3 +1781,74 @@ class ComboboxListboxHighlightExample { this.popupExpanded.set(false); } } + +@Component({ + template: ` +
+ + + +
+ @for (option of options(); track option) { +
+ {{option}} +
+ } +
+
+
+ `, + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option], +}) +class ComboboxListboxCustomTriggerExample { + openOnInput = signal(true); + popupExpanded = signal(false); + searchString = signal(''); + value = signal([]); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + onCommit() { + const val = this.value(); + if (val.length > 0) { + this.searchString.set(val[0]); + } + this.popupExpanded.set(false); + } +} + +@Component({ + template: ` +
+ + + + +
+
Alabama
+
+
+
+ `, + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option], +}) +class ComboboxListboxTriggerZoneExample { + popupExpanded = signal(false); +} diff --git a/src/aria/simple-combobox/simple-combobox.ts b/src/aria/simple-combobox/simple-combobox.ts index f0865c2d3c7b..7c8553b6a5c4 100644 --- a/src/aria/simple-combobox/simple-combobox.ts +++ b/src/aria/simple-combobox/simple-combobox.ts @@ -102,9 +102,17 @@ export class Combobox extends DeferredContentAware implements OnInit { /** An inline suggestion to be displayed in the input. */ readonly inlineSuggestion = input(undefined); + /** Whether the combobox opens automatically on text input. */ + readonly openOnInput = input(true, {transform: booleanAttribute}); + + /** Optional trigger element associated with the combobox. */ + readonly trigger = input(undefined); + /** The combobox ui pattern. */ readonly _pattern = new SimpleComboboxPattern({ ...this, + openOnInput: () => this.openOnInput(), + trigger: () => this.trigger(), element: () => this.element, expandable: () => true, popup: computed(() => this._popup()?._pattern), diff --git a/src/components-examples/aria/simple-combobox/index.ts b/src/components-examples/aria/simple-combobox/index.ts index 735dc7b86ee3..cda13d3008f3 100644 --- a/src/components-examples/aria/simple-combobox/index.ts +++ b/src/components-examples/aria/simple-combobox/index.ts @@ -15,6 +15,7 @@ export {SimpleComboboxAutocompleteAutoSelectExample} from './simple-combobox-aut export {SimpleComboboxAutocompleteDisabledExample} from './simple-combobox-autocomplete-disabled/simple-combobox-autocomplete-disabled-example'; export {SimpleComboboxAutocompleteHighlightExample} from './simple-combobox-autocomplete-highlight/simple-combobox-autocomplete-highlight-example'; export {SimpleComboboxAutocompleteManualExample} from './simple-combobox-autocomplete-manual/simple-combobox-autocomplete-manual-example'; +export {SimpleComboboxCustomTriggerExample} from './simple-combobox-custom-trigger/simple-combobox-custom-trigger-example'; export {SimpleComboboxMultiselectDialogExample} from './simple-combobox-multiselect-dialog/simple-combobox-multiselect-dialog-example'; // Force watcher update diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-custom-trigger/simple-combobox-custom-trigger-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-custom-trigger/simple-combobox-custom-trigger-example.html new file mode 100644 index 000000000000..808522eca98c --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-custom-trigger/simple-combobox-custom-trigger-example.html @@ -0,0 +1,36 @@ +
+
+ search + + +
+ +
+ {{options().length === 0 ? 'No results found for ' + searchString() : ''}} +
+ + + +
+ @if (options().length === 0) { +
No results found
+ } +
+ @for (option of options(); track option) { +
+ {{option}} + +
+ } +
+
+
+
+
\ No newline at end of file diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-custom-trigger/simple-combobox-custom-trigger-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-custom-trigger/simple-combobox-custom-trigger-example.ts new file mode 100644 index 000000000000..eeeb735f3b4f --- /dev/null +++ b/src/components-examples/aria/simple-combobox/simple-combobox-custom-trigger/simple-combobox-custom-trigger-example.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; + +/** @title Simple Combobox with Custom Trigger Button */ +@Component({ + selector: 'simple-combobox-custom-trigger-example', + templateUrl: 'simple-combobox-custom-trigger-example.html', + styleUrl: '../simple-combobox-example.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], +}) +export class SimpleComboboxCustomTriggerExample { + readonly listbox = viewChild(Listbox); + readonly combobox = viewChild(Combobox); + + popupExpanded = signal(false); + searchString = signal(''); + selectedOption = signal([]); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + constructor() { + afterRenderEffect(() => { + this.listbox()?.scrollActiveItemIntoView(); + }); + } + + onCommit() { + const selectedOption = this.selectedOption(); + if (selectedOption.length > 0) { + this.searchString.set(selectedOption[0]); + } + this.popupExpanded.set(false); + } + + togglePopup() { + this.popupExpanded.set(!this.popupExpanded()); + if (this.popupExpanded()) { + this.combobox()?.element.focus(); + } + } +} + +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-example.css b/src/components-examples/aria/simple-combobox/simple-combobox-example.css index 4bcf3d08001f..fc8b29a595c4 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-example.css +++ b/src/components-examples/aria/simple-combobox/simple-combobox-example.css @@ -377,3 +377,34 @@ ul[role='group'] { .example-popup-no-margin { margin-block-start: 0; } + +.example-combobox-input-container:has(.example-combobox-button) .example-combobox-input { + padding-right: 3rem; +} + +.example-combobox-button { + position: absolute; + right: 0; + top: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + border-left: 1px solid color-mix(in srgb, var(--mat-sys-outline) 60%, transparent); + border-top-right-radius: var(--mat-sys-corner-extra-small, 4px); + border-bottom-right-radius: var(--mat-sys-corner-extra-small, 4px); +} + +.example-combobox-container:focus-within .example-combobox-button { + border-left-color: var(--mat-sys-primary); + opacity: 1; + color: var(--mat-sys-primary); +} + +.example-combobox-button.example-button:focus-visible { + outline: 2px solid var(--mat-sys-primary); + outline-offset: -3px; + background-color: color-mix(in srgb, var(--mat-sys-primary) 10%, transparent); + opacity: 1; +} diff --git a/src/dev-app/aria-simple-combobox/simple-combobox-demo.html b/src/dev-app/aria-simple-combobox/simple-combobox-demo.html index 58f88845818a..c52fed17a5db 100644 --- a/src/dev-app/aria-simple-combobox/simple-combobox-demo.html +++ b/src/dev-app/aria-simple-combobox/simple-combobox-demo.html @@ -6,7 +6,7 @@

Listbox autocomplete examples

Combobox with manual filtering

- +

Combobox with auto-select

@@ -15,11 +15,17 @@

Combobox with auto-select

Combobox with highlight

+ +

Combobox with disabled

+
+

Combobox with Custom Trigger Button

+ +

Tree autocomplete examples

@@ -60,8 +66,8 @@

Combobox with Readonly + Disabled

-

Combobox with Dialog Popup

- +

Combobox with Dialog Popup

+

Combobox with Dialog Popup

@@ -74,7 +80,7 @@

Editable Combobox with Multi-Select Dialog

Combobox Grid Examples

- +

Combobox with Grid

@@ -84,5 +90,5 @@

Combobox with Grid

Combobox with Datepicker Grid

-
+
\ No newline at end of file diff --git a/src/dev-app/aria-simple-combobox/simple-combobox-demo.ts b/src/dev-app/aria-simple-combobox/simple-combobox-demo.ts index d56a255440e8..b148e467446c 100644 --- a/src/dev-app/aria-simple-combobox/simple-combobox-demo.ts +++ b/src/dev-app/aria-simple-combobox/simple-combobox-demo.ts @@ -22,6 +22,7 @@ import { SimpleComboboxTreeAutoSelectExample, SimpleComboboxTreeHighlightExample, SimpleComboboxMultiselectDialogExample, + SimpleComboboxCustomTriggerExample, } from '@angular/components-examples/aria/simple-combobox'; @Component({ @@ -42,6 +43,7 @@ import { SimpleComboboxDialogExample, SimpleComboboxTreeAutoSelectExample, SimpleComboboxTreeHighlightExample, + SimpleComboboxCustomTriggerExample, SimpleComboboxMultiselectDialogExample, ], })