import { aTimeout, expect, fixture, html, nextFrame, oneEvent, waitUntil } from '@open-wc/testing'; import './nile-virtual-select'; import '../nile-input/nile-input'; import type { NileVirtualSelect } from './nile-virtual-select'; describe('NileVirtualSelect', () => { 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-virtual-select'); }); it('4. is defined', async () => { expect(customElements.get('nile-virtual-select')).to.exist; }); it('5. has styles', async () => { expect((await import('./nile-virtual-select')).NileVirtualSelect.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-virtual-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-virtual-select')).to.equal(el); }); it('12. cloneNode', async () => { const el = await fixture(html``); expect((el.cloneNode(true) as Element).tagName.toLowerCase()).to.equal('nile-virtual-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('vs')).to.be.true; }); it('18. id attr', async () => { const el = await fixture(html``); expect(el.id).to.equal('vs1'); }); 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-virtual-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-virtual-select').length).to.equal(2); }); it('25. parent-child', async () => { const c = await fixture(html`
`); expect(c.querySelector('nile-virtual-select')!.parentElement).to.equal(c); }); it('26. createElement', async () => { const el = document.createElement('nile-virtual-select') as NileVirtualSelect; 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('VS'); }); 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-virtual-select')).to.exist; }); it('44. no canvas', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('canvas')).to.be.null; }); it('45. no video', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('video')).to.be.null; }); it('46. no audio', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('audio')).to.be.null; }); it('47. no nav', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nav')).to.be.null; }); it('48. no aside', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('aside')).to.be.null; }); it('49. no table', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('table')).to.be.null; }); it('50. no img', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('img')).to.be.null; }); it('51. no anchor', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('a')).to.be.null; }); it('52. no svg', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('svg')).to.be.null; }); it('53. no form', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('form')).to.be.null; }); it('54. title attr', async () => { const el = await fixture(html``); expect(el.title).to.equal('VS'); }); 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. no ul', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('ul')).to.be.null; }); it('61. no textarea', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('textarea')).to.be.null; }); it('62. no select html', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('select')).to.be.null; }); it('63. no nile-badge', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-badge')).to.be.null; }); it('64. no nile-radio', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-radio')).to.be.null; }); it('65. no nile-dialog', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-dialog')).to.be.null; }); it('66. no nile-drawer', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-drawer')).to.be.null; }); it('67. no nile-slider', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-slider')).to.be.null; }); it('68. no nile-accordion', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-accordion')).to.be.null; }); it('69. setAttribute data', async () => { const el = await fixture(html``); el.setAttribute('data-test', '1'); expect(el.dataset.test).to.equal('1'); }); it('70. removeAttribute hidden', async () => { const el = await fixture(html``); el.removeAttribute('hidden'); expect(el.hidden).to.be.false; }); it('71. setAttribute class', async () => { const el = await fixture(html``); el.setAttribute('class', 'test'); expect(el.classList.contains('test')).to.be.true; }); it('72. aria-describedby', async () => { const el = await fixture(html``); expect(el.getAttribute('aria-describedby')).to.equal('d'); }); it('73. 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('74. contains', async () => { const el = await fixture(html`C`); expect(el.contains(el.querySelector('#s1'))).to.be.true; }); it('75. data attr', async () => { const el = await fixture(html``); expect(el.getAttribute('data-x')).to.equal('1'); }); it('76. role attr', async () => { const el = await fixture(html``); expect(el.getAttribute('role')).to.equal('listbox'); }); it('77. no section', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('section')).to.be.null; }); it('78. no header', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('header')).to.be.null; }); it('79. no footer', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('footer')).to.be.null; }); it('80. no main', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('main')).to.be.null; }); it('81. no article', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('article')).to.be.null; }); it('82. no nile-toolbar', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-toolbar')).to.be.null; }); it('83. no nile-link', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-link')).to.be.null; }); it('84. no nile-menu', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-menu')).to.be.null; }); it('85. no nile-textarea', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-textarea')).to.be.null; }); it('86. no nile-button', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-button')).to.be.null; }); it('87. no nile-card', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-card')).to.be.null; }); it('88. no nile-tab', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-tab')).to.be.null; }); it('89. no nile-stepper', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-stepper')).to.be.null; }); it('90. no nile-pagination', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-pagination')).to.be.null; }); it('91. show method', async () => { const el = await fixture(html``); expect(el.show).to.be.a('function'); }); it('92. hide method', async () => { const el = await fixture(html``); expect(el.hide).to.be.a('function'); }); it('93. nile-popup in shadow', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-popup')).to.exist; }); it('94. nile-icon in shadow', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-icon')).to.exist; }); it('95. label slot', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('slot[name="label"]')).to.exist; }); it('96. label slot', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('slot[name="label"]')).to.exist; }); it('97. combobox div', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('.select__combobox')).to.exist; }); it('98. no nile-input', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-input')).to.be.null; }); it('99. no nile-calendar', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-calendar')).to.be.null; }); it('100. full integration', async () => { const el = await fixture(html``); expect(el.id).to.equal('vs1'); expect(el.shadowRoot).to.not.be.null; expect(el.shadowRoot!.querySelector('nile-popup')).to.exist; expect(el.shadowRoot!.querySelector('.select__combobox')).to.exist; }); }); describe('NileVirtualSelect chip fitting behaviour matrix', () => { const LABELS = [ 'Engineering Operations Team', 'Human Resources Department', 'Finance And Accounting Group', 'Customer Success Organisation', 'Product Design Studio Unit' ]; const DATA = LABELS.map(label => ({ label, value: label })); async function settle(el: NileVirtualSelect) { await el.updateComplete; await nextFrame(); await nextFrame(); await el.updateComplete; } function tagsRoot(el: NileVirtualSelect) { return el.shadowRoot!.querySelector('.select__tags'); } function chips(el: NileVirtualSelect) { return Array.from( el.shadowRoot!.querySelectorAll('.select__tags nile-tag') ) as HTMLElement[]; } function hiddenChips(el: NileVirtualSelect) { return chips(el).filter(chip => chip.style.display === 'none'); } it('V1. multiple + no max-options-visible + autoResize=false renders fitted chips', async () => { const host = await fixture( html`
` ); const el = host.querySelector('nile-virtual-select') as NileVirtualSelect; 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('V2. multiple + no max-options-visible + autoResize=true refits when the container resizes', async () => { const host = await fixture( html`
` ); const el = host.querySelector('nile-virtual-select') as NileVirtualSelect; 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('V3. explicit max-options-visible switches to fixed-count layout', async () => { const host = await fixture( html`
` ); const el = host.querySelector('nile-virtual-select') as NileVirtualSelect; 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('V3b. max-options-visible set as a property also switches to fixed-count layout', async () => { const host = await fixture( html`
` ); const el = host.querySelector('nile-virtual-select') as NileVirtualSelect; 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('V3c. the default max-options-visible is still 3 and reading it does not opt out', async () => { const host = await fixture( html`
` ); const el = host.querySelector('nile-virtual-select') as NileVirtualSelect; 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('V5. 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('V6. the +N button count matches the number of hidden chips', async () => { const host = await fixture( html`
` ); const el = host.querySelector('nile-virtual-select') as NileVirtualSelect; 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; } }); it('V7. nile-select and nile-virtual-select agree on when fitting is active', async () => { const host = await fixture( html`
` ); const [fitted, fixed] = Array.from( host.querySelectorAll('nile-virtual-select') ) as NileVirtualSelect[]; await settle(fitted); await settle(fixed); expect(tagsRoot(fitted)!.classList.contains('select__tags--fitted')).to.be .true; expect(tagsRoot(fixed)!.classList.contains('select__tags--fitted')).to.be .false; }); // The tooltip is opt-in: `showtooltip` present, and the chip's text clipped. function tips(el: NileVirtualSelect) { return Array.from( el.shadowRoot!.querySelectorAll('.select__tag-tip') ) as HTMLElement[]; } function liveTips(el: NileVirtualSelect) { return tips(el).filter(tip => !tip.hasAttribute('disabled')); } // 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: NileVirtualSelect) { return chips(el) .filter(chip => chip.style.display !== 'none') .filter(chip => { const content = chipContent(chip); return content.scrollWidth > content.clientWidth + 1; }).length; } it('V12. without showtooltip the pre-2.0.9 chip row is used', async () => { const host = await fixture( html`
` ); const el = host.querySelector('nile-virtual-select') as NileVirtualSelect; 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('V13. showtooltip is what allows a fitted chip to be truncated', async () => { const host = await fixture( html`
` ); const el = host.querySelector('nile-virtual-select') as NileVirtualSelect; await settle(el); expect(el.showTooltip).to.be.true; expect(clippedChipCount(el)).to.be.greaterThan(0); }); it('V8. no showtooltip attribute renders no chip tooltips', async () => { const host = await fixture( html`
` ); const el = host.querySelector('nile-virtual-select') as NileVirtualSelect; await settle(el); expect(el.showTooltip).to.be.false; expect(el.hasAttribute('showtooltip')).to.be.false; expect(tips(el).length).to.equal(0); }); it('V9. showtooltip shows the chip tooltip', async () => { const host = await fixture( html`
` ); const el = host.querySelector('nile-virtual-select') as NileVirtualSelect; await settle(el); expect(el.showTooltip).to.be.true; expect(liveTips(el).length).to.be.greaterThan(0); }); it('V10. removing showtooltip hides the chip tooltip again', async () => { const host = await fixture( html`
` ); const [el, control] = Array.from( host.querySelectorAll('nile-virtual-select') ) as NileVirtualSelect[]; 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('V11. the overflow button label and text never disagree, including first paint', async () => { const host = await fixture( html`
` ); const el = host.querySelector('nile-virtual-select') as NileVirtualSelect; // 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('V12. no overflow button is rendered when every chip fits', async () => { const host = await fixture( html`
` ); const el = host.querySelector('nile-virtual-select') as NileVirtualSelect; await settle(el); expect(hiddenChips(el).length).to.equal(0); expect(el.shadowRoot!.querySelector('button.select__tags-count')).to.not .exist; }); it('G1. default chips are not capped by the 2.0.9 chip styles; showtooltip caps them', async () => { const host = await fixture( html`
` ); const [plain, tooltip] = Array.from( host.querySelectorAll('nile-virtual-select') ) as NileVirtualSelect[]; await settle(plain); await settle(tooltip); const [plainChip] = chips(plain); const [tooltipChip] = chips(tooltip); expect(plainChip, 'plain select rendered no chips').to.exist; expect(tooltipChip, 'tooltip select rendered no chips').to.exist; expect(getComputedStyle(plainChip).maxWidth).to.equal('none'); expect(getComputedStyle(tooltipChip).maxWidth).to.not.equal('none'); }); }); describe('NileVirtualSelect chipMinWidth / chipMaxWidth attributes', () => { const LABELS = ['Engineering Operations Team', 'Human Resources Department']; const DATA = LABELS.map(label => ({ label, value: label })); async function settle(el: NileVirtualSelect) { await el.updateComplete; await nextFrame(); await nextFrame(); await el.updateComplete; } const firstChip = (el: NileVirtualSelect) => 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``); 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. attributes still set the properties; properties do not add attributes', async () => { const el = await fixture( html`` ); await settle(el); expect(el.chipMinWidth).to.equal(90); expect(el.chipMaxWidth).to.equal(150); const plain = await fixture(html``); plain.chipMaxWidth = 140; await settle(plain); expect(plain.chipMaxWidth).to.equal(140); expect(plain.hasAttribute('chipmaxwidth')).to.be.false; }); it('R4c. showtooltip still caps squeezed chips at chipMaxWidth', async () => { const host = await fixture(html`
`); const el = host.querySelector('nile-virtual-select') as NileVirtualSelect; 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); }); }); describe('NileVirtualSelect footer width with and without noWidthSync', () => { const DATA = [{ label: 'a', value: 'a' }, { label: 'b', value: 'b' }]; async function openSelect(width: number, noWidthSync: boolean, portal = false) { const host = await fixture(html`
`); const el = host.querySelector('nile-virtual-select') as NileVirtualSelect; await el.updateComplete; el.open = true; const listbox = () => (portal ? document.body.querySelector('.nile-virtual-select-portal-append #listbox') : el.shadowRoot!.querySelector('#listbox')) as HTMLElement | null; await waitUntil(() => (listbox()?.getBoundingClientRect().width ?? 0) > 0, 'listbox never opened'); await nextFrame(); const box = listbox()!; const footer = box.querySelector('.select__footer') as HTMLElement; const combobox = el.shadowRoot!.querySelector('[part~="combobox"]') as HTMLElement; return { el, listbox: box, 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')); afterEach(async () => { document.body.querySelectorAll('.nile-virtual-select-portal-append').forEach(n => n.remove()); document.head.querySelectorAll('style[id^="nile-virtual-select-styles-"]').forEach(n => n.remove()); }); 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; }); for (const noWidthSync of [false, true]) { it(`R3d. portal (noWidthSync=${noWidthSync}) pins the width and keeps the narrow-footer queries`, async () => { const { el, listbox, combobox, footer } = await openSelect(130, noWidthSync, true); expect(listbox.offsetWidth).to.be.within(combobox.offsetWidth - 8, combobox.offsetWidth); expect(labelShown(footer, '.select__clear .select__footer-label')).to.be.false; el.open = false; await waitUntil(() => !document.body.querySelector('.nile-virtual-select-portal-append'), 'portal not cleaned up'); }); } }); describe('NileVirtualSelect showtooltip chip layout taken while hidden', () => { const LABELS = ['Engineering Operations Team', 'Human Resources Department']; const DATA = LABELS.map(label => ({ label, value: label })); async function frames(n = 3) { for (let i = 0; i < n; i++) await nextFrame(); } const chipsOf = (el: NileVirtualSelect) => Array.from(el.shadowRoot!.querySelectorAll('.select__tags nile-tag')) as HTMLElement[]; async function hiddenSelect(showTooltip: boolean) { const host = await fixture(html`
`); const el = host.querySelector('nile-virtual-select') as NileVirtualSelect; await el.updateComplete; await frames(); await el.updateComplete; return { host, el }; } it('R5a. showtooltip chips measured while hidden get real widths once shown', async () => { const { host, el } = await hiddenSelect(true); host.style.display = 'block'; await frames(4); await el.updateComplete; const chips = chipsOf(el); expect(chips.length).to.equal(2); for (const chip of chips) { expect(chip.style.width).to.not.equal('0px'); expect(chip.offsetWidth).to.be.greaterThan(50); } }); it('R5c. without showtooltip a hidden-then-shown select is unaffected (no inline widths)', async () => { const { host, el } = await hiddenSelect(false); host.style.display = 'block'; await frames(4); for (const chip of chipsOf(el)) { expect(chip.style.width).to.equal(''); expect(chip.offsetWidth).to.be.greaterThan(50); } }); }); describe('NileVirtualSelect 2.0.8 compatibility matrix', () => { const DATA = ['a', 'b', 'c'].map(v => ({ label: `L${v}`, value: v })); async function settle(el: NileVirtualSelect) { await el.updateComplete; await nextFrame(); await nextFrame(); await el.updateComplete; } const chipCount = (el: NileVirtualSelect) => el.shadowRoot!.querySelectorAll('.select__tags nile-tag').length; function record(el: EventTarget, names: string[]) { const seen: string[] = []; names.forEach(name => el.addEventListener(name, () => seen.push(name))); return seen; } it('CV1. a value set before data arrives is kept, and survives the data being replaced', async () => { const el = await fixture(html``); await settle(el); el.data = DATA; await settle(el); expect(el.value).to.deep.equal(['b']); expect(chipCount(el)).to.equal(1); el.data = [...DATA]; await settle(el); expect(el.value).to.deep.equal(['b']); expect(chipCount(el)).to.equal(1); }); it('CV2. programmatic value updates the chips', async () => { const el = await fixture(html``); await settle(el); el.value = ['a', 'c']; await settle(el); expect(chipCount(el)).to.equal(2); el.value = []; await settle(el); expect(chipCount(el)).to.equal(0); }); it('CV3. removing a chip updates the value and emits tag-remove', async () => { const el = await fixture(html``); await settle(el); const seen = record(el, ['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).to.deep.equal(['nile-tag-remove']); }); it('CV4. the clear button empties the value and emits nile-clear', async () => { const el = await fixture(html``); await settle(el); const seen = record(el, ['nile-clear']); 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).to.include('nile-clear'); }); it('CV5. form reset restores the value attribute', async () => { const form = await fixture(html`
`); const el = form.querySelector('nile-virtual-select') as NileVirtualSelect; await settle(el); el.value = ['c']; await settle(el); form.reset(); await settle(el); expect(el.value).to.deep.equal(['a']); }); it('CV6. portal + search: typing in the portalled search keeps the value', async () => { const el = await fixture(html``); await settle(el); el.open = true; await waitUntil(() => document.body.querySelector('.nile-virtual-select-portal-append .select__search nile-input'), 'portal search never rendered'); const search = document.body.querySelector('.nile-virtual-select-portal-append .select__search nile-input') as any; await search.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']); el.open = false; await waitUntil(() => !document.body.querySelector('.nile-virtual-select-portal-append'), 'portal not cleaned up'); }); it('CV7. reconnecting without showtooltip keeps value and chips', async () => { const host = await fixture(html`
`); const el = host.querySelector('nile-virtual-select') as NileVirtualSelect; 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); }); }); describe('NileVirtualSelect 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`
({ label: v, value: v }))} .value=${[A, B]}>
`); const el = host.querySelector('nile-virtual-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; } }); }); describe('NileVirtualSelect external value while Selected is open', () => { const OPTIONS = Array.from({ length: 200 }, (_, i) => ({ value: `option-${i + 1}`, label: `Option ${i + 1}` })); let mounted: any; // Let the dropdown finish hiding before the fixture is torn down, so the virtualizer is not still laying out // a list that has already been detached. afterEach(async () => { if (mounted?.open) { const hidden = oneEvent(mounted, 'nile-after-hide'); mounted.open = false; await Promise.race([hidden, aTimeout(1500)]); await nextFrame(); await nextFrame(); } mounted = undefined; }); // Known, pre-existing noise from @lit-labs/virtualizer (a "ResizeObserver loop" notice, and a detached-list error // when the visible list swaps between a short and a long data set). The browser raises them as window errors, // which would fail whichever test is running; the assertions below still decide pass or fail. The detached-list // errors are matched on message AND a `virtualizer` frame in the stack, so the same message from anywhere else // still fails the test. "ResizeObserver loop" is a browser notice with no stack. const virtualizerErrors = ['ChildPart` has no `parentNode`', "reading 'insertBefore'"]; let previousOnError: OnErrorEventHandler; before(() => { previousOnError = window.onerror; window.onerror = (message, source, lineno, colno, error) => { if (typeof message === 'string') { if (message.includes('ResizeObserver loop')) return true; if (virtualizerErrors.some(known => message.includes(known)) && error?.stack?.toLowerCase().includes('virtualizer')) { return true; } } return previousOnError ? previousOnError.call(window, message, source, lineno, colno, error) : false; }; }); after(() => { window.onerror = previousOnError; }); async function mount(showTooltip: boolean, value: string[] = ['option-1', 'option-2']) { const el = await fixture(html``); await el.updateComplete; const shown = oneEvent(el, 'nile-after-show'); (el.shadowRoot!.querySelector('[part="combobox"]') as HTMLElement).dispatchEvent( new MouseEvent('mousedown', { bubbles: true, composed: true, button: 0 }) ); await shown; await aTimeout(50); mounted = el; return el as any; } async function toggleSelected(el: any) { (el.shadowRoot.querySelector('.select__footer nile-checkbox') as HTMLElement).click(); await el.updateComplete; await aTimeout(30); } // The standalone select has no sized container here, so its options are not laid out; drive the same // selection handler the option click uses. async function pick(el: any, value: string) { el.handleOptionSelection({ value }); await el.updateComplete; await aTimeout(30); } const values = (el: any) => el.data.map((item: any) => item.value); it('E1. showtooltip: an external value replace recomputes the Selected list', async () => { const el = await mount(true); await toggleSelected(el); expect(values(el)).to.deep.equal(['option-1', 'option-2']); el.value = ['option-5']; await el.updateComplete; await aTimeout(30); expect(el.showSelected).to.be.true; expect(values(el)).to.deep.equal(['option-5']); }); it('E2. showtooltip: an external value that adds or removes items follows the Selected list', async () => { const el = await mount(true); await toggleSelected(el); el.value = ['option-1', 'option-2', 'option-7']; await el.updateComplete; await aTimeout(30); expect(values(el)).to.deep.equal(['option-1', 'option-2', 'option-7']); el.value = ['option-2']; await el.updateComplete; await aTimeout(30); expect(values(el)).to.deep.equal(['option-2']); }); it('E3. showtooltip: an external empty value leaves the Selected view', async () => { const el = await mount(true); await toggleSelected(el); el.value = []; await el.updateComplete; await aTimeout(30); expect(el.showSelected).to.be.false; expect(el.data.length).to.equal(200); }); it('E4. showtooltip: a normal uncheck in the Selected view is not treated as external', async () => { const el = await mount(true); await toggleSelected(el); await pick(el, 'option-2'); expect(el.value).to.deep.equal(['option-1']); expect(values(el)).to.deep.equal(['option-1', 'option-2']); }); it('E5. without showtooltip an external value leaves the Selected list as it was (pre-2.0.9)', async () => { const el = await mount(false); await toggleSelected(el); el.value = ['option-5']; await el.updateComplete; await aTimeout(30); expect(el.showSelected).to.be.true; expect(values(el)).to.deep.equal(['option-1', 'option-2']); }); });