Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion goldens/aria/private/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export class ComboboxPattern {
readonly isEditable: _angular_core.Signal<boolean>;
readonly isExpanded: _angular_core.Signal<boolean>;
readonly isFocused: _angular_core.WritableSignal<boolean>;
readonly keyboardEventRelay: _angular_core.WritableSignal<KeyboardEvent | undefined>;
readonly keyboardEventRelay: _angular_core.WritableSignal<KeyboardEvent[]>;
keyboardEventRelayEffect(): void;
keydown: _angular_core.Signal<KeyboardEventManager<KeyboardEvent>>;
readonly nativeDisabled: _angular_core.Signal<"" | null>;
Expand Down
79 changes: 79 additions & 0 deletions src/aria/combobox/combobox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,42 @@ describe('Combobox', () => {
options[options.length - 1].id,
);
});

it('should handle multiple navigation keys pressed back to back in the same render', async () => {
await down(); // Open popup (focuses options[0]: Alabama)

dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'ArrowDown');
dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'ArrowDown');
dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'ArrowDown');
await fixture.whenStable();

const options = getOptions();
expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[3].id);
});

it('should handle navigation keys pressed in the same render that opens the popup', async () => {
await focus();

dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'ArrowDown'); // Open (focuses options[0])
dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'ArrowDown'); // Move to options[1]
await fixture.whenStable();

const options = getOptions();
expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[1].id);
});

it('should relay back-to-back typeahead characters in order on a non-editable combobox', async () => {
await setupCombobox(ComboboxSelectOnlyListboxExample);
await down(); // Open popup (focuses options[0]: Alabama)

for (const char of 'new y') {
dispatchKeyboardEvent(inputElement, 'keydown', undefined, char);
}
await fixture.whenStable();

const newYork = getOption('New York')!;
expect(inputElement.getAttribute('aria-activedescendant')).toBe(newYork.id);
});
});

describe('Expansion', () => {
Expand Down Expand Up @@ -346,6 +382,18 @@ describe('Combobox', () => {

expect(fixture.componentInstance.valueChanges).toEqual([]);
});

it('should select the target option when navigation keys and Enter are pressed in the same render', async () => {
await down(); // Open popup (focuses Alabama)

dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'ArrowDown');
dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'ArrowDown');
dispatchKeyboardEvent(inputElement, 'keydown', undefined, 'Enter');
await fixture.whenStable();

expect(fixture.componentInstance.value()).toEqual(['Arizona']);
expect(inputElement.value).toBe('Arizona');
});
});

