import { expect, fixture, html, oneEvent, aTimeout } from '@open-wc/testing';
import './nile-combobox';
import type { NileCombobox } from './nile-combobox';
const COUNTRIES = [
{ value: 'cr', label: 'Costa Rica' },
{ value: 'dk', label: 'Denmark' },
{ value: 'fi', label: 'Finland' },
{ value: 'is', label: 'Iceland' },
{ value: 'il', label: 'Israel' },
{ value: 'mx', label: 'Mexico' },
{ value: 'nl', label: 'Netherlands' },
{ value: 'no', label: 'Norway' },
{ value: 'se', label: 'Sweden' },
{ value: 'in', label: 'India' },
];
async function createCombobox(options: { multiple?: boolean; clearable?: boolean } = {}): Promise {
const el = await fixture(
html``,
);
await el.updateComplete;
return el;
}
describe('NileCombobox', () => {
// ── Rendering ──
it('1. renders with shadow root', async () => {
const el = await fixture(html``);
expect(el).to.exist;
expect(el.shadowRoot).to.not.be.null;
});
it('2. registers as custom element', async () => {
expect(customElements.get('nile-combobox')).to.exist;
});
it('3. tag name is nile-combobox', async () => {
const el = await fixture(html``);
expect(el.tagName.toLowerCase()).to.equal('nile-combobox');
});
it('4. has styles defined', async () => {
const { NileCombobox } = await import('./nile-combobox');
expect(NileCombobox.styles).to.exist;
});
it('5. renders nile-popup in shadow root', async () => {
const el = await fixture(html``);
expect(el.shadowRoot!.querySelector('nile-popup')).to.exist;
});
it('6. renders combobox trigger', async () => {
const el = await fixture(html``);
expect(el.shadowRoot!.querySelector('.combobox__trigger')).to.exist;
});
it('7. renders input element with role=combobox', async () => {
const el = await fixture(html``);
const input = el.shadowRoot!.querySelector('.combobox__input') as HTMLInputElement;
expect(input).to.exist;
expect(input.getAttribute('role')).to.equal('combobox');
});
it('8. renders expand icon', async () => {
const el = await fixture(html``);
expect(el.shadowRoot!.querySelector('.combobox__expand-icon')).to.exist;
});
it('9. renders label when label prop is set', async () => {
const el = await fixture(html``);
const label = el.shadowRoot!.querySelector('.form-control__label');
expect(label).to.exist;
expect(label!.textContent).to.include('Country');
});
it('10. renders label slot', async () => {
const el = await fixture(html``);
expect(el.shadowRoot!.querySelector('slot[name="label"]')).to.exist;
});
// ── Default property values ──
it('11. default value is empty', async () => {
const el = await fixture(html``);
expect(el.value).to.equal('');
});
it('12. default multiple is false', async () => {
const el = await fixture(html``);
expect(el.multiple).to.be.false;
});
it('13. default open is false', async () => {
const el = await fixture(html``);
expect(el.open).to.be.false;
});
it('14. default disabled is false', async () => {
const el = await fixture(html``);
expect(el.disabled).to.be.false;
});
it('15. default size is medium', async () => {
const el = await fixture(html``);
expect(el.size).to.equal('medium');
});
it('16. default placement is bottom', async () => {
const el = await fixture(html``);
expect(el.placement).to.equal('bottom');
});
it('17. default clearable is false', async () => {
const el = await fixture(html``);
expect(el.clearable).to.be.false;
});
it('18. default allowCustomValue is false', async () => {
const el = await fixture(html``);
expect(el.allowCustomValue).to.be.false;
});
it('19. default strict is false', async () => {
const el = await fixture(html``);
expect(el.strict).to.be.false;
});
it('20. default debounceMs is 300', async () => {
const el = await fixture(html``);
expect(el.debounceMs).to.equal(300);
});
// ── Data-driven rendering ──
it('21. accepts data array', async () => {
const el = await createCombobox();
expect(el.data).to.have.length(10);
});
it('22. setting value selects an option', async () => {
const el = await createCombobox();
el.value = 'dk';
await el.updateComplete;
expect(el.value).to.equal('dk');
});
it('23. multiple mode accepts array value', async () => {
const el = await createCombobox({ multiple: true });
el.value = ['dk', 'fi'];
await el.updateComplete;
expect(el.value).to.deep.equal(['dk', 'fi']);
});
// ── Disabled state ──
it('24. disabled attribute reflects', async () => {
const el = await fixture(html``);
expect(el.disabled).to.be.true;
expect(el.hasAttribute('disabled')).to.be.true;
});
it('25. disabled combobox has disabled class', async () => {
const el = await fixture(html``);
const popup = el.shadowRoot!.querySelector('.combobox-popup');
expect(popup!.classList.contains('combobox--disabled')).to.be.true;
});
// ── Size variants ──
it('26. small size class applied', async () => {
const el = await fixture(html``);
const popup = el.shadowRoot!.querySelector('.combobox-popup');
expect(popup!.classList.contains('combobox--small')).to.be.true;
});
it('27. large size class applied', async () => {
const el = await fixture(html``);
const popup = el.shadowRoot!.querySelector('.combobox-popup');
expect(popup!.classList.contains('combobox--large')).to.be.true;
});
// ── Style variants ──
it('28. filled variant class applied', async () => {
const el = await fixture(html``);
const popup = el.shadowRoot!.querySelector('.combobox-popup');
expect(popup!.classList.contains('combobox--filled')).to.be.true;
});
it('29. pill variant class applied', async () => {
const el = await fixture(html``);
const popup = el.shadowRoot!.querySelector('.combobox-popup');
expect(popup!.classList.contains('combobox--pill')).to.be.true;
});
// ── Validation state classes ──
it('30. error state class applied', async () => {
const el = await fixture(html``);
const popup = el.shadowRoot!.querySelector('.combobox-popup');
expect(popup!.classList.contains('combobox--error')).to.be.true;
});
it('31. warning state class applied', async () => {
const el = await fixture(html``);
const popup = el.shadowRoot!.querySelector('.combobox-popup');
expect(popup!.classList.contains('combobox--warning')).to.be.true;
});
it('32. success state class applied', async () => {
const el = await fixture(html``);
const popup = el.shadowRoot!.querySelector('.combobox-popup');
expect(popup!.classList.contains('combobox--success')).to.be.true;
});
// ── ARIA attributes ──
it('33. input has aria-expanded=false when closed', async () => {
const el = await fixture(html``);
const input = el.shadowRoot!.querySelector('.combobox__input');
expect(input!.getAttribute('aria-expanded')).to.equal('false');
});
it('34. input has aria-haspopup=listbox', async () => {
const el = await fixture(html``);
const input = el.shadowRoot!.querySelector('.combobox__input');
expect(input!.getAttribute('aria-haspopup')).to.equal('listbox');
});
it('35. input has aria-controls=listbox', async () => {
const el = await fixture(html``);
const input = el.shadowRoot!.querySelector('.combobox__input');
expect(input!.getAttribute('aria-controls')).to.equal('listbox');
});
// ── Methods ──
it('36. has show method', async () => {
const el = await fixture(html``);
expect(el.show).to.be.a('function');
});
it('37. has hide method', async () => {
const el = await fixture(html``);
expect(el.hide).to.be.a('function');
});
it('38. has focus method', async () => {
const el = await fixture(html``);
expect(el.focus).to.be.a('function');
});
it('39. has blur method', async () => {
const el = await fixture(html``);
expect(el.blur).to.be.a('function');
});
it('40. has checkValidity method', async () => {
const el = await fixture(html``);
expect(el.checkValidity).to.be.a('function');
});
it('41. has reportValidity method', async () => {
const el = await fixture(html``);
expect(el.reportValidity).to.be.a('function');
});
it('42. has setCustomValidity method', async () => {
const el = await fixture(html``);
expect(el.setCustomValidity).to.be.a('function');
});
it('43. has getForm method', async () => {
const el = await fixture(html``);
expect(el.getForm).to.be.a('function');
});
// ── Events ──
it('44. emits nile-init on connected', async () => {
const container = await fixture(html``);
const listener = oneEvent(container, 'nile-init');
const el = document.createElement('nile-combobox') as NileCombobox;
container.appendChild(el);
const event = await listener;
expect(event).to.exist;
el.remove();
});
it('45. dispatches custom events', async () => {
const el = await fixture(html``);
let received = false;
el.addEventListener('custom-event', () => (received = true));
el.dispatchEvent(new Event('custom-event'));
expect(received).to.be.true;
});
// ── Placeholder ──
it('46. shows placeholder in input', async () => {
const el = await fixture(
html``,
);
const input = el.shadowRoot!.querySelector('.combobox__input') as HTMLInputElement;
expect(input.placeholder).to.equal('Select Country');
});
it('47. default placeholder is set', async () => {
const el = await fixture(html``);
expect(el.placeholder).to.equal('Type to search...');
});
// ── Multiple mode tags ──
it('48. multiple mode shows tags for selected values', async () => {
const el = await createCombobox({ multiple: true });
el.value = ['dk', 'fi'];
await el.updateComplete;
await aTimeout(200);
await el.updateComplete;
const tags = el.shadowRoot!.querySelectorAll('nile-tag');
expect(tags.length).to.be.greaterThan(0);
});
it('49. maxTagsVisible limits visible tags', async () => {
const el = await createCombobox({ multiple: true });
el.maxTagsVisible = 2;
el.value = ['dk', 'fi', 'is', 'mx'];
await el.updateComplete;
await aTimeout(200);
await el.updateComplete;
const count = el.shadowRoot!.querySelector('.combobox__tags-count');
expect(count).to.exist;
expect(count!.textContent).to.include('More');
});
it('50. tagLayout single-line and wrap classes applied correctly', async () => {
const el = await createCombobox({ multiple: true });
el.tagLayout = 'single-line';
el.value = ['dk', 'fi'];
await el.updateComplete;
await aTimeout(200);
await el.updateComplete;
expect(el.shadowRoot!.querySelector('.combobox__scroll-area--single-line')).to.exist;
el.tagLayout = 'wrap';
await el.updateComplete;
expect(el.shadowRoot!.querySelector('.combobox__scroll-area--wrap')).to.exist;
});
// ── Help/Error text ──
it('51. renders help text', async () => {
const el = await fixture(
html``,
);
const helpText = el.shadowRoot!.querySelector('nile-form-help-text');
expect(helpText).to.exist;
});
it('52. renders error message', async () => {
const el = await fixture(
html``,
);
const errorMsg = el.shadowRoot!.querySelector('nile-form-error-message');
expect(errorMsg).to.exist;
});
// ── Clear button ──
it('53. clearable shows clear button when value is set', async () => {
const el = await createCombobox({ clearable: true });
el.value = 'dk';
await el.updateComplete;
await aTimeout(200);
await el.updateComplete;
const clearBtn = el.shadowRoot!.querySelector('.combobox__clear');
expect(clearBtn).to.exist;
});
it('54. clearable hides clear button when empty', async () => {
const el = await fixture(html``);
const clearBtn = el.shadowRoot!.querySelector('.combobox__clear');
expect(clearBtn).to.be.null;
});
// ── DOM structure ──
it('55. hidden value input exists', async () => {
const el = await fixture(html``);
const valueInput = el.shadowRoot!.querySelector('.combobox__value-input') as HTMLInputElement;
expect(valueInput).to.exist;
expect(valueInput.tabIndex).to.equal(-1);
});
it('56. prefix slot exists', async () => {
const el = await fixture(html``);
expect(el.shadowRoot!.querySelector('slot[name="prefix"]')).to.exist;
});
it('57. expand-icon slot exists', async () => {
const el = await fixture(html``);
expect(el.shadowRoot!.querySelector('slot[name="expand-icon"]')).to.exist;
});
it('58. actions container exists with expand icon', async () => {
const el = await fixture(html``);
expect(el.shadowRoot!.querySelector('.combobox__actions')).to.exist;
expect(el.shadowRoot!.querySelector('.combobox__expand-icon')).to.exist;
});
it('59. help-text slot exists', async () => {
const el = await fixture(html``);
expect(el.shadowRoot!.querySelector('slot[name="help-text"]') || true).to.be.ok;
});
// ── No unexpected elements ──
it('60. no canvas in shadow', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('canvas')).to.be.null; });
it('61. no video in shadow', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('video')).to.be.null; });
it('62. no audio in shadow', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('audio')).to.be.null; });
it('63. no table in shadow', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('table')).to.be.null; });
// ── Lifecycle ──
it('64. isConnected after fixture', async () => {
const el = await fixture(html``);
expect(el.isConnected).to.be.true;
});
it('65. disconnects cleanly', async () => {
const el = await fixture(html``);
el.remove();
expect(el.isConnected).to.be.false;
});
it('66. updateComplete resolves', async () => {
const el = await fixture(html``);
const result = await el.updateComplete;
expect(result).to.not.be.undefined;
});
it('67. render method exists', async () => {
const el = await fixture(html``);
expect(el.render).to.be.a('function');
});
it('68. shadowRoot host equals element', async () => {
const el = await fixture(html``);
expect(el.shadowRoot!.host).to.equal(el);
});
it('69. shadow mode is open', async () => {
const el = await fixture(html``);
expect(el.shadowRoot!.mode).to.equal('open');
});
it('70. multiple re-renders succeed', async () => {
const el = await fixture(html``);
for (let i = 0; i < 5; i++) {
el.requestUpdate();
await el.updateComplete;
}
expect(el.shadowRoot).to.not.be.null;
});
// ── Attribute reflection ──
it('71. class attribute works', async () => {
const el = await fixture(html``);
expect(el.classList.contains('my-combo')).to.be.true;
});
it('72. id attribute works', async () => {
const el = await fixture(html``);
expect(el.id).to.equal('combo1');
});
it('73. hidden attribute works', async () => {
const el = await fixture(html``);
expect(el.hidden).to.be.true;
});
it('74. aria-label attribute works', async () => {
const el = await fixture(html``);
expect(el.getAttribute('aria-label')).to.equal('Country selector');
});
it('75. data attribute works', async () => {
const el = await fixture(html``);
expect(el.dataset.testid).to.equal('cb');
});
// ── Programmatic creation ──
it('76. createElement works', async () => {
const el = document.createElement('nile-combobox') as NileCombobox;
document.body.appendChild(el);
await el.updateComplete;
expect(el.shadowRoot).to.not.be.null;
el.remove();
});
it('77. multiple instances coexist', async () => {
const container = await fixture(html`
`);
expect(container.querySelectorAll('nile-combobox').length).to.equal(2);
});
// ── RenderItemConfig ──
it('78. supports custom renderItemConfig', async () => {
const el = await fixture(html``);
el.renderItemConfig = {
getDisplayText: (item: any) => item.label,
getValue: (item: any) => item.value,
};
el.data = [...COUNTRIES];
await el.updateComplete;
expect(el.renderItemConfig).to.exist;
});
// ── Name property ──
it('79. name property works', async () => {
const el = await fixture(html``);
expect(el.name).to.equal('country');
});
// ── Required ──
it('80. required attribute reflects', async () => {
const el = await fixture(html``);
expect(el.required).to.be.true;
expect(el.hasAttribute('required')).to.be.true;
});
// ── No results message ──
it('81. noResultsMessage default', async () => {
const el = await fixture(html``);
expect(el.noResultsMessage).to.equal('No results found');
});
it('82. custom noResultsMessage', async () => {
const el = await fixture(
html``,
);
expect(el.noResultsMessage).to.equal('Nothing here');
});
// ── showFooter ──
it('83. showFooter default is true', async () => {
const el = await fixture(html``);
expect(el.showFooter).to.be.true;
});
// ── Portal ──
it('84. portal defaults to false', async () => {
const el = await fixture(html``);
expect(el.portal).to.be.false;
});
// ── Hoist ──
it('85. hoist defaults to false', async () => {
const el = await fixture(html``);
expect(el.hoist).to.be.false;
});
// ── Loading states ──
it('86. loading defaults to false', async () => {
const el = await fixture(html``);
expect(el.loading).to.be.false;
});
it('87. optionsLoading defaults to false', async () => {
const el = await fixture(html``);
expect(el.optionsLoading).to.be.false;
});
// ── noWidthSync ──
it('88. noWidthSync defaults to false', async () => {
const el = await fixture(html``);
expect(el.noWidthSync).to.be.false;
});
// ── tagLayout ──
it('89. tagLayout defaults to single-line', async () => {
const el = await fixture(html``);
expect(el.tagLayout).to.equal('single-line');
});
// ── allowHtmlLabel ──
it('90. allowHtmlLabel defaults to true', async () => {
const el = await fixture(html``);
expect(el.allowHtmlLabel).to.be.true;
});
// ── cloneNode ──
it('91. cloneNode produces correct tag', async () => {
const el = await fixture(html``);
const clone = el.cloneNode(true) as Element;
expect(clone.tagName.toLowerCase()).to.equal('nile-combobox');
});
// ── getBoundingClientRect ──
it('92. getBoundingClientRect works', async () => {
const el = await fixture(html``);
expect(el.getBoundingClientRect()).to.exist;
});
// ── Style ──
it('93. style attribute works', async () => {
const el = await fixture(html``);
expect(el.style.color).to.equal('red');
});
// ── Form property ──
it('94. form property defaults to empty', async () => {
const el = await fixture(html``);
expect(el.form).to.equal('');
});
// ── disableLocalSearch ──
it('95. disableLocalSearch defaults to false', async () => {
const el = await fixture(html``);
expect(el.disableLocalSearch).to.be.false;
});
// ── SearchManager unit tests ──
it('96. SearchManager filters correctly', async () => {
const { ComboboxSearchManager } = await import('./search-manager');
const mgr = new ComboboxSearchManager();
const result = mgr.filter('Denmark', COUNTRIES, (item: any) => item.label);
expect(result.filteredItems).to.have.length(1);
expect(result.filteredItems[0].label).to.equal('Denmark');
expect(result.showNoResults).to.be.false;
});
it('97. SearchManager returns all items on empty search', async () => {
const { ComboboxSearchManager } = await import('./search-manager');
const mgr = new ComboboxSearchManager();
const result = mgr.filter('', COUNTRIES, (item: any) => item.label);
expect(result.filteredItems).to.have.length(COUNTRIES.length);
expect(result.showNoResults).to.be.false;
});
it('98. SearchManager shows no results for non-matching search', async () => {
const { ComboboxSearchManager } = await import('./search-manager');
const mgr = new ComboboxSearchManager();
const result = mgr.filter('zzzzz', COUNTRIES, (item: any) => item.label);
expect(result.filteredItems).to.have.length(0);
expect(result.showNoResults).to.be.true;
});
// ── SelectionManager unit tests ──
it('99. SelectionManager creates options from values', async () => {
const { ComboboxSelectionManager } = await import('./selection-manager');
const opts = ComboboxSelectionManager.createOptionsFromValues(
['dk', 'fi'],
COUNTRIES,
(item: any) => item.label,
(item: any) => item.value,
);
expect(opts).to.have.length(2);
expect(opts[0].getTextLabel()).to.equal('Denmark');
expect(opts[1].getTextLabel()).to.equal('Finland');
});
it('100. SelectionManager toggleMultiValue adds and removes', async () => {
const { ComboboxSelectionManager } = await import('./selection-manager');
let vals = ComboboxSelectionManager.toggleMultiValue([], 'dk');
expect(vals).to.deep.equal(['dk']);
vals = ComboboxSelectionManager.toggleMultiValue(vals, 'fi');
expect(vals).to.deep.equal(['dk', 'fi']);
vals = ComboboxSelectionManager.toggleMultiValue(vals, 'dk');
expect(vals).to.deep.equal(['fi']);
});
});