Skip to content

Commit a08f1cb

Browse files
authored
refactor(aria/combobox): forward advanced combination keys in SimpleComboboxPattern (#33196)
* 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. * 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. * refactor(aria/listbox): sync range selection anchor with active item + add test
1 parent 3f631c8 commit a08f1cb

11 files changed

Lines changed: 382 additions & 2 deletions

File tree

src/aria/listbox/listbox.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,17 @@ describe('Listbox', () => {
602602
expect(listboxInstance.value().sort()).toEqual([0, 1, 2]);
603603
});
604604

605+
it('should move selection anchor along with focus during normal non-shift navigation', () => {
606+
setupListbox({multi: true, selectionMode: 'explicit'});
607+
down({shiftKey: true});
608+
expect(listboxInstance.value().sort()).toEqual([0, 1]);
609+
down();
610+
down();
611+
down();
612+
up({shiftKey: true});
613+
expect(listboxInstance.value().sort()).toEqual([0, 1, 3, 4]);
614+
});
615+
605616
it('should toggle selection of all options on Ctrl+A', () => {
606617
setupListbox({multi: true, selectionMode: 'explicit', value: [0]});
607618
keydown('A', {ctrlKey: true});

src/aria/private/behaviors/list/list.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,9 @@ export class List<T extends ListItem<V>, V> {
229229

230230
if (moved) {
231231
this.updateSelection(opts);
232+
if (!opts?.selectRange) {
233+
this.anchor(this.activeIndex());
234+
}
232235
}
233236

234237
this._wrap.set(true);

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,4 +222,53 @@ describe('SimpleComboboxPattern', () => {
222222
expect(expanded()).toBe(true);
223223
});
224224
});
225+
226+
describe('Advanced Combo Keys Relay', () => {
227+
it('should forward Shift + ArrowUp/ArrowDown for editable inputs', () => {
228+
const {pattern, expanded} = setup();
229+
expanded.set(true);
230+
231+
const shiftUp = createKeyboardEvent('keydown', 38, 'ArrowUp');
232+
Object.defineProperty(shiftUp, 'shiftKey', {value: true});
233+
pattern.onKeydown(shiftUp);
234+
expect(pattern.keyboardEventRelay()).toBe(shiftUp);
235+
236+
const shiftDown = createKeyboardEvent('keydown', 40, 'ArrowDown');
237+
Object.defineProperty(shiftDown, 'shiftKey', {value: true});
238+
pattern.onKeydown(shiftDown);
239+
expect(pattern.keyboardEventRelay()).toBe(shiftDown);
240+
});
241+
242+
it('should NOT forward Ctrl+A or Shift+Home/End for editable inputs', () => {
243+
const {pattern, expanded} = setup();
244+
expanded.set(true);
245+
246+
const ctrlA = createKeyboardEvent('keydown', 65, 'a');
247+
Object.defineProperty(ctrlA, 'ctrlKey', {value: true});
248+
pattern.onKeydown(ctrlA);
249+
expect(pattern.keyboardEventRelay()).toBeUndefined();
250+
251+
const shiftHome = createKeyboardEvent('keydown', 36, 'Home');
252+
Object.defineProperty(shiftHome, 'shiftKey', {value: true});
253+
pattern.onKeydown(shiftHome);
254+
expect(pattern.keyboardEventRelay()).toBeUndefined();
255+
});
256+
257+
it('should forward Ctrl+A and Shift+Home/End for select-only (non-editable) comboboxes', () => {
258+
const selectOnlyElement = document.createElement('div');
259+
const {pattern, expanded} = setup();
260+
pattern.inputs.element = signal(selectOnlyElement);
261+
expanded.set(true);
262+
263+
const ctrlA = createKeyboardEvent('keydown', 65, 'a');
264+
Object.defineProperty(ctrlA, 'ctrlKey', {value: true});
265+
pattern.onKeydown(ctrlA);
266+
expect(pattern.keyboardEventRelay()).toBe(ctrlA);
267+
268+
const shiftHome = createKeyboardEvent('keydown', 36, 'Home');
269+
Object.defineProperty(shiftHome, 'shiftKey', {value: true});
270+
pattern.onKeydown(shiftHome);
271+
expect(pattern.keyboardEventRelay()).toBe(shiftHome);
272+
});
273+
});
225274
});

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {KeyboardEventManager, ClickEventManager} from '../behaviors/event-manager';
9+
import {KeyboardEventManager, ClickEventManager, Modifier} from '../behaviors/event-manager';
1010
import {computed, signal, untracked} from '@angular/core';
1111
import {SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like';
1212
import {ExpansionItem} from '../behaviors/expansion/expansion';
@@ -130,6 +130,8 @@ export class SimpleComboboxPattern {
130130
)
131131
.on('ArrowUp', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false})
132132
.on('ArrowDown', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false})
133+
.on(Modifier.Shift, 'ArrowUp', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false})
134+
.on(Modifier.Shift, 'ArrowDown', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false})
133135
.on('Home', e => this.keyboardEventRelay.set(e))
134136
.on('End', e => this.keyboardEventRelay.set(e))
135137
.on('Enter', e => this.keyboardEventRelay.set(e))
@@ -144,6 +146,10 @@ export class SimpleComboboxPattern {
144146
if (!this.isEditable()) {
145147
manager
146148
.on(' ', e => this.keyboardEventRelay.set(e))
149+
.on([Modifier.Ctrl, Modifier.Meta], 'a', e => this.keyboardEventRelay.set(e))
150+
.on([Modifier.Ctrl, Modifier.Meta], 'A', e => this.keyboardEventRelay.set(e))
151+
.on(Modifier.Shift, 'Home', e => this.keyboardEventRelay.set(e))
152+
.on(Modifier.Shift, 'End', e => this.keyboardEventRelay.set(e))
147153
.on(/^.$/, e => {
148154
this.keyboardEventRelay.set(e);
149155
});

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ 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 {SimpleComboboxMultiselectDialogExample} from './simple-combobox-multiselect-dialog/simple-combobox-multiselect-dialog-example';
1819

1920
// Force watcher update
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<div class="example-combobox-container" ngCombobox #combobox="ngCombobox" [(expanded)]="popupExpanded">
2+
<div #origin class="example-combobox-input-container">
3+
<input class="example-combobox-input example-dialog-input" placeholder="Select countries..." [value]="value()"
4+
[readonly]="true" [tabindex]="-1" />
5+
<span class="material-symbols-outlined example-icon example-arrow-icon">arrow_drop_down</span>
6+
</div>
7+
8+
<ng-template [cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
9+
[cdkConnectedOverlayOpen]="popupExpanded()" [cdkConnectedOverlayDisableClose]="false"
10+
(overlayOutsideClick)="popupExpanded.set(false)">
11+
<ng-template ngComboboxPopup [combobox]="combobox" popupType="dialog">
12+
13+
<div class="example-popover">
14+
<div class="example-dialog" ngComboboxWidget>
15+
<div class="example-combobox-container">
16+
<div class="example-combobox-input-container">
17+
<span class="material-symbols-outlined example-icon example-search-icon">search</span>
18+
<input ngCombobox #innerCombobox="ngCombobox" #searchInput class="example-combobox-input"
19+
placeholder="Search..." [(ngModel)]="searchString" [alwaysExpanded]="true"
20+
(keydown.escape)="onSearchEscape($event)" />
21+
</div>
22+
23+
<div aria-live="polite" class="cdk-visually-hidden">
24+
{{countries().length === 0 ? 'No results found for ' + searchString() : ''}}
25+
</div>
26+
27+
<ng-template ngComboboxPopup [combobox]="innerCombobox">
28+
<div class="example-popup example-popup-no-margin">
29+
@if (countries().length === 0) {
30+
<div class="example-no-results">No results found</div>
31+
}
32+
<div #listbox="ngListbox" ngListbox [multi]="true" ngComboboxWidget class="example-listbox" focusMode="activedescendant"
33+
tabindex="-1" selectionMode="explicit" [(value)]="selectedOptions" (click)="onCommit()"
34+
(keydown.enter)="onCommit()" [activeDescendant]="listbox.activeDescendant()"
35+
[class.example-empty]="countries().length === 0">
36+
@for (country of countries(); track country) {
37+
<div class="example-option example-selectable example-stateful" ngOption [value]="country"
38+
[label]="country">
39+
<span>{{country}}</span>
40+
<span aria-hidden="true"
41+
class="material-symbols-outlined example-icon example-selected-icon">check</span>
42+
</div>
43+
}
44+
</div>
45+
</div>
46+
</ng-template>
47+
</div>
48+
</div>
49+
</div>
50+
</ng-template>
51+
</ng-template>
52+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox';
10+
import {Listbox, Option} from '@angular/aria/listbox';
11+
import {
12+
afterRenderEffect,
13+
ChangeDetectionStrategy,
14+
Component,
15+
computed,
16+
signal,
17+
untracked,
18+
viewChild,
19+
ElementRef,
20+
} from '@angular/core';
21+
import {COUNTRIES} from '../countries';
22+
import {OverlayModule} from '@angular/cdk/overlay';
23+
import {FormsModule} from '@angular/forms';
24+
25+
/** @title Editable multiselectable combobox with a dialog layout. */
26+
@Component({
27+
selector: 'simple-combobox-editable-multiselect-example',
28+
templateUrl: 'simple-combobox-editable-multiselect-example.html',
29+
styleUrl: '../simple-combobox-example.css',
30+
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule],
31+
changeDetection: ChangeDetectionStrategy.OnPush,
32+
})
33+
export class SimpleComboboxEditableMultiselectExample {
34+
readonly listbox = viewChild(Listbox);
35+
readonly combobox = viewChild(Combobox);
36+
readonly searchInput = viewChild<ElementRef<HTMLInputElement>>('searchInput');
37+
38+
popupExpanded = signal(false);
39+
searchString = signal('');
40+
selectedOptions = signal<string[]>([]);
41+
42+
/** The display text for the primary readonly trigger input. */
43+
value = computed(() => {
44+
const current = this.selectedOptions();
45+
if (current.length === 0) return '';
46+
if (current.length === 1) return current[0];
47+
return `${current[0]} + ${current.length - 1} more`;
48+
});
49+
50+
/** The list of countries filtered by the inner search input query. */
51+
countries = computed(() => {
52+
const currentQuery = this.searchString().toLowerCase();
53+
return COUNTRIES.filter(country => country.toLowerCase().startsWith(currentQuery));
54+
});
55+
56+
constructor() {
57+
// Automatically auto-focus the inner search text field when the dialog expands
58+
afterRenderEffect(() => {
59+
if (this.popupExpanded()) {
60+
untracked(() => {
61+
setTimeout(() => {
62+
this.searchInput()?.nativeElement.focus();
63+
});
64+
});
65+
}
66+
});
67+
68+
afterRenderEffect(() => {
69+
if (this.popupExpanded()) {
70+
this.listbox()?.scrollActiveItemIntoView();
71+
}
72+
});
73+
}
74+
75+
/** Clears the search query and all selected options. */
76+
clear(): void {
77+
this.searchString.set('');
78+
this.selectedOptions.set([]);
79+
}
80+
81+
/** Keeps focus inside the dialog when selection changes. */
82+
onCommit() {
83+
this.searchString.set('');
84+
}
85+
86+
/** Dismisses the dialog overlay on Escape key. */
87+
onSearchEscape(event: Event) {
88+
this.popupExpanded.set(false);
89+
this.combobox()?.element.focus();
90+
}
91+
92+
/** Handles keydown events on the clear button. */
93+
onKeydown(event: KeyboardEvent): void {
94+
if (event.key === 'Enter') {
95+
this.clear();
96+
this.popupExpanded.set(false);
97+
event.stopPropagation();
98+
}
99+
}
100+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<div class="example-combobox-container" ngCombobox #combobox="ngCombobox" [(expanded)]="popupExpanded">
2+
<div #origin class="example-combobox-input-container">
3+
<input class="example-combobox-input example-dialog-input" placeholder="Select countries..." [value]="value()"
4+
[readonly]="true" [tabindex]="-1" />
5+
<span class="material-symbols-outlined example-icon example-arrow-icon">arrow_drop_down</span>
6+
</div>
7+
8+
<ng-template [cdkConnectedOverlay]="{origin: combobox.element, usePopover: 'inline', matchWidth: true}"
9+
[cdkConnectedOverlayOpen]="popupExpanded()" [cdkConnectedOverlayDisableClose]="false"
10+
(overlayOutsideClick)="popupExpanded.set(false)">
11+
<ng-template ngComboboxPopup [combobox]="combobox" popupType="dialog">
12+
13+
<div class="example-popover">
14+
<div class="example-dialog" ngComboboxWidget>
15+
<div class="example-combobox-container">
16+
<div class="example-combobox-input-container">
17+
<span class="material-symbols-outlined example-icon example-search-icon">search</span>
18+
<input ngCombobox #innerCombobox="ngCombobox" #searchInput class="example-combobox-input"
19+
placeholder="Search..." [(ngModel)]="searchString" [alwaysExpanded]="true"
20+
(keydown.escape)="onSearchEscape($event)" />
21+
</div>
22+
23+
<div aria-live="polite" class="cdk-visually-hidden">
24+
{{countries().length === 0 ? 'No results found for ' + searchString() : ''}}
25+
</div>
26+
27+
<ng-template ngComboboxPopup [combobox]="innerCombobox">
28+
<div class="example-popup example-popup-no-margin">
29+
@if (countries().length === 0) {
30+
<div class="example-no-results">No results found</div>
31+
}
32+
<div #listbox="ngListbox" ngListbox [multi]="true" ngComboboxWidget class="example-listbox" focusMode="activedescendant"
33+
tabindex="-1" selectionMode="explicit" [(value)]="selectedOptions" (click)="onCommit()"
34+
(keydown.enter)="onCommit()" [activeDescendant]="listbox.activeDescendant()"
35+
[class.example-empty]="countries().length === 0">
36+
@for (country of countries(); track country) {
37+
<div class="example-option example-selectable example-stateful" ngOption [value]="country"
38+
[label]="country">
39+
<span>{{country}}</span>
40+
<span aria-hidden="true"
41+
class="material-symbols-outlined example-icon example-selected-icon">check</span>
42+
</div>
43+
}
44+
</div>
45+
</div>
46+
</ng-template>
47+
</div>
48+
</div>
49+
</div>
50+
</ng-template>
51+
</ng-template>
52+
</div>

0 commit comments

Comments
 (0)