import { expect, fixture, html } from '@open-wc/testing';
import './nile-nav-tab';
import type { NileNavTab } from './nile-nav-tab';
const getLink = (el: NileNavTab) =>
el.shadowRoot?.querySelector('nile-link.nav-tab') as HTMLElement | null;
const getInnerAnchor = (el: NileNavTab) => {
const nl = getLink(el);
return nl?.shadowRoot?.querySelector('a') ?? null;
};
describe('NileNavTab', () => {
// ---- Rendering ----
it('renders with shadow root and list shell', async () => {
const el = await fixture(html``);
expect(el).to.exist;
expect(el.shadowRoot).to.not.be.null;
const shell = el.shadowRoot!.querySelector('.nav-tab-container');
expect(shell).to.exist;
expect(shell!.getAttribute('role')).to.equal('presentation');
expect(shell!.getAttribute('part')?.split(/\s+/).sort()).to.deep.equal([
'base',
'tab',
]);
});
it('renders nile-link with nav-tab class and three slots', async () => {
const el = await fixture(html``);
const link = getLink(el);
expect(link).to.exist;
expect(link!.classList.contains('nav-tab')).to.be.true;
expect(el.shadowRoot!.querySelector('slot:not([name])')).to.exist;
expect(el.shadowRoot!.querySelector('slot[name="prefix"]')).to.exist;
expect(el.shadowRoot!.querySelector('slot[name="suffix"]')).to.exist;
});
it('shows default slot content in light DOM', async () => {
const el = await fixture(
html`Settings`
);
expect(el.textContent).to.contain('Settings');
});
it('does not render close button unless closable', async () => {
const el = await fixture(html``);
expect(el.shadowRoot!.querySelector('nile-icon-button')).to.be.null;
});
it('renders close button when closable', async () => {
const el = await fixture(
html`Tab`
);
const btn = el.shadowRoot!.querySelector('nile-icon-button');
expect(btn).to.exist;
expect(btn!.getAttribute('part')).to.equal('close-button');
});
it('exposes part base on nile-link via exportparts', async () => {
const el = await fixture(html`A`);
const link = getLink(el)!;
expect(link.getAttribute('exportparts')).to.contain('base:link');
});
it('nile-link uses role none and hides link from a11y tree', async () => {
const el = await fixture(html`T`);
const link = getLink(el)!;
expect(link.getAttribute('role')).to.equal('none');
expect(link.getAttribute('aria-hidden')).to.equal('true');
expect(link.getAttribute('tabindex')).to.equal('-1');
});
// ---- Properties & reflection ----
it('defaults panel to empty string', async () => {
const el = await fixture(html``);
expect(el.panel).to.equal('');
});
it('defaults active, closable, disabled, and centered to false', async () => {
const el = await fixture(html``);
expect(el.active).to.be.false;
expect(el.closable).to.be.false;
expect(el.disabled).to.be.false;
expect(el.centered).to.be.false;
});
it('reflects panel, active, disabled, and centered to attributes', async () => {
const el = await fixture(html`
`);
expect(el.getAttribute('panel')).to.equal('p1');
expect(el.hasAttribute('active')).to.be.true;
expect(el.hasAttribute('disabled')).to.be.true;
expect(el.hasAttribute('centered')).to.be.true;
});
it('reflects link attribute when set in markup', async () => {
const el = await fixture(html`
`);
expect(el.getAttribute('link')).to.equal('https://x.example/');
});
it('applies state classes on nile-link', async () => {
const el = await fixture(html`
`);
await el.updateComplete;
const link = getLink(el)!;
expect(link.classList.contains('nav-tab--active')).to.be.true;
expect(link.classList.contains('nav-tab--closable')).to.be.true;
expect(link.classList.contains('nav-tab--disabled')).to.be.true;
expect(link.classList.contains('tab--centered')).to.be.true;
});
it('passes link href to inner anchor when link is set', async () => {
const el = await fixture(html`
`);
await el.updateComplete;
const anchor = getInnerAnchor(el);
expect(anchor).to.exist;
expect(anchor!.getAttribute('href')).to.equal('https://example.com/path');
});
it('updates inner anchor href when link property changes', async () => {
const el = await fixture(html``);
el.link = 'https://a.test/';
await el.updateComplete;
expect(getInnerAnchor(el)!.getAttribute('href')).to.equal('https://a.test/');
});
it('removes closable UI when closable set false', async () => {
const el = await fixture(html`T`);
el.closable = false;
await el.updateComplete;
expect(el.shadowRoot!.querySelector('nile-icon-button')).to.be.null;
});
it('exposes static styles', async () => {
const mod = await import('./nile-nav-tab');
expect(mod.NileNavTab.styles).to.exist;
});
it('registers custom element', () => {
expect(customElements.get('nile-nav-tab')).to.exist;
});
it('exports default class matching NileNavTab', async () => {
const mod = await import('./nile-nav-tab');
expect(mod.default).to.equal(mod.NileNavTab);
});
// ---- Accessibility ----
it('sets role tab on host', async () => {
const el = await fixture(html``);
expect(el.getAttribute('role')).to.equal('tab');
});
it('syncs aria-selected with active', async () => {
const inactive = await fixture(html``);
expect(inactive.getAttribute('aria-selected')).to.equal('false');
const active = await fixture(
html``
);
expect(active.getAttribute('aria-selected')).to.equal('true');
inactive.active = true;
await inactive.updateComplete;
expect(inactive.getAttribute('aria-selected')).to.equal('true');
});
it('syncs aria-disabled with disabled', async () => {
const el = await fixture(html``);
expect(el.getAttribute('aria-disabled')).to.equal('false');
expect(el.hasAttribute('aria-hidden')).to.be.false;
el.disabled = true;
await el.updateComplete;
expect(el.getAttribute('aria-disabled')).to.equal('true');
expect(el.getAttribute('aria-hidden')).to.equal('true');
el.disabled = false;
await el.updateComplete;
expect(el.hasAttribute('aria-hidden')).to.be.false;
});
it('sets host tabIndex 0 when active and not disabled', async () => {
const el = await fixture(
html``
);
expect(el.tabIndex).to.equal(0);
});
it('sets host tabIndex -1 when inactive or disabled', async () => {
const inactive = await fixture(html``);
expect(inactive.tabIndex).to.equal(-1);
const disabled = await fixture(
html``
);
expect(disabled.tabIndex).to.equal(-1);
});
it('sets aria-label from slotted text (normalized whitespace)', async () => {
const el = await fixture(html`
My tab label
`);
await el.updateComplete;
expect(el.getAttribute('aria-label')).to.equal('My tab label');
const el2 = await fixture(html`
My
tab
`);
await el2.updateComplete;
expect(el2.getAttribute('aria-label')).to.equal('My tab');
});
it('does not overwrite author aria-label when data-auto-aria-label is absent', async () => {
const el = await fixture(html`
Home
`);
await el.updateComplete;
expect(el.getAttribute('aria-label')).to.equal('Open settings');
expect(el.hasAttribute('data-auto-aria-label')).to.be.false;
});
it('auto-derived aria-label updates when text changes', async () => {
const el = await fixture(html`A`);
await el.updateComplete;
expect(el.getAttribute('data-auto-aria-label')).to.equal('true');
const defaultSlot = el.shadowRoot?.querySelector('slot:not([name])');
expect(defaultSlot).to.exist;
const slot = defaultSlot as HTMLSlotElement;
const slotChangedDone = new Promise(resolve =>
slot.addEventListener('slotchange', () => resolve(), { once: true })
);
el.textContent = 'B';
await slotChangedDone;
expect(el.getAttribute('aria-label')).to.equal('B');
});
it('removes aria-label when there is no visible label text', async () => {
const el = await fixture(html``);
await el.updateComplete;
expect(el.hasAttribute('aria-label')).to.be.false;
expect(el.hasAttribute('data-auto-aria-label')).to.be.false;
});
it('does not leave aria-hidden on host', async () => {
const el = await fixture(html``);
expect(el.hasAttribute('aria-hidden')).to.be.false;
});
it('assigns auto-generated id when host id is empty', async () => {
const el = await fixture(html``);
expect(el.id).to.match(/^nile-nav-tab-/);
});
it('preserves explicit host id', async () => {
const el = await fixture(
html``
);
expect(el.id).to.equal('custom-tab');
});
it('assigns unique ids to separate instances', async () => {
const wrap = await fixture(html`
`);
const tabs = wrap.querySelectorAll('nile-nav-tab');
expect(tabs[0].id).to.not.equal(tabs[1].id);
});
// ---- Close & click behavior ----
it('emits nile-close when close button is clicked', async () => {
const el = await fixture(
html`Tab`
);
let fired = false;
el.addEventListener('nile-close', () => {
fired = true;
});
const closeButton = el.shadowRoot?.querySelector('nile-icon-button');
expect(closeButton).to.exist;
closeButton?.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true })
);
expect(fired).to.be.true;
});
it('disables close button when tab is disabled', async () => {
const el = await fixture(html`
Tab
`);
const btn = el.shadowRoot!.querySelector('nile-icon-button')!;
expect(btn.hasAttribute('disabled')).to.be.true;
});
it('prevents default on primary click on nile-link when not disabled', async () => {
const el = await fixture(html`Tab`);
const link = getLink(el)!;
const ev = new MouseEvent('click', {
bubbles: true,
composed: true,
cancelable: true,
button: 0,
});
link.dispatchEvent(ev);
expect(ev.defaultPrevented).to.be.true;
});
it('does not prevent default for modified clicks on nile-link (ctrlKey)', async () => {
const el = await fixture(html`Tab`);
const link = getLink(el)!;
const ev = new MouseEvent('click', {
bubbles: true,
composed: true,
cancelable: true,
button: 0,
ctrlKey: true,
});
link.dispatchEvent(ev);
expect(ev.defaultPrevented).to.be.false;
});
it('does not prevent default for metaKey modified click', async () => {
const el = await fixture(html`Tab`);
const link = getLink(el)!;
const ev = new MouseEvent('click', {
bubbles: true,
composed: true,
cancelable: true,
button: 0,
metaKey: true,
});
link.dispatchEvent(ev);
expect(ev.defaultPrevented).to.be.false;
});
it('does not prevent default for non-primary button', async () => {
const el = await fixture(html`Tab`);
const link = getLink(el)!;
const ev = new MouseEvent('click', {
bubbles: true,
composed: true,
cancelable: true,
button: 1,
});
link.dispatchEvent(ev);
expect(ev.defaultPrevented).to.be.false;
});
it('blocks click when disabled', async () => {
const el = await fixture(
html`Tab`
);
const link = getLink(el)!;
const ev = new MouseEvent('click', {
bubbles: true,
composed: true,
cancelable: true,
button: 0,
});
link.dispatchEvent(ev);
expect(ev.defaultPrevented).to.be.true;
});
// ---- DOM & lifecycle ----
it('reports open shadow root mode', async () => {
const el = await fixture(html``);
expect(el.shadowRoot!.mode).to.equal('open');
});
it('uses localName nile-nav-tab', async () => {
const el = await fixture(html``);
expect(el.localName).to.equal('nile-nav-tab');
});
it('is connected when fixture completes', async () => {
const el = await fixture(html``);
expect(el.isConnected).to.be.true;
});
it('disconnects when removed from document', async () => {
const el = await fixture(html``);
el.remove();
expect(el.isConnected).to.be.false;
});
it('closest returns self for nile-nav-tab selector', async () => {
const el = await fixture(html``);
expect(el.closest('nile-nav-tab')).to.equal(el);
});
it('supports focus without throwing', async () => {
const el = await fixture(html``);
expect(() => el.focus()).to.not.throw();
});
it('updateComplete resolves after render', async () => {
const el = await fixture(html``);
const done = await el.updateComplete;
expect(done).to.be.true;
});
it('requestUpdate followed by updateComplete refreshes shadow DOM', async () => {
const el = await fixture(html`A`);
el.closable = true;
el.requestUpdate();
await el.updateComplete;
expect(el.shadowRoot!.querySelector('nile-icon-button')).to.exist;
});
it('slot prefix content appears in light tree', async () => {
const el = await fixture(html`
PMain
`);
expect(el.textContent).to.contain('P');
expect(el.textContent).to.contain('Main');
});
it('slot suffix content appears in light tree', async () => {
const el = await fixture(html`
MainS
`);
expect(el.textContent).to.contain('S');
});
it('updates panel via property and reflects attribute', async () => {
const el = await fixture(html``);
el.panel = 'pane-a';
await el.updateComplete;
expect(el.getAttribute('panel')).to.equal('pane-a');
});
it('toggles centered class on link when centered changes', async () => {
const el = await fixture(html``);
el.centered = true;
await el.updateComplete;
expect(getLink(el)!.classList.contains('tab--centered')).to.be.true;
el.centered = false;
await el.updateComplete;
expect(getLink(el)!.classList.contains('tab--centered')).to.be.false;
});
it('removes nav-link active class when active cleared', async () => {
const el = await fixture(html`X`);
el.active = false;
await el.updateComplete;
expect(getLink(el)!.classList.contains('nav-tab--active')).to.be.false;
});
it('dispatches bubbled custom events from host', async () => {
const el = await fixture(html``);
let hit = false;
el.addEventListener('x-test', () => {
hit = true;
});
el.dispatchEvent(new CustomEvent('x-test', { bubbles: true }));
expect(hit).to.be.true;
});
it('exposes getBoundingClientRect on host', async () => {
const el = await fixture(html`Tab`);
const rect = el.getBoundingClientRect();
expect(rect.height).to.be.a('number');
expect(rect.width).to.be.a('number');
});
// ---- Extended coverage ----
it('does not prevent default for shiftKey modified click on nile-link', async () => {
const el = await fixture(html`Tab`);
const link = getLink(el)!;
const ev = new MouseEvent('click', { bubbles: true, composed: true, cancelable: true, button: 0, shiftKey: true });
link.dispatchEvent(ev);
expect(ev.defaultPrevented).to.be.false;
});
it('does not prevent default for altKey modified click on nile-link', async () => {
const el = await fixture(html`Tab`);
const link = getLink(el)!;
const ev = new MouseEvent('click', { bubbles: true, composed: true, cancelable: true, button: 0, altKey: true });
link.dispatchEvent(ev);
expect(ev.defaultPrevented).to.be.false;
});
it('does not emit nile-close when closable but close not activated', async () => {
const el = await fixture(html`T`);
let n = 0;
el.addEventListener('nile-close', () => n++);
const link = getLink(el);
expect(link).to.exist;
link?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
expect(n).to.equal(0);
});
it('close click uses stopPropagation so parent sees fewer bubbled clicks', async () => {
const el = await fixture(html`T`);
let parentClicks = 0;
el.addEventListener('click', () => parentClicks++);
const closeButton = el.shadowRoot?.querySelector('nile-icon-button');
expect(closeButton).to.exist;
closeButton?.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true })
);
expect(parentClicks).to.equal(0);
});
it('syncs panel property when setAttribute panel is used', async () => {
const el = await fixture(html``);
el.setAttribute('panel', 'via-attr');
await el.updateComplete;
expect(el.panel).to.equal('via-attr');
});
it('removing active attribute clears link active class', async () => {
const el = await fixture(html`X`);
el.removeAttribute('active');
await el.updateComplete;
expect(el.active).to.be.false;
expect(getLink(el)!.classList.contains('nav-tab--active')).to.be.false;
});
it('tagName is uppercase NileNavTab-style', async () => {
const el = await fixture(html``);
expect(el.tagName.toLowerCase()).to.equal('nile-nav-tab');
});
it('creates via createElement and upgrades with shadow', async () => {
const el = document.createElement('nile-nav-tab') as NileNavTab;
document.body.appendChild(el);
try{
await el.updateComplete;
expect(el.shadowRoot).to.not.be.null;
} finally {
el.remove();
}
});
it('disabled then enabled restores tabIndex when active', async () => {
const el = await fixture(html`Z`);
el.disabled = false;
await el.updateComplete;
expect(el.tabIndex).to.equal(0);
});
it('active inactive active toggles tabIndex correctly', async () => {
const el = await fixture(html`Q`);
el.active = true;
await el.updateComplete;
expect(el.tabIndex).to.equal(0);
el.active = false;
await el.updateComplete;
expect(el.tabIndex).to.equal(-1);
});
it('aria-label removes when label becomes whitespace only', async () => {
const el = await fixture(html`x`);
await el.updateComplete;
el.textContent = ' ';
await el.updateComplete;
expect(el.hasAttribute('aria-label')).to.be.false;
expect(el.hasAttribute('data-auto-aria-label')).to.be.false;
});
it('href on inner anchor updates when link changes second time', async () => {
const el = await fixture(html``);
el.link = 'https://b/';
await el.updateComplete;
expect(getInnerAnchor(el)!.getAttribute('href')).to.equal('https://b/');
});
it('nile-link has href binding present when link empty string', async () => {
const el = await fixture(html``);
await el.updateComplete;
const anchor = getInnerAnchor(el);
expect(anchor).to.exist;
});
it('closable close button is inside nile-link sibling structure', async () => {
const el = await fixture(html`C`);
expect(el.shadowRoot!.querySelector('nile-link')).to.exist;
expect(el.shadowRoot!.querySelector('nile-icon-button')).to.exist;
});
it('list item wrapper uses role presentation', async () => {
const el = await fixture(html``);
const li = el.shadowRoot!.querySelector('.nav-tab-container');
expect(li!.getAttribute('role')).to.equal('presentation');
});
it('matches selector :host not needed but host matches tag', async () => {
const el = await fixture(html``);
expect(el.matches('nile-nav-tab.nt')).to.be.true;
});
it('blur is callable on host', async () => {
const el = await fixture(html`B`);
el.focus();
expect(() => el.blur()).to.not.throw();
});
it('does not prevent default when metaKey and shiftKey together', async () => {
const el = await fixture(html`T`);
const ev = new MouseEvent('click', {
bubbles: true,
composed: true,
cancelable: true,
button: 0,
metaKey: true,
shiftKey: true,
});
getLink(el)!.dispatchEvent(ev);
expect(ev.defaultPrevented).to.be.false;
});
it('allows panel id with hyphen and digits', async () => {
const el = await fixture(html``);
expect(el.panel).to.equal('tab-99');
});
it('prefix and suffix slots compose label text', async () => {
const el = await fixture(html`
PreMidSuf
`);
await el.updateComplete;
expect(el.textContent).to.contain('Pre');
expect(el.textContent).to.contain('Mid');
expect(el.textContent).to.contain('Suf');
});
it('aria-label includes digits from slotted text', async () => {
const el = await fixture(html`Tab 42`);
await el.updateComplete;
expect(el.getAttribute('aria-label')).to.equal('Tab 42');
});
it('link with relative path appears on anchor', async () => {
const el = await fixture(html``);
await el.updateComplete;
expect(getInnerAnchor(el)!.getAttribute('href')).to.equal('/rel/path');
});
it('closable true and link set coexist', async () => {
const el = await fixture(html`X`);
await el.updateComplete;
expect(el.shadowRoot!.querySelector('nile-icon-button')).to.exist;
expect(getInnerAnchor(el)!.getAttribute('href')).to.equal('https://x/');
});
it('nile-close fires at most once per close button activation', async () => {
const el = await fixture(html`Z`);
let n = 0;
el.addEventListener('nile-close', () => n++);
const btn = el.shadowRoot?.querySelector('nile-icon-button');
expect(btn).to.exist;
btn?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
expect(n).to.equal(1);
});
it('host has no aria-selected until upgraded then reflects active', async () => {
const el = await fixture(html``);
expect(el.getAttribute('aria-selected')).to.equal('false');
el.active = true;
await el.updateComplete;
expect(el.getAttribute('aria-selected')).to.equal('true');
});
it('removeAttribute disabled restores aria-disabled false', async () => {
const el = await fixture(html`D`);
el.removeAttribute('disabled');
await el.updateComplete;
expect(el.getAttribute('aria-disabled')).to.equal('false');
});
it('setAttribute disabled without property uses reflected state', async () => {
const el = await fixture(html`A`);
el.setAttribute('disabled', '');
await el.updateComplete;
expect(el.disabled).to.be.true;
});
it('childNodes in light DOM include slotted text node', async () => {
const el = await fixture(html`K`);
expect(el.childNodes.length).to.be.at.least(1);
});
/**
* 100 generated cases — compiled to dist/src/nile-nav-tab/nile-nav-tab.test.js via `yarn tsc`.
* Each iteration asserts a small invariant (panel, active, link, closable, centered, disabled, a11y, DOM).
*/
describe('bulk generated coverage (100 cases)', () => {
for (let i = 0; i < 100; i++) {
const suffix = `${i}`;
it(`bulk case ${i + 1}/100 (mode ${i % 10})`, async () => {
const el = await fixture(html`T${suffix}`);
await el.updateComplete;
const mode = i % 10;
if (mode === 0) {
el.panel = `panel-${suffix}`;
await el.updateComplete;
expect(el.getAttribute('panel')).to.equal(`panel-${suffix}`);
} else if (mode === 1) {
el.active = true;
await el.updateComplete;
expect(el.getAttribute('aria-selected')).to.equal('true');
expect(el.tabIndex).to.equal(0);
} else if (mode === 2) {
el.link = `https://example.test/${suffix}`;
await el.updateComplete;
expect(getInnerAnchor(el)!.getAttribute('href')).to.equal(`https://example.test/${suffix}`);
} else if (mode === 3) {
el.closable = true;
await el.updateComplete;
expect(el.shadowRoot!.querySelector('nile-icon-button')).to.exist;
expect(getLink(el)!.classList.contains('nav-tab--closable')).to.be.true;
} else if (mode === 4) {
el.centered = true;
await el.updateComplete;
expect(getLink(el)!.classList.contains('tab--centered')).to.be.true;
} else if (mode === 5) {
el.disabled = true;
await el.updateComplete;
expect(el.getAttribute('aria-disabled')).to.equal('true');
expect(el.tabIndex).to.equal(-1);
} else if (mode === 6) {
el.active = true;
el.disabled = true;
await el.updateComplete;
expect(el.tabIndex).to.equal(-1);
} else if (mode === 7) {
el.setAttribute('panel', `attr-${suffix}`);
await el.updateComplete;
expect(el.panel).to.equal(`attr-${suffix}`);
} else if (mode === 8) {
expect(el.getAttribute('role')).to.equal('tab');
expect(el.shadowRoot!.querySelector('.nav-tab-container')).to.exist;
} else {
const link = getLink(el)!;
const ev = new MouseEvent('click', {
bubbles: true,
composed: true,
cancelable: true,
button: 0,
ctrlKey: Boolean(i % 2),
});
link.dispatchEvent(ev);
expect(ev.defaultPrevented).to.equal(!Boolean(i % 2));
}
});
}
});
});