From c12bb55a4c96720455f04803c5391525e68ae1d6 Mon Sep 17 00:00:00 2001
From: tjshiu <35056071+tjshiu@users.noreply.github.com>
Date: Mon, 4 May 2026 11:20:26 -0700
Subject: [PATCH 1/3] refactor(aria/combobox): forward advanced combination
keys in SimpleComboboxPattern Implements keyboard event forwarding for
multi-selection combinations within `SimpleComboboxPattern` to fulfill
requirements in components#33101. - Always relays `Shift + ArrowUp` and
`Shift + ArrowDown` range selection events for all combobox types (including
editable inputs). - Relays text-selection combo keys (`Ctrl/Cmd + A`, `Shift
+ Home/End`) exclusively for non-editable (select-only) comboboxes to prevent
interference with browser-native input field text manipulation per W3C APG
specs. - Extends `simple-combobox.spec.ts` with multi-key verification tests.
---
.../simple-combobox/simple-combobox.spec.ts | 49 +++++++++++++++++++
.../simple-combobox/simple-combobox.ts | 8 ++-
2 files changed, 56 insertions(+), 1 deletion(-)
diff --git a/src/aria/private/simple-combobox/simple-combobox.spec.ts b/src/aria/private/simple-combobox/simple-combobox.spec.ts
index ef318061e04e..4aea95f87edb 100644
--- a/src/aria/private/simple-combobox/simple-combobox.spec.ts
+++ b/src/aria/private/simple-combobox/simple-combobox.spec.ts
@@ -222,4 +222,53 @@ describe('SimpleComboboxPattern', () => {
expect(expanded()).toBe(true);
});
});
+
+ describe('Advanced Combo Keys Relay', () => {
+ it('should forward Shift + ArrowUp/ArrowDown for editable inputs', () => {
+ const {pattern, expanded} = setup();
+ expanded.set(true);
+
+ const shiftUp = createKeyboardEvent('keydown', 38, 'ArrowUp');
+ Object.defineProperty(shiftUp, 'shiftKey', {value: true});
+ pattern.onKeydown(shiftUp);
+ expect(pattern.keyboardEventRelay()).toBe(shiftUp);
+
+ const shiftDown = createKeyboardEvent('keydown', 40, 'ArrowDown');
+ Object.defineProperty(shiftDown, 'shiftKey', {value: true});
+ pattern.onKeydown(shiftDown);
+ expect(pattern.keyboardEventRelay()).toBe(shiftDown);
+ });
+
+ it('should NOT forward Ctrl+A or Shift+Home/End for editable inputs', () => {
+ const {pattern, expanded} = setup();
+ expanded.set(true);
+
+ const ctrlA = createKeyboardEvent('keydown', 65, 'a');
+ Object.defineProperty(ctrlA, 'ctrlKey', {value: true});
+ pattern.onKeydown(ctrlA);
+ expect(pattern.keyboardEventRelay()).toBeUndefined();
+
+ const shiftHome = createKeyboardEvent('keydown', 36, 'Home');
+ Object.defineProperty(shiftHome, 'shiftKey', {value: true});
+ pattern.onKeydown(shiftHome);
+ expect(pattern.keyboardEventRelay()).toBeUndefined();
+ });
+
+ it('should forward Ctrl+A and Shift+Home/End for select-only (non-editable) comboboxes', () => {
+ const selectOnlyElement = document.createElement('div');
+ const {pattern, expanded} = setup();
+ pattern.inputs.element = signal(selectOnlyElement);
+ expanded.set(true);
+
+ const ctrlA = createKeyboardEvent('keydown', 65, 'a');
+ Object.defineProperty(ctrlA, 'ctrlKey', {value: true});
+ pattern.onKeydown(ctrlA);
+ expect(pattern.keyboardEventRelay()).toBe(ctrlA);
+
+ const shiftHome = createKeyboardEvent('keydown', 36, 'Home');
+ Object.defineProperty(shiftHome, 'shiftKey', {value: true});
+ pattern.onKeydown(shiftHome);
+ expect(pattern.keyboardEventRelay()).toBe(shiftHome);
+ });
+ });
});
diff --git a/src/aria/private/simple-combobox/simple-combobox.ts b/src/aria/private/simple-combobox/simple-combobox.ts
index 9e621bfb9ab5..bf73cb2717f2 100644
--- a/src/aria/private/simple-combobox/simple-combobox.ts
+++ b/src/aria/private/simple-combobox/simple-combobox.ts
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import {KeyboardEventManager, ClickEventManager} from '../behaviors/event-manager';
+import {KeyboardEventManager, ClickEventManager, Modifier} from '../behaviors/event-manager';
import {computed, signal, untracked} from '@angular/core';
import {SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like';
import {ExpansionItem} from '../behaviors/expansion/expansion';
@@ -130,6 +130,8 @@ export class SimpleComboboxPattern {
)
.on('ArrowUp', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false})
.on('ArrowDown', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false})
+ .on(Modifier.Shift, 'ArrowUp', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false})
+ .on(Modifier.Shift, 'ArrowDown', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false})
.on('Home', e => this.keyboardEventRelay.set(e))
.on('End', e => this.keyboardEventRelay.set(e))
.on('Enter', e => this.keyboardEventRelay.set(e))
@@ -144,6 +146,10 @@ export class SimpleComboboxPattern {
if (!this.isEditable()) {
manager
.on(' ', e => this.keyboardEventRelay.set(e))
+ .on([Modifier.Ctrl, Modifier.Meta], 'a', e => this.keyboardEventRelay.set(e))
+ .on([Modifier.Ctrl, Modifier.Meta], 'A', e => this.keyboardEventRelay.set(e))
+ .on(Modifier.Shift, 'Home', e => this.keyboardEventRelay.set(e))
+ .on(Modifier.Shift, 'End', e => this.keyboardEventRelay.set(e))
.on(/^.$/, e => {
this.keyboardEventRelay.set(e);
});
From ccd63b2da2633091829c65aed5d208eebc487af5 Mon Sep 17 00:00:00 2001
From: tjshiu <35056071+tjshiu@users.noreply.github.com>
Date: Mon, 4 May 2026 12:27:10 -0700
Subject: [PATCH 2/3] docs(aria/combobox): add multi-select dialog example
component Introduces a new example component
`SimpleComboboxMultiselectDialogExample` demonstrating the definitive pattern
for a multiselectable combobox using a nested dialog layout with an inner
search filter input.
---
.../aria/simple-combobox/index.ts | 1 +
...combobox-editable-multiselect-example.html | 52 +++++++++
...e-combobox-editable-multiselect-example.ts | 100 ++++++++++++++++++
...e-combobox-multiselect-dialog-example.html | 52 +++++++++
...ple-combobox-multiselect-dialog-example.ts | 100 ++++++++++++++++++
.../simple-combobox-demo.html | 6 +-
.../simple-combobox-demo.ts | 2 +
7 files changed, 312 insertions(+), 1 deletion(-)
create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-editable-multiselect/simple-combobox-editable-multiselect-example.html
create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-editable-multiselect/simple-combobox-editable-multiselect-example.ts
create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-multiselect-dialog/simple-combobox-multiselect-dialog-example.html
create mode 100644 src/components-examples/aria/simple-combobox/simple-combobox-multiselect-dialog/simple-combobox-multiselect-dialog-example.ts
diff --git a/src/components-examples/aria/simple-combobox/index.ts b/src/components-examples/aria/simple-combobox/index.ts
index 9f2366140249..735dc7b86ee3 100644
--- a/src/components-examples/aria/simple-combobox/index.ts
+++ b/src/components-examples/aria/simple-combobox/index.ts
@@ -15,5 +15,6 @@ 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 {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-editable-multiselect/simple-combobox-editable-multiselect-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-editable-multiselect/simple-combobox-editable-multiselect-example.html
new file mode 100644
index 000000000000..b888a8afc9c0
--- /dev/null
+++ b/src/components-examples/aria/simple-combobox/simple-combobox-editable-multiselect/simple-combobox-editable-multiselect-example.html
@@ -0,0 +1,52 @@
+
+
+
+ arrow_drop_down
+
+
+
+
+
+
+
+
+
+ search
+
+
+
+
+ {{countries().length === 0 ? 'No results found for ' + searchString() : ''}}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-editable-multiselect/simple-combobox-editable-multiselect-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-editable-multiselect/simple-combobox-editable-multiselect-example.ts
new file mode 100644
index 000000000000..ba50cde53047
--- /dev/null
+++ b/src/components-examples/aria/simple-combobox/simple-combobox-editable-multiselect/simple-combobox-editable-multiselect-example.ts
@@ -0,0 +1,100 @@
+/**
+ * @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,
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ signal,
+ untracked,
+ viewChild,
+ ElementRef,
+} from '@angular/core';
+import {COUNTRIES} from '../countries';
+import {OverlayModule} from '@angular/cdk/overlay';
+import {FormsModule} from '@angular/forms';
+
+/** @title Editable multiselectable combobox with a dialog layout. */
+@Component({
+ selector: 'simple-combobox-editable-multiselect-example',
+ templateUrl: 'simple-combobox-editable-multiselect-example.html',
+ styleUrl: '../simple-combobox-example.css',
+ imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class SimpleComboboxEditableMultiselectExample {
+ readonly listbox = viewChild(Listbox);
+ readonly combobox = viewChild(Combobox);
+ readonly searchInput = viewChild>('searchInput');
+
+ popupExpanded = signal(false);
+ searchString = signal('');
+ selectedOptions = signal([]);
+
+ /** The display text for the primary readonly trigger input. */
+ value = computed(() => {
+ const current = this.selectedOptions();
+ if (current.length === 0) return '';
+ if (current.length === 1) return current[0];
+ return `${current[0]} + ${current.length - 1} more`;
+ });
+
+ /** The list of countries filtered by the inner search input query. */
+ countries = computed(() => {
+ const currentQuery = this.searchString().toLowerCase();
+ return COUNTRIES.filter(country => country.toLowerCase().startsWith(currentQuery));
+ });
+
+ constructor() {
+ // Automatically auto-focus the inner search text field when the dialog expands
+ afterRenderEffect(() => {
+ if (this.popupExpanded()) {
+ untracked(() => {
+ setTimeout(() => {
+ this.searchInput()?.nativeElement.focus();
+ });
+ });
+ }
+ });
+
+ afterRenderEffect(() => {
+ if (this.popupExpanded()) {
+ this.listbox()?.scrollActiveItemIntoView();
+ }
+ });
+ }
+
+ /** Clears the search query and all selected options. */
+ clear(): void {
+ this.searchString.set('');
+ this.selectedOptions.set([]);
+ }
+
+ /** Keeps focus inside the dialog when selection changes. */
+ onCommit() {
+ this.searchString.set('');
+ }
+
+ /** Dismisses the dialog overlay on Escape key. */
+ onSearchEscape(event: Event) {
+ this.popupExpanded.set(false);
+ this.combobox()?.element.focus();
+ }
+
+ /** Handles keydown events on the clear button. */
+ onKeydown(event: KeyboardEvent): void {
+ if (event.key === 'Enter') {
+ this.clear();
+ this.popupExpanded.set(false);
+ event.stopPropagation();
+ }
+ }
+}
diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-multiselect-dialog/simple-combobox-multiselect-dialog-example.html b/src/components-examples/aria/simple-combobox/simple-combobox-multiselect-dialog/simple-combobox-multiselect-dialog-example.html
new file mode 100644
index 000000000000..b888a8afc9c0
--- /dev/null
+++ b/src/components-examples/aria/simple-combobox/simple-combobox-multiselect-dialog/simple-combobox-multiselect-dialog-example.html
@@ -0,0 +1,52 @@
+
+
+
+ arrow_drop_down
+
+
+
+
+
+
+
+
+
+ search
+
+
+
+
+ {{countries().length === 0 ? 'No results found for ' + searchString() : ''}}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-multiselect-dialog/simple-combobox-multiselect-dialog-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-multiselect-dialog/simple-combobox-multiselect-dialog-example.ts
new file mode 100644
index 000000000000..950a84ca9d01
--- /dev/null
+++ b/src/components-examples/aria/simple-combobox/simple-combobox-multiselect-dialog/simple-combobox-multiselect-dialog-example.ts
@@ -0,0 +1,100 @@
+/**
+ * @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,
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ signal,
+ untracked,
+ viewChild,
+ ElementRef,
+} from '@angular/core';
+import {COUNTRIES} from '../countries';
+import {OverlayModule} from '@angular/cdk/overlay';
+import {FormsModule} from '@angular/forms';
+
+/** @title Multiselectable combobox with a dialog layout. */
+@Component({
+ selector: 'simple-combobox-multiselect-dialog-example',
+ templateUrl: 'simple-combobox-multiselect-dialog-example.html',
+ styleUrl: '../simple-combobox-example.css',
+ imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class SimpleComboboxMultiselectDialogExample {
+ readonly listbox = viewChild(Listbox);
+ readonly combobox = viewChild(Combobox);
+ readonly searchInput = viewChild>('searchInput');
+
+ popupExpanded = signal(false);
+ searchString = signal('');
+ selectedOptions = signal([]);
+
+ /** The display text for the primary readonly trigger input. */
+ value = computed(() => {
+ const current = this.selectedOptions();
+ if (current.length === 0) return '';
+ if (current.length === 1) return current[0];
+ return `${current[0]} + ${current.length - 1} more`;
+ });
+
+ /** The list of countries filtered by the inner search input query. */
+ countries = computed(() => {
+ const currentQuery = this.searchString().toLowerCase();
+ return COUNTRIES.filter(country => country.toLowerCase().startsWith(currentQuery));
+ });
+
+ constructor() {
+ // Automatically focus the inner search text field when the dialog expands
+ afterRenderEffect(() => {
+ if (this.popupExpanded()) {
+ untracked(() => {
+ setTimeout(() => {
+ this.searchInput()?.nativeElement.focus();
+ });
+ });
+ }
+ });
+
+ afterRenderEffect(() => {
+ if (this.popupExpanded()) {
+ this.listbox()?.scrollActiveItemIntoView();
+ }
+ });
+ }
+
+ /** Clears the search query and all selected options. */
+ clear(): void {
+ this.searchString.set('');
+ this.selectedOptions.set([]);
+ }
+
+ /** Keeps selection state clean inside the open dialog. */
+ onCommit() {
+ this.searchString.set('');
+ }
+
+ /** Dismisses the dialog overlay on Escape key. */
+ onSearchEscape(event: Event) {
+ this.popupExpanded.set(false);
+ this.combobox()?.element.focus();
+ }
+
+ /** Handles keydown events on the clear button. */
+ onKeydown(event: KeyboardEvent): void {
+ if (event.key === 'Enter') {
+ this.clear();
+ this.popupExpanded.set(false);
+ event.stopPropagation();
+ }
+ }
+}
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 d651ab1aa070..58f88845818a 100644
--- a/src/dev-app/aria-simple-combobox/simple-combobox-demo.html
+++ b/src/dev-app/aria-simple-combobox/simple-combobox-demo.html
@@ -63,11 +63,15 @@ Combobox with Readonly + Disabled
Combobox with Dialog Popup
-
+
Combobox with Dialog Popup
+
+
Editable Combobox with Multi-Select Dialog
+
+
Combobox Grid Examples
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 63eaff4dea51..d56a255440e8 100644
--- a/src/dev-app/aria-simple-combobox/simple-combobox-demo.ts
+++ b/src/dev-app/aria-simple-combobox/simple-combobox-demo.ts
@@ -21,6 +21,7 @@ import {
SimpleComboboxDialogExample,
SimpleComboboxTreeAutoSelectExample,
SimpleComboboxTreeHighlightExample,
+ SimpleComboboxMultiselectDialogExample,
} from '@angular/components-examples/aria/simple-combobox';
@Component({
@@ -41,6 +42,7 @@ import {
SimpleComboboxDialogExample,
SimpleComboboxTreeAutoSelectExample,
SimpleComboboxTreeHighlightExample,
+ SimpleComboboxMultiselectDialogExample,
],
})
export class ComboboxDemo {}
From b675d7f388835be7a87b521f6125607163245a92 Mon Sep 17 00:00:00 2001
From: tjshiu <35056071+tjshiu@users.noreply.github.com>
Date: Mon, 4 May 2026 14:25:16 -0700
Subject: [PATCH 3/3] refactor(aria/listbox): sync range selection anchor with
active item + add test
---
src/aria/listbox/listbox.spec.ts | 11 +++++++++++
src/aria/private/behaviors/list/list.ts | 3 +++
2 files changed, 14 insertions(+)
diff --git a/src/aria/listbox/listbox.spec.ts b/src/aria/listbox/listbox.spec.ts
index 8399824b14b4..f3a5be44261c 100644
--- a/src/aria/listbox/listbox.spec.ts
+++ b/src/aria/listbox/listbox.spec.ts
@@ -602,6 +602,17 @@ describe('Listbox', () => {
expect(listboxInstance.value().sort()).toEqual([0, 1, 2]);
});
+ it('should move selection anchor along with focus during normal non-shift navigation', () => {
+ setupListbox({multi: true, selectionMode: 'explicit'});
+ down({shiftKey: true});
+ expect(listboxInstance.value().sort()).toEqual([0, 1]);
+ down();
+ down();
+ down();
+ up({shiftKey: true});
+ expect(listboxInstance.value().sort()).toEqual([0, 1, 3, 4]);
+ });
+
it('should toggle selection of all options on Ctrl+A', () => {
setupListbox({multi: true, selectionMode: 'explicit', value: [0]});
keydown('A', {ctrlKey: true});
diff --git a/src/aria/private/behaviors/list/list.ts b/src/aria/private/behaviors/list/list.ts
index e70e6e020a6e..7378ba27ae13 100644
--- a/src/aria/private/behaviors/list/list.ts
+++ b/src/aria/private/behaviors/list/list.ts
@@ -229,6 +229,9 @@ export class List
, V> {
if (moved) {
this.updateSelection(opts);
+ if (!opts?.selectRange) {
+ this.anchor(this.activeIndex());
+ }
}
this._wrap.set(true);