diff --git a/src/aria/combobox/combobox.spec.ts b/src/aria/combobox/combobox.spec.ts index 4eae17061f5e..5dacc5cdfb16 100644 --- a/src/aria/combobox/combobox.spec.ts +++ b/src/aria/combobox/combobox.spec.ts @@ -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'); }); @@ -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'); @@ -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'); @@ -459,6 +483,7 @@ describe('Combobox', () => { }); it('should show no options if nothing matches', () => { + setupCombobox(); focus(); input('xyz'); const options = getOptions(); @@ -466,6 +491,7 @@ describe('Combobox', () => { }); it('should show all options when the input is cleared', () => { + setupCombobox(); focus(); input('Alabama'); expect(getOptions().length).toBe(1); @@ -473,6 +499,31 @@ describe('Combobox', () => { 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', () => { @@ -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'); }); diff --git a/src/aria/combobox/combobox.ts b/src/aria/combobox/combobox.ts index 2c0af9581db7..8490508f7476 100644 --- a/src/aria/combobox/combobox.ts +++ b/src/aria/combobox/combobox.ts @@ -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()); }); diff --git a/src/aria/ui-patterns/combobox/combobox.ts b/src/aria/ui-patterns/combobox/combobox.ts index 9d8163d68db5..32887abd1e94 100644 --- a/src/aria/ui-patterns/combobox/combobox.ts +++ b/src/aria/ui-patterns/combobox/combobox.ts @@ -152,7 +152,8 @@ export class ComboboxPattern, 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(); @@ -166,21 +167,7 @@ export class ComboboxPattern, 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') { @@ -253,6 +240,10 @@ export class ComboboxPattern, V> { this.inputs.popupControls()?.clearSelection(); } } + + if (this.inputs.filterMode() === 'highlight' && !this.isDeleting) { + this.highlight(); + } } /** Handles focus in events for the combobox. */ @@ -367,15 +358,50 @@ export class ComboboxPattern, 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(); }