import { aTimeout, expect, fixture, html } from '@open-wc/testing';
import { ifDefined } from 'lit/directives/if-defined.js';
import './nile-nav-tab-group';
import '../nile-nav-tab/nile-nav-tab';
import '../nile-nav-tab-panel/nile-nav-tab-panel';
import type { NileNavTabGroup } from './nile-nav-tab-group';
import type { NileNavTab } from '../nile-nav-tab/nile-nav-tab';
import type { NileNavTabPanel } from '../nile-nav-tab-panel/nile-nav-tab-panel';
/** Use real Lit bindings — raw strings like `placement="bottom"` in the tag do not apply attributes. */
type MakeGroupOpts = {
placement?: NileNavTabGroup['placement'];
variant?: NileNavTabGroup['variant'];
value?: string;
noTrack?: boolean;
fullWidth?: boolean;
centered?: boolean;
showIndicatorOnHover?: boolean;
indicatorPlacement?: string;
width?: string;
};
const makeGroup = async (opts: MakeGroupOpts = {}) =>
fixture(html`
General
Custom
Disabled
General panel
Custom panel
Disabled panel
`);
const getTabs = (el: NileNavTabGroup) =>
Array.from(el.querySelectorAll('nile-nav-tab'));
const getPanels = (el: NileNavTabGroup) =>
Array.from(el.querySelectorAll('nile-nav-tab-panel'));
describe('NileNavTabGroup', () => {
// ---- Rendering ----
it('renders with nav and body slots', async () => {
const el = await makeGroup();
expect(el).to.exist;
expect(el.shadowRoot).to.not.be.null;
expect(el.shadowRoot!.querySelector('slot[name="nav"]')).to.exist;
expect(el.shadowRoot!.querySelector('slot:not([name])')).to.exist;
});
it('renders base wrapper with default top placement class', async () => {
const el = await makeGroup();
const base = el.shadowRoot!.querySelector('.nav-tab-group');
expect(base).to.exist;
expect(base!.classList.contains('nav-tab-group--top')).to.be.true;
});
it('applies placement bottom class', async () => {
const el = await makeGroup({ placement: 'bottom' });
const base = el.shadowRoot!.querySelector('.nav-tab-group')!;
expect(base.classList.contains('nav-tab-group--bottom')).to.be.true;
});
it('applies placement start class', async () => {
const el = await makeGroup({ placement: 'start' });
const base = el.shadowRoot!.querySelector('.nav-tab-group')!;
expect(base.classList.contains('nav-tab-group--start')).to.be.true;
});
it('applies placement end class', async () => {
const el = await makeGroup({ placement: 'end' });
const base = el.shadowRoot!.querySelector('.nav-tab-group')!;
expect(base.classList.contains('nav-tab-group--end')).to.be.true;
});
it('renders indicator by default (underline variant)', async () => {
const el = await makeGroup();
expect(el.shadowRoot!.querySelector('.nav-tab-group__indicator')).to.exist;
});
it('renders pill and no indicator for filled variant', async () => {
const el = await makeGroup({ variant: 'filled' });
expect(el.shadowRoot!.querySelector('.nav-tab-group__pill')).to.exist;
expect(el.shadowRoot!.querySelector('.nav-tab-group__indicator')).to.be.null;
});
it('renders pill for neutral-filled variant', async () => {
const el = await makeGroup({ variant: 'neutral-filled' });
expect(el.shadowRoot!.querySelector('.nav-tab-group__pill')).to.exist;
});
it('toggle variant renders toggle frame and pill not underline indicator', async () => {
const el = await makeGroup({ variant: 'toggle' });
expect(el.shadowRoot!.querySelector('.nav-tab-group__toggle-frame')).to.exist;
expect(el.shadowRoot!.querySelector('.nav-tab-group__pill')).to.exist;
expect(el.shadowRoot!.querySelector('.nav-tab-group__indicator')).to.be.null;
});
it('toggle-button variant renders pill without underline indicator', async () => {
const el = await makeGroup({ variant: 'toggle-button' });
expect(el.shadowRoot!.querySelector('.nav-tab-group__pill')).to.exist;
expect(el.shadowRoot!.querySelector('.nav-tab-group__indicator')).to.be.null;
});
it('tab list has role tablist', async () => {
const el = await makeGroup();
const list = el.shadowRoot?.querySelector('.nav-tab-group__tabs');
expect(list?.getAttribute('role')).to.equal('tablist');
});
it('nav region exposes part nav on container', async () => {
const el = await makeGroup();
const nav = el.shadowRoot!.querySelector('.nav-tab-group__nav-container');
expect(nav!.getAttribute('part')).to.equal('nav');
});
it('body slot exposes part body', async () => {
const el = await makeGroup();
const body = el.shadowRoot!.querySelector('.nav-tab-group__body');
expect(body!.getAttribute('part')).to.equal('body');
});
it('no-track adds hide__track on base', async () => {
const el = await makeGroup({ noTrack: true });
const base = el.shadowRoot!.querySelector('.nav-tab-group')!;
expect(base.classList.contains('hide__track')).to.be.true;
});
it('fullWidth reflects to attribute and tabs stretch', async () => {
const el = await makeGroup({ fullWidth: true });
expect(el.hasAttribute('fullwidth')).to.be.true;
const base = el.shadowRoot!.querySelector('.nav-tab-group')!;
const tabs = base.querySelector('.nav-tab-group__tabs');
expect(tabs).to.exist;
});
it('showIndicatorOnHover adds hover indicator node for underline', async () => {
const el = await makeGroup({ showIndicatorOnHover: true });
expect(el.shadowRoot!.querySelector('.nav-tab-group__hover-indicator')).to.exist;
});
it('indicatorPlacement left applies alongside placement start', async () => {
const el = await makeGroup({
placement: 'start',
indicatorPlacement: 'left',
value: 'general',
});
await aTimeout(0);
expect(el.indicatorPlacement).to.equal('left');
expect(el.placement).to.equal('start');
});
// ---- Defaults & reflection ----
it('has expected defaults', async () => {
const el = await makeGroup();
expect(el.placement).to.equal('top');
expect(el.activeTabProp).to.equal('');
expect(el.noTrack).to.be.false;
expect(el.noScrollControls).to.be.false;
expect(el.centered).to.be.false;
expect(el.fullWidth).to.be.false;
expect(el.variant).to.equal('underline');
});
it('reflects `value` attribute to activeTabProp', async () => {
const el = await makeGroup({ value: 'custom' });
await el.updateComplete;
expect(el.activeTabProp).to.equal('custom');
});
it('reflects centered attribute', async () => {
const el = await makeGroup({ centered: true });
expect(el.centered).to.be.true;
});
it('localName is nile-nav-tab-group', async () => {
const el = await makeGroup();
expect(el.localName).to.equal('nile-nav-tab-group');
});
it('shadow root is open', async () => {
const el = await makeGroup();
expect(el.shadowRoot!.mode).to.equal('open');
});
it('exposes static styles and custom element registration', async () => {
const mod = await import('./nile-nav-tab-group');
expect(mod.NileNavTabGroup.styles).to.exist;
expect(customElements.get('nile-nav-tab-group')).to.exist;
});
it('default export matches named class', async () => {
const mod = await import('./nile-nav-tab-group');
expect(mod.default).to.equal(mod.NileNavTabGroup);
});
// ---- Selection behavior ----
it('selects tab/panel from value at startup', async () => {
const el = await makeGroup({ value: 'custom' });
await aTimeout(0);
await el.updateComplete;
const tabs = getTabs(el);
const panels = getPanels(el);
const customTab = tabs.find(t => t.getAttribute('panel') === 'custom')!;
const generalTab = tabs.find(t => t.getAttribute('panel') === 'general')!;
const customPanel = panels.find(p => p.getAttribute('name') === 'custom')!;
const generalPanel = panels.find(p => p.getAttribute('name') === 'general')!;
expect(customTab.hasAttribute('active')).to.be.true;
expect(generalTab.hasAttribute('active')).to.be.false;
expect(customPanel.hasAttribute('active')).to.be.true;
expect(generalPanel.hasAttribute('active')).to.be.false;
});
it('updates selected tab/panel when activeTabProp changes', async () => {
const el = await makeGroup({ value: 'general' });
el.activeTabProp = 'custom';
await aTimeout(0);
await el.updateComplete;
const tabs = getTabs(el);
const panels = getPanels(el);
expect(tabs.find(t => t.getAttribute('panel') === 'custom')!.hasAttribute('active')).to.be.true;
expect(panels.find(p => p.getAttribute('name') === 'custom')!.hasAttribute('active')).to.be.true;
});
it('clicking an enabled tab changes activeTabName', async () => {
const el = await makeGroup({ value: 'general' });
const customTab = getTabs(el).find(t => t.getAttribute('panel') === 'custom')!;
customTab.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await aTimeout(0);
await el.updateComplete;
expect(el.activeTabName).to.equal('custom');
expect(document.activeElement).to.equal(customTab);
});
it('clicking a disabled tab does not change activeTabName', async () => {
const el = await makeGroup({ value: 'general' });
const disabledTab = getTabs(el).find(t => t.getAttribute('panel') === 'disabled')!;
disabledTab.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await aTimeout(0);
await el.updateComplete;
expect(el.activeTabName).to.equal('general');
});
it('modifier click on tab does not change activeTabName (native link / group both ignore)', async () => {
const el = await makeGroup({ value: 'general' });
const customTab = getTabs(el).find(t => t.getAttribute('panel') === 'custom');
expect(customTab).to.exist;
customTab?.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true, ctrlKey: true, button: 0 })
);
await aTimeout(0);
await el.updateComplete;
expect(el.activeTabName).to.equal('general');
});
it('emits nile-tab-change with details when selection changes', async () => {
const el = await makeGroup({ value: 'general' });
let detail: any;
el.addEventListener('nile-tab-change', (e: Event) => {
detail = (e as CustomEvent).detail;
});
const customTab = getTabs(el).find(t => t.getAttribute('panel') === 'custom')!;
customTab.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await aTimeout(0);
await el.updateComplete;
expect(detail).to.exist;
expect(detail.value).to.equal('custom');
expect(detail.previousValue).to.equal('general');
expect(detail.index).to.equal(1);
});
it('nile-tab-change detail includes link when selected tab has link', async () => {
const el = await fixture(html`
A
B
`);
await aTimeout(0);
let detail: any;
el.addEventListener('nile-tab-change', (e: Event) => {
detail = (e as CustomEvent).detail;
});
getTabs(el)
.find(t => t.getAttribute('panel') === 'a')!
.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await aTimeout(0);
await el.updateComplete;
expect(detail.value).to.equal('a');
expect(detail.link).to.equal('https://u.example/a');
});
it('re-emits nile-close from child tab with panel detail', async () => {
const el = await makeGroup();
const generalTab = getTabs(el).find(t => t.getAttribute('panel') === 'general')!;
let closeDetail: any;
el.addEventListener('nile-close', (e: Event) => {
closeDetail = (e as CustomEvent).detail;
});
generalTab.dispatchEvent(new CustomEvent('nile-close', { bubbles: true, composed: true }));
await el.updateComplete;
expect(closeDetail).to.deep.equal({ panel: 'general' });
});
it('centered distributes to slotted nav tabs', async () => {
const el = await makeGroup({ centered: true, value: 'general' });
await aTimeout(0);
const first = getTabs(el)[0];
expect(first?.centered).to.be.true;
});
// ---- Keyboard navigation ----
it('ArrowRight moves focus across enabled tabs', async () => {
const el = await makeGroup({ value: 'general' });
const tabs = getTabs(el);
const first = tabs.find(t => t.getAttribute('panel') === 'general')!;
const second = tabs.find(t => t.getAttribute('panel') === 'custom')!;
first.focus();
first.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowRight',
bubbles: true,
composed: true,
cancelable: true,
})
);
await aTimeout(0);
expect(document.activeElement).to.equal(second);
});
it('ArrowLeft wraps from first enabled tab to last enabled', async () => {
const el = await makeGroup({ value: 'general' });
const tabs = getTabs(el);
const first = tabs.find(t => t.getAttribute('panel') === 'general')!;
const second = tabs.find(t => t.getAttribute('panel') === 'custom')!;
first.focus();
first.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowLeft',
bubbles: true,
composed: true,
cancelable: true,
})
);
await aTimeout(0);
expect(document.activeElement).to.equal(second);
});
it('RTL horizontal: ArrowLeft moves focus like LTR ArrowRight', async () => {
const el = await makeGroup({ value: 'general' });
el.setAttribute('dir', 'rtl');
const tabs = getTabs(el);
const first = tabs.find(t => t.getAttribute('panel') === 'general')!;
const second = tabs.find(t => t.getAttribute('panel') === 'custom')!;
first.focus();
first.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowLeft',
bubbles: true,
composed: true,
cancelable: true,
})
);
await aTimeout(0);
expect(document.activeElement).to.equal(second);
});
it('RTL horizontal: ArrowRight wraps from first like LTR ArrowLeft', async () => {
const el = await makeGroup({ value: 'general' });
el.setAttribute('dir', 'rtl');
const tabs = getTabs(el);
const first = tabs.find(t => t.getAttribute('panel') === 'general')!;
const second = tabs.find(t => t.getAttribute('panel') === 'custom')!;
first.focus();
first.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowRight',
bubbles: true,
composed: true,
cancelable: true,
})
);
await aTimeout(0);
expect(document.activeElement).to.equal(second);
});
it('Home focuses first enabled tab', async () => {
const el = await makeGroup({ value: 'custom' });
const tabs = getTabs(el);
const custom = tabs.find(t => t.getAttribute('panel') === 'custom')!;
const general = tabs.find(t => t.getAttribute('panel') === 'general')!;
custom.focus();
custom.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Home',
bubbles: true,
composed: true,
cancelable: true,
})
);
await aTimeout(0);
expect(document.activeElement).to.equal(general);
});
it('End focuses last enabled tab', async () => {
const el = await makeGroup({ value: 'general' });
const tabs = getTabs(el);
const custom = tabs.find(t => t.getAttribute('panel') === 'custom')!;
const general = tabs.find(t => t.getAttribute('panel') === 'general')!;
general.focus();
general.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'End',
bubbles: true,
composed: true,
cancelable: true,
})
);
await aTimeout(0);
expect(document.activeElement).to.equal(custom);
});
it('Enter selects the focused tab', async () => {
const el = await makeGroup({ value: 'general' });
const customTab = getTabs(el).find(t => t.getAttribute('panel') === 'custom')!;
customTab.focus();
customTab.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
composed: true,
cancelable: true,
})
);
await aTimeout(0);
await el.updateComplete;
expect(el.activeTabName).to.equal('custom');
});
it('Space selects the focused tab', async () => {
const el = await makeGroup({ value: 'general' });
const customTab = getTabs(el).find(t => t.getAttribute('panel') === 'custom')!;
customTab.focus();
customTab.dispatchEvent(
new KeyboardEvent('keydown', {
key: ' ',
bubbles: true,
composed: true,
cancelable: true,
})
);
await aTimeout(0);
await el.updateComplete;
expect(el.activeTabName).to.equal('custom');
});
it('ArrowDown moves roving focus when placement is start', async () => {
const el = await makeGroup({ placement: 'start', value: 'general' });
const tabs = getTabs(el);
const general = tabs.find(t => t.getAttribute('panel') === 'general')!;
const custom = tabs.find(t => t.getAttribute('panel') === 'custom')!;
general.focus();
general.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowDown',
bubbles: true,
composed: true,
cancelable: true,
})
);
await aTimeout(0);
expect(document.activeElement).to.equal(custom);
});
it('ArrowUp moves roving focus when placement is start', async () => {
const el = await makeGroup({ placement: 'start', value: 'custom' });
const tabs = getTabs(el);
const general = tabs.find(t => t.getAttribute('panel') === 'general')!;
const custom = tabs.find(t => t.getAttribute('panel') === 'custom')!;
custom.focus();
custom.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowUp',
bubbles: true,
composed: true,
cancelable: true,
})
);
await aTimeout(0);
expect(document.activeElement).to.equal(general);
});
it('keydown on disabled tab calls preventDefault', async () => {
const el = await makeGroup({ value: 'general' });
const disabled = getTabs(el).find(t => t.getAttribute('panel') === 'disabled')!;
disabled.focus();
const ev = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
composed: true,
cancelable: true,
});
disabled.dispatchEvent(ev);
expect(ev.defaultPrevented).to.be.true;
});
// ---- A11y sync ----
it('sets aria-posinset and aria-setsize on enabled tabs only', async () => {
const el = await makeGroup();
await aTimeout(0);
const tabs = getTabs(el);
expect(tabs[0].getAttribute('aria-posinset')).to.equal('1');
expect(tabs[1].getAttribute('aria-posinset')).to.equal('2');
expect(tabs[2].hasAttribute('aria-posinset')).to.be.false;
expect(tabs[0].getAttribute('aria-setsize')).to.equal('2');
expect(tabs[1].getAttribute('aria-setsize')).to.equal('2');
expect(tabs[2].hasAttribute('aria-setsize')).to.be.false;
expect(tabs[2].getAttribute('aria-hidden')).to.equal('true');
});
it('wires aria-controls and aria-labelledby between tab and panel', async () => {
const el = await makeGroup();
await el.updateComplete;
await Promise.all(getTabs(el).map(tab => tab.updateComplete));
await Promise.all(getPanels(el).map(panel => panel.updateComplete));
const tab = getTabs(el).find(t => t.getAttribute('panel') === 'general')!;
const panel = getPanels(el).find(p => p.getAttribute('name') === 'general')!;
expect(tab.getAttribute('aria-controls')).to.equal(panel.id);
expect(panel.getAttribute('aria-labelledby')).to.equal(tab.id);
});
// ---- Scroll controls ----
it('setScrollControls sets hasScrollControls for horizontal overflow', async () => {
const el = await makeGroup();
const nav = el.shadowRoot!.querySelector('.nav-tab-group__nav') as HTMLElement;
Object.defineProperty(nav, 'scrollWidth', { configurable: true, value: 500 });
Object.defineProperty(nav, 'clientWidth', { configurable: true, value: 100 });
(el as any).setScrollControls();
expect(el.hasScrollControls).to.be.true;
el.noScrollControls = true;
(el as any).setScrollControls();
expect(el.hasScrollControls).to.be.false;
});
it('hasScrollControls stays false for vertical placement even when overflow math holds', async () => {
const el = await makeGroup({ placement: 'start' });
const nav = el.shadowRoot!.querySelector('.nav-tab-group__nav') as HTMLElement;
Object.defineProperty(nav, 'scrollWidth', { configurable: true, value: 500 });
Object.defineProperty(nav, 'clientWidth', { configurable: true, value: 100 });
(el as any).setScrollControls();
expect(el.hasScrollControls).to.be.false;
});
it('renders scroll buttons when hasScrollControls becomes true', async () => {
const el = await makeGroup();
el.hasScrollControls = true;
await el.updateComplete;
const buttons = el.shadowRoot!.querySelectorAll('nile-icon-button.nav-tab-group__scroll-button');
expect(buttons.length).to.equal(2);
});
// ---- Lifecycle ----
it('disconnecting the group does not throw', async () => {
const el = await makeGroup();
expect(() => el.remove()).to.not.throw();
});
it('updateComplete resolves', async () => {
const el = await makeGroup();
expect(await el.updateComplete).to.be.true;
});
it('width property applies inline style token on base', async () => {
const el = await makeGroup({ width: '120px' });
await el.updateComplete;
const base = el.shadowRoot!.querySelector('.nav-tab-group') as HTMLElement;
expect(base.style.getPropertyValue('--nav-tab-item-width').trim()).to.equal('120px');
});
// ---- Extended coverage ----
it('noScrollControls property default false', async () => {
const el = await makeGroup();
expect(el.noScrollControls).to.be.false;
});
it('noScrollControls can be set true', async () => {
const el = await makeGroup();
el.noScrollControls = true;
await el.updateComplete;
expect(el.noScrollControls).to.be.true;
});
it('activeTabName matches value after programmatic value set', async () => {
const el = await makeGroup({ value: 'general' });
await aTimeout(0);
el.activeTabProp = 'custom';
await el.updateComplete;
await aTimeout(0);
expect(el.activeTabName).to.equal('custom');
});
it('clicking already selected tab keeps activeTabName', async () => {
const el = await makeGroup({ value: 'custom' });
await aTimeout(0);
const tab = getTabs(el).find(t => t.getAttribute('panel') === 'custom')!;
tab.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await el.updateComplete;
expect(el.activeTabName).to.equal('custom');
});
it('nile-tab-change includes previousIndex when moving from first', async () => {
const el = await makeGroup({ value: 'general' });
await aTimeout(0);
let detail: any;
el.addEventListener('nile-tab-change', (e: Event) => {
detail = (e as CustomEvent).detail;
});
getTabs(el)
.find(t => t.getAttribute('panel') === 'custom')!
.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await el.updateComplete;
expect(detail.previousIndex).to.equal(0);
expect(detail.index).to.equal(1);
});
it('setAttribute value updates selection after tick', async () => {
const el = await makeGroup({ value: 'general' });
await aTimeout(0);
el.setAttribute('value', 'custom');
await aTimeout(0);
await el.updateComplete;
expect(getTabs(el).find(t => t.getAttribute('panel') === 'custom')!.active).to.be.true;
});
it('ArrowRight from custom wraps to general', async () => {
const el = await makeGroup({ value: 'custom' });
await aTimeout(0);
const tabs = getTabs(el);
const custom = tabs.find(t => t.getAttribute('panel') === 'custom')!;
const general = tabs.find(t => t.getAttribute('panel') === 'general')!;
custom.focus();
custom.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, composed: true, cancelable: true })
);
await aTimeout(0);
expect(document.activeElement).to.equal(general);
});
it('re-emits nile-close for custom panel id', async () => {
const el = await makeGroup();
let d: any;
el.addEventListener('nile-close', (e: Event) => {
d = (e as CustomEvent).detail;
});
getTabs(el)
.find(t => t.getAttribute('panel') === 'custom')!
.dispatchEvent(new CustomEvent('nile-close', { bubbles: true, composed: true }));
await el.updateComplete;
expect(d).to.deep.equal({ panel: 'custom' });
});
it('indicator part exists for underline variant', async () => {
const el = await makeGroup();
expect(el.shadowRoot!.querySelector('[part~="active-tab-indicator"]')).to.exist;
});
it('pill part exists for filled variant', async () => {
const el = await makeGroup({ variant: 'filled' });
expect(el.shadowRoot!.querySelector('[part~="active-tab-pill"]')).to.exist;
});
it('body part remains when no panels rendered in shadow alone', async () => {
const el = await makeGroup();
expect(el.shadowRoot!.querySelector('[part="body"]')).to.exist;
});
it('base part on wrapper exists', async () => {
const el = await makeGroup();
expect(el.shadowRoot!.querySelector('[part="base"]')).to.exist;
});
it('tabs part exists on list', async () => {
const el = await makeGroup();
expect(el.shadowRoot!.querySelector('[part="tabs"]')).to.exist;
});
it('centered false does not set centered on first tab', async () => {
const el = await makeGroup({ centered: false, value: 'general' });
await aTimeout(0);
expect(getTabs(el)[0].centered).to.be.false;
});
it('hasScrollControls reflects to attribute', async () => {
const el = await makeGroup();
el.hasScrollControls = true;
await el.updateComplete;
expect(el.hasAttribute('hasscrollcontrols')).to.be.true;
});
it('variant underline is default string', async () => {
const el = await makeGroup();
expect(el.variant).to.equal('underline');
});
it('variant neutral-filled string', async () => {
const el = await makeGroup({ variant: 'neutral-filled' });
expect(el.variant).to.equal('neutral-filled');
});
it('keydown Tab key does not change selection', async () => {
const el = await makeGroup({ value: 'general' });
await aTimeout(0);
getTabs(el)[0].focus();
getTabs(el)[0].dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, composed: true, cancelable: true })
);
await el.updateComplete;
expect(el.activeTabName).to.equal('general');
});
it('keydown unrelated key a does not change selection', async () => {
const el = await makeGroup({ value: 'general' });
await aTimeout(0);
getTabs(el)[0].focus();
getTabs(el)[0].dispatchEvent(
new KeyboardEvent('keydown', { key: 'a', bubbles: true, composed: true, cancelable: true })
);
await el.updateComplete;
expect(el.activeTabName).to.equal('general');
});
it('placement bottom still has tablist role', async () => {
const el = await makeGroup({ placement: 'bottom' });
expect(el.shadowRoot?.querySelector('.nav-tab-group__tabs')?.getAttribute('role')).to.equal('tablist');
});
it('ArrowLeft from second tab moves to first', async () => {
const el = await makeGroup({ value: 'custom' });
await aTimeout(0);
const tabs = getTabs(el);
const custom = tabs.find(t => t.getAttribute('panel') === 'custom')!;
const general = tabs.find(t => t.getAttribute('panel') === 'general')!;
custom.focus();
custom.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true, composed: true, cancelable: true })
);
await aTimeout(0);
expect(document.activeElement).to.equal(general);
});
it('End from first tab focuses last enabled tab', async () => {
const el = await makeGroup({ value: 'general' });
await aTimeout(0);
const general = getTabs(el).find(t => t.getAttribute('panel') === 'general')!;
const custom = getTabs(el).find(t => t.getAttribute('panel') === 'custom')!;
general.focus();
general.dispatchEvent(
new KeyboardEvent('keydown', { key: 'End', bubbles: true, composed: true, cancelable: true })
);
await aTimeout(0);
expect(document.activeElement).to.equal(custom);
});
it('fullWidth false by default', async () => {
const el = await makeGroup();
expect(el.fullWidth).to.be.false;
});
it('noTrack false by default', async () => {
const el = await makeGroup();
expect(el.noTrack).to.be.false;
});
it('showIndicatorOnHover false by default', async () => {
const el = await makeGroup();
expect(el.showIndicatorOnHover).to.be.false;
});
it('width defaults empty string', async () => {
const el = await makeGroup();
expect(el.width).to.equal('');
});
it('indicatorPlacement defaults empty', async () => {
const el = await makeGroup();
expect(el.indicatorPlacement).to.equal('');
});
it('getTabs returns three slotted nav tabs', async () => {
const el = await makeGroup();
await aTimeout(0);
expect(getTabs(el).length).to.equal(3);
});
it('getPanels returns three panels', async () => {
const el = await makeGroup();
await aTimeout(0);
expect(getPanels(el).length).to.equal(3);
});
it('removing value attribute clears value attr on host', async () => {
const el = await makeGroup({ value: 'general' });
await aTimeout(0);
el.removeAttribute('value');
await el.updateComplete;
await aTimeout(0);
expect(el.hasAttribute('value')).to.be.false;
});
it('tagName lowercases correctly', async () => {
const el = await makeGroup();
expect(el.tagName.toLowerCase()).to.equal('nile-nav-tab-group');
});
it('scroll buttons have label attributes', async () => {
const el = await makeGroup();
el.hasScrollControls = true;
await el.updateComplete;
const btns = el.shadowRoot!.querySelectorAll('nile-icon-button.nav-tab-group__scroll-button');
expect(btns.length).to.equal(2);
expect(btns[0]?.getAttribute('label')).to.exist;
expect(btns[1]?.getAttribute('label')).to.exist;
});
it('toggle variant still exposes tablist', async () => {
const el = await makeGroup({ variant: 'toggle' });
expect(el.shadowRoot!.querySelector('[role="tablist"]')).to.exist;
});
it('Enter after focus general keeps general selected', async () => {
const el = await makeGroup({ value: 'general' });
await aTimeout(0);
const g = getTabs(el).find(t => t.getAttribute('panel') === 'general')!;
g.focus();
g.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, composed: true, cancelable: true })
);
await el.updateComplete;
expect(el.activeTabName).to.equal('general');
});
it('Space on general keeps general', async () => {
const el = await makeGroup({ value: 'general' });
await aTimeout(0);
const g = getTabs(el).find(t => t.getAttribute('panel') === 'general')!;
g.focus();
g.dispatchEvent(
new KeyboardEvent('keydown', { key: ' ', bubbles: true, composed: true, cancelable: true })
);
await el.updateComplete;
expect(el.activeTabName).to.equal('general');
});
it('fullWidth and centered can both be true', async () => {
const el = await makeGroup({ fullWidth: true, centered: true, value: 'general' });
await aTimeout(0);
expect(el.fullWidth).to.be.true;
expect(el.centered).to.be.true;
expect(getTabs(el)[0].centered).to.be.true;
});
it('emits a single nile-tab-change for one user click', async () => {
const el = await makeGroup({ value: 'general' });
await aTimeout(0);
let n = 0;
el.addEventListener('nile-tab-change', () => n++);
getTabs(el)
.find(t => t.getAttribute('panel') === 'custom')!
.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await el.updateComplete;
expect(n).to.equal(1);
});
it('createElement upgrades nile-nav-tab-group', async () => {
const el = document.createElement('nile-nav-tab-group') as NileNavTabGroup;
document.body.appendChild(el);
await el.updateComplete;
expect(el.shadowRoot).to.not.be.null;
el.remove();
});
it('nav container has nav-container class', async () => {
const el = await makeGroup();
expect(el.shadowRoot!.querySelector('.nav-tab-group__nav-container')).to.exist;
});
it('underline variant exposes indicator path part', async () => {
const el = await makeGroup();
expect(el.shadowRoot!.querySelector('[part~="active-tab-indicator-path"]')).to.exist;
});
/**
* 100 generated cases — `yarn tsc` emits dist/src/nile-nav-tab-group/nile-nav-tab-group.test.js.
*/
describe('bulk generated coverage (100 cases)', () => {
for (let i = 0; i < 100; i++) {
it(`bulk case ${i + 1}/100 (mode ${i % 10})`, async () => {
const mode = i % 10;
if (mode === 0) {
const placements: NileNavTabGroup['placement'][] = ['top', 'bottom', 'start', 'end'];
const p = placements[i % 4]!;
const el = await makeGroup({ placement: p, value: 'general' });
await aTimeout(0);
const base = el.shadowRoot!.querySelector('.nav-tab-group')!;
expect(base.classList.contains(`nav-tab-group--${p}`)).to.be.true;
} else if (mode === 1) {
const variants: NileNavTabGroup['variant'][] = [
'underline',
'filled',
'toggle',
'neutral-filled',
'toggle-button',
];
const v = variants[i % 5]!;
const el = await makeGroup({ variant: v });
await aTimeout(0);
if (v === 'underline') {
expect(el.shadowRoot!.querySelector('.nav-tab-group__indicator')).to.exist;
} else {
expect(el.shadowRoot!.querySelector('.nav-tab-group__pill')).to.exist;
}
} else if (mode === 2) {
const el = await makeGroup({ value: 'custom' });
await aTimeout(0);
expect(getTabs(el).find(t => t.getAttribute('panel') === 'custom')!.active).to.be.true;
} else if (mode === 3) {
const el = await makeGroup({ value: 'general' });
await aTimeout(0);
getTabs(el)
.find(t => t.getAttribute('panel') === 'custom')!
.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await el.updateComplete;
expect(el.activeTabName).to.equal('custom');
} else if (mode === 4) {
const el = await makeGroup({ centered: true, value: 'general' });
await aTimeout(0);
expect(getTabs(el).every(t => t.centered)).to.be.true;
} else if (mode === 5) {
const el = await makeGroup({ noTrack: true });
await aTimeout(0);
expect(el.shadowRoot!.querySelector('.nav-tab-group')!.classList.contains('hide__track')).to.be.true;
} else if (mode === 6) {
const el = await makeGroup({ fullWidth: true });
await aTimeout(0);
expect(el.fullWidth).to.be.true;
} else if (mode === 7) {
const el = await makeGroup();
await aTimeout(0);
expect(el.shadowRoot!.querySelector('[role="tablist"]')).to.exist;
} else if (mode === 8) {
const el = await makeGroup();
await aTimeout(0);
expect(getTabs(el).length).to.equal(3);
expect(getPanels(el).length).to.equal(3);
} else {
const w = `${40 + (i % 20)}px`;
const el = await makeGroup({ width: w, value: 'general' });
await aTimeout(0);
const base = el.shadowRoot!.querySelector('.nav-tab-group') as HTMLElement;
expect(base.style.getPropertyValue('--nav-tab-item-width').trim()).to.equal(w);
}
});
}
});
});