@@ -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+ }
0 commit comments