From 8301b8b0898ada8caec956ac277f722c2438497a Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Mon, 3 Nov 2025 11:36:02 -0500 Subject: [PATCH 1/4] fix(aria/combobox): do not open on click --- src/aria/combobox/combobox.spec.ts | 12 ------------ src/aria/private/combobox/combobox.spec.ts | 7 ------- src/aria/private/combobox/combobox.ts | 2 -- 3 files changed, 21 deletions(-) diff --git a/src/aria/combobox/combobox.spec.ts b/src/aria/combobox/combobox.spec.ts index 0d1d50f24157..eb498c9458d3 100644 --- a/src/aria/combobox/combobox.spec.ts +++ b/src/aria/combobox/combobox.spec.ts @@ -211,12 +211,6 @@ describe('Combobox', () => { describe('Expansion', () => { beforeEach(() => setupCombobox()); - it('should open on click', () => { - focus(); - click(inputElement); - expect(inputElement.getAttribute('aria-expanded')).toBe('true'); - }); - it('should open on ArrowDown', () => { focus(); keydown('ArrowDown'); @@ -956,12 +950,6 @@ describe('Combobox', () => { describe('Expansion', () => { beforeEach(() => setupCombobox()); - it('should open on click', () => { - focus(); - click(inputElement); - expect(inputElement.getAttribute('aria-expanded')).toBe('true'); - }); - it('should open on ArrowDown', () => { focus(); keydown('ArrowDown'); diff --git a/src/aria/private/combobox/combobox.spec.ts b/src/aria/private/combobox/combobox.spec.ts index 4c2d39e00229..e0925eee572a 100644 --- a/src/aria/private/combobox/combobox.spec.ts +++ b/src/aria/private/combobox/combobox.spec.ts @@ -295,13 +295,6 @@ describe('Combobox with Listbox Pattern', () => { }); describe('Expansion', () => { - it('should open on click', () => { - const {combobox, inputEl} = getPatterns(); - expect(combobox.expanded()).toBe(false); - combobox.onPointerup(clickInput(inputEl)); - expect(combobox.expanded()).toBe(true); - }); - it('should open on ArrowDown', () => { const {combobox} = getPatterns(); expect(combobox.expanded()).toBe(false); diff --git a/src/aria/private/combobox/combobox.ts b/src/aria/private/combobox/combobox.ts index c6c73c74abac..983e16deb875 100644 --- a/src/aria/private/combobox/combobox.ts +++ b/src/aria/private/combobox/combobox.ts @@ -210,8 +210,6 @@ export class ComboboxPattern, V> { if (e.target === this.inputs.inputEl()) { if (this.readonly()) { this.expanded() ? this.close() : this.open({selected: true}); - } else { - this.open(); } } }), From fc90cb8b7f4e0b73b94fe10cf0a50b3bd3a6d793 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Wed, 5 Nov 2025 16:33:20 -0500 Subject: [PATCH 2/4] fix(aria/combobox): handle unselectable tree items --- src/aria/private/combobox/combobox.ts | 31 +++++++++++++++++-- src/aria/private/tree/combobox-tree.ts | 5 +++ .../combobox-tree-auto-select-example.html | 1 + .../combobox-tree-highlight-example.html | 1 + .../combobox-tree-manual-example.html | 1 + 5 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/aria/private/combobox/combobox.ts b/src/aria/private/combobox/combobox.ts index 983e16deb875..89272676485d 100644 --- a/src/aria/private/combobox/combobox.ts +++ b/src/aria/private/combobox/combobox.ts @@ -101,13 +101,19 @@ export interface ComboboxTreeControls, V> collapseItem: () => void; /** Checks if the currently active item in the popup is expandable. */ - isItemExpandable: () => boolean; + isItemExpandable: (item?: T) => boolean; /** Expands all nodes in the tree. */ expandAll: () => void; /** Collapses all nodes in the tree. */ collapseAll: () => void; + + /** Toggles the expansion state of the currently active item in the popup. */ + toggleExpansion: (item?: T) => void; + + /** Whether the current active item is selectable. */ + isItemSelectable: (item?: T) => boolean; } /** Controls the state of a combobox. */ @@ -175,16 +181,25 @@ export class ComboboxPattern, V> { .on('ArrowUp', () => this.prev()) .on('Home', () => this.first()) .on('End', () => this.last()) - .on('Escape', () => this.close({reset: true})) - .on('Enter', () => this.select({commit: true, close: true})); + .on('Escape', () => this.close({reset: true})); if (this.readonly()) { manager.on(' ', () => this.select({commit: true, close: true})); } + if (popupControls.role() === 'listbox') { + manager.on('Enter', () => this.select({commit: true, close: true})); + } + if (popupControls.role() === 'tree') { const treeControls = popupControls as ComboboxTreeControls; + if (treeControls.isItemSelectable()) { + manager.on('Enter', () => this.select({commit: true, close: true})); + } else if (treeControls.isItemExpandable()) { + manager.on('Enter', () => this.expandItem()); + } + if (treeControls.isItemExpandable() || treeControls.isItemCollapsible()) { manager.on(this.collapseKey(), () => this.collapseItem()); } @@ -203,6 +218,16 @@ export class ComboboxPattern, V> { const item = this.inputs.popupControls()?.getItem(e); if (item) { + if (this.inputs.popupControls()?.role() === 'tree') { + const treeControls = this.inputs.popupControls() as ComboboxTreeControls; + + if (treeControls.isItemExpandable(item) && !treeControls.isItemSelectable(item)) { + treeControls.toggleExpansion(item); + this.inputs.inputEl()?.focus(); + return; + } + } + this.select({item, commit: true, close: true}); this.inputs.inputEl()?.focus(); // Return focus to the input after selecting. } diff --git a/src/aria/private/tree/combobox-tree.ts b/src/aria/private/tree/combobox-tree.ts index 5e3953ce9baf..0fb90fdff439 100644 --- a/src/aria/private/tree/combobox-tree.ts +++ b/src/aria/private/tree/combobox-tree.ts @@ -104,4 +104,9 @@ export class ComboboxTreePattern /** Collapses all of the tree items. */ collapseAll = () => this.items().forEach(item => item.expansion.close()); + + /** Whether the currently active item is selectable. */ + isItemSelectable = (item: TreeItemPattern | undefined = this.inputs.activeItem()) => { + return item ? item.selectable() : false; + }; } diff --git a/src/components-examples/aria/combobox/combobox-tree-auto-select/combobox-tree-auto-select-example.html b/src/components-examples/aria/combobox/combobox-tree-auto-select/combobox-tree-auto-select-example.html index 4ef3639366a4..fe7a5fa8af62 100644 --- a/src/components-examples/aria/combobox/combobox-tree-auto-select/combobox-tree-auto-select-example.html +++ b/src/components-examples/aria/combobox/combobox-tree-auto-select/combobox-tree-auto-select-example.html @@ -34,6 +34,7 @@ [parent]="parent" [value]="node.name" [label]="node.name" + [selectable]="!node.children" #treeItem="ngTreeItem" class="example-tree-item example-selectable example-stateful" > diff --git a/src/components-examples/aria/combobox/combobox-tree-highlight/combobox-tree-highlight-example.html b/src/components-examples/aria/combobox/combobox-tree-highlight/combobox-tree-highlight-example.html index 7315770195e8..14f4f5999c41 100644 --- a/src/components-examples/aria/combobox/combobox-tree-highlight/combobox-tree-highlight-example.html +++ b/src/components-examples/aria/combobox/combobox-tree-highlight/combobox-tree-highlight-example.html @@ -34,6 +34,7 @@ [parent]="parent" [value]="node.name" [label]="node.name" + [selectable]="!node.children" #treeItem="ngTreeItem" class="example-tree-item example-selectable example-stateful" > diff --git a/src/components-examples/aria/combobox/combobox-tree-manual/combobox-tree-manual-example.html b/src/components-examples/aria/combobox/combobox-tree-manual/combobox-tree-manual-example.html index 994e956f3535..41c67e436ab0 100644 --- a/src/components-examples/aria/combobox/combobox-tree-manual/combobox-tree-manual-example.html +++ b/src/components-examples/aria/combobox/combobox-tree-manual/combobox-tree-manual-example.html @@ -34,6 +34,7 @@ [parent]="parent" [value]="node.name" [label]="node.name" + [selectable]="!node.children" #treeItem="ngTreeItem" class="example-tree-item example-selectable example-stateful" > From d458f5d0768f15f6dc5f4e4b7ced3f8050c00782 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Wed, 5 Nov 2025 17:51:42 -0500 Subject: [PATCH 3/4] fix(aria/combobox): multi selection --- src/aria/combobox/combobox.spec.ts | 21 ---- src/aria/combobox/combobox.ts | 2 +- .../list-selection/list-selection.ts | 8 +- src/aria/private/behaviors/list/list.ts | 4 +- src/aria/private/combobox/combobox.spec.ts | 113 ++++++++---------- src/aria/private/combobox/combobox.ts | 76 ++++++++---- src/aria/private/listbox/combobox-listbox.ts | 27 ++++- src/aria/private/tree/combobox-tree.ts | 7 +- 8 files changed, 141 insertions(+), 117 deletions(-) diff --git a/src/aria/combobox/combobox.spec.ts b/src/aria/combobox/combobox.spec.ts index eb498c9458d3..7ff4dd7ca4a0 100644 --- a/src/aria/combobox/combobox.spec.ts +++ b/src/aria/combobox/combobox.spec.ts @@ -539,15 +539,6 @@ describe('Combobox', () => { escape(); expect(inputElement.getAttribute('aria-expanded')).toBe('false'); }); - - it('should clear selection on escape when closed', () => { - focus(); - down(); - enter(); - expect(inputElement.value).toBe('Alabama'); - escape(); - expect(inputElement.value).toBe(''); - }); }); // describe('with programmatic value changes', () => { @@ -1096,18 +1087,6 @@ describe('Combobox', () => { escape(); expect(inputElement.getAttribute('aria-expanded')).toBe('false'); }); - - it('should clear selection on escape when closed', () => { - focus(); - down(); - right(); - right(); - enter(); - expect(inputElement.value).toBe('December'); - expect(inputElement.getAttribute('aria-expanded')).toBe('false'); - escape(); - expect(inputElement.value).toBe(''); - }); }); }); }); diff --git a/src/aria/combobox/combobox.ts b/src/aria/combobox/combobox.ts index b901c88facdc..49ba07f0ca10 100644 --- a/src/aria/combobox/combobox.ts +++ b/src/aria/combobox/combobox.ts @@ -71,7 +71,7 @@ export class Combobox { /** Whether the combobox is focused. */ readonly isFocused = signal(false); - /** Whether the listbox has received focus yet. */ + /** Whether the combobox has received focus yet. */ private _hasBeenFocused = signal(false); /** Whether the combobox is disabled. */ diff --git a/src/aria/private/behaviors/list-selection/list-selection.ts b/src/aria/private/behaviors/list-selection/list-selection.ts index d687b9939e3d..f9ef4a33fe16 100644 --- a/src/aria/private/behaviors/list-selection/list-selection.ts +++ b/src/aria/private/behaviors/list-selection/list-selection.ts @@ -71,7 +71,7 @@ export class ListSelection, V> { } /** Deselects the item at the current active index. */ - deselect(item?: T | null) { + deselect(item?: ListSelectionItem) { item = item ?? this.inputs.focusManager.inputs.activeItem(); if (item && !item.disabled() && item.selectable()) { @@ -80,10 +80,10 @@ export class ListSelection, V> { } /** Toggles the item at the current active index. */ - toggle() { - const item = this.inputs.focusManager.inputs.activeItem(); + toggle(item?: ListSelectionItem) { + item = item ?? this.inputs.focusManager.inputs.activeItem(); if (item) { - this.inputs.value().includes(item.value()) ? this.deselect() : this.select(); + this.inputs.value().includes(item.value()) ? this.deselect(item) : this.select(item); } } diff --git a/src/aria/private/behaviors/list/list.ts b/src/aria/private/behaviors/list/list.ts index e22f218ede4b..55b48d34f4fc 100644 --- a/src/aria/private/behaviors/list/list.ts +++ b/src/aria/private/behaviors/list/list.ts @@ -171,8 +171,8 @@ export class List, V> { } /** Toggles the currently active item in the list. */ - toggle() { - this.selectionBehavior.toggle(); + toggle(item?: T) { + this.selectionBehavior.toggle(item); } /** Toggles the currently active item in the list, deselecting all other items. */ diff --git a/src/aria/private/combobox/combobox.spec.ts b/src/aria/private/combobox/combobox.spec.ts index e0925eee572a..7c10480eb608 100644 --- a/src/aria/private/combobox/combobox.spec.ts +++ b/src/aria/private/combobox/combobox.spec.ts @@ -375,7 +375,7 @@ describe('Combobox with Listbox Pattern', () => { it('should select and commit on click', () => { combobox.onPointerup(clickOption(listbox.inputs.items(), 0)); - expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); + expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[0]); expect(listbox.inputs.value()).toEqual(['Apple']); expect(inputEl.value).toBe('Apple'); }); @@ -383,7 +383,7 @@ describe('Combobox with Listbox Pattern', () => { it('should select and commit to input on Enter', () => { combobox.onKeydown(down()); combobox.onKeydown(enter()); - expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); + expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[0]); expect(listbox.inputs.value()).toEqual(['Apple']); expect(inputEl.value).toBe('Apple'); }); @@ -391,7 +391,7 @@ describe('Combobox with Listbox Pattern', () => { it('should select on focusout if the input text exactly matches an item', () => { type('Apple'); combobox.onFocusOut(new FocusEvent('focusout')); - expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); + expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[0]); expect(listbox.inputs.value()).toEqual(['Apple']); }); @@ -402,26 +402,26 @@ describe('Combobox with Listbox Pattern', () => { type('Appl', {backspace: true}); combobox.onInput(new InputEvent('input', {inputType: 'deleteContentBackward'})); - expect(listbox.getSelectedItem()).toBe(undefined); + expect(listbox.getSelectedItems().length).toBe(0); expect(listbox.inputs.value()).toEqual([]); }); it('should not select on navigation', () => { combobox.onKeydown(down()); - expect(listbox.getSelectedItem()).toBe(undefined); + expect(listbox.getSelectedItems().length).toBe(0); expect(listbox.inputs.value()).toEqual([]); }); it('should not select on input', () => { type('A'); - expect(listbox.getSelectedItem()).toBe(undefined); + expect(listbox.getSelectedItems().length).toBe(0); expect(listbox.inputs.value()).toEqual([]); }); it('should not select on focusout if the input text does not match an item', () => { type('Appl'); combobox.onFocusOut(new FocusEvent('focusout')); - expect(listbox.getSelectedItem()).toBe(undefined); + expect(listbox.getSelectedItems().length).toBe(0); expect(listbox.inputs.value()).toEqual([]); expect(inputEl.value).toBe('Appl'); }); @@ -436,7 +436,7 @@ describe('Combobox with Listbox Pattern', () => { it('should select and commit on click', () => { combobox.onPointerup(clickOption(listbox.inputs.items(), 3)); - expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[3]); + expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[3]); expect(listbox.inputs.value()).toEqual(['Blackberry']); expect(inputEl.value).toBe('Blackberry'); }); @@ -446,20 +446,20 @@ describe('Combobox with Listbox Pattern', () => { combobox.onKeydown(down()); combobox.onKeydown(down()); combobox.onKeydown(enter()); - expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[2]); + expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[2]); expect(listbox.inputs.value()).toEqual(['Banana']); expect(inputEl.value).toBe('Banana'); }); it('should select the first item on arrow down when collapsed', () => { combobox.onKeydown(down()); - expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); + expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[0]); expect(listbox.inputs.value()).toEqual(['Apple']); }); it('should select the last item on arrow up when collapsed', () => { combobox.onKeydown(up()); - expect(listbox.getSelectedItem()).toBe( + expect(listbox.getSelectedItems()[0]).toBe( listbox.inputs.items()[listbox.inputs.items().length - 1], ); expect(listbox.inputs.value()).toEqual(['Cranberry']); @@ -468,7 +468,7 @@ describe('Combobox with Listbox Pattern', () => { it('should select on navigation', () => { combobox.onKeydown(down()); combobox.onKeydown(down()); - expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[1]); + expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[1]); expect(listbox.inputs.value()).toEqual(['Apricot']); }); @@ -497,7 +497,7 @@ describe('Combobox with Listbox Pattern', () => { it('should select and commit on click', () => { combobox.onPointerup(clickOption(listbox.inputs.items(), 3)); - expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[3]); + expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[3]); expect(listbox.inputs.value()).toEqual(['Blackberry']); expect(inputEl.value).toBe('Blackberry'); }); @@ -507,20 +507,20 @@ describe('Combobox with Listbox Pattern', () => { combobox.onKeydown(down()); combobox.onKeydown(down()); combobox.onKeydown(enter()); - expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[2]); + expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[2]); expect(listbox.inputs.value()).toEqual(['Banana']); expect(inputEl.value).toBe('Banana'); }); it('should select the first item on arrow down when collapsed', () => { combobox.onKeydown(down()); - expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); + expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[0]); expect(listbox.inputs.value()).toEqual(['Apple']); }); it('should select the last item on arrow up when collapsed', () => { combobox.onKeydown(up()); - expect(listbox.getSelectedItem()).toBe( + expect(listbox.getSelectedItems()[0]).toBe( listbox.inputs.items()[listbox.inputs.items().length - 1], ); expect(listbox.inputs.value()).toEqual(['Cranberry']); @@ -529,7 +529,7 @@ describe('Combobox with Listbox Pattern', () => { it('should select on navigation', () => { combobox.onKeydown(down()); combobox.onKeydown(down()); - expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[1]); + expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[1]); expect(listbox.inputs.value()).toEqual(['Apricot']); }); @@ -580,31 +580,36 @@ describe('Combobox with Listbox Pattern', () => { }); describe('Readonly mode', () => { - it('should select and close on selection', () => { - const {combobox, listbox, inputEl} = getPatterns({readonly: true}); - combobox.onPointerup(clickOption(listbox.inputs.items(), 2)); - expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[2]); - expect(listbox.inputs.value()).toEqual(['Banana']); - expect(inputEl.value).toBe('Banana'); - expect(combobox.expanded()).toBe(false); - }); + describe('with single-select', () => { + it('should select and close on selection', () => { + const {combobox, listbox, inputEl} = getPatterns({readonly: true}); + combobox.onPointerup(clickOption(listbox.inputs.items(), 2)); + expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[2]); + expect(listbox.inputs.value()).toEqual(['Banana']); + expect(inputEl.value).toBe('Banana'); + expect(combobox.expanded()).toBe(false); + }); - it('should close on escape', () => { - const {combobox} = getPatterns({readonly: true}); - combobox.onKeydown(down()); - expect(combobox.expanded()).toBe(true); - combobox.onKeydown(escape()); - expect(combobox.expanded()).toBe(false); + it('should close on escape', () => { + const {combobox} = getPatterns({readonly: true}); + combobox.onKeydown(down()); + expect(combobox.expanded()).toBe(true); + combobox.onKeydown(escape()); + expect(combobox.expanded()).toBe(false); + }); }); - it('should clear selection on escape when already closed', () => { - const {combobox, listbox} = getPatterns({readonly: true}); - combobox.onPointerup(clickOption(listbox.inputs.items(), 2)); - expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[2]); - expect(listbox.inputs.value()).toEqual(['Banana']); - combobox.onKeydown(escape()); - expect(listbox.getSelectedItem()).toBe(undefined); - expect(listbox.inputs.value()).toEqual([]); + describe('with multi-select', () => { + it('should allow users to select multiple options', () => { + const {combobox, listbox, inputEl} = getPatterns({readonly: true}); + (listbox.inputs.multi as WritableSignal).set(true); + + combobox.onPointerup(clickOption(listbox.inputs.items(), 1)); + combobox.onPointerup(clickOption(listbox.inputs.items(), 2)); + + expect(listbox.inputs.value()).toEqual(['Apricot', 'Banana']); + expect(inputEl.value).toBe('Apricot, Banana'); + }); }); }); }); @@ -742,7 +747,7 @@ describe('Combobox with Tree Pattern', () => { it('should select and commit to input on Enter', () => { combobox.onKeydown(down()); combobox.onKeydown(enter()); - expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[0]); + expect(tree.getSelectedItems()[0]).toBe(tree.inputs.allItems()[0]); expect(tree.inputs.value()).toEqual(['Fruit']); expect(inputEl.value).toBe('Fruit'); }); @@ -760,26 +765,26 @@ describe('Combobox with Tree Pattern', () => { type('Appl', {backspace: true}); - expect(tree.getSelectedItem()).toBe(undefined); + expect(tree.getSelectedItems().length).toBe(0); expect(tree.inputs.value()).toEqual([]); }); it('should not select on navigation', () => { combobox.onKeydown(down()); - expect(tree.getSelectedItem()).toBe(undefined); + expect(tree.getSelectedItems().length).toBe(0); expect(tree.inputs.value()).toEqual([]); }); it('should not select on input', () => { type('A'); - expect(tree.getSelectedItem()).toBe(undefined); + expect(tree.getSelectedItems().length).toBe(0); expect(tree.inputs.value()).toEqual([]); }); it('should not select on focusout if the input text does not match an item', () => { type('Appl'); combobox.onFocusOut(new FocusEvent('focusout')); - expect(tree.getSelectedItem()).toBe(undefined); + expect(tree.getSelectedItems().length).toBe(0); expect(tree.inputs.value()).toEqual([]); expect(inputEl.value).toBe('Appl'); }); @@ -794,7 +799,7 @@ describe('Combobox with Tree Pattern', () => { it('should select and commit on click', () => { combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 2)); - expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[2]); + expect(tree.getSelectedItems()[0]).toBe(tree.inputs.allItems()[2]); expect(tree.inputs.value()).toEqual(['Banana']); expect(inputEl.value).toBe('Banana'); }); @@ -810,7 +815,7 @@ describe('Combobox with Tree Pattern', () => { it('should select the first item on arrow down when collapsed', () => { combobox.onKeydown(down()); - expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[0]); + expect(tree.getSelectedItems()[0]).toBe(tree.inputs.allItems()[0]); expect(tree.inputs.value()).toEqual(['Fruit']); }); @@ -851,7 +856,7 @@ describe('Combobox with Tree Pattern', () => { it('should select and commit on click', () => { combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 2)); - expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[2]); + expect(tree.getSelectedItems()[0]).toBe(tree.inputs.allItems()[2]); expect(tree.inputs.value()).toEqual(['Banana']); expect(inputEl.value).toBe('Banana'); }); @@ -867,7 +872,7 @@ describe('Combobox with Tree Pattern', () => { it('should select the first item on arrow down when collapsed', () => { combobox.onKeydown(down()); - expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[0]); + expect(tree.getSelectedItems()[0]).toBe(tree.inputs.allItems()[0]); expect(tree.inputs.value()).toEqual(['Fruit']); }); @@ -935,17 +940,5 @@ describe('Combobox with Tree Pattern', () => { combobox.onKeydown(escape()); expect(combobox.expanded()).toBe(false); }); - - it('should clear selection on escape when already closed', () => { - const {combobox, tree, inputEl} = getPatterns({readonly: true}); - combobox.onPointerup(clickInput(inputEl)); - combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 0)); - expect(tree.inputs.value()).toEqual(['Fruit']); - expect(inputEl.value).toBe('Fruit'); - expect(combobox.expanded()).toBe(false); - combobox.onKeydown(escape()); - expect(tree.inputs.value()).toEqual([]); - expect(inputEl.value).toBe(''); - }); }); }); diff --git a/src/aria/private/combobox/combobox.ts b/src/aria/private/combobox/combobox.ts index 89272676485d..1246b7398e59 100644 --- a/src/aria/private/combobox/combobox.ts +++ b/src/aria/private/combobox/combobox.ts @@ -49,6 +49,11 @@ export interface ComboboxListboxControls, V> { /** The ARIA role for the popup. */ role: SignalLike<'listbox' | 'tree' | 'grid'>; + // TODO(wagnermaciel): Add validation that ensures only readonly comboboxes can have multi-select popups. + + /** Whether multiple items in the popup can be selected at once. */ + multi: SignalLike; + /** The ID of the active item in the popup. */ activeId: SignalLike; @@ -56,7 +61,7 @@ export interface ComboboxListboxControls, V> { items: SignalLike; /** Navigates to the given item in the popup. */ - focus: (item: T) => void; + focus: (item: T, opts?: {focusElement?: boolean}) => void; /** Navigates to the next item in the popup. */ next: () => void; @@ -73,6 +78,9 @@ export interface ComboboxListboxControls, V> { /** Selects the current item in the popup. */ select: (item?: T) => void; + /** Toggles the selection state of the given item in the popup. */ + toggle: (item?: T) => void; + /** Clears the selection state of the popup. */ clearSelection: () => void; @@ -82,8 +90,8 @@ export interface ComboboxListboxControls, V> { /** Returns the item corresponding to the given event. */ getItem: (e: PointerEvent) => T | undefined; - /** Returns the currently selected item in the popup. */ - getSelectedItem: () => T | undefined; + /** Returns the currently selected items in the popup. */ + getSelectedItems: () => T[]; /** Sets the value of the combobox based on the selected item. */ setValue: (value: V | undefined) => void; // For re-setting the value if the popup was destroyed. @@ -159,7 +167,7 @@ export class ComboboxPattern, V> { const manager = new KeyboardEventManager() .on('ArrowDown', () => this.open({first: true})) .on('ArrowUp', () => this.open({last: true})) - .on('Escape', () => this.close({reset: true})); + .on('Escape', () => this.close({reset: !this.readonly()})); if (this.readonly()) { manager @@ -181,14 +189,14 @@ export class ComboboxPattern, V> { .on('ArrowUp', () => this.prev()) .on('Home', () => this.first()) .on('End', () => this.last()) - .on('Escape', () => this.close({reset: true})); + .on('Escape', () => this.close({reset: !this.readonly()})); if (this.readonly()) { - manager.on(' ', () => this.select({commit: true, close: true})); + manager.on(' ', () => this.select({commit: true, close: !popupControls.multi()})); } if (popupControls.role() === 'listbox') { - manager.on('Enter', () => this.select({commit: true, close: true})); + manager.on('Enter', () => this.select({commit: true, close: !popupControls.multi()})); } if (popupControls.role() === 'tree') { @@ -228,7 +236,7 @@ export class ComboboxPattern, V> { } } - this.select({item, commit: true, close: true}); + this.select({item, commit: true, close: !this.inputs.popupControls()?.multi()}); this.inputs.inputEl()?.focus(); // Return focus to the input after selecting. } @@ -273,7 +281,8 @@ export class ComboboxPattern, V> { this.isDeleting = event instanceof InputEvent && !!event.inputType.match(/^delete/); if (this.inputs.filterMode() === 'manual') { - const searchTerm = this.inputs.popupControls()?.getSelectedItem()?.searchTerm(); + const selectedItems = this.inputs.popupControls()?.getSelectedItems(); + const searchTerm = selectedItems?.[0]?.searchTerm(); if (searchTerm && this.inputs.inputValue!() !== searchTerm) { this.inputs.popupControls()?.clearSelection(); @@ -301,6 +310,12 @@ export class ComboboxPattern, V> { !this.inputs.containerEl()?.contains(event.relatedTarget) ) { this.isFocused.set(false); + + if (this.readonly()) { + this.close(); + return; + } + if (this.inputs.filterMode() !== 'manual') { this.commit(); } else { @@ -335,6 +350,10 @@ export class ComboboxPattern, V> { /** Handles filtering logic for the combobox. */ onFilter() { + if (this.readonly()) { + 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,7 +396,8 @@ export class ComboboxPattern, V> { /** Highlights the currently selected item in the combobox. */ highlight() { const inputEl = this.inputs.inputEl(); - const item = this.inputs.popupControls()?.getSelectedItem(); + const selectedItems = this.inputs.popupControls()?.getSelectedItems(); + const item = selectedItems?.[0]; if (!inputEl || !item) { return; @@ -418,7 +438,7 @@ export class ComboboxPattern, V> { } else if (this.expanded()) { this.close(); - const selectedItem = popupControls?.getSelectedItem(); + const selectedItem = popupControls?.getSelectedItems()?.[0]; if (selectedItem?.searchTerm() !== this.inputs.inputValue!()) { popupControls?.clearSelection(); @@ -435,7 +455,6 @@ export class ComboboxPattern, V> { /** Opens the combobox. */ open(nav?: {first?: boolean; last?: boolean; selected?: boolean}) { this.expanded.set(true); - const inputEl = this.inputs.inputEl(); if (inputEl && this.inputs.filterMode() === 'highlight') { @@ -453,7 +472,10 @@ export class ComboboxPattern, V> { this.last(); } if (nav?.selected) { - const selectedItem = this.inputs.popupControls()?.getSelectedItem(); + const selectedItem = this.inputs + .popupControls() + ?.items() + .find(i => this.inputs.popupControls()?.getSelectedItems().includes(i)); selectedItem ? this.inputs.popupControls()?.focus(selectedItem) : this.first(); } } @@ -492,7 +514,13 @@ export class ComboboxPattern, V> { /** Selects an item in the combobox popup. */ select(opts: {item?: T; commit?: boolean; close?: boolean} = {}) { - this.inputs.popupControls()?.select(opts.item); + const controls = this.inputs.popupControls(); + + if (opts.item) { + controls?.focus(opts.item, {focusElement: false}); + } + + controls?.multi() ? controls.toggle(opts.item) : controls?.select(opts.item); if (opts.commit) { this.commit(); @@ -505,16 +533,18 @@ export class ComboboxPattern, V> { /** Updates the value of the input based on the currently selected item. */ commit() { const inputEl = this.inputs.inputEl(); - const item = this.inputs.popupControls()?.getSelectedItem(); + const selectedItems = this.inputs.popupControls()?.getSelectedItems(); - if (inputEl && item) { - inputEl.value = item.searchTerm(); - this.inputs.inputValue?.set(item.searchTerm()); + if (!inputEl) { + return; + } - if (this.inputs.filterMode() === 'highlight') { - const length = inputEl.value.length; - inputEl.setSelectionRange(length, length); - } + inputEl.value = selectedItems?.map(i => i.searchTerm()).join(', ') || ''; + this.inputs.inputValue?.set(inputEl.value); + + if (this.inputs.filterMode() === 'highlight' && !this.readonly()) { + const length = inputEl.value.length; + inputEl.setSelectionRange(length, length); } } @@ -529,7 +559,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()?.getSelectedItem(); + const selectedItem = this.inputs.popupControls()?.getSelectedItems()[0]; if (!selectedItem) { return; diff --git a/src/aria/private/listbox/combobox-listbox.ts b/src/aria/private/listbox/combobox-listbox.ts index f4e7fb47aaea..518e56014541 100644 --- a/src/aria/private/listbox/combobox-listbox.ts +++ b/src/aria/private/listbox/combobox-listbox.ts @@ -36,9 +36,13 @@ export class ComboboxListboxPattern /** The tab index for the listbox. Always -1 because the combobox handles focus. */ override tabIndex: SignalLike<-1 | 0> = () => -1; + /** Whether multiple items in the list can be selected at once. */ + override multi = computed(() => { + return this.inputs.combobox()?.readonly() ? this.inputs.multi() : false; + }); + constructor(override readonly inputs: ComboboxListboxInputs) { if (inputs.combobox()) { - inputs.multi = () => false; inputs.focusMode = () => 'activedescendant'; inputs.element = inputs.combobox()!.inputs.inputEl; } @@ -56,7 +60,9 @@ export class ComboboxListboxPattern override setDefaultState(): void {} /** Navigates to the specified item in the listbox. */ - focus = (item: OptionPattern) => this.listBehavior.goto(item); + focus = (item: OptionPattern, opts?: {focusElement?: boolean}) => { + this.listBehavior.goto(item, opts); + }; /** Navigates to the next focusable item in the listbox. */ next = () => this.listBehavior.next(); @@ -76,14 +82,27 @@ export class ComboboxListboxPattern /** Selects the specified item in the listbox. */ select = (item?: OptionPattern) => this.listBehavior.select(item); + /** Toggles the selection state of the given item in the listbox. */ + toggle = (item?: OptionPattern) => this.listBehavior.toggle(item); + /** Clears the selection in the listbox. */ clearSelection = () => this.listBehavior.deselectAll(); /** Retrieves the OptionPattern associated with a pointer event. */ getItem = (e: PointerEvent) => this._getItem(e); - /** Retrieves the currently selected item in the listbox. */ - getSelectedItem = () => this.inputs.items().find(i => i.selected()); + /** Retrieves the currently selected items in the listbox. */ + getSelectedItems = () => { + // NOTE: We need to do this funky for loop to preserve the order of the selected values. + const items = []; + for (const value of this.inputs.value()) { + const item = this.items().find(i => i.value() === value); + if (item) { + items.push(item); + } + } + return items; + }; /** Sets the value of the combobox listbox. */ setValue = (value: V | undefined) => this.inputs.value.set(value ? [value] : []); diff --git a/src/aria/private/tree/combobox-tree.ts b/src/aria/private/tree/combobox-tree.ts index 0fb90fdff439..71005978af7e 100644 --- a/src/aria/private/tree/combobox-tree.ts +++ b/src/aria/private/tree/combobox-tree.ts @@ -76,14 +76,17 @@ export class ComboboxTreePattern /** Selects the specified item in the tree or the current active item if not provided. */ select = (item?: TreeItemPattern) => this.listBehavior.select(item); + /** Toggles the selection state of the given item in the tree or the current active item if not provided. */ + toggle = (item?: TreeItemPattern) => this.listBehavior.toggle(item); + /** Clears the selection in the tree. */ clearSelection = () => this.listBehavior.deselectAll(); /** Retrieves the TreeItemPattern associated with a pointer event. */ getItem = (e: PointerEvent) => this._getItem(e); - /** Retrieves the currently selected item in the tree */ - getSelectedItem = () => this.inputs.allItems().find(i => i.selected()); + /** Retrieves the currently selected items in the tree */ + getSelectedItems = () => this.inputs.allItems().filter(item => item.selected()); /** Sets the value of the combobox tree. */ setValue = (value: V | undefined) => this.inputs.value.set(value ? [value] : []); From e446fbdbba27937d0fec9cb34a741310738962a0 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Wed, 5 Nov 2025 17:52:04 -0500 Subject: [PATCH 4/4] docs(aria/combobox): multi select examples --- .../aria/combobox/combobox-examples.css | 52 ++++++++----- ...combobox-readonly-multiselect-example.html | 28 +++++++ .../combobox-readonly-multiselect-example.ts | 78 +++++++++++++++++++ .../aria/combobox/index.ts | 4 +- src/dev-app/aria-combobox/combobox-demo.css | 29 ++++--- src/dev-app/aria-combobox/combobox-demo.html | 31 ++++++-- src/dev-app/aria-combobox/combobox-demo.ts | 6 +- src/dev-app/common-classes.css | 1 - 8 files changed, 183 insertions(+), 46 deletions(-) create mode 100644 src/components-examples/aria/combobox/combobox-readonly-multiselect/combobox-readonly-multiselect-example.html create mode 100644 src/components-examples/aria/combobox/combobox-readonly-multiselect/combobox-readonly-multiselect-example.ts diff --git a/src/components-examples/aria/combobox/combobox-examples.css b/src/components-examples/aria/combobox/combobox-examples.css index a6ff5a0e6e80..7a065acdd242 100644 --- a/src/components-examples/aria/combobox/combobox-examples.css +++ b/src/components-examples/aria/combobox/combobox-examples.css @@ -1,12 +1,16 @@ .example-combobox-container { position: relative; - width: 300px; + 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: 200px; +} + .example-combobox-input-container { display: flex; position: relative; @@ -78,6 +82,7 @@ overflow: auto; max-height: 10rem; padding: 0.5rem; + gap: 4px; } .example-option { @@ -89,6 +94,26 @@ 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 { @@ -99,26 +124,17 @@ visibility: visible; } -.example-option[inert], -.example-tree-item[inert] { - display: none; +.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'] { - background: color-mix( - in srgb, - var(--mat-sys-on-surface) calc(var(--mat-sys-focus-state-layer-opacity) * 100%), - transparent - ); -} - -.example-combobox-container:focus-within [data-active='true'][aria-selected='true'] { - background: color-mix( - in srgb, - var(--mat-sys-primary) calc(var(--mat-sys-pressed-state-layer-opacity) * 100%), - transparent - ); - color: var(--mat-sys-primary); + outline: 2px solid color-mix(in srgb, var(--mat-sys-primary) 80%, transparent); } .example-tree { diff --git a/src/components-examples/aria/combobox/combobox-readonly-multiselect/combobox-readonly-multiselect-example.html b/src/components-examples/aria/combobox/combobox-readonly-multiselect/combobox-readonly-multiselect-example.html new file mode 100644 index 000000000000..5f3f38a8c951 --- /dev/null +++ b/src/components-examples/aria/combobox/combobox-readonly-multiselect/combobox-readonly-multiselect-example.html @@ -0,0 +1,28 @@ +
+
+ + arrow_drop_down +
+ +
+ +
+ @for (option of options(); track option) { +
+ {{option}} + +
+ } +
+
+
+
diff --git a/src/components-examples/aria/combobox/combobox-readonly-multiselect/combobox-readonly-multiselect-example.ts b/src/components-examples/aria/combobox/combobox-readonly-multiselect/combobox-readonly-multiselect-example.ts new file mode 100644 index 000000000000..b99301a26245 --- /dev/null +++ b/src/components-examples/aria/combobox/combobox-readonly-multiselect/combobox-readonly-multiselect-example.ts @@ -0,0 +1,78 @@ +/** + * @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, + ComboboxInput, + ComboboxPopup, + ComboboxPopupContainer, +} from '@angular/aria/combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + ElementRef, + signal, + viewChild, +} from '@angular/core'; +import {FormsModule} from '@angular/forms'; + +/** @title Readonly multiselectable combobox. */ +@Component({ + selector: 'combobox-readonly-multiselect-example', + templateUrl: 'combobox-readonly-multiselect-example.html', + styleUrl: '../combobox-examples.css', + imports: [ + Combobox, + ComboboxInput, + ComboboxPopup, + ComboboxPopupContainer, + Listbox, + Option, + FormsModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ComboboxReadonlyMultiselectExample { + popover = viewChild('popover'); + listbox = viewChild>(Listbox); + combobox = viewChild>(Combobox); + + options = () => states; + searchString = signal(''); + + constructor() { + afterRenderEffect(() => { + const popover = this.popover()!; + const combobox = this.combobox()!; + combobox._pattern.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); + + // TODO(wagnermaciel): Make this easier for developers to do. + this.listbox()?._pattern.inputs.activeItem()?.element()?.scrollIntoView({block: 'nearest'}); + }); + } + + showPopover() { + const popover = this.popover()!; + const combobox = this.combobox()!; + + const comboboxRect = combobox._pattern.inputs.inputEl()?.getBoundingClientRect(); + const popoverEl = popover.nativeElement; + + if (comboboxRect) { + popoverEl.style.width = `${comboboxRect.width}px`; + popoverEl.style.top = `${comboboxRect.bottom + 4}px`; + popoverEl.style.left = `${comboboxRect.left - 1}px`; + } + + popover.nativeElement.showPopover(); + } +} + +const states = ['Option 1', 'Option 2', 'Option 3']; diff --git a/src/components-examples/aria/combobox/index.ts b/src/components-examples/aria/combobox/index.ts index a5a42a9599e3..05fda59a78b2 100644 --- a/src/components-examples/aria/combobox/index.ts +++ b/src/components-examples/aria/combobox/index.ts @@ -1,7 +1,9 @@ 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'; +export {ComboboxReadonlyExample} from './combobox-readonly/combobox-readonly-example'; +export {ComboboxReadonlyMultiselectExample} from './combobox-readonly-multiselect/combobox-readonly-multiselect-example'; + export {ComboboxTreeManualExample} from './combobox-tree-manual/combobox-tree-manual-example'; export {ComboboxTreeAutoSelectExample} from './combobox-tree-auto-select/combobox-tree-auto-select-example'; export {ComboboxTreeHighlightExample} from './combobox-tree-highlight/combobox-tree-highlight-example'; -export {ComboboxReadonlyExample} from './combobox-readonly/combobox-readonly-example'; diff --git a/src/dev-app/aria-combobox/combobox-demo.css b/src/dev-app/aria-combobox/combobox-demo.css index 8233ba47f19b..4a24859ff76c 100644 --- a/src/dev-app/aria-combobox/combobox-demo.css +++ b/src/dev-app/aria-combobox/combobox-demo.css @@ -1,24 +1,21 @@ -.example-combobox-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); - gap: 20px; - justify-items: center; +.example-combobox-row { + display: flex; + gap: 20px; } .example-combobox-container { - display: flex; - flex-direction: column; - justify-content: flex-start; - - /* stylelint-disable material/no-prefixes */ - width: fit-content; -} - -.example-configurable-combobox-container { - padding-top: 40px; + display: flex; + flex-direction: column; + justify-content: flex-start; + min-width: 350px; + padding: 20px 0; } h2 { - font-size: 1.1rem; + font-size: 1.5rem; + padding-top: 20px; } +h3 { + font-size: 1rem; +} diff --git a/src/dev-app/aria-combobox/combobox-demo.html b/src/dev-app/aria-combobox/combobox-demo.html index 1de5491b6488..52678600ac8b 100644 --- a/src/dev-app/aria-combobox/combobox-demo.html +++ b/src/dev-app/aria-combobox/combobox-demo.html @@ -1,38 +1,53 @@
-
+

Listbox autocomplete examples

+ +
-

Combobox with manual filtering

+

Combobox with manual filtering

-

Combobox with auto-select filtering

+

Combobox with auto-select filtering

-

Combobox with highlight filtering

+

Combobox with highlight filtering

+
+

Tree autocomplete examples

+ +
-

Combobox with tree popup and manual filtering

+

Combobox with tree popup and manual filtering

-

Combobox with tree popup and auto-select filtering

+

Combobox with tree popup and auto-select filtering

-

Combobox with tree popup and highlight filtering

+

Combobox with tree popup and highlight filtering

+
+

Select examples

+ +
-

Readonly Combobox

+

Readonly Combobox

+ +
+

Readonly Multiselect Combobox

+ +
diff --git a/src/dev-app/aria-combobox/combobox-demo.ts b/src/dev-app/aria-combobox/combobox-demo.ts index 5dc4c74ce522..1ee06edce0f5 100644 --- a/src/dev-app/aria-combobox/combobox-demo.ts +++ b/src/dev-app/aria-combobox/combobox-demo.ts @@ -10,10 +10,11 @@ import { ComboboxAutoSelectExample, ComboboxHighlightExample, ComboboxManualExample, + ComboboxReadonlyExample, + ComboboxReadonlyMultiselectExample, ComboboxTreeAutoSelectExample, ComboboxTreeHighlightExample, ComboboxTreeManualExample, - ComboboxReadonlyExample, } from '@angular/components-examples/aria/combobox'; import {ChangeDetectionStrategy, Component} from '@angular/core'; @@ -24,10 +25,11 @@ import {ChangeDetectionStrategy, Component} from '@angular/core'; ComboboxManualExample, ComboboxAutoSelectExample, ComboboxHighlightExample, + ComboboxReadonlyExample, + ComboboxReadonlyMultiselectExample, ComboboxTreeManualExample, ComboboxTreeAutoSelectExample, ComboboxTreeHighlightExample, - ComboboxReadonlyExample, ], changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/src/dev-app/common-classes.css b/src/dev-app/common-classes.css index 768f4e57708a..7da4d3c95c5b 100644 --- a/src/dev-app/common-classes.css +++ b/src/dev-app/common-classes.css @@ -68,7 +68,6 @@ [aria-disabled='true'] .example-selectable:focus-within, [aria-activedescendant] .example-selectable:focus-within { outline-color: transparent; - border-radius: 0; } [aria-disabled='true'] .example-selectable[aria-selected='true'],