import { aTimeout, expect, fixture, html } from '@open-wc/testing'; import './nile-otp-input'; import type { NileOtpInput } from './nile-otp-input'; const getCells = (el: NileOtpInput) => Array.from(el.shadowRoot!.querySelectorAll('.otp__cell')); const inputCell = async (el: NileOtpInput, index: number, value: string) => { const cell = getCells(el)[index]; cell.value = value; cell.dispatchEvent(new Event('input', { bubbles: true, composed: true })); await el.updateComplete; return cell; }; const pressKey = async ( el: NileOtpInput, index: number, key: string, opts: Partial = {} ) => { const cell = getCells(el)[index]; cell.dispatchEvent( new KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true, ...opts, }) ); await el.updateComplete; return cell; }; const pasteInto = async (el: NileOtpInput, index: number, text: string) => { const cell = getCells(el)[index]; const event = new Event('paste', { bubbles: true, composed: true, cancelable: true, }) as ClipboardEvent; Object.defineProperty(event, 'clipboardData', { value: { getData: () => text }, }); cell.dispatchEvent(event); await el.updateComplete; }; describe('NileOtpInput', () => { // ---- Rendering ---- it('renders default 6 cells', async () => { const el = await fixture( html`` ); expect(el).to.exist; expect(getCells(el).length).to.equal(6); }); it('normalizes incoming value (strips non-numeric in numeric mode)', async () => { const el = await fixture( html`` ); await el.updateComplete; expect(el.value).to.equal('1234'); expect(getCells(el).map(c => c.value)).to.deep.equal([ '1', '2', '3', '4', '', '', ]); }); it('supports configurable length and clamps below 4', async () => { const el = await fixture( html`` ); expect(getCells(el).length).to.equal(8); el.length = 2; await el.updateComplete; expect(el.length).to.equal(4); expect(getCells(el).length).to.equal(4); }); it('renders label when provided', async () => { const el = await fixture( html`` ); const label = el.shadowRoot!.querySelector('.form-control__label'); expect(label).to.exist; expect(label!.textContent).to.contain('Verification code'); }); it('renders help text when provided', async () => { const el = await fixture( html`` ); const helpText = el.shadowRoot!.querySelector('.form-control__help-text'); expect(helpText).to.exist; expect(helpText!.textContent).to.contain('Enter your code'); }); it('renders error message when provided', async () => { const el = await fixture( html`` ); const errorMsg = el.shadowRoot!.querySelector('.form-control__error-message'); expect(errorMsg).to.exist; expect(errorMsg!.textContent).to.contain('Invalid code'); }); it('renders separators with separator-every', async () => { const el = await fixture(html` `); const separators = el.shadowRoot!.querySelectorAll('.otp__separator'); expect(separators.length).to.equal(1); expect(separators[0].textContent!.trim()).to.equal('-'); }); it('renders separators from separator-positions', async () => { const el = await fixture(html` `); const separators = el.shadowRoot!.querySelectorAll('.otp__separator'); expect(separators.length).to.equal(3); separators.forEach(s => expect(s.textContent!.trim()).to.equal('/') ); }); // ---- Input behavior ---- it('auto-advances focus to next empty cell after typing', async () => { const el = await fixture( html`` ); const cells = getCells(el); cells[0].focus(); await inputCell(el, 0, '1'); await aTimeout(0); expect(el.value).to.equal('1'); expect(el.shadowRoot!.activeElement).to.equal(cells[1]); }); it('typing a character on a filled cell replaces it and advances', async () => { const el = await fixture( html`` ); await el.updateComplete; const cells = getCells(el); cells[2].focus(); await el.updateComplete; await pressKey(el, 2, '9'); await aTimeout(0); expect(el.value[2]).to.equal('9'); }); it('blocks invalid characters in numeric mode', async () => { const el = await fixture( html`` ); const first = getCells(el)[0]; first.focus(); const event = new KeyboardEvent('keydown', { key: 'A', bubbles: true, composed: true, cancelable: true, }); first.dispatchEvent(event); expect(event.defaultPrevented).to.be.true; }); it('allows alphanumeric values when alphanumeric attribute is set', async () => { const el = await fixture( html`` ); const cells = getCells(el); cells[0].focus(); await el.updateComplete; await pressKey(el, 0, 'A'); expect(el.value).to.equal('A'); }); it('alphanumeric overrides numeric-only', async () => { const el = await fixture( html`` ); const cells = getCells(el); cells[0].focus(); await el.updateComplete; await pressKey(el, 0, 'B'); expect(el.value).to.equal('B'); }); it('supports controlled value updates', async () => { const el = await fixture( html`` ); el.value = '2468'; await el.updateComplete; expect(getCells(el).map(c => c.value)).to.deep.equal([ '2', '4', '6', '8', '', '', ]); }); // ---- Focus management (ShadCN style) ---- it('focus() targets the first empty cell', async () => { const el = await fixture( html`` ); await el.updateComplete; const cells = getCells(el); el.focus(); await aTimeout(0); expect(el.shadowRoot!.activeElement).to.equal(cells[2]); }); it('clicking an empty cell beyond the cursor redirects to first empty', async () => { const el = await fixture( html`` ); await el.updateComplete; const cells = getCells(el); cells[5].focus(); await aTimeout(0); await el.updateComplete; expect(el.shadowRoot!.activeElement).to.equal(cells[2]); }); it('clicking a filled cell focuses it directly', async () => { const el = await fixture( html`` ); await el.updateComplete; const cells = getCells(el); cells[1].focus(); await aTimeout(0); await el.updateComplete; expect(el.shadowRoot!.activeElement).to.equal(cells[1]); }); // ---- Keyboard navigation ---- it('Backspace clears current cell and retreats', async () => { const el = await fixture( html`` ); await el.updateComplete; const cells = getCells(el); cells[2].focus(); await pressKey(el, 2, 'Backspace'); await aTimeout(0); expect(el.value).to.equal('12'); expect(el.shadowRoot!.activeElement).to.equal(cells[1]); }); it('Backspace on empty cell clears previous and moves there', async () => { const el = await fixture( html`` ); await el.updateComplete; const cells = getCells(el); cells[2].focus(); await pressKey(el, 2, 'Backspace'); await aTimeout(0); expect(el.value).to.equal('1'); expect(cells[1].value).to.equal(''); expect(el.shadowRoot!.activeElement).to.equal(cells[1]); }); it('Delete clears current cell without moving', async () => { const el = await fixture( html`` ); await el.updateComplete; const cells = getCells(el); cells[1].focus(); await pressKey(el, 1, 'Delete'); await aTimeout(0); expect(el.value).to.equal('13'); expect(el.shadowRoot!.activeElement).to.equal(cells[1]); }); it('ArrowLeft moves focus back', async () => { const el = await fixture( html`` ); await el.updateComplete; const cells = getCells(el); cells[2].focus(); await pressKey(el, 2, 'ArrowLeft'); await aTimeout(0); expect(el.shadowRoot!.activeElement).to.equal(cells[1]); }); it('ArrowRight cannot move past the first empty cell', async () => { const el = await fixture( html`` ); await el.updateComplete; const cells = getCells(el); cells[1].focus(); await pressKey(el, 1, 'ArrowRight'); await aTimeout(0); expect(el.shadowRoot!.activeElement).to.equal(cells[2]); await pressKey(el, 2, 'ArrowRight'); await aTimeout(0); expect(el.shadowRoot!.activeElement).to.equal(cells[2]); }); it('Home goes to first cell', async () => { const el = await fixture( html`` ); await el.updateComplete; const cells = getCells(el); cells[2].focus(); await pressKey(el, 2, 'Home'); await aTimeout(0); expect(el.shadowRoot!.activeElement).to.equal(cells[0]); }); it('End goes to first empty cell', async () => { const el = await fixture( html`` ); await el.updateComplete; const cells = getCells(el); cells[0].focus(); await pressKey(el, 0, 'End'); await aTimeout(0); expect(el.shadowRoot!.activeElement).to.equal(cells[3]); }); it('Space is blocked', async () => { const el = await fixture( html`` ); const cells = getCells(el); cells[0].focus(); const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true, composed: true, cancelable: true, }); cells[0].dispatchEvent(event); expect(event.defaultPrevented).to.be.true; }); // ---- Paste ---- it('paste fills from position 0 and focuses next empty', async () => { const el = await fixture( html`` ); await pasteInto(el, 3, '987654'); expect(el.value).to.equal('987654'); expect(getCells(el).map(c => c.value).join('')).to.equal('987654'); }); it('paste emits nile-paste event', async () => { const el = await fixture( html`` ); let pasteValue = ''; el.addEventListener('nile-paste', (e: Event) => { pasteValue = (e as CustomEvent).detail.value; }); await pasteInto(el, 0, '123456'); expect(pasteValue).to.equal('123456'); }); it('paste is ignored when disabled', async () => { const el = await fixture( html`` ); await pasteInto(el, 0, '123456'); expect(el.value).to.equal(''); }); // ---- Visual states ---- it('applies warning class', async () => { const el = await fixture( html`` ); const base = el.shadowRoot!.querySelector('.otp'); expect(base!.classList.contains('otp--warning')).to.be.true; }); it('applies error class', async () => { const el = await fixture( html`` ); const base = el.shadowRoot!.querySelector('.otp'); expect(base!.classList.contains('otp--error')).to.be.true; }); it('applies success class', async () => { const el = await fixture( html`` ); const base = el.shadowRoot!.querySelector('.otp'); expect(base!.classList.contains('otp--success')).to.be.true; }); it('applies disabled class and blocks input', async () => { const el = await fixture( html`` ); const base = el.shadowRoot!.querySelector('.otp'); expect(base!.classList.contains('otp--disabled')).to.be.true; const first = getCells(el)[0]; first.value = '9'; first.dispatchEvent(new Event('input', { bubbles: true, composed: true })); await el.updateComplete; expect(el.value).to.equal(''); expect(first.value).to.equal(''); }); it('applies readonly class and blocks input', async () => { const el = await fixture( html`` ); const base = el.shadowRoot!.querySelector('.otp'); expect(base!.classList.contains('otp--readonly')).to.be.true; const first = getCells(el)[0]; first.value = '9'; first.dispatchEvent(new Event('input', { bubbles: true, composed: true })); await el.updateComplete; expect(el.value).to.equal('123456'); }); // ---- Masked ---- it('masked cells use type="password" for filled non-active cells', async () => { const el = await fixture( html`` ); await el.updateComplete; const cells = getCells(el); expect(cells[0].type).to.equal('password'); expect(cells[1].type).to.equal('password'); expect(cells[2].type).to.equal('text'); }); it('active masked cell shows text while typing', async () => { const el = await fixture( html`` ); await el.updateComplete; const cells = getCells(el); cells[1].focus(); await el.updateComplete; expect(cells[1].type).to.equal('text'); expect(cells[0].type).to.equal('password'); }); it('unmasked cells are always type="text"', async () => { const el = await fixture( html`` ); await el.updateComplete; const cells = getCells(el); cells.forEach(c => expect(c.type).to.equal('text')); }); // ---- Events ---- it('emits nile-input and nile-change with value detail', async () => { const el = await fixture( html`` ); let inputDetail: any; let changeDetail: any; el.addEventListener('nile-input', (e: Event) => { inputDetail = (e as CustomEvent).detail; }); el.addEventListener('nile-change', (e: Event) => { changeDetail = (e as CustomEvent).detail; }); getCells(el)[0].focus(); await el.updateComplete; await pressKey(el, 0, '7'); expect(inputDetail.value).to.equal('7'); expect(inputDetail.complete).to.be.false; expect(changeDetail.value).to.equal('7'); }); it('emits nile-complete when all cells are filled', async () => { const el = await fixture( html`` ); await el.updateComplete; let completedValue = ''; el.addEventListener('nile-complete', (e: Event) => { completedValue = (e as CustomEvent).detail.value; }); const cells = getCells(el); cells[3].focus(); await el.updateComplete; await pressKey(el, 3, '4'); expect(completedValue).to.equal('1234'); }); it('emits nile-focus when entering and nile-blur when leaving', async () => { const el = await fixture( html`` ); let focused = false; let blurred = false; el.addEventListener('nile-focus', () => { focused = true; }); el.addEventListener('nile-blur', () => { blurred = true; }); getCells(el)[0].focus(); await el.updateComplete; expect(focused).to.be.true; getCells(el)[0].blur(); await aTimeout(50); expect(blurred).to.be.true; }); // ---- Public methods ---- it('clear() empties all cells', async () => { const el = await fixture( html`` ); await el.updateComplete; el.clear(); await el.updateComplete; expect(el.value).to.equal(''); getCells(el).forEach(c => expect(c.value).to.equal('')); }); it('clear() is blocked when disabled', async () => { const el = await fixture( html`` ); await el.updateComplete; el.clear(); await el.updateComplete; expect(el.value).to.equal('123456'); }); it('complete getter returns true when all cells filled', async () => { const el = await fixture( html`` ); await el.updateComplete; expect(el.complete).to.be.true; }); it('complete getter returns false when cells are empty', async () => { const el = await fixture( html`` ); await el.updateComplete; expect(el.complete).to.be.false; }); // ---- Validation ---- it('validates required and exact length using checkValidity', async () => { const el = await fixture( html`` ); el.value = '12'; await el.updateComplete; expect(el.checkValidity()).to.be.false; el.value = '123456'; await el.updateComplete; expect(el.checkValidity()).to.be.true; }); it('supports custom validity messaging', async () => { const el = await fixture( html`` ); el.setCustomValidity('Invalid OTP'); await el.updateComplete; expect(el.checkValidity()).to.be.false; expect(el.validationMessage).to.equal('Invalid OTP'); el.setCustomValidity(''); await el.updateComplete; expect(el.checkValidity()).to.be.true; }); it('validates against custom pattern', async () => { const el = await fixture( html`` ); await el.updateComplete; expect(el.checkValidity()).to.be.false; el.value = '123456'; await el.updateComplete; expect(el.checkValidity()).to.be.true; }); // ---- Form integration ---- it('participates in form data submission', async () => { const form = await fixture(html`
`); const data = new FormData(form); expect(data.get('otp')).to.equal('321654'); }); // ---- Placeholder ---- it('shows no placeholder by default', async () => { const el = await fixture(html` `); const placeholders = getCells(el).map(c => c.getAttribute('placeholder')); expect(placeholders).to.deep.equal([null, null, null, null, null, null]); }); it('moves custom placeholder to active cell on focus', async () => { const el = await fixture( html`` ); const cells = getCells(el); cells[0].focus(); await el.updateComplete; const placeholders = getCells(el).map(c => c.getAttribute('placeholder')); expect(placeholders[0]).to.equal('0'); expect(placeholders.slice(1).every(p => p === null)).to.be.true; }); // ---- CSS parts ---- it('exposes all required CSS parts', async () => { const el = await fixture(html` `); expect(el.shadowRoot!.querySelector('[part="form-control"]')).to.exist; expect(el.shadowRoot!.querySelector('[part="form-control-label"]')).to.exist; expect(el.shadowRoot!.querySelector('[part="form-control-input"]')).to.exist; expect(el.shadowRoot!.querySelector('[part="base"]')).to.exist; expect(el.shadowRoot!.querySelector('[part="cell"]')).to.exist; expect(el.shadowRoot!.querySelector('[part="separator"]')).to.exist; expect(el.shadowRoot!.querySelector('[part="form-control-help-text"]')).to.exist; expect(el.shadowRoot!.querySelector('[part="form-control-error-message"]')).to.exist; }); });