(
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``);
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');
});
});