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