import { aTimeout, expect, fixture, html, nextFrame, oneEvent, waitUntil } from '@open-wc/testing'; import './nile-select'; import '../nile-option/nile-option'; import '../nile-input/nile-input'; import type { NileSelect } from './nile-select'; describe('NileSelect', () => { it('1. renders', async () => { const el = await fixture(html``); expect(el).to.exist; }); it('2. shadow root', async () => { const el = await fixture(html``); expect(el.shadowRoot).to.not.be.null; }); it('3. tag name', async () => { const el = await fixture(html``); expect(el.tagName.toLowerCase()).to.equal('nile-select'); }); it('4. is defined', async () => { expect(customElements.get('nile-select')).to.exist; }); it('5. has styles', async () => { expect((await import('./nile-select')).NileSelect.styles).to.exist; }); it('6. shadow mode', async () => { const el = await fixture(html``); expect(el.shadowRoot!.mode).to.equal('open'); }); it('7. isConnected', async () => { const el = await fixture(html``); expect(el.isConnected).to.be.true; }); it('8. removal', async () => { const el = await fixture(html``); el.remove(); expect(el.isConnected).to.be.false; }); it('9. outerHTML', async () => { const el = await fixture(html``); expect(el.outerHTML).to.contain('nile-select'); }); it('10. matches', async () => { const el = await fixture(html``); expect(el.matches('.x')).to.be.true; }); it('11. closest', async () => { const el = await fixture(html``); expect(el.closest('nile-select')).to.equal(el); }); it('12. cloneNode', async () => { const el = await fixture(html``); expect((el.cloneNode(true) as Element).tagName.toLowerCase()).to.equal('nile-select'); }); it('13. dispatchEvent', async () => { const el = await fixture(html``); let f = false; el.addEventListener('c', () => (f = true)); el.dispatchEvent(new Event('c')); expect(f).to.be.true; }); it('14. updateComplete', async () => { const el = await fixture(html``); const r = await el.updateComplete; expect(r).to.not.be.undefined; }); it('15. render method', async () => { const el = await fixture(html``); expect(el.render).to.be.a('function'); }); it('16. shadowRoot host', async () => { const el = await fixture(html``); expect(el.shadowRoot!.host).to.equal(el); }); it('17. class attr', async () => { const el = await fixture(html``); expect(el.classList.contains('sl')).to.be.true; }); it('18. id attr', async () => { const el = await fixture(html``); expect(el.id).to.equal('sl1'); }); it('19. hidden', async () => { const el = await fixture(html``); expect(el.hidden).to.be.true; }); it('20. nodeType', async () => { const el = await fixture(html``); expect(el.nodeType).to.equal(1); }); it('21. localName', async () => { const el = await fixture(html``); expect(el.localName).to.equal('nile-select'); }); it('22. namespaceURI', async () => { const el = await fixture(html``); expect(el.namespaceURI).to.equal('http://www.w3.org/1999/xhtml'); }); it('23. ownerDocument', async () => { const el = await fixture(html``); expect(el.ownerDocument).to.equal(document); }); it('24. multiple instances', async () => { const c = await fixture(html`
`); expect(c.querySelectorAll('nile-select').length).to.equal(2); }); it('25. parent-child', async () => { const c = await fixture(html`
`); expect(c.querySelector('nile-select')!.parentElement).to.equal(c); }); it('26. createElement', async () => { const el = document.createElement('nile-select') as NileSelect; document.body.appendChild(el); await el.updateComplete; expect(el.shadowRoot).to.not.be.null; document.body.removeChild(el); }); it('27. dataset', async () => { const el = await fixture(html``); expect(el.dataset.idx).to.equal('0'); }); it('28. classList add', async () => { const el = await fixture(html``); el.classList.add('z'); expect(el.classList.contains('z')).to.be.true; }); it('29. getBoundingClientRect', async () => { const el = await fixture(html``); expect(el.getBoundingClientRect()).to.exist; }); it('30. style attr', async () => { const el = await fixture(html``); expect(el.style.color).to.equal('red'); }); it('31. aria-label', async () => { const el = await fixture(html``); expect(el.getAttribute('aria-label')).to.equal('Select'); }); it('32. dir', async () => { const el = await fixture(html``); expect(el.dir).to.equal('rtl'); }); it('33. lang attr', async () => { const el = await fixture(html``); expect(el.lang).to.equal('en'); }); it('34. tabindex', async () => { const el = await fixture(html``); expect(el.getAttribute('tabindex')).to.equal('0'); }); it('35. requestUpdate', async () => { const el = await fixture(html``); el.requestUpdate(); await el.updateComplete; expect(el.shadowRoot).to.not.be.null; }); it('36. shadow childNodes', async () => { const el = await fixture(html``); expect(el.shadowRoot!.childNodes.length).to.be.greaterThan(0); }); it('37. scrollIntoView', async () => { const el = await fixture(html``); expect(el.scrollIntoView).to.be.a('function'); }); it('38. focus method', async () => { const el = await fixture(html``); expect(el.focus).to.be.a('function'); }); it('39. blur method', async () => { const el = await fixture(html``); expect(el.blur).to.be.a('function'); }); it('40. class toggle', async () => { const el = await fixture(html``); el.classList.toggle('a'); expect(el.classList.contains('a')).to.be.true; }); it('41. toggle hidden', async () => { const el = await fixture(html``); el.hidden = true; expect(el.hidden).to.be.true; }); it('42. dispatchCustomEvent', async () => { const el = await fixture(html``); let d: any; el.addEventListener('t', ((e: CustomEvent) => { d = e.detail; }) as EventListener); el.dispatchEvent(new CustomEvent('t', { detail: 'x' })); expect(d).to.equal('x'); }); it('43. nested in div', async () => { const c = await fixture(html`
`); expect(c.querySelector('nile-select')).to.exist; }); it('44. no table', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('table')).to.be.null; }); it('45. no canvas', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('canvas')).to.be.null; }); it('46. no video', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('video')).to.be.null; }); it('47. no audio', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('audio')).to.be.null; }); it('48. no nav', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nav')).to.be.null; }); it('49. no aside', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('aside')).to.be.null; }); it('50. setAttribute data', async () => { const el = await fixture(html``); el.setAttribute('data-test', '1'); expect(el.dataset.test).to.equal('1'); }); it('51. removeAttribute hidden', async () => { const el = await fixture(html``); el.removeAttribute('hidden'); expect(el.hidden).to.be.false; }); it('52. setAttribute class', async () => { const el = await fixture(html``); el.setAttribute('class', 'test'); expect(el.classList.contains('test')).to.be.true; }); it('53. aria-describedby', async () => { const el = await fixture(html``); expect(el.getAttribute('aria-describedby')).to.equal('d'); }); it('54. multiple re-renders', async () => { const el = await fixture(html``); for (let i = 0; i < 3; i++) { el.requestUpdate(); await el.updateComplete; } expect(el.shadowRoot).to.not.be.null; }); it('55. no h1', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('h1')).to.be.null; }); it('56. no pre', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('pre')).to.be.null; }); it('57. no code', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('code')).to.be.null; }); it('58. no hr', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('hr')).to.be.null; }); it('59. no p', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('p')).to.be.null; }); it('60. title attr', async () => { const el = await fixture(html``); expect(el.title).to.equal('SL'); }); it('61. no ul', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('ul')).to.be.null; }); it('62. no textarea', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('textarea')).to.be.null; }); it('63. label slot', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('slot[name="label"]')).to.exist; }); it('64. label slot', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('slot[name="label"]')).to.exist; }); it('65. no img', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('img')).to.be.null; }); it('66. no anchor', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('a')).to.be.null; }); it('67. no svg', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('svg')).to.be.null; }); it('68. no form', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('form')).to.be.null; }); it('69. show method', async () => { const el = await fixture(html``); expect(el.show).to.be.a('function'); }); it('70. hide method', async () => { const el = await fixture(html``); expect(el.hide).to.be.a('function'); }); it('71. data attr', async () => { const el = await fixture(html``); expect(el.getAttribute('data-x')).to.equal('1'); }); it('72. role attr', async () => { const el = await fixture(html``); expect(el.getAttribute('role')).to.equal('listbox'); }); it('73. contains', async () => { const el = await fixture(html`C`); expect(el.contains(el.querySelector('#s1'))).to.be.true; }); it('74. no button', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('button')).to.be.null; }); it('75. no select', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('select')).to.be.null; }); it('76. prefix slot', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('slot[name="prefix"]')).to.exist; }); it('77. no nile-badge', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-badge')).to.be.null; }); it('78. no nile-button', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-button')).to.be.null; }); it('79. no nile-checkbox', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-checkbox')).to.be.null; }); it('80. no section', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('section')).to.be.null; }); it('81. no header', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('header')).to.be.null; }); it('82. no footer', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('footer')).to.be.null; }); it('83. no main', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('main')).to.be.null; }); it('84. no article', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('article')).to.be.null; }); it('85. combobox role on inner', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('[role="combobox"]')).to.exist; }); it('86. listbox role on inner', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('[role="listbox"]')).to.exist; }); it('87. expand-icon slot', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('slot[name="expand-icon"]')).to.exist; }); it('88. expand-icon slot', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('slot[name="expand-icon"]')).to.exist; }); it('89. default slot', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('slot:not([name])')).to.exist; }); it('90. no nile-radio', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-radio')).to.be.null; }); it('91. no nile-slider', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-slider')).to.be.null; }); it('92. no nile-dialog', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-dialog')).to.be.null; }); it('93. no nile-drawer', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-drawer')).to.be.null; }); it('94. no nile-accordion', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-accordion')).to.be.null; }); it('95. no nile-input', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-input')).to.be.null; }); it('96. no nile-textarea', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-textarea')).to.be.null; }); it('97. no nile-stepper', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-stepper')).to.be.null; }); it('98. no nile-tab', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-tab')).to.be.null; }); it('99. no nile-card', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-card')).to.be.null; }); it('100. full integration', async () => { const el = await fixture(html``); expect(el.id).to.equal('sl1'); expect(el.shadowRoot).to.not.be.null; expect(el.shadowRoot!.querySelector('slot:not([name])')).to.exist; }); }); describe('NileSelect chip fitting behaviour matrix', () => { const LABELS = [ 'Engineering Operations Team', 'Human Resources Department', 'Finance And Accounting Group', 'Customer Success Organisation', 'Product Design Studio Unit' ]; const options = () => LABELS.map(label => html`${label}`); async function settle(el: NileSelect) { await el.updateComplete; await nextFrame(); await nextFrame(); await el.updateComplete; } function tagsRoot(el: NileSelect) { return el.shadowRoot!.querySelector('.select__tags'); } function chips(el: NileSelect) { return Array.from( el.shadowRoot!.querySelectorAll('.select__tags nile-tag') ) as HTMLElement[]; } function hiddenChips(el: NileSelect) { return chips(el).filter(chip => chip.style.display === 'none'); } it('M1. multiple + no max-options-visible + autoResize=false renders fitted chips', async () => { const host = await fixture( html`
${options()}
` ); const el = host.querySelector('nile-select') as NileSelect; await settle(el); expect(el.autoResize).to.be.false; expect(tagsRoot(el)!.classList.contains('select__tags--fitted')).to.be.true; expect(el.shadowRoot!.querySelector('button.select__tags-count')).to.exist; expect(chips(el).length).to.equal(LABELS.length); }); it('M2. multiple + no max-options-visible + autoResize=true refits when the container resizes', async () => { const host = await fixture( html`
${options()}
` ); const el = host.querySelector('nile-select') as NileSelect; await settle(el); expect(el.autoResize).to.be.true; expect(tagsRoot(el)!.classList.contains('select__tags--fitted')).to.be.true; const wideHidden = hiddenChips(el).length; host.style.width = '180px'; await aTimeout(200); await settle(el); expect(hiddenChips(el).length).to.be.at.least(wideHidden); }); it('M3. explicit max-options-visible switches to fixed-count layout', async () => { const host = await fixture( html`
${options()}
` ); const el = host.querySelector('nile-select') as NileSelect; await settle(el); expect(el.maxOptionsVisible).to.equal(2); expect(tagsRoot(el)!.classList.contains('select__tags--fitted')).to.be.false; expect(chips(el).length).to.equal(2); expect(el.shadowRoot!.querySelector('span.select__tags-count')).to.exist; }); it('M3b. max-options-visible set as a property also switches to fixed-count layout', async () => { const host = await fixture( html`
${options()}
` ); const el = host.querySelector('nile-select') as NileSelect; await settle(el); expect(tagsRoot(el)!.classList.contains('select__tags--fitted')).to.be.true; el.maxOptionsVisible = 1; await settle(el); expect(tagsRoot(el)!.classList.contains('select__tags--fitted')).to.be.false; expect(chips(el).length).to.equal(1); }); it('M3c. the default max-options-visible is still 3 and reading it does not opt out', async () => { const host = await fixture( html`
${options()}
` ); const el = host.querySelector('nile-select') as NileSelect; await settle(el); expect(el.maxOptionsVisible).to.equal(3); expect(el.maxOptionsVisible).to.equal(3); await settle(el); expect(tagsRoot(el)!.classList.contains('select__tags--fitted')).to.be.true; }); it('M5. autoresize works as a markup attribute', async () => { const b = await fixture( html`` ); await b.updateComplete; expect(b.autoResize).to.be.true; const c = await fixture( html`` ); await c.updateComplete; expect(c.autoResize).to.be.false; }); it('M8. virtual-scroll mode keeps fitted chips when max-options-visible was never set', async () => { const host = await fixture( html`
({ label, value: label }))} .value=${LABELS} >
` ); const el = host.querySelector('nile-select') as NileSelect; await settle(el); const inner = el.shadowRoot!.querySelector('nile-virtual-select') as any; expect(inner, 'inner nile-virtual-select should render').to.exist; await inner.updateComplete; await nextFrame(); const innerTags = inner.shadowRoot.querySelector('.select__tags'); expect(innerTags, 'inner .select__tags should render').to.exist; expect(innerTags.classList.contains('select__tags--fitted')).to.be.true; }); it('M9. virtual-scroll mode forwards an explicit max-options-visible', async () => { const host = await fixture( html`
({ label, value: label }))} .value=${LABELS} >
` ); const el = host.querySelector('nile-select') as NileSelect; await settle(el); const inner = el.shadowRoot!.querySelector('nile-virtual-select') as any; await inner.updateComplete; await nextFrame(); expect(inner.maxOptionsVisible).to.equal(2); expect( inner.shadowRoot .querySelector('.select__tags') .classList.contains('select__tags--fitted') ).to.be.false; }); it('M10. autoResize reflects to the autoresize attribute', async () => { const el = await fixture( html`` ); await el.updateComplete; expect(el.hasAttribute('autoresize')).to.be.false; el.autoResize = true; await el.updateComplete; expect(el.hasAttribute('autoresize')).to.be.true; el.autoResize = false; await el.updateComplete; expect(el.hasAttribute('autoresize')).to.be.false; }); it('M11. toggling max-options-visible does not leak resize observers or lose the observed container', async () => { const host = await fixture( html`
${options()}
` ); const el = host.querySelector('nile-select') as NileSelect; await settle(el); el.maxOptionsVisible = 2; await settle(el); expect(tagsRoot(el)!.classList.contains('select__tags--fitted')).to.be.false; const observed = (el as any).observedTagsContainer; expect(observed, 'observer detached in fixed-count mode').to.be.undefined; }); it('M6. the +N button count matches the number of hidden chips', async () => { const host = await fixture( html`
${options()}
` ); const el = host.querySelector('nile-select') as NileSelect; await settle(el); const overflow = el.shadowRoot!.querySelector( 'button.select__tags-count' ) as HTMLElement | null; const hidden = hiddenChips(el).length; if (hidden > 0) { expect(overflow).to.exist; expect(overflow!.textContent!.trim()).to.equal(`+${hidden}`); expect(overflow!.getAttribute('aria-label')).to.equal( `${hidden} more selected` ); } else { // Nothing overflows, so the button is not rendered at all. expect(overflow).to.not.exist; } }); // The tooltip is opt-in: `showtooltip` present, and the chip's text clipped. function tips(el: NileSelect) { return Array.from( el.shadowRoot!.querySelectorAll('.select__tag-tip') ) as HTMLElement[]; } function liveTips(el: NileSelect) { return tips(el).filter(tip => !tip.hasAttribute('disabled')); } it('M12. no showtooltip attribute renders no chip tooltips', async () => { const host = await fixture( html`
${options()}
` ); const el = host.querySelector('nile-select') as NileSelect; await settle(el); expect(el.showTooltip).to.be.false; expect(el.hasAttribute('showtooltip')).to.be.false; expect(tips(el).length).to.equal(0); }); it('M13. showtooltip shows the chip tooltip', async () => { const host = await fixture( html`
${options()}
` ); const el = host.querySelector('nile-select') as NileSelect; await settle(el); expect(el.showTooltip).to.be.true; expect(liveTips(el).length).to.be.greaterThan(0); }); // Truncation is bound to the tooltip: a clipped chip is only acceptable when // something can reveal the value it hid. A chip wider than the whole field is // the one exception - there is nowhere for it to go. function chipContent(chip: HTMLElement) { return chip.shadowRoot!.querySelector('[part~="content"]') as HTMLElement; } function clippedChipCount(el: NileSelect) { return chips(el) .filter(chip => chip.style.display !== 'none') .filter(chip => { const content = chipContent(chip); return content.scrollWidth > content.clientWidth + 1; }).length; } it('M16. without showtooltip the pre-2.0.9 chip row is used', async () => { const host = await fixture( html`
${options()}
` ); const el = host.querySelector('nile-select') as NileSelect; await settle(el); await aTimeout(20); await settle(el); expect(el.showTooltip).to.be.false; expect(tagsRoot(el)!.classList.contains('select__tags--fitted')).to.be.false; expect(el.shadowRoot!.querySelector('button.select__tags-count')).to.not.exist; expect(chips(el).every(chip => !chip.style.maxWidth)).to.be.true; const overflowCount = el.shadowRoot!.querySelector('span.select__tags-count'); expect(overflowCount).to.exist; expect(overflowCount!.textContent).to.contain('More'); }); it('M17. showtooltip is what allows a fitted chip to be truncated', async () => { const host = await fixture( html`
${options()}
` ); const el = host.querySelector('nile-select') as NileSelect; await settle(el); expect(el.showTooltip).to.be.true; expect(clippedChipCount(el)).to.be.greaterThan(0); }); it('M14. removing showtooltip hides the chip tooltip again', async () => { const host = await fixture( html`
${options()} ${options()}
` ); const [el, control] = Array.from( host.querySelectorAll('nile-select') ) as NileSelect[]; await settle(el); await settle(control); expect(liveTips(el).length).to.be.greaterThan(0); el.removeAttribute('showtooltip'); await settle(el); expect(el.showTooltip).to.be.false; expect(liveTips(el).length).to.equal(0); // Removing the attribute must land back on the default. expect(tips(el).map(tip => tip.hasAttribute('disabled'))).to.deep.equal( tips(control).map(tip => tip.hasAttribute('disabled')) ); }); it('M15. the overflow button label and text never disagree, including first paint', async () => { const host = await fixture( html`
${options()}
` ); const el = host.querySelector('nile-select') as NileSelect; // Before the layout frame runs, no button may claim a count it cannot back up. await el.updateComplete; const early = el.shadowRoot!.querySelector('button.select__tags-count'); if (early) { const shown = early.textContent!.trim().replace('+', ''); expect(early.getAttribute('aria-label')).to.equal( `${shown} more selected` ); } await settle(el); const overflow = el.shadowRoot!.querySelector( 'button.select__tags-count' ) as HTMLElement | null; expect(overflow).to.exist; const shown = overflow!.textContent!.trim().replace('+', ''); expect(overflow!.getAttribute('aria-label')).to.equal( `${shown} more selected` ); expect(Number(shown)).to.equal(hiddenChips(el).length); }); it('M16. no overflow button is rendered when every chip fits', async () => { const host = await fixture( html`
${options()}
` ); const el = host.querySelector('nile-select') as NileSelect; await settle(el); expect(hiddenChips(el).length).to.equal(0); expect(el.shadowRoot!.querySelector('button.select__tags-count')).to.not .exist; }); // `` renders a nile-virtual-select in its own // shadow root and hands it the public API by hand. Anything added to one and // not to that list is silently dead on the wrapper form, which is how // showTooltip / chipMinWidth / chipMaxWidth were missed - so assert the whole // chip-fitting surface crosses over, defaults included. function innerVirtual(el: NileSelect) { return el.shadowRoot!.querySelector('nile-virtual-select') as unknown as { showTooltip: boolean; chipMinWidth: number; chipMaxWidth: number; autoResize: boolean; } | null; } it('M17. enableVirtualScroll forwards the chip-fitting properties inward', async () => { const el = await fixture( html`` ); await el.updateComplete; const inner = innerVirtual(el); expect(inner).to.exist; expect(el.showTooltip).to.be.true; expect(el.chipMinWidth).to.equal(90); expect(el.chipMaxWidth).to.equal(160); expect(el.autoResize).to.be.true; expect(inner!.showTooltip).to.equal(el.showTooltip); expect(inner!.chipMinWidth).to.equal(el.chipMinWidth); expect(inner!.chipMaxWidth).to.equal(el.chipMaxWidth); expect(inner!.autoResize).to.equal(el.autoResize); }); it('M18. enableVirtualScroll forwards the chip-fitting defaults too', async () => { const el = await fixture( html`` ); await el.updateComplete; const inner = innerVirtual(el); expect(inner).to.exist; // chipMinWidth defaults to 110 on both sides, so a missing forward looks // correct here - the other two are what actually catch a dropped property. expect(inner!.showTooltip).to.be.false; expect(inner!.chipMinWidth).to.equal(110); expect(inner!.chipMaxWidth).to.equal(180); }); it('M19. changing a chip property after first render reaches the inner select', async () => { const el = await fixture( html`` ); await el.updateComplete; el.showTooltip = true; el.chipMaxWidth = 240; await el.updateComplete; const inner = innerVirtual(el); expect(inner!.showTooltip).to.be.true; expect(inner!.chipMaxWidth).to.equal(240); }); it('G1. default chips are not capped or squeezed by the 2.0.9 chip styles', async () => { const host = await fixture( html`
${options()}
` ); const el = host.querySelector('nile-select') as NileSelect; await settle(el); const [chip] = chips(el); expect(chip, 'select rendered no chips').to.exist; expect(getComputedStyle(chip).maxWidth).to.equal('none'); expect(getComputedStyle(chip).minWidth).to.not.equal('0px'); }); it('G2. showtooltip caps chips at the chip max width', async () => { const host = await fixture( html`
${options()}
` ); const el = host.querySelector('nile-select') as NileSelect; await settle(el); const [firstChip] = chips(el); expect(firstChip, 'select rendered no chips').to.exist; expect(getComputedStyle(firstChip).maxWidth).to.not.equal('none'); }); it('G3. autoresize without showtooltip refits with the 2.0.8 width fit', async () => { const host = await fixture( html`
${options()}
` ); const el = host.querySelector('nile-select') as NileSelect; await settle(el); await aTimeout(20); const wide = el.maxOptionsVisible; host.style.width = '300px'; await aTimeout(200); await settle(el); expect(tagsRoot(el)!.classList.contains('select__tags--fitted')).to.be.false; expect(wide).to.equal(0); expect(el.maxOptionsVisible).to.be.greaterThan(0); expect(el.shadowRoot!.querySelector('span.select__tags-count')).to.exist; }); it('G4. toggling showTooltip switches between the fitted and the 2.0.8 chip row', async () => { const host = await fixture( html`
${options()}
` ); const el = host.querySelector('nile-select') as NileSelect; await settle(el); expect(tagsRoot(el)!.classList.contains('select__tags--fitted')).to.be.false; el.showTooltip = true; await settle(el); expect(tagsRoot(el)!.classList.contains('select__tags--fitted')).to.be.true; expect(el.shadowRoot!.querySelector('button.select__tags-count')).to.exist; el.showTooltip = false; await settle(el); await aTimeout(20); await settle(el); expect(tagsRoot(el)!.classList.contains('select__tags--fitted')).to.be.false; expect(el.shadowRoot!.querySelector('button.select__tags-count')).to.not.exist; }); }); describe('NileSelect enableVirtualScroll value sync (showTooltip)', () => { const ROLES = ['Administrators', 'Users', 'Guests', 'Print Operators']; const DATA = ROLES.map(role => ({ label: role, value: role })); type InnerVirtual = HTMLElement & { value: string | string[]; updateComplete: Promise; handleOptionSelection(option: { value: string }): void; handleTagRemove(event: Event, option: { value: string }): void; }; async function virtualSelect(tpl = html``) { const el = await fixture(tpl); await el.updateComplete; const inner = el.shadowRoot!.querySelector('nile-virtual-select') as unknown as InnerVirtual; await inner.updateComplete; return { el, inner }; } async function settle(el: NileSelect, inner: InnerVirtual) { await inner.updateComplete; await el.updateComplete; await inner.updateComplete; } it('V1. picking an option updates the wrapper value', async () => { const { el, inner } = await virtualSelect(); inner.handleOptionSelection({ value: 'Administrators' }); await settle(el, inner); expect(el.value).to.deep.equal(['Administrators']); }); it('V2. the first pick survives a wrapper re-render', async () => { const { el, inner } = await virtualSelect(); inner.handleOptionSelection({ value: 'Administrators' }); await settle(el, inner); el.requestUpdate(); await settle(el, inner); expect(inner.value).to.deep.equal(['Administrators']); expect(el.value).to.deep.equal(['Administrators']); }); it('V3. a removed chip stays removed after a wrapper re-render', async () => { const { el, inner } = await virtualSelect(html``); inner.handleTagRemove(new CustomEvent('nile-remove'), { value: 'Administrators' }); await settle(el, inner); inner.handleTagRemove(new CustomEvent('nile-remove'), { value: 'Users' }); await settle(el, inner); el.requestUpdate(); await settle(el, inner); expect(el.value).to.deep.equal(['Guests']); expect(inner.value).to.deep.equal(['Guests']); }); it('V4. nile-change listeners on the wrapper already see the new value', async () => { const { el, inner } = await virtualSelect(); const seen: unknown[] = []; el.addEventListener('nile-change', () => seen.push(el.value)); inner.handleOptionSelection({ value: 'Guests' }); await settle(el, inner); expect(seen).to.deep.equal([['Guests']]); }); it('V5. single-select virtual mode keeps the wrapper value in sync', async () => { const { el, inner } = await virtualSelect(html``); inner.handleOptionSelection({ value: 'Users' }); await settle(el, inner); el.requestUpdate(); await settle(el, inner); expect(el.value).to.equal('Users'); expect(inner.value).to.equal('Users'); }); it('V6. resizing the host does not re-render the wrapper without autoResize', async () => { const host = await fixture(html`
`); const el = host.querySelector('nile-select') as NileSelect; await el.updateComplete; await nextFrame(); await nextFrame(); let updates = 0; const update = (el as any).update; (el as any).update = function (this: NileSelect, changed: Map) { updates++; return update.call(this, changed); }; host.style.width = '250px'; await nextFrame(); await nextFrame(); await aTimeout(50); expect(updates).to.equal(0); }); }); describe('NileSelect enableVirtualScroll + searchEnabled value sync (showTooltip)', () => { const ROLES = ['Administrators', 'Users', 'Guests', 'Print Operators']; const DATA = ROLES.map(role => ({ label: role, value: role })); type InnerVirtual = HTMLElement & { value: string | string[]; updateComplete: Promise; handleOptionSelection(option: { value: string }): void; handleTagRemove(event: Event, option: { value: string }): void; }; async function settle(el: NileSelect, inner: InnerVirtual) { await inner.updateComplete; await el.updateComplete; await inner.updateComplete; await nextFrame(); await el.updateComplete; await inner.updateComplete; } async function searchableVirtual({ multiple = true, portal = false, value = ['Users'] as string | string[] } = {}) { const el = await fixture(html``); await el.updateComplete; const inner = el.shadowRoot!.querySelector('nile-virtual-select') as unknown as InnerVirtual; await inner.updateComplete; (inner as any).open = true; await settle(el, inner); if (portal) { await waitUntil(() => document.body.querySelector('.nile-virtual-select-portal-append #listbox'), 'listbox never portalled'); } await waitUntil(() => searchInput(inner)?.shadowRoot?.querySelector('input'), 'search box never rendered'); return { el, inner }; } function searchInput(inner: InnerVirtual): HTMLElement { const inShadow = inner.shadowRoot!.querySelector('.select__search nile-input'); const inPortal = document.body.querySelector('.nile-virtual-select-portal-append .select__search nile-input'); return (inShadow ?? inPortal) as HTMLElement; } async function typeSearch(el: NileSelect, inner: InnerVirtual, text: string) { const input = searchInput(inner).shadowRoot!.querySelector('input') as HTMLInputElement; input.value = text; input.dispatchEvent(new Event('input', { bubbles: true, composed: true })); await settle(el, inner); } afterEach(() => { document.body.querySelectorAll('.nile-virtual-select-portal-append').forEach(n => n.remove()); }); it('R1a. typing in the search box keeps the selected values (multiple)', async () => { const { el, inner } = await searchableVirtual(); expect(searchInput(inner), 'search box rendered').to.exist; await typeSearch(el, inner, 'Adm'); expect(el.value).to.deep.equal(['Users']); expect(inner.value).to.deep.equal(['Users']); }); it('R1b. typing in the search box keeps the selected value (single)', async () => { const { el, inner } = await searchableVirtual({ multiple: false, value: 'Users' }); await typeSearch(el, inner, 'zz'); expect(el.value).to.equal('Users'); expect(inner.value).to.equal('Users'); }); it('R1c. clearing the search text does not clear the selection', async () => { const { el, inner } = await searchableVirtual({ value: ['Users', 'Guests'] }); await typeSearch(el, inner, 'Gu'); await typeSearch(el, inner, ''); expect(el.value).to.deep.equal(['Users', 'Guests']); await typeSearch(el, inner, 'Gu'); const clear = searchInput(inner).shadowRoot!.querySelector('.input__clear') as HTMLElement; expect(clear, 'search clear button rendered').to.exist; clear.click(); await settle(el, inner); expect(el.value).to.deep.equal(['Users', 'Guests']); expect(inner.value).to.deep.equal(['Users', 'Guests']); }); it('R1d. a real selection while searching still updates the wrapper value', async () => { const { el, inner } = await searchableVirtual(); await typeSearch(el, inner, 'Gu'); inner.handleOptionSelection({ value: 'Guests' }); await settle(el, inner); expect(el.value).to.deep.equal(['Users', 'Guests']); el.requestUpdate(); await settle(el, inner); expect(inner.value).to.deep.equal(['Users', 'Guests']); }); it('R1e. removing a chip still updates the wrapper value', async () => { const { el, inner } = await searchableVirtual({ value: ['Users', 'Guests'] }); await typeSearch(el, inner, 'U'); inner.handleTagRemove(new CustomEvent('nile-remove'), { value: 'Users' }); await settle(el, inner); expect(el.value).to.deep.equal(['Guests']); }); it('R1f. nile-input and nile-change from the inner select sync; nested ones do not', async () => { const { el, inner } = await searchableVirtual(); const nested = inner.shadowRoot!.querySelector('.select__search') as HTMLElement; const fire = (target: EventTarget, name: string, value: unknown) => target.dispatchEvent(new CustomEvent(name, { detail: { value }, bubbles: true, composed: true })); fire(nested, 'nile-input', 'nested-input'); fire(nested, 'nile-change', 'nested-change'); expect(el.value).to.deep.equal(['Users']); fire(inner, 'nile-input', ['Guests']); expect(el.value).to.deep.equal(['Guests']); fire(inner, 'nile-change', ['Administrators']); expect(el.value).to.deep.equal(['Administrators']); }); it('R1g. portal mode: list is portalled, search keeps the value, selection syncs', async () => { const { el, inner } = await searchableVirtual({ portal: true }); const listbox = document.body.querySelector('.nile-virtual-select-portal-append #listbox'); expect(listbox, 'listbox moved under document.body').to.exist; expect(inner.shadowRoot!.querySelector('#listbox')).to.equal(null); await typeSearch(el, inner, 'Adm'); expect(el.value).to.deep.equal(['Users']); inner.handleOptionSelection({ value: 'Administrators' }); await settle(el, inner); expect(el.value).to.deep.equal(['Users', 'Administrators']); (inner as any).open = false; await settle(el, inner); await waitUntil(() => !document.body.querySelector('.nile-virtual-select-portal-append'), 'portal not cleaned up'); }); }); describe('NileSelect chipMinWidth / chipMaxWidth attributes', () => { const LABELS = ['Engineering Operations Team', 'Human Resources Department']; const options = () => LABELS.map(label => html`${label}`); async function settle(el: NileSelect) { await el.updateComplete; await nextFrame(); await nextFrame(); await el.updateComplete; } const firstChip = (el: NileSelect) => el.shadowRoot!.querySelector('.select__tags nile-tag') as HTMLElement; it('R4a. defaults are 110 / 180 and are not written to the host', async () => { const el = await fixture(html`${options()}`); await settle(el); expect(el.chipMinWidth).to.equal(110); expect(el.chipMaxWidth).to.equal(180); expect(el.hasAttribute('chipminwidth')).to.be.false; expect(el.hasAttribute('chipmaxwidth')).to.be.false; }); it('R4b. the chipminwidth / chipmaxwidth attributes still set the properties', async () => { const el = await fixture( html`${options()}` ); await settle(el); expect(el.chipMinWidth).to.equal(90); expect(el.chipMaxWidth).to.equal(150); el.setAttribute('chipmaxwidth', '160'); await settle(el); expect(el.chipMaxWidth).to.equal(160); }); it('R4c. setting the properties programmatically works and does not add attributes', async () => { const el = await fixture(html`${options()}`); await settle(el); el.chipMinWidth = 95; el.chipMaxWidth = 140; await settle(el); expect(el.chipMinWidth).to.equal(95); expect(el.chipMaxWidth).to.equal(140); expect(el.hasAttribute('chipmaxwidth')).to.be.false; }); it('R4d. showtooltip still caps squeezed chips at chipMaxWidth (attribute and property)', async () => { const host = await fixture(html`
${options()}
`); const el = host.querySelector('nile-select') as NileSelect; await settle(el); const chip = firstChip(el); expect(chip, 'select rendered no chips').to.exist; expect(chip.getBoundingClientRect().width).to.be.within(1, 150.5); el.chipMaxWidth = 120; await settle(el); await nextFrame(); expect(firstChip(el).getBoundingClientRect().width).to.be.within(1, 120.5); }); it('R4e. virtual mode forwards explicit values without reflecting defaults on either host', async () => { const data = LABELS.map(label => ({ label, value: label })); const el = await fixture( html`` ); await settle(el); const inner = el.shadowRoot!.querySelector('nile-virtual-select') as any; await inner.updateComplete; expect(inner.chipMaxWidth).to.equal(150); expect(inner.chipMinWidth).to.equal(110); expect(el.hasAttribute('chipminwidth')).to.be.false; expect(inner.hasAttribute('chipminwidth')).to.be.false; expect(inner.hasAttribute('chipmaxwidth')).to.be.false; }); }); describe('NileSelect footer width with and without noWidthSync', () => { async function openSelect(width: number, noWidthSync: boolean) { const host = await fixture(html`
a b
`); const el = host.querySelector('nile-select') as NileSelect; await el.updateComplete; await el.show(); await nextFrame(); const listbox = el.shadowRoot!.querySelector('#listbox') as HTMLElement; const footer = el.shadowRoot!.querySelector('.select__footer') as HTMLElement; const combobox = el.shadowRoot!.querySelector('[part~="combobox"]') as HTMLElement; return { el, listbox, footer, combobox }; } const labelShown = (footer: HTMLElement, selector: string) => getComputedStyle(footer.querySelector(selector) as HTMLElement).display !== 'none'; before(() => document.documentElement.style.setProperty('--nile-display-flex', 'flex')); after(() => document.documentElement.style.removeProperty('--nile-display-flex')); for (const width of [120, 400]) { it(`R3a. noWidthSync + multiple at ${width}px does not collapse and shows the whole footer`, async () => { const { listbox, footer } = await openSelect(width, true); expect(listbox.offsetWidth).to.be.greaterThan(60); expect(listbox.offsetWidth).to.be.at.least(footer.offsetWidth); expect(footer.scrollWidth).to.be.at.most(footer.clientWidth + 1); expect(labelShown(footer, '.select__clear .select__footer-label')).to.be.true; expect(labelShown(footer, '.select__footer-action .select__footer-label')).to.be.true; }); } it('R3b. width sync at normal width keeps the dropdown as wide as the trigger', async () => { const { listbox, combobox, footer } = await openSelect(400, false); expect(Math.abs(listbox.offsetWidth - combobox.offsetWidth)).to.be.below(2); expect(labelShown(footer, '.select__clear .select__footer-label')).to.be.true; }); it('R3c. width sync at a narrow width still collapses the footer labels (container queries kept)', async () => { const { listbox, combobox, footer } = await openSelect(130, false); expect(Math.abs(listbox.offsetWidth - combobox.offsetWidth)).to.be.below(2); expect(labelShown(footer, '.select__clear .select__footer-label')).to.be.false; }); }); describe('NileSelect showtooltip chip fit recovery (hidden, reconnect, re-parent)', () => { const LABELS = [ 'Engineering Operations Team', 'Human Resources Department', 'Finance And Accounting Group', 'Customer Success Organisation', 'Product Design Studio Unit' ]; const options = () => LABELS.map(label => html`${label}`); const NativeRO = window.ResizeObserver; const active = new Map>(); const liveCount = () => Array.from(active.values()).filter(targets => targets.size > 0).length; before(() => { (window as any).ResizeObserver = class extends NativeRO { observe(target: Element, options?: ResizeObserverOptions) { if (!active.has(this)) active.set(this, new Set()); active.get(this)!.add(target); super.observe(target, options); } unobserve(target: Element) { active.get(this)?.delete(target); super.unobserve(target); } disconnect() { active.get(this)?.clear(); super.disconnect(); } }; }); after(() => { (window as any).ResizeObserver = NativeRO; }); async function frames(n = 4) { for (let i = 0; i < n; i++) await nextFrame(); } async function settle(el: NileSelect) { await el.updateComplete; await frames(); await el.updateComplete; await frames(2); } const chipsOf = (el: NileSelect) => Array.from(el.shadowRoot!.querySelectorAll('.select__tags nile-tag')) as HTMLElement[]; const watchActive = (el: NileSelect) => !!(el as any).measurableWatch; function fitOf(el: NileSelect) { const chips = chipsOf(el); const visible = chips.filter(chip => chip.style.display !== 'none'); const button = el.shadowRoot!.querySelector('button.select__tags-count') as HTMLElement | null; return { chips: chips.length, visible: visible.length, more: button ? button.textContent!.trim() : '', widths: visible.map(chip => chip.offsetWidth) }; } function box(width: number, display = 'block') { const div = document.createElement('div'); div.style.width = `${width}px`; div.style.display = display; document.body.appendChild(div); boxes.push(div); return div; } let boxes: HTMLElement[] = []; afterEach(() => { boxes.forEach(b => b.remove()); boxes = []; }); async function makeSelect(parent: HTMLElement, { showTooltip = true, autoResize = false, value = LABELS } = {}) { const el = document.createElement('nile-select') as NileSelect; el.multiple = true; el.showTooltip = showTooltip; el.autoResize = autoResize; for (const label of LABELS) { const option = document.createElement('nile-option') as any; option.value = label; option.textContent = label; el.appendChild(option); } el.value = value; parent.appendChild(el); await settle(el); return el; } async function reference(width: number, opts: { showTooltip?: boolean; value?: string[] } = {}) { const el = await makeSelect(box(width), opts); const fit = fitOf(el); el.parentElement!.remove(); return fit; } function expectFitted(actual: ReturnType, expected: ReturnType, el: NileSelect) { expect(actual.chips).to.equal(expected.chips); expect(actual.visible, 'visible chips').to.equal(expected.visible); expect(actual.more, '+N button').to.equal(expected.more); actual.widths.forEach((w, i) => { expect(w, `chip ${i} width`).to.be.within(el.chipMinWidth - 1, el.chipMaxWidth + 1); expect(Math.abs(w - expected.widths[i]), `chip ${i} matches reference`).to.be.at.most(2); }); } it('R5-ref. the 300px reference really is a fitted row (so a stale row cannot match it)', async () => { const ref = await reference(300); expect(ref.chips).to.equal(5); expect(ref.visible).to.be.within(1, 4); expect(ref.more).to.equal(`+${5 - ref.visible}`); }); it('R5a. hidden -> visible without reconnect recovers the fitted row, then the watch ends', async () => { const ref = await reference(300); const parent = box(300, 'none'); const el = await makeSelect(parent); expect(watchActive(el), 'watch armed while hidden').to.be.true; parent.style.display = 'block'; await settle(el); expectFitted(fitOf(el), ref, el); expect(watchActive(el)).to.be.false; }); it('R5b. hidden -> reconnected into a visible 300px parent re-fits, twice, without extra observers', async () => { const ref = await reference(300); const el = await makeSelect(box(300, 'none')); expect(watchActive(el)).to.be.true; el.remove(); expect(watchActive(el), 'disconnect stops the watch').to.be.false; box(300).appendChild(el); await settle(el); expectFitted(fitOf(el), ref, el); expect(watchActive(el)).to.be.false; const liveAfterFirst = liveCount(); el.remove(); box(300).appendChild(el); await settle(el); expectFitted(fitOf(el), ref, el); expect(watchActive(el)).to.be.false; expect(liveCount(), 'no observer growth across reconnects').to.equal(liveAfterFirst); }); it('R5c. visible -> disconnect -> reconnect into a narrower parent re-fits to the new width', async () => { const ref = await reference(300); const el = await makeSelect(box(700)); expect(fitOf(el).visible).to.be.greaterThan(ref.visible); el.remove(); box(300).appendChild(el); await settle(el); expectFitted(fitOf(el), ref, el); expect(watchActive(el)).to.be.false; }); it('R5d. value changed while hidden and detached, then reconnected, fits the new value', async () => { const four = LABELS.slice(0, 4); const ref = await reference(300, { value: four }); const el = await makeSelect(box(300, 'none')); el.remove(); el.value = four; await settle(el); expect(watchActive(el), 'no watch left on a detached select').to.be.false; box(300).appendChild(el); await settle(el); expectFitted(fitOf(el), ref, el); expect(watchActive(el)).to.be.false; }); it('R5e. reconnect into another hidden parent keeps one watch, and recovers when shown', async () => { const ref = await reference(300); const el = await makeSelect(box(300, 'none')); const liveHidden = liveCount(); el.remove(); const second = box(300, 'none'); second.appendChild(el); await settle(el); expect(watchActive(el), 're-armed in the new hidden parent').to.be.true; expect(liveCount(), 'the old watch was replaced, not duplicated').to.equal(liveHidden); second.style.display = 'block'; await settle(el); expectFitted(fitOf(el), ref, el); expect(watchActive(el)).to.be.false; }); it('R5f. showTooltip true -> false before reconnect gives the untouched 2.0.8 row', async () => { const legacy = await makeSelect(box(300), { showTooltip: false }); const legacyText = legacy.shadowRoot!.querySelector('.select__tags')!.textContent!.replace(/\s+/g, ' ').trim(); const el = await makeSelect(box(300, 'none')); el.remove(); el.showTooltip = false; box(300).appendChild(el); await settle(el); expect(watchActive(el)).to.be.false; expect(el.shadowRoot!.querySelector('.select__tags--fitted')).to.equal(null); chipsOf(el).forEach(chip => expect(chip.style.width, 'no fitted width on the 2.0.8 row').to.equal('')); expect(el.shadowRoot!.querySelector('.select__tags')!.textContent!.replace(/\s+/g, ' ').trim()).to.equal(legacyText); }); it('R5g. showtooltip=false: hidden -> shown and reconnect never arm a watch or write chip widths', async () => { const parent = box(300, 'none'); const el = await makeSelect(parent, { showTooltip: false }); expect(watchActive(el)).to.be.false; parent.style.display = 'block'; await settle(el); el.remove(); box(300).appendChild(el); await settle(el); expect(watchActive(el)).to.be.false; chipsOf(el).forEach(chip => expect(chip.style.width).to.equal('')); }); it('R5h. a visible showtooltip select never arms the watch', async () => { const el = await makeSelect(box(300)); expect(watchActive(el)).to.be.false; }); it('R5i. repeated disconnect/reconnect cycles stay stable', async () => { const ref = await reference(300); const el = await makeSelect(box(300)); const baseline = liveCount(); for (let i = 0; i < 5; i++) { el.remove(); box(300).appendChild(el); await settle(el); } expectFitted(fitOf(el), ref, el); expect(watchActive(el)).to.be.false; expect(liveCount(), 'no observer growth across cycles').to.equal(baseline); }); async function updatesOver(el: NileSelect, ms: number) { let updates = 0; const update = (el as any).update; (el as any).update = function (this: NileSelect, changed: Map) { updates++; return update.call(this, changed); }; await aTimeout(ms); (el as any).update = update; return updates; } it('R5j. autoResize + showtooltip: hidden -> reconnect fits, resizes refit, and it settles', async () => { const ref300 = await reference(300); const ref500 = await reference(500); const el = await makeSelect(box(300, 'none'), { autoResize: true }); el.remove(); const parent = box(300); parent.appendChild(el); await settle(el); expectFitted(fitOf(el), ref300, el); parent.style.width = '500px'; await settle(el); expectFitted(fitOf(el), ref500, el); expect(await updatesOver(el, 300), 'no render loop with showtooltip').to.equal(0); }); it('R5k. autoResize: turning showtooltip on over the 2.0.8 row gives the fitted row and settles', async () => { const ref = await reference(300); const el = await makeSelect(box(300), { showTooltip: false, autoResize: true }); el.showTooltip = true; await settle(el); await aTimeout(20); await settle(el); expectFitted(fitOf(el), ref, el); expect(await updatesOver(el, 300), 'no render loop once showtooltip is on').to.equal(0); }); }); class R7PrefixProbe extends HTMLElement { static created = 0; constructor() { super(); R7PrefixProbe.created++; } } if (!customElements.get('r7-prefix-probe')) customElements.define('r7-prefix-probe', R7PrefixProbe); describe('NileSelect option prefix copies across re-renders', () => { async function settle(el: NileSelect) { await el.updateComplete; await nextFrame(); await nextFrame(); await el.updateComplete; } const prefixIn = (el: NileSelect, selector: string) => el.shadowRoot!.querySelector(`${selector} r7-prefix-probe`) as HTMLElement | null; const withPrefix = (value: string) => html`${value}`; it('R7a. single select: the trigger prefix node is reused across re-renders', async () => { const el = await fixture(html`${withPrefix('a')}${withPrefix('b')}`); await settle(el); const first = prefixIn(el, '.select__prefix--from-options'); expect(first, 'prefix copied into the trigger').to.exist; const created = R7PrefixProbe.created; for (let i = 0; i < 3; i++) { el.requestUpdate(); await settle(el); } expect(prefixIn(el, '.select__prefix--from-options')).to.equal(first); expect(R7PrefixProbe.created).to.equal(created); }); for (const showTooltip of [false, true]) { it(`R7b. multiple (showtooltip=${showTooltip}): chip prefix nodes are reused across re-renders`, async () => { const el = await fixture(html` ${withPrefix('a')}${withPrefix('b')} `); await settle(el); const before = Array.from(el.shadowRoot!.querySelectorAll('.select__tags r7-prefix-probe')); expect(before.length).to.equal(2); const created = R7PrefixProbe.created; el.requestUpdate(); await settle(el); const after = Array.from(el.shadowRoot!.querySelectorAll('.select__tags r7-prefix-probe')); expect(after.length).to.equal(2); after.forEach((node, i) => expect(node === before[i], `chip ${i} prefix replaced`).to.be.true); expect(R7PrefixProbe.created).to.equal(created); }); } it('R7c. changing the selection or the option prefix still updates the copy', async () => { const el = await fixture(html`${withPrefix('a')}${withPrefix('b')}`); await settle(el); el.value = 'b'; await settle(el); expect(prefixIn(el, '.select__prefix--from-options')!.dataset.v).to.equal('b'); const source = el.querySelector('nile-option[value="b"] r7-prefix-probe') as HTMLElement; source.setAttribute('data-v', 'b2'); el.requestUpdate(); await settle(el); expect(prefixIn(el, '.select__prefix--from-options')!.dataset.v).to.equal('b2'); }); }); describe('NileSelect 2.0.8 compatibility matrix', () => { const OPTS = ['a', 'b', 'c']; const options = () => OPTS.map(v => html`L${v}`); const DATA = OPTS.map(v => ({ label: `L${v}`, value: v })); async function settle(el: NileSelect) { await el.updateComplete; await nextFrame(); await nextFrame(); await el.updateComplete; } const selectedValues = (el: NileSelect) => Array.from(el.querySelectorAll('nile-option')) .filter(o => (o as any).selected) .map(o => (o as any).value); const chipCount = (el: NileSelect) => el.shadowRoot!.querySelectorAll('.select__tags nile-tag').length; function record(el: EventTarget, names: string[]) { const seen: [string, unknown][] = []; names.forEach(name => el.addEventListener(name, e => seen.push([name, JSON.parse(JSON.stringify((e as CustomEvent).detail ?? null))])) ); return seen; } it('C1. a value in the template is applied when the options arrive later (multiple and single)', async () => { const multi = await fixture(html``); await settle(multi); OPTS.forEach(v => { const o = document.createElement('nile-option'); (o as any).value = v; o.textContent = v; multi.appendChild(o); }); await settle(multi); expect(multi.value).to.deep.equal(['a', 'b']); expect(selectedValues(multi)).to.deep.equal(['a', 'b']); expect(chipCount(multi)).to.equal(2); const single = await fixture(html``); await settle(single); OPTS.forEach(v => { const o = document.createElement('nile-option'); (o as any).value = v; o.textContent = v; single.appendChild(o); }); await settle(single); expect(single.value).to.equal('b'); expect(selectedValues(single)).to.deep.equal(['b']); }); it('C2. programmatic value updates reach options, chips and the form value', async () => { const el = await fixture(html`${options()}`); await settle(el); el.value = ['b', 'c']; await settle(el); expect(selectedValues(el)).to.deep.equal(['b', 'c']); expect(chipCount(el)).to.equal(2); el.value = []; await settle(el); expect(selectedValues(el)).to.deep.equal([]); expect(chipCount(el)).to.equal(0); }); it('C3. removing a chip updates the value and emits input, change, tag-remove in order', async () => { const el = await fixture(html`${options()}`); await settle(el); const seen = record(el, ['nile-input', 'nile-change', 'nile-tag-remove']); const chip = el.shadowRoot!.querySelector('.select__tags nile-tag') as HTMLElement; expect(chip, 'select rendered no chips').to.exist; chip.dispatchEvent(new CustomEvent('nile-remove', { bubbles: true, composed: true })); await settle(el); expect(el.value).to.deep.equal(['b']); expect(seen.map(([name]) => name)).to.deep.equal(['nile-input', 'nile-change', 'nile-tag-remove']); expect((seen[2][1] as any).removedtagvalue).to.equal('a'); }); it('C4. the clear button empties the value and emits clear, input, change', async () => { const el = await fixture(html`${options()}`); await settle(el); const seen = record(el, ['nile-clear', 'nile-input', 'nile-change']); const clear = el.shadowRoot!.querySelector('[part~="clear-button"]') as HTMLElement; expect(clear, 'clear button rendered').to.exist; clear.click(); await settle(el); expect(el.value).to.deep.equal([]); expect(chipCount(el)).to.equal(0); expect(seen.map(([name]) => name)).to.deep.equal(['nile-clear', 'nile-input', 'nile-change']); }); it('C5. form reset restores the value attribute', async () => { const form = await fixture(html`
${options()}
`); const el = form.querySelector('nile-select') as NileSelect; await settle(el); el.value = ['c']; await settle(el); form.reset(); await settle(el); expect(el.value).to.deep.equal(['a', 'b']); expect(selectedValues(el)).to.deep.equal(['a', 'b']); }); it('C6. portal + search: typing filters without touching the value', async () => { const el = await fixture(html`${options()}`); await settle(el); await el.show(); await aTimeout(50); const search = Array.from(document.body.querySelectorAll('nile-input')).find(n => !el.shadowRoot!.contains(n)) as HTMLElement | undefined; expect(search, 'portal search box rendered').to.exist; await (search as any).updateComplete; const input = search!.shadowRoot!.querySelector('input') as HTMLInputElement; input.value = 'Lb'; input.dispatchEvent(new Event('input', { bubbles: true, composed: true })); await settle(el); expect(el.value).to.deep.equal(['a']); await el.hide(); }); it('C7. reconnecting a multiple select without showtooltip keeps value and chips', async () => { const host = await fixture(html`
${options()}
`); const el = host.querySelector('nile-select') as NileSelect; await settle(el); el.remove(); host.appendChild(el); await settle(el); expect(el.value).to.deep.equal(['a', 'b']); expect(chipCount(el)).to.equal(2); }); it('C8. enableVirtualScroll: a value set before data arrives is kept and shown', async () => { const el = await fixture(html``); await settle(el); el.data = DATA; await settle(el); const inner = el.shadowRoot!.querySelector('nile-virtual-select') as any; await inner.updateComplete; expect(el.value).to.deep.equal(['b']); expect(inner.value).to.deep.equal(['b']); expect(inner.shadowRoot.querySelectorAll('.select__tags nile-tag').length).to.equal(1); }); it('C9. enableVirtualScroll: programmatic value reaches the inner select; clear-all leaves the outer value alone (pre-2.0.9)', async () => { const el = await fixture(html``); await settle(el); const inner = el.shadowRoot!.querySelector('nile-virtual-select') as any; el.value = ['a', 'c']; await settle(el); await inner.updateComplete; expect(inner.value).to.deep.equal(['a', 'c']); inner.unSelectAll(); await inner.updateComplete; await settle(el); expect(inner.value).to.deep.equal([]); // Without showTooltip the inner select owns the selection; the outer value is not written back. expect(el.value).to.deep.equal(['a', 'c']); }); it('C9b. enableVirtualScroll + showTooltip: programmatic value and clear-all reach both selects', async () => { const el = await fixture(html``); await settle(el); const inner = el.shadowRoot!.querySelector('nile-virtual-select') as any; el.value = ['a', 'c']; await settle(el); await inner.updateComplete; expect(inner.value).to.deep.equal(['a', 'c']); inner.unSelectAll(); await inner.updateComplete; await settle(el); expect(inner.value).to.deep.equal([]); expect(el.value).to.deep.equal([]); }); }); describe('NileSelect showtooltip tooltip on sub-pixel truncation', () => { const A = 'Engineering Operations'; const B = 'Human Resources Department And Payroll'; async function frames(n = 5) { for (let i = 0; i < n; i++) await nextFrame(); } async function mount(width: number, chipMaxWidth: number) { const host = await fixture(html`
${A} ${B}
`); const el = host.querySelector('nile-select') as any; await el.updateComplete; await frames(); await el.updateComplete; await frames(2); return el; } function visiblyCut(chip: HTMLElement) { const content = chip.shadowRoot!.querySelector('[part="content"]') as HTMLSlotElement; const text = content.assignedNodes({ flatten: true }).find(n => n.textContent?.trim())!; const range = document.createRange(); range.selectNodeContents(text); const over = range.getBoundingClientRect().right - content.getBoundingClientRect().right; return { cut: over > 1 / 64, over, pxGap: content.scrollWidth - content.clientWidth }; } function chipsAndTips(el: any) { const chips = Array.from(el.shadowRoot.querySelectorAll('.select__tags nile-tag')) as HTMLElement[]; const tips = Array.from(el.shadowRoot.querySelectorAll('.select__tags nile-lite-tooltip')) as HTMLElement[]; return chips .map((chip, i) => ({ chip, tipOn: !!tips[i] && !tips[i].hasAttribute('disabled') })) .filter(({ chip }) => chip.style.display !== 'none'); } it('TT1. every visibly cut chip has its tooltip, every uncut chip has none (sweep across the edge)', async () => { const wide = await mount(900, 400); const natural = chipsAndTips(wide)[0].chip.offsetWidth; let subPixelCuts = 0; for (let max = natural - 4; max <= natural + 1; max += 0.25) { const el = await mount(330, max); for (const { chip, tipOn } of chipsAndTips(el)) { const { cut, pxGap } = visiblyCut(chip); expect(tipOn, `chipMaxWidth ${max}: tooltip ${tipOn} but cut ${cut}`).to.equal(cut); if (cut && pxGap <= 1) subPixelCuts++; } } expect(subPixelCuts, 'sweep reached the sub-pixel truncation window').to.be.greaterThan(0); }); it('TT2. chips at their natural width get no tooltip', async () => { const el = await mount(900, 400); for (const { chip, tipOn } of chipsAndTips(el)) { expect(visiblyCut(chip).cut).to.be.false; expect(tipOn).to.be.false; } }); }); /** * enableVirtualScroll: the two value/data paths. * * - without `showTooltip` the wrapper behaves as it did before 2.0.9: the inner select owns the * selection, the wrapper `value` is not written back, and `.data` is re-applied as before; * - with `showTooltip` the inner value is synced up, `.data` is only re-applied when its reference * changes, and an external `value` change recomputes an open "Selected" view. * * These tests drive the rendered DOM (option mouseup, footer checkbox, search input, chip remove button) * rather than the inner select's methods. */ type VsInner = HTMLElement & { value: string | string[]; data: Array<{ value: string; label: string }>; showSelected: boolean; searchValue: string; maxOptionsVisible: number; updateComplete: Promise; }; const vsMounted: VsInner[] = []; const VS_OPTIONS = Array.from({ length: 300 }, (_, i) => ({ value: `option-${i + 1}`, label: `Option ${i + 1}` })); function vsRoots(inner: VsInner): ParentNode[] { const portal = document.body.querySelector('.nile-virtual-select-portal-append'); return [inner.shadowRoot!, ...(portal ? [portal] : [])]; } function vsAll(inner: VsInner, selector: string): HTMLElement[] { return vsRoots(inner).flatMap(root => Array.from(root.querySelectorAll(selector))); } async function vsMount({ showTooltip = false, multiple = true, search = false, portal = false, value = undefined as string | string[] | undefined, data = VS_OPTIONS, } = {}) { const initial = value ?? (multiple ? [] : ''); const el = await fixture(html``); await el.updateComplete; const inner = el.shadowRoot!.querySelector('nile-virtual-select') as unknown as VsInner; await inner.updateComplete; vsMounted.push(inner); let updates = 0; const update = (el as any).update; (el as any).update = function (this: NileSelect, changed: Map) { updates++; return update.call(this, changed); }; return { el, inner, updates: () => updates, resetUpdates: () => (updates = 0) }; } async function vsSettle(el: NileSelect, inner: VsInner) { await inner.updateComplete; await el.updateComplete; await inner.updateComplete; await aTimeout(30); } async function vsOpen(inner: VsInner) { const shown = oneEvent(inner, 'nile-after-show'); (inner.shadowRoot!.querySelector('[part="combobox"]') as HTMLElement).dispatchEvent( new MouseEvent('mousedown', { bubbles: true, composed: true, button: 0 }) ); await shown; await aTimeout(50); } async function vsPick(el: NileSelect, inner: VsInner, value: string) { await waitUntil( () => vsAll(inner, 'nile-option').find(o => (o as any).value === value), `option ${value} was not rendered` ); const option = vsAll(inner, 'nile-option').find(o => (o as any).value === value)!; option.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, composed: true, button: 0 })); await vsSettle(el, inner); } async function vsToggleSelected(el: NileSelect, inner: VsInner) { (vsAll(inner, '.select__footer nile-checkbox')[0] as HTMLElement).click(); await vsSettle(el, inner); } async function vsType(el: NileSelect, inner: VsInner, text: string) { const input = vsAll(inner, '.select__search nile-input')[0].shadowRoot!.querySelector('input') as HTMLInputElement; input.value = text; input.dispatchEvent(new Event('input', { bubbles: true, composed: true })); await vsSettle(el, inner); } async function vsRemoveChip(el: NileSelect, inner: VsInner, index = 0) { const tag = inner.shadowRoot!.querySelectorAll('nile-tag')[index] as HTMLElement; const remove = tag.shadowRoot!.querySelector('[part="remove-button"], nile-icon-button, button') as HTMLElement; ((remove.shadowRoot?.querySelector('button') as HTMLElement | null) ?? remove).click(); await vsSettle(el, inner); } const VS_SEARCH = 'Option 99'; const VS_SEARCH_HITS = VS_OPTIONS.filter(o => o.label.includes(VS_SEARCH)).length; const vsDataValues = (inner: VsInner) => inner.data.map(item => item.value); // Known, pre-existing noise from @lit-labs/virtualizer, unrelated to these tests: a "ResizeObserver loop" notice // while a list is laid out, and a detached-list error when the visible list swaps between a short and a long data // set (for example leaving the Selected view). The browser raises them as window errors, which would fail // whichever test happens to be running. Only the new virtual-scroll describes below install this filter; their // assertions still decide pass or fail. // The virtualizer-only errors are matched on message AND on a `virtualizer` frame in the stack, so the same message // thrown from anywhere else still fails the test. "ResizeObserver loop" is a browser notice with no stack. const VS_VIRTUALIZER_ERRORS = ['ChildPart` has no `parentNode`', "reading 'insertBefore'"]; function vsIsKnownVirtualizerNoise(message: unknown, error?: Error | null): boolean { if (typeof message !== 'string') return false; if (message.includes('ResizeObserver loop')) return true; return ( VS_VIRTUALIZER_ERRORS.some(known => message.includes(known)) && !!error?.stack && error.stack.toLowerCase().includes('virtualizer') ); } function vsIgnoreKnownVirtualizerErrors(): () => void { const previous = window.onerror; window.onerror = (message, source, lineno, colno, error) => { if (vsIsKnownVirtualizerNoise(message, error)) return true; return previous ? previous.call(window, message, source, lineno, colno, error) : false; }; return () => { window.onerror = previous; }; } // Close every dropdown and let it finish hiding before its fixture goes away, so the virtualizer is not // still laying out a list that has already been detached. Registered per describe below, not file-wide. async function vsCloseDropdowns() { const mounted = vsMounted.splice(0); for (const inner of mounted) { if (!(inner as any).open) continue; const hidden = oneEvent(inner, 'nile-after-hide'); (inner as any).open = false; await Promise.race([hidden, aTimeout(1500)]); } if (mounted.length) { await nextFrame(); await nextFrame(); await aTimeout(250); } document.body.querySelectorAll('.nile-virtual-select-portal-append').forEach(n => n.remove()); } describe('NileSelect enableVirtualScroll without showTooltip keeps the pre-2.0.9 behaviour', () => { let restoreOnError: () => void; before(() => { restoreOnError = vsIgnoreKnownVirtualizerErrors(); }); after(() => { restoreOnError(); }); afterEach(vsCloseDropdowns); it('L1. a pick does not write the outer value', async () => { const { el, inner } = await vsMount(); await vsOpen(inner); await vsPick(el, inner, 'option-1'); expect(inner.value).to.deep.equal(['option-1']); expect(el.value).to.deep.equal([]); }); it('L2. a normal pick, uncheck and chip removal cause no outer update', async () => { const { el, inner, updates, resetUpdates } = await vsMount({ value: ['option-9'] }); await vsOpen(inner); resetUpdates(); await vsPick(el, inner, 'option-1'); await vsPick(el, inner, 'option-1'); await vsRemoveChip(el, inner, 0); expect(updates()).to.equal(0); }); it('L3. a parent re-render pushes the stale outer value back over the selection', async () => { const { el, inner } = await vsMount(); await vsOpen(inner); await vsPick(el, inner, 'option-1'); expect(inner.value).to.deep.equal(['option-1']); el.requestUpdate(); await vsSettle(el, inner); expect(inner.value).to.deep.equal([]); }); it('L4. nile-input / nile-change from the inner select are not synced (no sync listeners)', async () => { const { el, inner } = await vsMount({ value: ['option-1'] }); const fire = (name: string, value: unknown) => inner.dispatchEvent(new CustomEvent(name, { detail: { value }, bubbles: true, composed: true })); fire('nile-input', ['option-2']); fire('nile-change', ['option-3']); await vsSettle(el, inner); expect(el.value).to.deep.equal(['option-1']); }); it('L5. a direct syncValueFromVirtualSelect call does not change the outer value', async () => { const { el, inner } = await vsMount({ value: ['option-1'] }); const event = new CustomEvent('nile-change', { detail: { value: ['option-2'] } }); Object.defineProperty(event, 'currentTarget', { value: inner }); Object.defineProperty(event, 'composedPath', { value: () => [inner] }); el.syncValueFromVirtualSelect(event as any); await vsSettle(el, inner); expect(el.value).to.deep.equal(['option-1']); }); it('L6. Selected stays filtered through a normal uncheck', async () => { const { el, inner } = await vsMount(); await vsOpen(inner); await vsPick(el, inner, 'option-1'); await vsPick(el, inner, 'option-2'); await vsPick(el, inner, 'option-3'); await vsToggleSelected(el, inner); expect(vsDataValues(inner)).to.deep.equal(['option-1', 'option-2', 'option-3']); await vsPick(el, inner, 'option-2'); expect(inner.showSelected).to.be.true; expect(inner.value).to.deep.equal(['option-1', 'option-3']); expect(vsDataValues(inner)).to.deep.equal(['option-1', 'option-2', 'option-3']); }); it('L7. search stays filtered through a pick and an unpick', async () => { const { el, inner } = await vsMount({ search: true }); await vsOpen(inner); await vsType(el, inner, VS_SEARCH); expect(inner.data.length).to.equal(VS_SEARCH_HITS); await vsPick(el, inner, 'option-99'); expect(inner.data.length).to.equal(VS_SEARCH_HITS); expect(inner.searchValue).to.equal(VS_SEARCH); await vsPick(el, inner, 'option-99'); expect(inner.data.length).to.equal(VS_SEARCH_HITS); expect(inner.value).to.deep.equal([]); }); it('L9. the wrapper pushes maxOptionsVisible to the inner select (pre-2.0.9 binding)', async () => { const { el, inner } = await vsMount({ value: ['option-1'] }); const writes: number[] = []; const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(inner), 'maxOptionsVisible')!; Object.defineProperty(inner, 'maxOptionsVisible', { configurable: true, get() { return descriptor.get!.call(this); }, set(value: number) { writes.push(value); descriptor.set!.call(this, value); }, }); el.maxOptionsVisible = 5; await vsSettle(el, inner); expect(writes).to.include(5); }); it('L10. the untouched default maxOptionsVisible is not pushed, so the inner select is not pinned as explicit', async () => { const { inner } = await vsMount(); expect((inner as any).maxOptionsVisibleExplicit).to.be.false; }); it('L8. data is re-applied on every wrapper render (plain binding, pre-2.0.9)', async () => { const { el, inner } = await vsMount({ value: ['option-1'] }); await vsOpen(inner); // After opening, the inner select holds its own copy of the list. expect(inner.data).to.not.equal(el.data); el.requestUpdate(); await vsSettle(el, inner); expect(inner.data).to.equal(el.data); }); }); describe('NileSelect enableVirtualScroll with showTooltip: guarded data and external value updates', () => { let restoreOnError: () => void; before(() => { restoreOnError = vsIgnoreKnownVirtualizerErrors(); }); after(() => { restoreOnError(); }); afterEach(vsCloseDropdowns); it('T1. Selected + uncheck stays filtered', async () => { const { el, inner } = await vsMount({ showTooltip: true }); await vsOpen(inner); await vsPick(el, inner, 'option-1'); await vsPick(el, inner, 'option-2'); await vsPick(el, inner, 'option-3'); await vsToggleSelected(el, inner); expect(vsDataValues(inner)).to.deep.equal(['option-1', 'option-2', 'option-3']); await vsPick(el, inner, 'option-2'); expect(inner.showSelected).to.be.true; expect(vsDataValues(inner)).to.deep.equal(['option-1', 'option-2', 'option-3']); expect(el.value).to.deep.equal(['option-1', 'option-3']); }); it('T2. search + pick/unpick stays filtered', async () => { const { el, inner } = await vsMount({ showTooltip: true, search: true }); await vsOpen(inner); await vsType(el, inner, VS_SEARCH); expect(inner.data.length).to.equal(VS_SEARCH_HITS); await vsPick(el, inner, 'option-99'); expect(inner.data.length).to.equal(VS_SEARCH_HITS); expect(inner.searchValue).to.equal(VS_SEARCH); expect(el.value).to.deep.equal(['option-99']); await vsPick(el, inner, 'option-99'); expect(inner.data.length).to.equal(VS_SEARCH_HITS); expect(el.value).to.deep.equal([]); }); it('T3. chip removal while Selected stays filtered', async () => { const { el, inner } = await vsMount({ showTooltip: true, value: ['option-1', 'option-2', 'option-3'] }); await vsOpen(inner); await vsToggleSelected(el, inner); expect(inner.data.length).to.equal(3); await vsRemoveChip(el, inner, 0); expect(inner.showSelected).to.be.true; expect(inner.data.length).to.equal(3); expect(el.value).to.deep.equal(['option-2', 'option-3']); }); it('T4. portal: Selected + uncheck stays filtered', async () => { const { el, inner } = await vsMount({ showTooltip: true, portal: true }); await vsOpen(inner); await waitUntil(() => document.body.querySelector('.nile-virtual-select-portal-append #listbox'), 'listbox never portalled'); await vsPick(el, inner, 'option-1'); await vsPick(el, inner, 'option-2'); await vsToggleSelected(el, inner); expect(vsDataValues(inner)).to.deep.equal(['option-1', 'option-2']); await vsPick(el, inner, 'option-2'); expect(inner.showSelected).to.be.true; expect(vsDataValues(inner)).to.deep.equal(['option-1', 'option-2']); expect(el.value).to.deep.equal(['option-1']); }); it('T5. picking, removing a chip and clearing sync the outer value; nile-change listeners see it', async () => { const { el, inner } = await vsMount({ showTooltip: true }); const seen: unknown[] = []; el.addEventListener('nile-change', () => seen.push(el.value)); await vsOpen(inner); await vsPick(el, inner, 'option-1'); await vsPick(el, inner, 'option-2'); expect(el.value).to.deep.equal(['option-1', 'option-2']); expect(seen[0]).to.deep.equal(['option-1']); await vsRemoveChip(el, inner, 0); expect(el.value).to.deep.equal(['option-2']); (vsAll(inner, '.select__clear')[0] as HTMLElement).click(); await vsSettle(el, inner); expect(el.value).to.deep.equal([]); }); it('T6. an external value replace while Selected recomputes the list', async () => { const { el, inner } = await vsMount({ showTooltip: true, value: ['option-1', 'option-2'] }); await vsOpen(inner); await vsToggleSelected(el, inner); el.value = ['option-5']; await vsSettle(el, inner); expect(inner.showSelected).to.be.true; expect(vsDataValues(inner)).to.deep.equal(['option-5']); }); it('T7. an external value that adds an item while Selected shows the new item', async () => { const { el, inner } = await vsMount({ showTooltip: true, value: ['option-1', 'option-2'] }); await vsOpen(inner); await vsToggleSelected(el, inner); el.value = ['option-1', 'option-2', 'option-7']; await vsSettle(el, inner); expect(vsDataValues(inner)).to.deep.equal(['option-1', 'option-2', 'option-7']); }); it('T8. an external value that removes an item while Selected drops it from the list', async () => { const { el, inner } = await vsMount({ showTooltip: true, value: ['option-1', 'option-2'] }); await vsOpen(inner); await vsToggleSelected(el, inner); el.value = ['option-1']; await vsSettle(el, inner); expect(vsDataValues(inner)).to.deep.equal(['option-1']); }); it('T9. an external empty value leaves the Selected view', async () => { const { el, inner } = await vsMount({ showTooltip: true, value: ['option-1', 'option-2'] }); await vsOpen(inner); await vsToggleSelected(el, inner); el.value = []; await vsSettle(el, inner); expect(inner.showSelected).to.be.false; expect(inner.data.length).to.equal(VS_OPTIONS.length); }); it('T10. a normal pick after an external value update still keeps the item listed', async () => { const { el, inner } = await vsMount({ showTooltip: true, value: ['option-1', 'option-2'] }); await vsOpen(inner); await vsToggleSelected(el, inner); el.value = ['option-5', 'option-6']; await vsSettle(el, inner); await vsPick(el, inner, 'option-5'); expect(inner.value).to.deep.equal(['option-6']); expect(el.value).to.deep.equal(['option-6']); expect(inner.showSelected).to.be.true; expect(vsDataValues(inner)).to.deep.equal(['option-5', 'option-6']); }); it('T11. a normal pick does not re-send the data array', async () => { const { el, inner } = await vsMount({ showTooltip: true, value: ['option-1', 'option-2'] }); await vsOpen(inner); await vsToggleSelected(el, inner); const before = inner.data; await vsPick(el, inner, 'option-2'); expect(inner.data).to.equal(before); }); it('T12. turning showTooltip on at runtime keeps the selection', async () => { const { el, inner } = await vsMount({ showTooltip: false }); await vsOpen(inner); await vsPick(el, inner, 'option-1'); await vsPick(el, inner, 'option-2'); expect(el.value).to.deep.equal([]); expect(inner.value).to.deep.equal(['option-1', 'option-2']); el.showTooltip = true; await vsSettle(el, inner); expect(el.value).to.deep.equal(['option-1', 'option-2']); expect(inner.value).to.deep.equal(['option-1', 'option-2']); }); it('T15. turning showTooltip on at runtime engages the fitted chip row', async () => { const { el, inner } = await vsMount({ showTooltip: false, value: ['option-1', 'option-2', 'option-3', 'option-4', 'option-5'], }); const fitted = () => !!inner.shadowRoot!.querySelector('.select__tags--fitted'); expect(fitted()).to.be.false; el.showTooltip = true; await vsSettle(el, inner); await waitUntil(fitted, 'the fitted chip row never engaged'); expect(el.value).to.deep.equal(['option-1', 'option-2', 'option-3', 'option-4', 'option-5']); }); it('T16. changing showTooltip and value in the same update keeps the consumer value', async () => { const { el, inner } = await vsMount({ showTooltip: false }); await vsOpen(inner); await vsPick(el, inner, 'option-1'); expect(inner.value).to.deep.equal(['option-1']); expect(el.value).to.deep.equal([]); el.value = ['option-7']; el.showTooltip = true; await vsSettle(el, inner); expect(el.value).to.deep.equal(['option-7']); expect(inner.value).to.deep.equal(['option-7']); }); it('T13. assigning a new data array propagates', async () => { const { el, inner } = await vsMount({ showTooltip: true }); await vsOpen(inner); el.data = [...VS_OPTIONS.slice(0, 10), { value: 'extra', label: 'Extra' }]; await vsSettle(el, inner); expect(inner.data.length).to.equal(11); expect(vsDataValues(inner)).to.include('extra'); }); it('T14. mutating data in place (data.push) is not propagated; assign a new array instead', async () => { const data = VS_OPTIONS.slice(0, 20); const { el, inner } = await vsMount({ showTooltip: true, data }); await vsOpen(inner); data.push({ value: 'extra', label: 'Extra' }); el.requestUpdate(); await vsSettle(el, inner); expect(inner.data.length).to.equal(20); expect(vsDataValues(inner)).to.not.include('extra'); }); });