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