Skip to content
Open
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
12 changes: 6 additions & 6 deletions src/aria/combobox/combobox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe('Combobox', () => {

const click = (element: HTMLElement, eventInit?: PointerEventInit) => {
focus();
element.dispatchEvent(new PointerEvent('pointerup', {bubbles: true, ...eventInit}));
element.dispatchEvent(new PointerEvent('click', {bubbles: true, ...eventInit}));
fixture.detectChanges();
};

Expand Down Expand Up @@ -550,13 +550,13 @@ describe('Combobox', () => {
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
});

it('should clear selection on escape when closed', () => {
it('should NOT clear selection on escape when closed', () => {
focus();
down();
enter();
expect(inputElement.value).toBe('Alabama');
escape();
expect(inputElement.value).toBe('');
expect(inputElement.value).toBe('Alabama');
});
});

Expand Down Expand Up @@ -603,7 +603,7 @@ describe('Combobox', () => {

const click = (element: HTMLElement, eventInit?: PointerEventInit) => {
focus();
element.dispatchEvent(new PointerEvent('pointerup', {bubbles: true, ...eventInit}));
element.dispatchEvent(new PointerEvent('click', {bubbles: true, ...eventInit}));
fixture.detectChanges();
};

Expand Down Expand Up @@ -1114,7 +1114,7 @@ describe('Combobox', () => {
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
});

it('should clear selection on escape when closed', () => {
it('should NOT clear selection on escape when closed', () => {
focus();
down();
right();
Expand All @@ -1123,7 +1123,7 @@ describe('Combobox', () => {
expect(inputElement.value).toBe('December');
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
escape();
expect(inputElement.value).toBe('');
expect(inputElement.value).toBe('December');
});
});
});
Expand Down
2 changes: 1 addition & 1 deletion src/aria/combobox/combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {toSignal} from '@angular/core/rxjs-interop';
'[attr.data-expanded]': 'expanded()',
'(input)': '_pattern.onInput($event)',
'(keydown)': '_pattern.onKeydown($event)',
'(pointerup)': '_pattern.onPointerup($event)',
'(click)': '_pattern.onClick($event)',
'(focusin)': '_pattern.onFocusIn()',
'(focusout)': '_pattern.onFocusOut($event)',
},
Expand Down
11 changes: 7 additions & 4 deletions src/aria/private/behaviors/list-typeahead/list-typeahead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,13 @@ export class ListTypeahead<T extends ListTypeaheadItem> {
*/
private _getItem() {
let items = this.focusManager.inputs.items();
const after = items.slice(this._startIndex()! + 1);
const before = items.slice(0, this._startIndex()!);
items = after.concat(before);
items.push(this.inputs.items()[this._startIndex()!]);

if (this._startIndex() !== -1) {
const after = items.slice(this._startIndex()! + 1);
const before = items.slice(0, this._startIndex()!);
items = after.concat(before);
items.push(this.inputs.items()[this._startIndex()!]);
}

const focusableItems = [];
for (const item of items) {
Expand Down
68 changes: 49 additions & 19 deletions src/aria/private/combobox/combobox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ describe('Combobox with Listbox Pattern', () => {
it('should open on click', () => {
const {combobox, inputEl} = getPatterns();
expect(combobox.expanded()).toBe(false);
combobox.onPointerup(clickInput(inputEl));
combobox.onClick(clickInput(inputEl));
expect(combobox.expanded()).toBe(true);
});

Expand Down Expand Up @@ -357,7 +357,7 @@ describe('Combobox with Listbox Pattern', () => {
it('should not expand when disabled', () => {
const {combobox, inputEl} = getPatterns({disabled: true});
expect(combobox.expanded()).toBe(false);
combobox.onPointerup(clickInput(inputEl));
combobox.onClick(clickInput(inputEl));
expect(combobox.expanded()).toBe(false);
});
});
Expand All @@ -381,7 +381,7 @@ describe('Combobox with Listbox Pattern', () => {
});

it('should select and commit on click', () => {
combobox.onPointerup(clickOption(listbox.inputs.items(), 0));
combobox.onClick(clickOption(listbox.inputs.items(), 0));
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]);
expect(listbox.inputs.value()).toEqual(['Apple']);
expect(inputEl.value).toBe('Apple');
Expand Down Expand Up @@ -442,7 +442,7 @@ describe('Combobox with Listbox Pattern', () => {
});

it('should select and commit on click', () => {
combobox.onPointerup(clickOption(listbox.inputs.items(), 3));
combobox.onClick(clickOption(listbox.inputs.items(), 3));
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[3]);
expect(listbox.inputs.value()).toEqual(['Blackberry']);
expect(inputEl.value).toBe('Blackberry');
Expand Down Expand Up @@ -503,7 +503,7 @@ describe('Combobox with Listbox Pattern', () => {
});

it('should select and commit on click', () => {
combobox.onPointerup(clickOption(listbox.inputs.items(), 3));
combobox.onClick(clickOption(listbox.inputs.items(), 3));
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[3]);
expect(listbox.inputs.value()).toEqual(['Blackberry']);
expect(inputEl.value).toBe('Blackberry');
Expand Down Expand Up @@ -589,7 +589,7 @@ describe('Combobox with Listbox Pattern', () => {
describe('Readonly mode', () => {
it('should select and close on selection', () => {
const {combobox, listbox, inputEl} = getPatterns({readonly: true});
combobox.onPointerup(clickOption(listbox.inputs.items(), 2));
combobox.onClick(clickOption(listbox.inputs.items(), 2));
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[2]);
expect(listbox.inputs.value()).toEqual(['Banana']);
expect(inputEl.value).toBe('Banana');
Expand All @@ -604,14 +604,44 @@ describe('Combobox with Listbox Pattern', () => {
expect(combobox.expanded()).toBe(false);
});

it('should clear selection on escape when already closed', () => {
it('should NOT clear selection on escape when already closed', () => {
const {combobox, listbox} = getPatterns({readonly: true});
combobox.onPointerup(clickOption(listbox.inputs.items(), 2));
combobox.onClick(clickOption(listbox.inputs.items(), 2));
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[2]);
expect(listbox.inputs.value()).toEqual(['Banana']);
combobox.onKeydown(escape());
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[2]);
expect(listbox.inputs.value()).toEqual(['Banana']);
});

it('should navigate on typeahead', () => {
const {combobox, listbox} = getPatterns({readonly: true});
expect(listbox.inputs.activeItem()).toBe(undefined);
combobox.onKeydown(createKeyboardEvent('keydown', 67, 'C'));
expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[5]);
});

it('should select on typeahead and filter mode is auto-select', () => {
const {combobox, listbox, inputEl} = getPatterns({readonly: true, filterMode: 'auto-select'});
combobox.onKeydown(createKeyboardEvent('keydown', 67, 'C'));
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[5]);
expect(listbox.inputs.value()).toEqual(['Cantaloupe']);
expect(inputEl.value).toBe('');
});

it('should NOT select on typeahead when filter mode is manual', () => {
const {combobox, listbox, inputEl} = getPatterns({readonly: true, filterMode: 'manual'});
combobox.onKeydown(createKeyboardEvent('keydown', 67, 'C'));
expect(listbox.getSelectedItem()).toBe(undefined);
expect(listbox.inputs.value()).toEqual([]);
expect(inputEl.value).toBe('');
});

it('should expand on typeahead', () => {
const {combobox} = getPatterns({readonly: true});
expect(combobox.expanded()).toBe(false);
combobox.onKeydown(createKeyboardEvent('keydown', 67, 'C'));
expect(combobox.expanded()).toBe(true);
});
});
});
Expand Down Expand Up @@ -741,7 +771,7 @@ describe('Combobox with Tree Pattern', () => {
});

it('should select and commit on click', () => {
combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 0));
combobox.onClick(clickTreeItem(tree.inputs.allItems(), 0));
expect(tree.inputs.value()).toEqual(['Fruit']);
expect(inputEl.value).toBe('Fruit');
});
Expand All @@ -755,7 +785,7 @@ describe('Combobox with Tree Pattern', () => {
});

it('should select on focusout if the input text exactly matches an item', () => {
combobox.onPointerup(clickInput(inputEl));
combobox.onClick(clickInput(inputEl));
type('Apple');
combobox.onFocusOut(new FocusEvent('focusout'));
expect(tree.inputs.value()).toEqual(['Apple']);
Expand Down Expand Up @@ -800,7 +830,7 @@ describe('Combobox with Tree Pattern', () => {
});

it('should select and commit on click', () => {
combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 2));
combobox.onClick(clickTreeItem(tree.inputs.allItems(), 2));
expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[2]);
expect(tree.inputs.value()).toEqual(['Banana']);
expect(inputEl.value).toBe('Banana');
Expand Down Expand Up @@ -857,7 +887,7 @@ describe('Combobox with Tree Pattern', () => {
});

it('should select and commit on click', () => {
combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 2));
combobox.onClick(clickTreeItem(tree.inputs.allItems(), 2));
expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[2]);
expect(tree.inputs.value()).toEqual(['Banana']);
expect(inputEl.value).toBe('Banana');
Expand Down Expand Up @@ -927,9 +957,9 @@ describe('Combobox with Tree Pattern', () => {
describe('Readonly mode', () => {
it('should select and close on selection', () => {
const {combobox, tree, inputEl} = getPatterns({readonly: true});
combobox.onPointerup(clickInput(inputEl));
combobox.onClick(clickInput(inputEl));
expect(combobox.expanded()).toBe(true);
combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 0));
combobox.onClick(clickTreeItem(tree.inputs.allItems(), 0));
expect(tree.inputs.value()).toEqual(['Fruit']);
expect(inputEl.value).toBe('Fruit');
expect(combobox.expanded()).toBe(false);
Expand All @@ -943,16 +973,16 @@ describe('Combobox with Tree Pattern', () => {
expect(combobox.expanded()).toBe(false);
});

it('should clear selection on escape when already closed', () => {
it('should NOT clear selection on escape when already closed', () => {
const {combobox, tree, inputEl} = getPatterns({readonly: true});
combobox.onPointerup(clickInput(inputEl));
combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 0));
combobox.onClick(clickInput(inputEl));
combobox.onClick(clickTreeItem(tree.inputs.allItems(), 0));
expect(tree.inputs.value()).toEqual(['Fruit']);
expect(inputEl.value).toBe('Fruit');
expect(combobox.expanded()).toBe(false);
combobox.onKeydown(escape());
expect(tree.inputs.value()).toEqual([]);
expect(inputEl.value).toBe('');
expect(tree.inputs.value()).toEqual(['Fruit']);
expect(inputEl.value).toBe('Fruit');
});
});
});
41 changes: 33 additions & 8 deletions src/aria/private/combobox/combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ export interface ComboboxListboxControls<T extends ListItem<V>, V> {

/** Sets the value of the combobox based on the selected item. */
setValue: (value: V | undefined) => void; // For re-setting the value if the popup was destroyed.

/** Handles typeahead search for the popup. */
search: (char: string, opts?: {selectOne?: boolean}) => void;

/** Whether the user is currently typing for typeahead purposes. */
isTyping: SignalLike<boolean>;
}

export interface ComboboxTreeControls<T extends ListItem<V>, V>
Expand Down Expand Up @@ -147,6 +153,12 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
/** Whether the combobox is read-only. */
readonly = computed(() => this.inputs.readonly() || null);

/** Represents the space key. Does nothing when the user is actively using typeahead. */
dynamicSpaceKey = computed(() => (this.inputs.popupControls()?.isTyping() ? '' : ' '));

/** The regexp used to decide if a key should trigger typeahead. */
typeaheadRegexp = /^.$/;

/** The keydown event manager for the combobox. */
keydown = computed(() => {
if (!this.expanded()) {
Expand All @@ -157,8 +169,9 @@ export class ComboboxPattern<T extends ListItem<V>, V> {

if (this.readonly()) {
manager
.on(' ', () => this.open({selected: true}))
.on('Enter', () => this.open({selected: true}))
.on(' ', () => this.open({selected: true}));
.on(this.typeaheadRegexp, e => this.search(e.key));
}

return manager;
Expand All @@ -179,7 +192,9 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
.on('Enter', () => this.select({commit: true, close: true}));

if (this.readonly()) {
manager.on(' ', () => this.select({commit: true, close: true}));
manager
.on(this.typeaheadRegexp, e => this.search(e.key))
.on(this.dynamicSpaceKey, () => this.select({commit: true, close: true}));
}

if (popupControls.role() === 'tree') {
Expand All @@ -197,8 +212,8 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
return manager;
});

/** The pointerup event manager for the combobox. */
pointerup = computed(() =>
/** The click event manager for the combobox. */
click = computed(() =>
new PointerEventManager().on(e => {
const item = this.inputs.popupControls()?.getItem(e);

Expand Down Expand Up @@ -226,10 +241,10 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
}
}

/** Handles pointerup events for the combobox. */
onPointerup(event: PointerEvent) {
/** Handles click events for the combobox. */
onClick(event: PointerEvent) {
if (!this.inputs.disabled()) {
this.pointerup().handle(event);
this.click().handle(event);
}
}

Expand Down Expand Up @@ -351,6 +366,16 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
}
}

/** Handles typeahead search for the combobox. */
search(char: string) {
if (!this.expanded()) {
this.open();
}

const selectOne = this.inputs.filterMode() !== 'manual';
this.inputs.popupControls()?.search(char, {selectOne});
}

/** Highlights the currently selected item in the combobox. */
highlight() {
const inputEl = this.inputs.inputEl();
Expand Down Expand Up @@ -383,7 +408,7 @@ export class ComboboxPattern<T extends ListItem<V>, V> {

const popupControls = this.inputs.popupControls();

if (!this.expanded()) {
if (!this.expanded() && !this.readonly()) {
this.inputs.inputValue?.set('');
popupControls?.clearSelection();

Expand Down
6 changes: 6 additions & 0 deletions src/aria/private/listbox/combobox-listbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,10 @@ export class ComboboxListboxPattern<V>

/** Sets the value of the combobox listbox. */
setValue = (value: V | undefined) => this.inputs.value.set(value ? [value] : []);

/** Whether the user is currently typing for typeahead. */
isTyping = () => this.listBehavior.isTyping();

/** Handles typeahead search for the listbox. */
search = (char: string, opts?: {selectOne?: boolean}) => this.listBehavior.search(char, opts);
}
6 changes: 6 additions & 0 deletions src/aria/private/tree/combobox-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,10 @@ export class ComboboxTreePattern<V>

/** Collapses all of the tree items. */
collapseAll = () => this.items().forEach(item => item.expansion.close());

/** Whether the user is currently typing for typeahead. */
isTyping = () => this.listBehavior.isTyping();

/** Handles typeahead search for the tree. */
search = (char: string, opts?: {selectOne?: boolean}) => this.listBehavior.search(char, opts);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div ngCombobox #combobox="ngCombobox" class="example-combobox-container" [readonly]="true">
<div ngCombobox #combobox="ngCombobox" class="example-combobox-container" [readonly]="true" filterMode="auto-select">
<div class="example-combobox-input-container">
<input
ngComboboxInput
Expand Down
Loading