describe('with auto-select behavior', () => {
Expand Down Expand Up @@ -1937,3 +1985,34 @@ class ComboboxListboxGeneratedIdExample {
popupExpanded = signal(false);
options = ['Apple', 'Banana'];
}

@Component({
template: `
<div>
<div
ngCombobox
#combobox="ngCombobox"
aria-label="State"
[(expanded)]="popupExpanded"
>Pick a state</div>
<ng-template ngComboboxPopup [combobox]="combobox">
<div
ngComboboxWidget
#listbox="ngListbox"
ngListbox
focusMode="activedescendant"
[activeDescendant]="listbox.activeDescendant()">
@for (option of options; track option) {
<div ngOption [value]="option" [label]="option">{{option}}</div>
}
</div>
</ng-template>
</div>
`,
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option],
})
class ComboboxSelectOnlyListboxExample {
popupExpanded = signal(false);
options = states;
}
23 changes: 17 additions & 6 deletions src/aria/private/combobox/combobox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,12 +273,12 @@ describe('ComboboxPattern', () => {
const shiftUp = createKeyboardEvent('keydown', 38, 'ArrowUp');
Object.defineProperty(shiftUp, 'shiftKey', {value: true});
pattern.onKeydown(shiftUp);
expect(pattern.keyboardEventRelay()).toBe(shiftUp);
expect(pattern.keyboardEventRelay()).toEqual([shiftUp]);

const shiftDown = createKeyboardEvent('keydown', 40, 'ArrowDown');
Object.defineProperty(shiftDown, 'shiftKey', {value: true});
pattern.onKeydown(shiftDown);
expect(pattern.keyboardEventRelay()).toBe(shiftDown);
expect(pattern.keyboardEventRelay()).toEqual([shiftUp, shiftDown]);
});

it('should NOT forward Ctrl+A or Shift+Home/End for editable inputs', () => {
Expand All @@ -288,12 +288,12 @@ describe('ComboboxPattern', () => {
const ctrlA = createKeyboardEvent('keydown', 65, 'a');
Object.defineProperty(ctrlA, 'ctrlKey', {value: true});
pattern.onKeydown(ctrlA);
expect(pattern.keyboardEventRelay()).toBeUndefined();
expect(pattern.keyboardEventRelay()).toEqual([]);

const shiftHome = createKeyboardEvent('keydown', 36, 'Home');
Object.defineProperty(shiftHome, 'shiftKey', {value: true});
pattern.onKeydown(shiftHome);
expect(pattern.keyboardEventRelay()).toBeUndefined();
expect(pattern.keyboardEventRelay()).toEqual([]);
});

it('should forward Ctrl+A and Shift+Home/End for select-only (non-editable) comboboxes', () => {
Expand All @@ -305,12 +305,23 @@ describe('ComboboxPattern', () => {
const ctrlA = createKeyboardEvent('keydown', 65, 'a');
Object.defineProperty(ctrlA, 'ctrlKey', {value: true});
pattern.onKeydown(ctrlA);
expect(pattern.keyboardEventRelay()).toBe(ctrlA);
expect(pattern.keyboardEventRelay()).toEqual([ctrlA]);

const shiftHome = createKeyboardEvent('keydown', 36, 'Home');
Object.defineProperty(shiftHome, 'shiftKey', {value: true});
pattern.onKeydown(shiftHome);
expect(pattern.keyboardEventRelay()).toBe(shiftHome);
expect(pattern.keyboardEventRelay()).toEqual([ctrlA, shiftHome]);
});

it('should only enqueue Space once on select-only (non-editable) comboboxes', () => {
const selectOnlyElement = document.createElement('div');
const {pattern, expanded} = setup();
pattern.inputs.element = signal(selectOnlyElement);
expanded.set(true);

const space = createKeyboardEvent('keydown', 32, ' ');
pattern.onKeydown(space);
expect(pattern.keyboardEventRelay()).toEqual([space]);
});
});

Expand Down
65 changes: 37 additions & 28 deletions src/aria/private/combobox/combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ export class ComboboxPattern {
return 'none';
});

/** A relay for keyboard events to the popup. */
readonly keyboardEventRelay = signal<KeyboardEvent | undefined>(undefined);
/** A relay queue for keyboard events to the popup. */
readonly keyboardEventRelay = signal<KeyboardEvent[]>([]);

/** Whether the combobox is focused. */
readonly isFocused = signal(false);
Expand Down Expand Up @@ -137,44 +137,43 @@ export class ComboboxPattern {
.on(
'ArrowLeft',
e => {
this.keyboardEventRelay.set(e);
this._relayKeydown(e);
},
{preventDefault: this.popupType() !== 'listbox', ignoreRepeat: false},
)
.on(
'ArrowRight',
e => {
this.keyboardEventRelay.set(e);
this._relayKeydown(e);
},
{preventDefault: this.popupType() !== 'listbox', ignoreRepeat: false},
)
.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('PageUp', e => this.keyboardEventRelay.set(e))
.on('PageDown', e => this.keyboardEventRelay.set(e))
.on('ArrowUp', e => this._relayKeydown(e), {ignoreRepeat: false})
.on('ArrowDown', e => this._relayKeydown(e), {ignoreRepeat: false})
.on(Modifier.Shift, 'ArrowUp', e => this._relayKeydown(e), {ignoreRepeat: false})
.on(Modifier.Shift, 'ArrowDown', e => this._relayKeydown(e), {ignoreRepeat: false})
.on('Home', e => this._relayKeydown(e))
.on('End', e => this._relayKeydown(e))
.on('PageUp', e => this._relayKeydown(e))
.on('PageDown', e => this._relayKeydown(e))
.on('Escape', () => {
if (!this.inputs.alwaysExpanded()) {
this.inputs.expanded.set(false);
this.keyboardEventRelay.set([]);
}
});

if (!this.readonly()) {
manager.on('Enter', e => this.keyboardEventRelay.set(e));
manager.on('Enter', e => this._relayKeydown(e));
}

if (!this.isEditable() && !this.readonly()) {
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([Modifier.Ctrl, Modifier.Meta], 'A', e => this._relayKeydown(e))
.on(Modifier.Shift, 'Home', e => this._relayKeydown(e))
.on(Modifier.Shift, 'End', e => this._relayKeydown(e))
.on(/^.$/, e => {
this.keyboardEventRelay.set(e);
this._relayKeydown(e);
});
}

Expand All @@ -196,6 +195,11 @@ export class ComboboxPattern {
this.value = inputs.value;
}

/** Queues a keyboard event to be relayed to the popup. */
private _relayKeydown(event: KeyboardEvent) {
this.keyboardEventRelay.update(events => [...events, event]);
}

/** Handles keydown events for the combobox. */
onKeydown(event: KeyboardEvent) {
if (!this.inputs.disabled()) {
Expand Down Expand Up @@ -266,19 +270,24 @@ export class ComboboxPattern {

/** Relays keyboard events to the popup. */
keyboardEventRelayEffect() {
const event = this.keyboardEventRelay();
if (event === undefined) return;
const events = this.keyboardEventRelay();
if (events.length === 0) return;

// Reset isDeleting when the user navigates, so that the highlight effect can run again.
this.isDeleting.set(false);

untracked(() => {
const popup = this.inputs.popup();
if (this.isExpanded()) {
const relayedEvent = new KeyboardEvent(event.type, event);
popup?.controlTarget()?.dispatchEvent(relayedEvent);
}
});
if (!untracked(() => this.isExpanded())) {
this.keyboardEventRelay.set([]);
return;
}

const target = this.inputs.popup()?.controlTarget();
if (!target) return;

this.keyboardEventRelay.set([]);
for (const event of events) {
target.dispatchEvent(new KeyboardEvent(event.type, event));
}
}
}

Expand Down
Loading