Skip to content

Commit 0a5903a

Browse files
committed
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.
1 parent a08f1cb commit 0a5903a

12 files changed

Lines changed: 364 additions & 10 deletions

File tree

goldens/aria/private/index.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,8 +671,10 @@ export interface SimpleComboboxInputs extends ExpansionItem {
671671
disabled: SignalLike<boolean>;
672672
element: SignalLike<HTMLElement>;
673673
inlineSuggestion: SignalLike<string | undefined>;
674+
openOnInput: SignalLike<boolean>;
674675
popup: SignalLike<SimpleComboboxPopupPattern | undefined>;
675676
softDisabled?: SignalLike<boolean>;
677+
trigger?: SignalLike<HTMLElement | undefined>;
676678
value: WritableSignalLike<string>;
677679
}
678680

goldens/aria/simple-combobox/index.api.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,17 @@ export class Combobox extends DeferredContentAware implements OnInit {
2222
readonly inlineSuggestion: _angular_core.InputSignal<string | undefined>;
2323
// (undocumented)
2424
ngOnInit(): void;
25+
readonly openOnInput: _angular_core.InputSignalWithTransform<boolean, unknown>;
2526
readonly _pattern: SimpleComboboxPattern;
2627
readonly _popup: _angular_core.WritableSignal<ComboboxPopup | undefined>;
2728
_registerPopup(popup: ComboboxPopup): void;
2829
readonly softDisabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
2930
readonly tabIndex: _angular_core.InputSignalWithTransform<number | undefined, string | number | undefined>;
31+
readonly trigger: _angular_core.InputSignal<HTMLElement | undefined>;
3032
_unregisterPopup(): void;
3133
readonly value: _angular_core.ModelSignal<string>;
3234
// (undocumented)
33-
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<Combobox, "[ngCombobox]", ["ngCombobox"], { "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "alwaysExpanded": { "alias": "alwaysExpanded"; "required": false; "isSignal": true; }; "tabIndex": { "alias": "tabindex"; "required": false; "isSignal": true; }; "expanded": { "alias": "expanded"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; "inlineSuggestion": { "alias": "inlineSuggestion"; "required": false; "isSignal": true; }; }, { "expanded": "expandedChange"; "value": "valueChange"; }, never, never, true, never>;
35+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<Combobox, "[ngCombobox]", ["ngCombobox"], { "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "alwaysExpanded": { "alias": "alwaysExpanded"; "required": false; "isSignal": true; }; "tabIndex": { "alias": "tabindex"; "required": false; "isSignal": true; }; "expanded": { "alias": "expanded"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; "inlineSuggestion": { "alias": "inlineSuggestion"; "required": false; "isSignal": true; }; "openOnInput": { "alias": "openOnInput"; "required": false; "isSignal": true; }; "trigger": { "alias": "trigger"; "required": false; "isSignal": true; }; }, { "expanded": "expandedChange"; "value": "valueChange"; }, never, never, true, never>;
3436
// (undocumented)
3537
static ɵfac: _angular_core.ɵɵFactoryDeclaration<Combobox, never>;
3638
}

src/aria/private/simple-combobox/simple-combobox.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ describe('SimpleComboboxPattern', () => {
4040
disabled,
4141
expanded,
4242
expandable: signal(true),
43+
openOnInput: signal(true),
44+
trigger: signal(undefined),
4345
});
4446

4547
return {

src/aria/private/simple-combobox/simple-combobox.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ export interface SimpleComboboxInputs extends ExpansionItem {
3333

3434
/** Whether the combobox is soft disabled. */
3535
softDisabled?: SignalLike<boolean>;
36+
37+
/** Whether the combobox opens automatically on text input. */
38+
openOnInput: SignalLike<boolean>;
39+
40+
/** Optional trigger element associated with the combobox. */
41+
trigger?: SignalLike<HTMLElement | undefined>;
3642
}
3743

3844
/** Controls the state of a simple combobox. */
@@ -98,7 +104,6 @@ export class SimpleComboboxPattern {
98104
);
99105

100106
/** The keydown event manager for the combobox. */
101-
// TODO(tjshiu): Allow combo keys in combobox (#33101).
102107
keydown = computed(() => {
103108
const manager = new KeyboardEventManager();
104109

@@ -202,7 +207,9 @@ export class SimpleComboboxPattern {
202207
if (!(event.target instanceof HTMLInputElement)) return;
203208
if (this.disabled()) return;
204209

205-
this.inputs.expanded.set(true);
210+
if (this.inputs.openOnInput()) {
211+
this.inputs.expanded.set(true);
212+
}
206213
this.value.set(event.target.value);
207214
this.isDeleting.set(event instanceof InputEvent && !!event.inputType.match(/^delete/));
208215
}
@@ -244,10 +251,14 @@ export class SimpleComboboxPattern {
244251

245252
/** Closes the popup when focus leaves the combobox and popup. */
246253
closePopupOnBlurEffect() {
247-
const expanded = this.isExpanded();
254+
if (!this.isExpanded() || this.inputs.alwaysExpanded()) return;
255+
248256
const comboboxFocused = this.isFocused();
249257
const popupFocused = !!this.inputs.popup()?.isFocused();
250-
if (expanded && !this.inputs.alwaysExpanded() && !comboboxFocused && !popupFocused) {
258+
const triggerEl = this.inputs.trigger?.();
259+
const triggerFocused = !!triggerEl?.contains(document.activeElement);
260+
261+
if (!comboboxFocused && !popupFocused && !triggerFocused) {
251262
this.inputs.expanded.set(false);
252263
}
253264
}

src/aria/simple-combobox/simple-combobox.spec.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,82 @@ describe('Combobox', () => {
252252
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
253253
});
254254
});
255+
256+
describe('Custom triggering via openOnInput', () => {
257+
it('should automatically open on text input by default', () => {
258+
setupCombobox(ComboboxListboxCustomTriggerExample);
259+
focus();
260+
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
261+
input('A');
262+
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
263+
});
264+
265+
it('should not open on text input when openOnInput is false', () => {
266+
setupCombobox(ComboboxListboxCustomTriggerExample);
267+
(fixture.componentInstance as any).openOnInput.set(false);
268+
fixture.detectChanges();
269+
270+
focus();
271+
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
272+
input('A');
273+
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
274+
});
275+
});
276+
277+
describe('Trigger element focus zone and toggling', () => {
278+
it('should open the popup when clicking the trigger element from a closed state', () => {
279+
setupCombobox(ComboboxListboxTriggerZoneExample);
280+
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
281+
282+
const btn = fixture.nativeElement.querySelector('#test-trigger-btn');
283+
btn.focus();
284+
btn.click();
285+
fixture.detectChanges();
286+
287+
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
288+
});
289+
290+
it('should close the popup when clicking the trigger element from an open state', () => {
291+
setupCombobox(ComboboxListboxTriggerZoneExample);
292+
293+
const btn = fixture.nativeElement.querySelector('#test-trigger-btn');
294+
btn.focus();
295+
btn.click();
296+
fixture.detectChanges();
297+
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
298+
299+
btn.click();
300+
fixture.detectChanges();
301+
302+
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
303+
});
304+
305+
it('should not close the popup on focusout when focus moves to the bound trigger element', () => {
306+
setupCombobox(ComboboxListboxTriggerZoneExample);
307+
308+
down();
309+
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
310+
311+
const btn = fixture.nativeElement.querySelector('#test-trigger-btn');
312+
btn.focus();
313+
blur(btn);
314+
315+
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
316+
});
317+
318+
it('should close the popup on focusout if focus leaves the combobox to an unrelated element', () => {
319+
setupCombobox(ComboboxListboxTriggerZoneExample);
320+
321+
down();
322+
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
323+
324+
const unrelatedElement = document.createElement('div');
325+
blur(unrelatedElement);
326+
327+
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
328+
});
329+
});
330+
255331
describe('Selection', () => {
256332
describe('with manual filtering', () => {
257333
beforeEach(() => setupCombobox(ComboboxListboxExample));
@@ -1705,3 +1781,74 @@ class ComboboxListboxHighlightExample {
17051781
this.popupExpanded.set(false);
17061782
}
17071783
}
1784+
1785+
@Component({
1786+
template: `
1787+
<div>
1788+
<input
1789+
ngCombobox
1790+
#combobox="ngCombobox"
1791+
placeholder="Search..."
1792+
[(value)]="searchString"
1793+
[(expanded)]="popupExpanded"
1794+
[openOnInput]="openOnInput()"
1795+
(click)="popupExpanded.set(true)"
1796+
/>
1797+
1798+
<ng-template ngComboboxPopup [combobox]="combobox">
1799+
<div ngComboboxWidget #listbox="ngListbox" ngListbox id="listbox" focusMode="activedescendant" selectionMode="explicit" [(value)]="value" (click)="onCommit()" (keydown.enter)="onCommit()" [activeDescendant]="listbox.activeDescendant()">
1800+
@for (option of options(); track option) {
1801+
<div ngOption [value]="option" [label]="option">
1802+
<span>{{option}}</span>
1803+
</div>
1804+
}
1805+
</div>
1806+
</ng-template>
1807+
</div>
1808+
`,
1809+
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option],
1810+
})
1811+
class ComboboxListboxCustomTriggerExample {
1812+
openOnInput = signal(true);
1813+
popupExpanded = signal(false);
1814+
searchString = signal('');
1815+
value = signal<string[]>([]);
1816+
1817+
options = computed(() =>
1818+
states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())),
1819+
);
1820+
1821+
onCommit() {
1822+
const val = this.value();
1823+
if (val.length > 0) {
1824+
this.searchString.set(val[0]);
1825+
}
1826+
this.popupExpanded.set(false);
1827+
}
1828+
}
1829+
1830+
@Component({
1831+
template: `
1832+
<div>
1833+
<input
1834+
ngCombobox
1835+
#combobox="ngCombobox"
1836+
[trigger]="triggerEl"
1837+
[(expanded)]="popupExpanded"
1838+
[openOnInput]="false"
1839+
aria-label="Search"
1840+
/>
1841+
<button #triggerEl id="test-trigger-btn" (click)="popupExpanded.set(!popupExpanded())">Toggle</button>
1842+
1843+
<ng-template ngComboboxPopup [combobox]="combobox">
1844+
<div ngComboboxWidget ngListbox id="listbox" focusMode="activedescendant">
1845+
<div ngOption value="Alabama" label="Alabama">Alabama</div>
1846+
</div>
1847+
</ng-template>
1848+
</div>
1849+
`,
1850+
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option],
1851+
})
1852+
class ComboboxListboxTriggerZoneExample {
1853+
popupExpanded = signal(false);
1854+
}

src/aria/simple-combobox/simple-combobox.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,17 @@ export class Combobox extends DeferredContentAware implements OnInit {
102102
/** An inline suggestion to be displayed in the input. */
103103
readonly inlineSuggestion = input<string | undefined>(undefined);
104104

105+
/** Whether the combobox opens automatically on text input. */
106+
readonly openOnInput = input(true, {transform: booleanAttribute});
107+
108+
/** Optional trigger element associated with the combobox. */
109+
readonly trigger = input<HTMLElement | undefined>(undefined);
110+
105111
/** The combobox ui pattern. */
106112
readonly _pattern = new SimpleComboboxPattern({
107113
...this,
114+
openOnInput: () => this.openOnInput(),
115+
trigger: () => this.trigger(),
108116
element: () => this.element,
109117
expandable: () => true,
110118
popup: computed(() => this._popup()?._pattern),

src/components-examples/aria/simple-combobox/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export {SimpleComboboxAutocompleteAutoSelectExample} from './simple-combobox-aut
1515
export {SimpleComboboxAutocompleteDisabledExample} from './simple-combobox-autocomplete-disabled/simple-combobox-autocomplete-disabled-example';
1616
export {SimpleComboboxAutocompleteHighlightExample} from './simple-combobox-autocomplete-highlight/simple-combobox-autocomplete-highlight-example';
1717
export {SimpleComboboxAutocompleteManualExample} from './simple-combobox-autocomplete-manual/simple-combobox-autocomplete-manual-example';
18+
export {SimpleComboboxCustomTriggerExample} from './simple-combobox-custom-trigger/simple-combobox-custom-trigger-example';
1819
export {SimpleComboboxMultiselectDialogExample} from './simple-combobox-multiselect-dialog/simple-combobox-multiselect-dialog-example';
1920

2021
// Force watcher update
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<div class="example-combobox-container">
2+
<div #origin class="example-combobox-input-container">
3+
<span class="material-symbols-outlined example-icon example-search-icon">search</span>
4+
<input ngCombobox #combobox="ngCombobox" [trigger]="btnEl" class="example-combobox-input" placeholder="Search states..."
5+
[(value)]="searchString" [(expanded)]="popupExpanded" [openOnInput]="false" />
6+
<button #btnEl (click)="togglePopup()" aria-label="Toggle popup" class="example-combobox-button example-button">
7+
<span class="material-symbols-outlined">{{ popupExpanded() ? 'arrow_drop_up' : 'arrow_drop_down' }}</span>
8+
</button>
9+
</div>
10+
11+
<div aria-live="polite" class="cdk-visually-hidden">
12+
{{options().length === 0 ? 'No results found for ' + searchString() : ''}}
13+
</div>
14+
15+
<ng-template [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
16+
[cdkConnectedOverlayOpen]="popupExpanded()" [cdkConnectedOverlayDisableClose]="true">
17+
<ng-template ngComboboxPopup [combobox]="combobox">
18+
<div class="example-popup">
19+
@if (options().length === 0) {
20+
<div class="example-no-results">No results found</div>
21+
}
22+
<div #listbox="ngListbox" ngListbox ngComboboxWidget class="example-listbox" focusMode="activedescendant"
23+
[tabindex]="-1" selectionMode="explicit" [(value)]="selectedOption" (click)="onCommit()"
24+
(keydown.enter)="onCommit()" [activeDescendant]="listbox.activeDescendant()"
25+
[class.example-empty]="options().length === 0">
26+
@for (option of options(); track option) {
27+
<div class="example-option example-selectable example-stateful" ngOption [value]="option" [label]="option">
28+
<span>{{option}}</span>
29+
<span aria-hidden="true" class="material-symbols-outlined example-icon example-selected-icon">check</span>
30+
</div>
31+
}
32+
</div>
33+
</div>
34+
</ng-template>
35+
</ng-template>
36+
</div>

0 commit comments

Comments
 (0)