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); 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); }); 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() : ''}} +
+ + +
+ @if (countries().length === 0) { +
No results found
+ } +
+ @for (country of countries(); track country) { +
+ {{country}} + +
+ } +
+
+
+
+
+
+
+
+
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() : ''}} +
+ + +
+ @if (countries().length === 0) { +
No results found
+ } +
+ @for (country of countries(); track country) { +
+ {{country}} + +
+ } +
+
+
+
+
+
+
+
+
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 {}