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)); } }); } }); });