Skip to content

Commit 8002bce

Browse files
authored
fix(aria/combobox): zonejs compatibility (#33453)
1 parent 9184787 commit 8002bce

3 files changed

Lines changed: 103 additions & 2 deletions

File tree

src/aria/combobox/combobox.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ export class Combobox extends DeferredContentAware implements OnInit {
130130
constructor() {
131131
super();
132132

133-
afterRenderEffect(() => this._pattern.keyboardEventRelayEffect());
133+
afterRenderEffect({write: () => this._pattern.keyboardEventRelayEffect()});
134134
afterRenderEffect(() => this._pattern.closePopupOnBlurEffect());
135135
afterRenderEffect(() => {
136136
this.contentVisible.set(this._pattern.isExpanded());
Lines changed: 100 additions & 0 deletions
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 {Component, computed, signal, provideZoneChangeDetection} from '@angular/core';
10+
import {ComponentFixture, TestBed, fakeAsync, tick} from '@angular/core/testing';
11+
import {By} from '@angular/platform-browser';
12+
import {Combobox} from './combobox';
13+
import {ComboboxPopup} from './combobox-popup';
14+
import {ComboboxWidget} from './combobox-widget';
15+
import {Listbox, Option} from '../listbox';
16+
17+
describe('Combobox Zone.js integration', () => {
18+
let fixture: ComponentFixture<ComboboxListboxZoneExample>;
19+
let inputElement: HTMLInputElement;
20+
21+
beforeEach(async () => {
22+
TestBed.configureTestingModule({
23+
providers: [provideZoneChangeDetection()],
24+
});
25+
fixture = TestBed.createComponent(ComboboxListboxZoneExample);
26+
await fixture.whenStable();
27+
const inputDebugElement = fixture.debugElement.query(By.directive(Combobox));
28+
inputElement = inputDebugElement.nativeElement as HTMLInputElement;
29+
});
30+
31+
const focus = () => {
32+
inputElement.dispatchEvent(new FocusEvent('focusin', {bubbles: true}));
33+
fixture.detectChanges();
34+
};
35+
36+
const keydown = (key: string) => {
37+
focus();
38+
inputElement.dispatchEvent(
39+
new KeyboardEvent('keydown', {
40+
key,
41+
bubbles: true,
42+
}),
43+
);
44+
fixture.detectChanges();
45+
};
46+
47+
function getOption(text: string): HTMLElement | null {
48+
const options = Array.from(document.querySelectorAll('[ngoption]')) as HTMLElement[];
49+
return options.find(option => option.textContent?.trim() === text) || null;
50+
}
51+
52+
it('should relay ArrowDown to the listbox and update active descendant', fakeAsync(() => {
53+
// Open the popup (sets active descendant to Alabama via default state)
54+
keydown('ArrowDown');
55+
tick();
56+
fixture.detectChanges();
57+
58+
// Check if expanded is true
59+
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
60+
// Active descendant is bound to host:
61+
const alabama = getOption('Alabama')!;
62+
expect(inputElement.getAttribute('aria-activedescendant')).toBe(alabama.id);
63+
64+
// Press ArrowDown again to move to Alaska
65+
keydown('ArrowDown');
66+
tick();
67+
fixture.detectChanges();
68+
69+
const alaska = getOption('Alaska')!;
70+
expect(inputElement.getAttribute('aria-activedescendant')).toBe(alaska.id);
71+
}));
72+
});
73+
74+
@Component({
75+
template: `
76+
<div class="parent">
77+
<input
78+
ngCombobox
79+
#combobox="ngCombobox"
80+
[(value)]="searchString"
81+
[(expanded)]="popupExpanded"
82+
(click)="popupExpanded.set(true)"
83+
/>
84+
<ng-template ngComboboxPopup [combobox]="combobox">
85+
<ul ngListbox ngComboboxWidget #listbox="ngListbox" [activeDescendant]="listbox.activeDescendant()" focusMode="activedescendant" selectionMode="explicit">
86+
@for (option of options(); track option) {
87+
<li ngOption [value]="option">{{option}}</li>
88+
}
89+
</ul>
90+
</ng-template>
91+
</div>
92+
`,
93+
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option],
94+
})
95+
class ComboboxListboxZoneExample {
96+
popupExpanded = signal(false);
97+
searchString = signal('');
98+
value = signal<string[]>([]);
99+
options = computed(() => ['Alabama', 'Alaska', 'Arizona', 'Arkansas']);
100+
}

src/aria/private/combobox/combobox.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,8 @@ export class ComboboxPattern {
260260
untracked(() => {
261261
const popup = this.inputs.popup();
262262
if (this.isExpanded()) {
263-
popup?.controlTarget()?.dispatchEvent(event);
263+
const relayedEvent = new KeyboardEvent(event.type, event);
264+
popup?.controlTarget()?.dispatchEvent(relayedEvent);
264265
}
265266
});
266267
}

0 commit comments

Comments
 (0)