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
73 changes: 62 additions & 11 deletions src/aria/combobox/combobox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,17 +242,19 @@ describe('Combobox', () => {
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
});

it('should clear the completion string and not close on escape when a completion is present', () => {
it('should close then clear the completion string', () => {
fixture.componentInstance.filterMode.set('highlight');
focus();
input('A');
input('Ala');
expect(inputElement.value).toBe('Alabama');
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
escape();
expect(inputElement.value).toBe('A');
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
expect(inputElement.value).toBe('Alabama');
expect(inputElement.selectionEnd).toBe(7);
expect(inputElement.selectionStart).toBe(3);
expect(inputElement.getAttribute('aria-expanded')).toBe('false'); // close
escape();
expect(inputElement.value).toBe('A');
expect(inputElement.value).toBe(''); // clear input
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
});

Expand Down Expand Up @@ -424,6 +426,28 @@ describe('Combobox', () => {
expect(inputElement.selectionEnd).toBe(7);
}));

it('should not insert a completion string on backspace', fakeAsync(() => {
focus();
input('New');
tick();

expect(inputElement.value).toBe('New Hampshire');
expect(inputElement.selectionStart).toBe(3);
expect(inputElement.selectionEnd).toBe(13);
}));

it('should insert a completion string even if the items are not changed', fakeAsync(() => {
focus();
input('New');
tick();

input('New ');
tick();
expect(inputElement.value).toBe('New Hampshire');
expect(inputElement.selectionStart).toBe(4);
expect(inputElement.selectionEnd).toBe(13);
}));

it('should commit the selected option on focusout', () => {
focus();
input('Cali');
Expand All @@ -438,15 +462,15 @@ describe('Combobox', () => {
// TODO(wagnermaciel): Add unit tests for disabled options.

describe('Filtering', () => {
beforeEach(() => setupCombobox());

it('should lazily render options', () => {
setupCombobox();
expect(getOptions().length).toBe(0);
focus();
expect(getOptions().length).toBe(50);
});

it('should filter the options based on the input value', () => {
setupCombobox();
focus();
input('New');

Expand All @@ -459,20 +483,47 @@ describe('Combobox', () => {
});

it('should show no options if nothing matches', () => {
setupCombobox();
focus();
input('xyz');
const options = getOptions();
expect(options.length).toBe(0);
});

it('should show all options when the input is cleared', () => {
setupCombobox();
focus();
input('Alabama');
expect(getOptions().length).toBe(1);

input('');
expect(getOptions().length).toBe(50);
});

it('should determine the highlighted state on open', () => {
setupCombobox({filterMode: 'highlight'});
focus();
input('N');
expect(inputElement.value).toBe('Nebraska');
expect(inputElement.selectionEnd).toBe(8);
expect(inputElement.selectionStart).toBe(1);
expect(getOptions().length).toBe(8);

escape(); // close
inputElement.selectionStart = 2; // Change highlighting
down(); // open

expect(inputElement.value).toBe('Nebraska');
expect(inputElement.selectionEnd).toBe(8);
expect(inputElement.selectionStart).toBe(2);
expect(getOptions().length).toBe(6);

escape(); // close
inputElement.selectionStart = 3; // Change highlighting
down(); // open

expect(getOptions().length).toBe(1);
});
});

// describe('with programmatic value changes', () => {
Expand Down Expand Up @@ -907,17 +958,17 @@ describe('Combobox', () => {
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
});

it('should clear the completion string and not close on escape when a completion is present', () => {
it('should close then clear the completion string', () => {
fixture.componentInstance.filterMode.set('highlight');
focus();
input('Mar');
expect(inputElement.value).toBe('March');
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
escape();
expect(inputElement.value).toBe('Mar');
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
expect(inputElement.value).toBe('March');
expect(inputElement.getAttribute('aria-expanded')).toBe('false'); // close
escape();
expect(inputElement.value).toBe('Mar');
expect(inputElement.value).toBe(''); // clear input
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
});

Expand Down
1 change: 1 addition & 0 deletions src/aria/combobox/combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export class ComboboxInput {

/** Focuses & selects the first item in the combobox if the user changes the input value. */
afterRenderEffect(() => {
this.value();
this.combobox.popup()?.controls()?.items();
untracked(() => this.combobox.pattern.onFilter());
});
Expand Down
64 changes: 45 additions & 19 deletions src/aria/ui-patterns/combobox/combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
if (!this.expanded()) {
return new KeyboardEventManager()
.on('ArrowDown', () => this.open({first: true}))
.on('ArrowUp', () => this.open({last: true}));
.on('ArrowUp', () => this.open({last: true}))
.on('Escape', () => this.close({reset: true}));
}

const popupControls = this.inputs.popupControls();
Expand All @@ -166,21 +167,7 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
.on('ArrowUp', () => this.prev())
.on('Home', () => this.first())
.on('End', () => this.last())
.on('Escape', () => {
// TODO(wagnermaciel): We may want to fold this logic into the close() method.
if (this.inputs.filterMode() === 'highlight' && popupControls.activeId()) {
popupControls.unfocus();
popupControls.clearSelection();

const inputEl = this.inputs.inputEl();
if (inputEl) {
inputEl.value = this.inputs.inputValue!();
}
} else {
this.close();
this.inputs.popupControls()?.clearSelection();
}
}) // TODO: When filter mode is 'highlight', escape should revert to the last committed value.
.on('Escape', () => this.close({reset: true}))
.on('Enter', () => this.select({commit: true, close: true}));

if (popupControls.role() === 'tree') {
Expand Down Expand Up @@ -253,6 +240,10 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
this.inputs.popupControls()?.clearSelection();
}
}

if (this.inputs.filterMode() === 'highlight' && !this.isDeleting) {
this.highlight();
}
}

/** Handles focus in events for the combobox. */
Expand Down Expand Up @@ -367,15 +358,50 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
}

/** Closes the combobox. */
close() {
this.expanded.set(false);
this.inputs.popupControls()?.unfocus();
close(opts?: {reset: boolean}) {
if (!opts?.reset) {
this.expanded.set(false);
this.inputs.popupControls()?.unfocus();
return;
}

const popupControls = this.inputs.popupControls();

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

const inputEl = this.inputs.inputEl();

if (inputEl) {
inputEl.value = '';
}
} else if (this.expanded()) {
this.close();

const selectedItem = popupControls?.getSelectedItem();

if (selectedItem?.searchTerm() !== this.inputs.inputValue!()) {
popupControls?.clearSelection();
}
}
}

/** Opens the combobox. */
open(nav?: {first?: boolean; last?: boolean}) {
this.expanded.set(true);

const inputEl = this.inputs.inputEl();

if (inputEl) {
const isHighlighting = inputEl.selectionStart !== inputEl.value.length;
this.inputs.inputValue?.set(inputEl.value.slice(0, inputEl.selectionStart || 0));

if (!isHighlighting) {
this.highlightedItem.set(undefined);
}
}

if (nav?.first) {
this.first();
}
Expand Down
Loading