diff --git a/src/components/combo/combo.spec.ts b/src/components/combo/combo.spec.ts index ce203d9da..50beb8a54 100644 --- a/src/components/combo/combo.spec.ts +++ b/src/components/combo/combo.spec.ts @@ -1,8 +1,25 @@ import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; import { spy } from 'sinon'; + +import { + altKey, + arrowDown, + arrowUp, + endKey, + enterKey, + escapeKey, + homeKey, + spaceBar, + tabKey, +} from '../common/controllers/key-bindings.js'; import { defineComponents } from '../common/definitions/defineComponents.js'; import { first } from '../common/util.js'; -import { createFormAssociatedTestBed } from '../common/utils.spec.js'; +import { + createFormAssociatedTestBed, + isFocused, + simulateClick, + simulateKeyboard, +} from '../common/utils.spec.js'; import { runValidationContainerTests, type ValidationContainerTestsParams, @@ -15,12 +32,12 @@ import type IgcComboItemComponent from './combo-item.js'; import type IgcComboListComponent from './combo-list.js'; describe('Combo', () => { - interface City { + type City = { id: string; name: string; country: string; zip: string; - } + }; let input: IgcInputComponent; let searchInput: IgcInputComponent; @@ -171,16 +188,16 @@ describe('Combo', () => { >` ); - options = combo.shadowRoot!.querySelector( + options = combo.renderRoot.querySelector( '[part="list"]' ) as IgcComboListComponent; - input = combo.shadowRoot!.querySelector( + input = combo.renderRoot.querySelector( 'igc-input#target' ) as IgcInputComponent; - searchInput = combo.shadowRoot!.querySelector( + searchInput = combo.renderRoot.querySelector( '[part="search-input"]' ) as IgcInputComponent; - list = combo.shadowRoot!.querySelector( + list = combo.renderRoot.querySelector( 'igc-combo-list' ) as IgcComboListComponent; }); @@ -262,8 +279,8 @@ describe('Combo', () => { it('should open the menu upon clicking on the input', async () => { const eventSpy = spy(combo, 'emitEvent'); - input.click(); + simulateClick(input); await elementUpdated(combo); expect(eventSpy).calledWith('igcOpening'); @@ -275,7 +292,7 @@ describe('Combo', () => { const eventSpy = spy(combo, 'emitEvent'); await combo.show(); - input.click(); + simulateClick(input); await elementUpdated(combo); expect(eventSpy).calledWith('igcClosing'); @@ -289,7 +306,8 @@ describe('Combo', () => { event.preventDefault(); }); const eventSpy = spy(combo, 'emitEvent'); - input.click(); + + simulateClick(input); await elementUpdated(combo); expect(eventSpy).calledOnceWithExactly('igcOpening', { @@ -304,7 +322,8 @@ describe('Combo', () => { event.preventDefault(); }); const eventSpy = spy(combo, 'emitEvent'); - input.click(); + + simulateClick(input); await elementUpdated(combo); expect(eventSpy).calledOnceWithExactly('igcClosing', { @@ -347,7 +366,7 @@ describe('Combo', () => { it('should configure the filtering options by attribute', async () => { combo.setAttribute( 'filtering-options', - '{"filterKey": "zip", "caseSensitive": true}' + JSON.stringify({ filterKey: 'zip', caseSensitive: true }) ); await elementUpdated(combo); @@ -356,7 +375,10 @@ describe('Combo', () => { }); it('should correctly merge partially provided filtering options', async () => { - combo.setAttribute('filtering-options', '{"caseSensitive": true }'); + combo.setAttribute( + 'filtering-options', + JSON.stringify({ caseSensitive: true }) + ); await elementUpdated(combo); expect(combo.filteringOptions.filterKey).not.to.be.undefined; @@ -364,14 +386,12 @@ describe('Combo', () => { }); it('should select/deselect an item by value key', async () => { - const item = cities[0]; + const item = first(cities); combo.open = true; combo.select([item[combo.valueKey!]]); await elementUpdated(combo); - await new Promise((resolve) => { - setTimeout(resolve, 200); - }); + await list.layoutComplete; const selected = items(combo).find((item) => item.selected); expect(selected?.innerText).to.equal(item[combo.displayKey!]); @@ -393,40 +413,30 @@ describe('Combo', () => { combo.open = true; await elementUpdated(combo); - const item = cities[0]; + const item = first(cities); combo.select([item]); await elementUpdated(combo); - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); + await list.layoutComplete; - const selected = items(combo).find((item) => item.selected); - expect(selected?.innerText).to.equal(item[combo.displayKey!]); + const selected = items(combo).find((item) => item.selected)!; + expect(selected.innerText).to.equal(item[combo.displayKey!]); combo.deselect([item]); - await elementUpdated(combo); - - items(combo).forEach((item) => { - expect(item.selected).to.be.false; - }); + expect(items(combo).every((item) => !item.selected)).to.be.true; }); it('should select/deselect all items', async () => { combo.select(); await elementUpdated(combo); - items(combo).forEach((item) => { - expect(item.selected).to.be.true; - }); + expect(items(combo).every((item) => item.selected)).to.be.true; combo.deselect(); await elementUpdated(combo); - items(combo).forEach((item) => { - expect(item.selected).to.be.false; - }); + expect(items(combo).every((item) => !item.selected)).to.be.true; }); it('should clear the selection by pressing on the clear button', async () => { @@ -439,10 +449,7 @@ describe('Combo', () => { (button! as HTMLSpanElement).click(); await elementUpdated(combo); - - items(combo).forEach((item) => { - expect(item.selected).to.be.false; - }); + expect(items(combo).every((item) => !item.selected)).to.be.true; }); it('should hide the clear button when disableClear is true', async () => { @@ -492,7 +499,7 @@ describe('Combo', () => { }); it('should not fire igcChange event on selection/deselection via methods calls', async () => { - const item = cities[0]; + const item = first(cities); combo.select([item[combo.valueKey!]]); combo.addEventListener('igcChange', (event: CustomEvent) => @@ -509,7 +516,7 @@ describe('Combo', () => { cancelable: true, detail: { newValue: ['BG01'], - items: [cities[0]], + items: [first(cities)], type: 'selection', }, }; @@ -518,8 +525,8 @@ describe('Combo', () => { await elementUpdated(combo); await list.layoutComplete; - items(combo)[0].click(); - expect(combo.value).to.deep.equal(['BG01']); + first(items(combo)).click(); + expect(combo.value).to.eql(['BG01']); expect(eventSpy).calledWithExactly('igcChange', args); }); @@ -529,7 +536,7 @@ describe('Combo', () => { cancelable: true, detail: { newValue: ['BG02', 'BG03'], - items: [cities[0]], + items: [first(cities)], type: 'deselection', }, }; @@ -539,11 +546,11 @@ describe('Combo', () => { await elementUpdated(combo); await list.layoutComplete; - expect(combo.value).to.deep.equal(['BG01', 'BG02', 'BG03']); + expect(combo.value).to.eql(['BG01', 'BG02', 'BG03']); - items(combo)[0].click(); + first(items(combo)).click(); await elementUpdated(combo); - expect(combo.value).to.deep.equal(['BG02', 'BG03']); + expect(combo.value).to.eql(['BG02', 'BG03']); expect(eventSpy).calledWithExactly('igcChange', args); }); @@ -558,11 +565,11 @@ describe('Combo', () => { await elementUpdated(combo); await list.layoutComplete; - items(combo)[0].click(); + first(items(combo)).click(); await elementUpdated(combo); expect(eventSpy).calledWith('igcChange'); - expect(combo.value.length).to.equal(0); + expect(combo.value).to.be.empty; }); it('should be able to cancel the deselection event', async () => { @@ -576,11 +583,11 @@ describe('Combo', () => { await elementUpdated(combo); await list.layoutComplete; - items(combo)[0].click(); + first(items(combo)).click(); await elementUpdated(combo); expect(eventSpy).calledWith('igcChange'); - expect(combo.value.length).to.equal(2); + expect(combo.value).lengthOf(2); }); it('should not stringify values in event', async () => { @@ -605,7 +612,7 @@ describe('Combo', () => { 'igcChange', ({ detail }) => { expect(detail.newValue).to.eql([data[0].id]); - expect(detail.items).to.deep.equal([data[0]]); + expect(detail.items).to.eql([data[0]]); }, { once: true } ); @@ -613,7 +620,7 @@ describe('Combo', () => { await combo.show(); await list.layoutComplete; - items(combo)[0].click(); + first(items(combo)).click(); await elementUpdated(combo); expect(combo.value).to.eql([0]); @@ -649,11 +656,12 @@ describe('Combo', () => { it('opens the list of options when Down or Alt+Down keys are pressed', async () => { combo.open = false; - pressKey(input, 'ArrowDown', 1, { altKey: false }); + + simulateKeyboard(input, arrowDown); expect(combo.open).to.be.true; combo.open = false; - pressKey(input, 'ArrowDown', 1, { altKey: true }); + simulateKeyboard(input, [altKey, arrowDown]); expect(combo.open).to.be.true; }); @@ -661,7 +669,7 @@ describe('Combo', () => { await combo.show(); expect(combo.open).to.be.true; - pressKey(searchInput, 'ArrowUp', 1, { altKey: false }); + simulateKeyboard(searchInput, arrowUp); expect(combo.open).to.be.false; }); @@ -670,7 +678,7 @@ describe('Combo', () => { await list.layoutComplete; expect(items(combo)[0].active).to.be.false; - pressKey(searchInput, 'ArrowDown', 1, { altKey: false }); + simulateKeyboard(searchInput, arrowDown); await elementUpdated(combo); @@ -685,12 +693,12 @@ describe('Combo', () => { await list.layoutComplete; expect(items(combo)[0].active).to.be.false; - pressKey(list, 'ArrowDown', 2, { altKey: false }); + simulateKeyboard(list, arrowDown, 2); await elementUpdated(combo); expect(items(combo)[1].active).to.be.true; - pressKey(options, 'ArrowUp', 1, { altKey: false }); + simulateKeyboard(options, arrowUp); await elementUpdated(combo); @@ -704,8 +712,7 @@ describe('Combo', () => { await combo.show(); await list.layoutComplete; - pressKey(options, 'Home', 1, { altKey: false }); - + simulateKeyboard(options, homeKey); await elementUpdated(combo); expect(items(combo)[0].active).to.be.true; @@ -718,12 +725,11 @@ describe('Combo', () => { await combo.show(); await list.layoutComplete; - pressKey(options, 'End', 1, { altKey: false }); - + simulateKeyboard(options, endKey); await elementUpdated(combo); - const itms = items(combo); - expect(itms[itms.length - 1].active).to.be.true; + const _items = items(combo); + expect(_items[_items.length - 1].active).to.be.true; }); it('should select the active item by pressing the Space key', async () => { @@ -733,27 +739,127 @@ describe('Combo', () => { await combo.show(); await list.layoutComplete; - pressKey(options, 'ArrowDown', 2, { altKey: false }); - pressKey(options, ' ', 1, { altKey: false }); - + simulateKeyboard(options, arrowDown, 2); + simulateKeyboard(options, spaceBar); await elementUpdated(combo); - const itms = items(combo); - expect(itms[1].active).to.be.true; - expect(itms[1].selected).to.be.true; + const _items = items(combo); + expect(_items[1].active).to.be.true; + expect(_items[1].selected).to.be.true; expect(combo.open).to.be.true; }); - it('should select the active item and close the menu by pressing Enter in single selection', async () => { + it('should move focus to the filter input and the close the dropdown on subsequent Arrow Up keypress', async () => { + await combo.show(); + await list.layoutComplete; + + // Move active state to first item and focus to the dropdown + simulateKeyboard(searchInput, arrowDown); + await elementUpdated(combo); + + expect(isFocused(list)).to.be.true; + expect(isFocused(searchInput)).to.be.false; + + // Move focus to the search input + simulateKeyboard(list, arrowUp); + await elementUpdated(combo); + + expect(isFocused(list)).to.be.false; + expect(isFocused(searchInput)).to.be.true; + + simulateKeyboard(searchInput, arrowUp); + await elementUpdated(combo); + + expect(combo.open).to.be.false; + }); + + it('should close the dropdown on Arrow Up when disable-filtering is set', async () => { + combo.disableFiltering = true; + await elementUpdated(combo); + + await combo.show(); + await list.layoutComplete; + + // Activate first item + simulateKeyboard(list, arrowDown); + await elementUpdated(combo); + + expect(isFocused(list)).to.be.true; + + simulateKeyboard(list, arrowUp); + await elementUpdated(combo); + + expect(combo.open).to.be.false; + }); + + it('should close the dropdown on Arrow Up in single-select mode', async () => { combo.singleSelect = true; await elementUpdated(combo); await combo.show(); await list.layoutComplete; - pressKey(options, 'ArrowDown', 1, { altKey: false }); - pressKey(options, 'Enter', 1, { altKey: false }); + // Activate first item + simulateKeyboard(list, arrowDown); + await elementUpdated(combo); + + simulateKeyboard(list, arrowUp); + await elementUpdated(combo); + + expect(combo.open).to.be.false; + }); + + it('should close the menu by pressing the Tab key', async () => { + await combo.show(); + await list.layoutComplete; + + simulateKeyboard(options, tabKey, 1); + await elementUpdated(combo); + expect(combo.open).to.be.false; + await combo.show(); + simulateKeyboard(searchInput, tabKey, 1); + await elementUpdated(combo); + + expect(combo.open).to.be.false; + + await combo.show(); + simulateKeyboard(input, tabKey, 1); + await elementUpdated(combo); + + expect(combo.open).to.be.false; + }); + + it('should clear the selection by pressing the Escape key when the combo is closed', async () => { + combo.autofocusList = true; + combo.select(['BG01', 'BG02']); + await elementUpdated(combo); + + await combo.show(); + await list.layoutComplete; + + simulateKeyboard(options, escapeKey, 1); + await elementUpdated(combo); + + expect(combo.open).to.be.false; + expect(combo.value.length).to.equal(2); + + simulateKeyboard(input, escapeKey, 1); + await elementUpdated(combo); + + expect(combo.open).to.be.false; + expect(combo.value.length).to.equal(0); + }); + + it('should select the active item and close the menu by pressing Enter in single selection', async () => { + combo.singleSelect = true; + await elementUpdated(combo); + + await combo.show(); + await list.layoutComplete; + + simulateKeyboard(options, arrowDown); + simulateKeyboard(options, enterKey); await elementUpdated(combo); expect(combo.value).to.eql(['BG01']); @@ -771,15 +877,51 @@ describe('Combo', () => { await combo.show(); await list.layoutComplete; - pressKey(options, 'ArrowDown', 1, { altKey: false }); - pressKey(options, 'Enter', 1, { altKey: false }); - + simulateKeyboard(options, arrowDown); + simulateKeyboard(options, enterKey); await elementUpdated(combo); expect(combo.value).to.eql(['BG01']); expect(combo.open).to.be.false; }); + it('should close the menu by pressing the Tab key in single selection', async () => { + await combo.show(); + await list.layoutComplete; + + simulateKeyboard(options, tabKey, 1); + await elementUpdated(combo); + + expect(combo.open).to.be.false; + + await combo.show(); + simulateKeyboard(input, tabKey, 1); + await elementUpdated(combo); + + expect(combo.open).to.be.false; + }); + + it('should clear the selection by pressing the Escape key in single selection', async () => { + combo.singleSelect = true; + combo.select('BG01'); + await elementUpdated(combo); + + await combo.show(); + await list.layoutComplete; + + simulateKeyboard(options, escapeKey, 1); + await elementUpdated(combo); + + expect(combo.open).to.be.false; + expect(combo.value.length).to.equal(1); + + simulateKeyboard(input, escapeKey, 1); + await elementUpdated(combo); + + expect(combo.open).to.be.false; + expect(combo.value.length).to.equal(0); + }); + it('should support a single selection variant', async () => { combo.singleSelect = true; await elementUpdated(combo); @@ -817,28 +959,28 @@ describe('Combo', () => { await list.layoutComplete; await filterCombo('Sao'); - expect(items(combo).length).to.equal(0); + expect(items(combo)).to.be.empty; await filterCombo('São'); - expect(items(combo).length).to.equal(1); + expect(items(combo)).lengthOf(1); }); it('should use the main input for filtering in single selection mode', async () => { - const filter = combo.shadowRoot!.querySelector('[part="filter-input"]'); + const filter = combo.shadowRoot!.querySelector('[part="filter-input"]')!; combo.singleSelect = true; await elementUpdated(combo); await combo.show(); await list.layoutComplete; - expect(filter!.getAttribute('hidden')).to.exist; + expect(filter.getAttribute('hidden')).to.exist; expect(input.getAttribute('readonly')).to.not.exist; - expect(items(combo).length).to.equal(cities.length); + expect(items(combo)).lengthOf(cities.length); await filterCombo('sof'); - expect(items(combo).length).to.equal(1); - expect(items(combo)[0].innerText).to.equal('Sofia'); + expect(items(combo)).lengthOf(1); + expect(first(items(combo)).innerText).to.equal('Sofia'); }); it('should select the first matched item upon pressing enter after search', async () => { @@ -850,12 +992,12 @@ describe('Combo', () => { await filterCombo('sof'); - expect(items(combo)[0].active).to.be.true; - - pressKey(input, 'Enter'); + expect(first(items(combo)).active).to.be.true; + simulateKeyboard(input, enterKey); await elementUpdated(combo); - expect(combo.value[0]).to.equal('BG01'); + + expect(first(combo.value)).to.equal('BG01'); }); it('should select only one item at a time in single selection mode', async () => { @@ -866,23 +1008,23 @@ describe('Combo', () => { await list.layoutComplete; input.dispatchEvent(new CustomEvent('igcInput', { detail: 'v' })); - pressKey(input, 'ArrowDown'); + simulateKeyboard(input, arrowDown); await elementUpdated(combo); await list.layoutComplete; - expect(items(combo)[0].active).to.be.true; - expect(items(combo)[0].selected).to.be.false; + expect(first(items(combo)).active).to.be.true; + expect(first(items(combo)).selected).to.be.false; - pressKey(options, ' '); + simulateKeyboard(options, spaceBar); await elementUpdated(combo); await list.layoutComplete; expect(items(combo)[1].selected).to.be.true; - pressKey(options, 'ArrowDown', 2); - pressKey(options, ' '); + simulateKeyboard(options, arrowDown, 2); + simulateKeyboard(options, spaceBar); await elementUpdated(combo); await list.layoutComplete; @@ -903,11 +1045,11 @@ describe('Combo', () => { input.dispatchEvent(new CustomEvent('igcInput', { detail: 'sof' })); await elementUpdated(combo); - pressKey(input, 'Enter'); + simulateKeyboard(input, enterKey); await elementUpdated(combo); expect(input.value).to.equal('Sofia'); - expect(combo.value).to.deep.equal(['BG01']); + expect(combo.value).to.eql(['BG01']); }); it('should clear selection upon changing the search term via input', async () => { @@ -918,26 +1060,23 @@ describe('Combo', () => { await list.layoutComplete; input.dispatchEvent(new CustomEvent('igcInput', { detail: 'v' })); - pressKey(input, 'ArrowDown'); + simulateKeyboard(input, arrowDown); await elementUpdated(combo); await list.layoutComplete; - pressKey(options, ' '); + simulateKeyboard(options, spaceBar); await elementUpdated(combo); await list.layoutComplete; expect(items(combo)[1].selected).to.be.true; - expect(combo.value).to.deep.equal(['BG02']); + expect(combo.value).to.eql(['BG02']); await filterCombo('sof'); - items(combo).forEach((i) => { - expect(i.selected).to.be.false; - }); - - expect(combo.value).to.deep.equal([]); + expect(items(combo).every((item) => !item.selected)).to.be.true; + expect(combo.value).to.be.empty; }); it('Selection API should select nothing in single selection mode if nothing is passed', async () => { @@ -950,11 +1089,8 @@ describe('Combo', () => { combo.select(); await elementUpdated(combo); - items(combo).forEach((i) => { - expect(i.selected).to.be.false; - }); - - expect(combo.value.length).to.equal(0); + expect(items(combo).every((item) => !item.selected)).to.be.true; + expect(combo.value).to.be.empty; }); it('Selection API should deselect everything in single selection mode if nothing is passed', async () => { @@ -971,7 +1107,7 @@ describe('Combo', () => { combo.deselect(); await elementUpdated(combo); - expect(combo.value).to.eql([]); + expect(combo.value).to.be.empty; }); it('Selection API should not deselect current value in single selection mode with wrong valueKey passed', async () => { @@ -1000,13 +1136,13 @@ describe('Combo', () => { await elementUpdated(combo); - const match = cities.find((i) => i.id === selection); - expect(combo.value[0]).to.equal(selection); + const match = cities.find((i) => i.id === selection)!; + expect(first(combo.value)).to.equal(selection); const selected = items(combo).filter((i) => i.selected); - expect(selected.length).to.equal(1); - expect(selected[0].innerText).to.equal(match?.name); + expect(selected).lengthOf(1); + expect(first(selected).innerText).to.equal(match.name); }); it('should deselect a single item using valueKey as argument with the Selection API', async () => { @@ -1020,12 +1156,12 @@ describe('Combo', () => { await elementUpdated(combo); - expect(combo.value[0]).to.equal(selection); + expect(first(combo.value)).to.equal(selection); combo.deselect(selection); await elementUpdated(combo); - expect(combo.value.length).to.equal(0); + expect(combo.value).to.be.empty; items(combo).forEach((i) => { expect(i.selected).to.be.false; @@ -1038,17 +1174,17 @@ describe('Combo', () => { await combo.show(); await list.layoutComplete; - const item = cities[0]; + const item = first(cities); combo.select(item); await elementUpdated(combo); - expect(combo.value[0]).to.equal(item); + expect(first(combo.value)).to.equal(item); const selected = items(combo).filter((i) => i.selected); - expect(selected.length).to.equal(1); - expect(selected[0].innerText).to.equal(item?.name); + expect(selected).lengthOf(1); + expect(first(selected).innerText).to.equal(item.name); }); it('should deselect the item passed as argument with the Selection API', async () => { @@ -1058,21 +1194,18 @@ describe('Combo', () => { await combo.show(); await list.layoutComplete; - const item = cities[0]; + const item = first(cities); combo.select(item); await elementUpdated(combo); - expect(combo.value[0]).to.equal(item); + expect(first(combo.value)).to.equal(item); combo.deselect(item); await elementUpdated(combo); - expect(combo.value.length).to.equal(0); - - items(combo).forEach((i) => { - expect(i.selected).to.be.false; - }); + expect(combo.value).to.be.empty; + expect(items(combo).every((item) => !item.selected)).to.be.true; }); it('should select item(s) even if the list of items has been filtered', async () => { @@ -1086,8 +1219,8 @@ describe('Combo', () => { await list.layoutComplete; // Verify we can only see one item in the list - expect(items(combo).length).to.equal(1); - expect(items(combo)[0].innerText).to.equal('Sofia'); + expect(items(combo)).lengthOf(1); + expect(first(items(combo)).innerText).to.equal('Sofia'); // Select an item not visible in the list using the API const selection = 'US01'; @@ -1095,7 +1228,7 @@ describe('Combo', () => { await elementUpdated(combo); // The combo value should've updated - expect(combo.value[0]).to.equal(selection); + expect(first(combo.value)).to.equal(selection); // Let's verify the list of items has been updated searchInput.dispatchEvent(new CustomEvent('igcInput', { detail: '' })); @@ -1107,10 +1240,10 @@ describe('Combo', () => { const selected = items(combo).filter((item) => item.selected); // We should only see one item as selected - expect(selected.length).to.equal(1); + expect(selected).lengthOf(1); // It should match the one selected via the API - expect(selected[0].innerText).to.equal('New York'); + expect(first(selected).innerText).to.equal('New York'); }); it('should deselect item(s) even if the list of items has been filtered', async () => { @@ -1125,11 +1258,11 @@ describe('Combo', () => { let selected = items(combo).filter((item) => item.selected); // We should only see one item as selected - expect(selected.length).to.equal(1); + expect(selected).lengthOf(1); // It should match the one selected via the API - expect(selected[0].innerText).to.equal('New York'); - expect(combo.value[0]).to.equal(selection); + expect(first(selected).innerText).to.equal('New York'); + expect(first(combo.value)).to.equal(selection); // Filter the list of items searchInput.dispatchEvent(new CustomEvent('igcInput', { detail: 'sof' })); @@ -1138,15 +1271,15 @@ describe('Combo', () => { await list.layoutComplete; // Verify we can only see one item in the list - expect(items(combo).length).to.equal(1); - expect(items(combo)[0].innerText).to.equal('Sofia'); + expect(items(combo)).lengthOf(1); + expect(first(items(combo)).innerText).to.equal('Sofia'); // Deselect the previously selected item while the list is filtered combo.deselect(selection); await elementUpdated(combo); // The value should be updated - expect(combo.value.length).to.equal(0); + expect(combo.value).to.be.empty; // Verify the list of items has been updated searchInput.dispatchEvent(new CustomEvent('igcInput', { detail: '' })); @@ -1158,7 +1291,7 @@ describe('Combo', () => { selected = items(combo).filter((item) => item.selected); // No items should be selected - expect(selected.length).to.equal(0); + expect(selected).to.be.empty; }); it('should display primitive values correctly', async () => { @@ -1179,7 +1312,7 @@ describe('Combo', () => { }); }); - it('should be able to get the currently selected items by calling the `selectoin` getter', async () => { + it('should be able to get the currently selected items by calling the `selection` getter', async () => { combo.select([cities[0].id, cities[1].id, cities[2].id]); await elementUpdated(combo); @@ -1543,21 +1676,3 @@ describe('Combo', () => { }); }); }); - -const pressKey = ( - target: HTMLElement, - key: string, - times = 1, - options?: object -) => { - for (let i = 0; i < times; i++) { - target.dispatchEvent( - new KeyboardEvent('keydown', { - key: key, - bubbles: true, - composed: true, - ...options, - }) - ); - } -}; diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index 25236da85..16b1f486b 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -3,13 +3,9 @@ import { type IComboResourceStrings, } from 'igniteui-i18n-core'; import { html, LitElement, nothing, type TemplateResult } from 'lit'; -import { - property, - query, - queryAssignedElements, - state, -} from 'lit/decorators.js'; +import { property, queryAssignedElements, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; +import { createRef, ref } from 'lit/directives/ref.js'; import { addThemingController } from '../../theming/theming-controller.js'; import { addRootClickController } from '../common/controllers/root-click.js'; @@ -39,7 +35,7 @@ import IgcComboHeaderComponent from './combo-header.js'; import IgcComboItemComponent from './combo-item.js'; import IgcComboListComponent from './combo-list.js'; import { DataController } from './controllers/data.js'; -import { NavigationController } from './controllers/navigation.js'; +import { ComboNavigationController } from './controllers/navigation.js'; import { SelectionController } from './controllers/selection.js'; import { styles } from './themes/combo.base.css.js'; import { styles as shared } from './themes/shared/combo.common.css.js'; @@ -71,6 +67,7 @@ import { comboValidators } from './validators.js'; * @slot suffix - Renders content after the input of the combo. * @slot header - Renders a container before the list of options of the combo. * @slot footer - Renders a container after the list of options of the combo. + * @slot empty - Renders content when the combo dropdown list has no items/data. * @slot helper-text - Renders content below the input of the combo. * @slot toggle-icon - Renders content inside the suffix container of the combo. * @slot clear-icon - Renders content inside the suffix container of the combo. @@ -137,6 +134,15 @@ export default class IgcComboComponent< return comboValidators; } + /** The primary input of the combo component. */ + private _inputRef = createRef(); + + /** The search input of the combo component. */ + private _searchRef = createRef(); + + /** The combo virtualized dropdown list. */ + private _listRef = createRef(); + private readonly _rootClickController = addRootClickController(this, { onHide: async () => { if (!this.handleClosing()) { @@ -165,6 +171,7 @@ export default class IgcComboComponent< setDefaultValue: asArray, }, }); + private _data: T[] = []; private _valueKey?: Keys; @@ -189,7 +196,11 @@ export default class IgcComboComponent< protected _state = new DataController(this); protected _selection = new SelectionController(this, this._state); - protected _navigation = new NavigationController(this, this._state); + protected _navigation = new ComboNavigationController(this, this._state, { + input: this._inputRef, + search: this._searchRef, + list: this._listRef, + }); @queryAssignedElements({ slot: 'suffix' }) protected inputSuffix!: HTMLElement[]; @@ -197,15 +208,6 @@ export default class IgcComboComponent< @queryAssignedElements({ slot: 'prefix' }) protected inputPrefix!: HTMLElement[]; - @query('[part="search-input"]') - protected _searchInput!: IgcInputComponent; - - @query('#target', true) - private _input!: IgcInputComponent; - - @query(IgcComboListComponent.tagName, true) - private _list!: IgcComboListComponent; - /** The data source used to generate the list of options. */ /* treatAsRef */ @property({ attribute: false }) @@ -532,12 +534,6 @@ export default class IgcComboComponent< addThemingController(this, all); addSafeEventListener(this, 'blur', this._handleBlur); addSafeEventListener(this, 'focusin', this._handleFocusIn); - - // TODO - this.addEventListener( - 'keydown', - this._navigation.navigateHost.bind(this._navigation) - ); } protected override async firstUpdated() { @@ -599,20 +595,20 @@ export default class IgcComboComponent< if (!initial) { this._validate(); - this._list.requestUpdate(); + this._listRef.value!.requestUpdate(); } } /* alternateName: focusComponent */ /** Sets focus on the component. */ public override focus(options?: FocusOptions) { - this._input.focus(options); + this._inputRef.value!.focus(options); } /* alternateName: blurComponent */ /** Removes focus from the component. */ public override blur() { - this._input.blur(); + this._inputRef.value!.blur(); } /** @@ -688,7 +684,7 @@ export default class IgcComboComponent< this._navigation.active = detail ? matchIndex : -1; // update the list after changing the active item - this._list.requestUpdate(); + this._listRef.value!.requestUpdate(); // clear the selection upon typing this.clearSingleSelection(); @@ -731,11 +727,11 @@ export default class IgcComboComponent< } if (!this.singleSelect) { - this._list.focus(); + this._listRef.value!.focus(); } if (!this.autofocusList) { - this._searchInput.focus(); + this._searchRef.value!.focus(); } return true; @@ -823,17 +819,6 @@ export default class IgcComboComponent< `; }; - protected listKeydownHandler(event: KeyboardEvent) { - const target = findElementFromEventPath( - IgcComboListComponent.tagName, - event - ); - - if (target) { - this._navigation.navigateList(event, target); - } - } - protected itemClickHandler(event: PointerEvent) { this._setTouchedState(); const target = findElementFromEventPath( @@ -848,10 +833,10 @@ export default class IgcComboComponent< this.toggleSelect(target.index); if (this.singleSelect) { - this._input.focus(); + this._inputRef.value!.focus(); this._hide(); } else { - this._searchInput.focus(); + this._searchRef.value!.focus(); } } @@ -871,6 +856,17 @@ export default class IgcComboComponent< this.updateValue(); } + /** @hidden @internal */ + public clearSelection() { + if (this.singleSelect) { + this.resetSearchTerm(); + this.clearSingleSelection(); + } else { + this._selection.deselect([], true); + } + this.updateValue(); + } + protected clearSingleSelection() { const _selection = this._selection.asArray; const selection = first(_selection); @@ -884,27 +880,10 @@ export default class IgcComboComponent< protected handleClearIconClick(e: PointerEvent) { e.stopPropagation(); - - if (this.singleSelect) { - this.resetSearchTerm(); - this.clearSingleSelection(); - } else { - this._selection.deselect([], true); - } - - this.updateValue(); + this.clearSelection(); this._navigation.active = -1; } - protected handleMainInputKeydown(e: KeyboardEvent) { - this._setTouchedState(); - this._navigation.navigateMainInput(e, this._list); - } - - protected handleSearchInputKeydown(e: KeyboardEvent) { - this._navigation.navigateSearchInput(e, this._list); - } - protected toggleCaseSensitivity() { this.filteringOptions = { caseSensitive: !this.filteringOptions.caseSensitive, @@ -957,6 +936,7 @@ export default class IgcComboComponent< private renderMainInput() { return html` +
${this.renderSearchInput()}
= -1; - -enum DIRECTION { - Up = -1, - Down = 1, -} - -export class NavigationController - implements ReactiveController -{ - protected hostHandlers = new Map( - Object.entries({ - Escape: this.escape, - }) - ); - - protected mainInputHandlers = new Map( - Object.entries({ - Escape: this.escape, - ArrowUp: this.hide, - ArrowDown: this.mainInputArrowDown, - Tab: this.tab, - Enter: this.enter, - }) - ); - - protected searchInputHandlers = new Map( - Object.entries({ - Escape: this.escape, - ArrowUp: this.escape, - ArrowDown: this.inputArrowDown, - Tab: this.inputArrowDown, - }) - ); - - protected listHandlers = new Map( - Object.entries({ - ArrowDown: this.arrowDown, - ArrowUp: this.arrowUp, - ' ': this.space, - Enter: this.enter, - Escape: this.escape, - Tab: this.tab, - Home: this.home, - End: this.end, - }) - ); - - protected _active = START_INDEX; +type ComboNavigationConfig = { + /** The primary input of the combo component. */ + input: Ref; + /** The search input of the combo component. */ + search: Ref; + /** The combo virtualized dropdown list. */ + list: Ref; +}; - public get input() { - // @ts-expect-error protected access - return this.host.singleSelect ? this.host._input : this.host._searchInput; - } - - public get dataState() { - return this.state.dataState; - } - - public show() { - // @ts-expect-error protected access - this.host._show(true); - } - - public hide() { - // @ts-expect-error protected access - this.host._hide(true); - } +export class ComboNavigationController { + private _active = -1; + private _config: ComboNavigationConfig; - public toggleSelect(index: number) { - // @ts-expect-error protected access - this.host.toggleSelect(index); + public get active(): number { + return this._active; } - public select(index: number) { - // @ts-expect-error protected access - this.host.selectByIndex(index); + public set active(value: number) { + this._active = value; + this.combo.requestUpdate(); } - protected get currentItem() { - const item = this.active; - return item === START_INDEX ? START_INDEX : item; + public get input(): IgcInputComponent { + return this._config.input.value!; } - protected get firstItem() { - return this.dataState.findIndex((i: ComboRecord) => i.header !== true); + public get searchInput(): IgcInputComponent { + return this._config.search.value!; } - protected get lastItem() { - return this.dataState.length - 1; + public get list(): IgcComboListComponent { + return this._config.list.value!; } - protected scrollToActive( - container: IgcComboListComponent, - behavior: ScrollBehavior = 'auto' - ) { - container.element(this.active)?.scrollIntoView({ - block: 'center', - behavior, - }); - - container.requestUpdate(); + protected get _firstItem(): number { + return this.state.dataState.findIndex((rec) => !rec.header); } - public get active() { - return this._active; + protected get _lastItem(): number { + return this.state.dataState.length - 1; } - public set active(node: number) { - this._active = node; - this.host.requestUpdate(); + protected async _hide(): Promise { + // @ts-expect-error: protected access + return await this.combo._hide(true); } - constructor( - protected host: ComboHost, - protected state: DataController - ) { - this.host.addController(this); + protected async _show(): Promise { + // @ts-expect-error: protected access + return await this.combo._show(true); } - protected home(container: IgcComboListComponent) { - this.active = this.firstItem; - this.scrollToActive(container, 'smooth'); + protected _toggleSelection(index: number): void { + // @ts-expect-error protected access + this.combo.toggleSelect(index); } - protected end(container: IgcComboListComponent) { - this.active = this.lastItem; - this.scrollToActive(container, 'smooth'); + protected _select(index: number): void { + // @ts-expect-error: protected access + this.combo.selectByIndex(index); } - protected space() { - if (this.active === START_INDEX) { + private _onSpace = (): void => { + if (this._active === -1) { return; } - const item = this.dataState[this.active]; - + const item = this.state.dataState[this._active]; if (!item.header) { - this.toggleSelect(this.active); + this._toggleSelection(this._active); } - } + }; - protected escape() { - this.hide(); - this.host.focus(); - } - - protected enter() { - if (this.active === START_INDEX) { + private _onEnter = async (): Promise => { + if (this._active === -1) { return; } - const item = this.dataState[this.active]; + const item = this.state.dataState[this._active]; - if (!item.header && this.host.singleSelect) { - this.select(this.active); + if (!item.header && this.combo.singleSelect) { + this._select(this.active); } - this.hide(); - requestAnimationFrame(() => this.input.select()); - this.host.focus(); - } - - protected inputArrowDown(container: IgcComboListComponent) { - container.focus(); - this.arrowDown(container); - } - - protected async mainInputArrowDown(container: IgcComboListComponent) { - this.show(); - await container.updateComplete; - - if (this.host.singleSelect) { - container.focus(); - this.arrowDown(container); + if (await this._hide()) { + this.input.select(); + this.combo.focus(); } - } - - protected tab() { - this.hide(); - this.host.blur(); - } - - protected arrowDown(container: IgcComboListComponent) { - this.getNextItem(DIRECTION.Down); - this.scrollToActive(container); - } - - protected arrowUp(container: IgcComboListComponent) { - this.getNextItem(DIRECTION.Up); - this.scrollToActive(container); - } - - protected getNextItem(direction: DIRECTION) { - const next = this.getNearestItem(this.currentItem, direction); - - if (next === -1) { - if (this.active === this.firstItem) { - this.input.focus(); - this.active = START_INDEX; + }; + + private _onTab = async ({ shiftKey }: KeyboardEvent): Promise => { + if (this.combo.open) { + if (shiftKey) { + // Move focus to the main input of the combo + // before the Shift+Tab behavior kicks in. + this.combo.focus(); } - return; + await this._hide(); } + }; - this.active = next; - } - - protected getNearestItem(startIndex: number, direction: number) { - let index = startIndex; - const items = this.dataState; - - while (items[index + direction]?.header) { - index += direction; + private _onEscape = async (): Promise => { + if (!this.combo.open) { + this.combo.clearSelection(); } - index += direction; + if (await this._hide()) { + this.input.focus(); + } + }; - if (index >= 0 && index < items.length) { - return index; + private _onMainInputArrowDown = async (): Promise => { + if (!this.combo.open && !(await this._show())) { + return; } - return -1; - } - public hostConnected() {} + if (this.combo.singleSelect) { + this._onSearchArrowDown(); + } + }; + + private _onSearchArrowDown = (): void => { + this.list.focus(); + this._onArrowDown(); + }; + + private _onHome = (): void => { + this.active = this._firstItem; + this._scrollToActive(); + }; + + private _onEnd = (): void => { + this.active = this._lastItem; + this._scrollToActive(); + }; + + private _onArrowUp = (): void => { + this._getNextItem(-1); + this._scrollToActive(); + }; + + private _onArrowDown = (): void => { + this._getNextItem(1); + this._scrollToActive(); + }; + + private _scrollToActive(behavior?: ScrollBehavior): void { + this.list.element(this.active)?.scrollIntoView({ + block: 'center', + behavior: behavior ?? 'auto', + }); - public hostDisconnected() { - this.active = START_INDEX; + this.list.requestUpdate(); } - public navigateTo(item: T, container: IgcComboListComponent) { - this.active = this.dataState.indexOf(item as ComboRecord); - this.scrollToActive(container, 'smooth'); - } + private _getNearestItem(start: number, delta: -1 | 1): number { + let index = start; + const items = this.state.dataState; - public navigateHost(event: KeyboardEvent) { - if (this.hostHandlers.has(event.key)) { - event.preventDefault(); - this.hostHandlers.get(event.key)!.call(this); + while (items[index + delta]?.header) { + index += delta; } - } - public navigateMainInput( - event: KeyboardEvent, - container: IgcComboListComponent - ) { - event.stopPropagation(); + index += delta; - if (this.mainInputHandlers.has(event.key)) { - event.preventDefault(); - this.mainInputHandlers.get(event.key)!.call(this, container); - } + return index >= 0 && index < items.length ? index : -1; } - public navigateSearchInput( - event: KeyboardEvent, - container: IgcComboListComponent - ) { - event.stopPropagation(); + private _getNextItem(delta: -1 | 1): void { + const next = this._getNearestItem(this._active, delta); - if (this.searchInputHandlers.has(event.key)) { - event.preventDefault(); - this.searchInputHandlers.get(event.key)!.call(this, container); + if (next === -1 && this.active === this._firstItem) { + this.searchInput.checkVisibility() // Non single-select or disable-filtering combo configuration + ? this.searchInput.focus() // Delegate to search input handlers + : this._onEscape(); // Close dropdown and move focus back to main input } + + this.active = next; } - public navigateList(event: KeyboardEvent, container: IgcComboListComponent) { - event.stopPropagation(); + constructor( + protected combo: ComboHost, + protected state: DataController, + config: ComboNavigationConfig + ) { + this.combo.addController(this as ReactiveController); + this._config = config; + + const bindingDefaults = { + triggers: ['keydownRepeat'], + } as KeyBindingOptions; + + const skip = (): boolean => this.combo.disabled; + + // Combo + addKeybindings(this.combo, { skip, bindingDefaults }) + .set(tabKey, this._onTab, { preventDefault: false }) + .set([shiftKey, tabKey], this._onTab, { + preventDefault: false, + }) + .set(escapeKey, this._onEscape); + + // Main input + addKeybindings(this.combo, { + skip, + ref: this._config.input, + bindingDefaults, + }) + .set(arrowUp, async () => await this._hide()) + .set([altKey, arrowDown], this._onMainInputArrowDown) + .set(arrowDown, this._onMainInputArrowDown) + .set(enterKey, this._onEnter); + + // Search input + addKeybindings(this.combo, { + skip, + ref: this._config.search, + bindingDefaults, + }) + .set(arrowUp, this._onEscape) + .set(arrowDown, this._onSearchArrowDown); + + // List + addKeybindings(this.combo, { + skip, + ref: this._config.list, + bindingDefaults, + }) + .set(arrowUp, this._onArrowUp) + .set(arrowDown, this._onArrowDown) + .set(homeKey, this._onHome) + .set(endKey, this._onEnd) + .set(spaceBar, this._onSpace) + .set(enterKey, this._onEnter); + } - if (this.listHandlers.has(event.key)) { - event.preventDefault(); - this.listHandlers.get(event.key)!.call(this, container); - } + public hostDisconnected(): void { + this._active = -1; } }