import { aTimeout, expect, fixture, html } from '@open-wc/testing';
import { isClockTime } from '../nile-calendar/timezones';
import './nile-date-time-picker';
import '../nile-calendar';
import '../nile-input';
import type { NileDateTimePicker } from './nile-date-time-picker';
import { settle2 } from './nile-date-time-picker.test-helpers';
/*
* Year handling, unparseable clock faces, Escape focus, dialog name, value formats, keyboard.
* Split out of nile-date-time-picker.test.ts so CI can run it on its own shard;
* helpers shared across the split files live in nile-date-time-picker.test-helpers.ts.
*/
/**
* Year handling in the structured mask and the calendar view.
*
* Y1 `YY` keeps a seeded value's century when an unrelated segment is edited (DTP-02);
* an explicitly edited year reads as 2000-2099
* Y2 a year being overtyped is normalised only once it is fully typed (range clamp, leap day)
* Y3 the year/month dropdowns only navigate; reopening the panel shows the selected date's month/year
*/
describe('NileDateTimePicker — year handling', () => {
const SEED = (y: string, md = '06-15') => `${y}-${md}T10:20:30.000[GMT+00:00 UTC]`;
type Opts = { fmt?: string; start?: number; end?: number; value?: string; hide?: string[]; dropdowns?: boolean };
const build = async (o: Opts = {}) => {
const el = await fixture(
html``
);
// explicit bounds, so these tests do not depend on the component's default year range
el.startYear = o.start ?? 1900;
el.endYear = o.end ?? 3000;
if (o.hide) el.hideTimeFields = o.hide as any;
if (o.dropdowns) { el.showMonthDropdown = true; el.showYearDropdown = true; }
if (o.value) el.value = o.value;
await settle(el); await settle(el);
await new Promise(r => setTimeout(r, 30));
return el;
};
const settle = async (el: NileDateTimePicker) => {
await el.updateComplete;
const cal = (el as any).calendarElement;
if (cal) await cal.updateComplete;
await inputOf(el).updateComplete;
return cal;
};
const calOf = (el: any): any => el.calendarElement;
const inputOf = (el: any): any => el.shadowRoot.querySelector('nile-input');
const nativeOf = (el: any): HTMLInputElement => inputOf(el).input;
const textOf = (el: any) => nativeOf(el).value;
const press = (el: any, key: string) =>
inputOf(el).dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true }));
const typeKeys = (el: any, s: string) => { for (const ch of s) press(el, ch); };
const caret = (el: any, p: number, q = p) => { nativeOf(el).focus(); nativeOf(el).setSelectionRange(p, q); };
const blur = async (el: any) => {
inputOf(el).dispatchEvent(new Event('focusout', { bubbles: true, composed: true }));
await settle(el);
};
const paste = (el: any, text: string) => {
const dt = new DataTransfer();
dt.setData('text', text);
inputOf(el).dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, composed: true, cancelable: true }));
};
/** the mask's year digits, e.g. "2026" or "26" */
const yearText = (el: any) => {
const t = textOf(el);
return (el.dateFormat as string).endsWith('YYYY') ? t.slice(6, 10) : t.slice(6, 8);
};
const apply = async (el: any) => {
const events: CustomEvent[] = [];
const rec = ((e: CustomEvent) => events.push(e)) as EventListener;
el.addEventListener('nile-change', rec);
const btn = calOf(el).shadowRoot.querySelector('.apply-button') as any;
await btn.updateComplete;
btn.click();
await settle(el);
el.removeEventListener('nile-change', rec);
return events;
};
const yearOf = (iso: string | undefined) => Number((iso ?? '').slice(0, 4));
const openPanel = async (el: any) => {
el.open = true;
await settle(el);
await new Promise(r => setTimeout(r, 60));
};
const closePanel = async (el: any) => {
el.open = false;
await settle(el);
await new Promise(r => setTimeout(r, 60));
};
const pickYear = async (el: any, y: number) => {
const cal = calOf(el);
const item = [...cal.shadowRoot.querySelectorAll('.year-menu-item')]
.find((m: any) => m.textContent.trim() === String(y)) as HTMLElement;
item.click();
await cal.updateComplete;
};
const pickMonth = async (el: any, m0: number) => {
const cal = calOf(el);
(cal.shadowRoot.querySelectorAll('.month-menu-item')[m0] as HTMLElement).click();
await cal.updateComplete;
};
// ── Y1. YY century ──────────────────────────────────────────────────────────
describe('Y1. two-digit year keeps the seeded century', () => {
const edits: Array<[string, (el: any) => void]> = [
['day', el => { caret(el, 0); typeKeys(el, '17'); }],
['month', el => { caret(el, 3); typeKeys(el, '03'); }],
['minutes', el => { caret(el, textOf(el).indexOf(':') + 1); typeKeys(el, '45'); }],
];
for (const [what, edit] of edits) {
it(`Y1a. YY seeded 1999, edit ${what} → still 1999 in value, epoch, trigger and calendar`, async () => {
const el = await build({ value: SEED('1999') });
expect(textOf(el)).to.match(/^15\/06\/99 - /);
edit(el);
await blur(el);
expect(yearText(el), 'trigger year').to.equal('99');
const [ev, ...rest] = await apply(el);
expect(rest).to.have.length(0);
expect(yearOf(ev.detail.value), 'emitted value').to.equal(1999);
expect(new Date(ev.detail.unixEpoch).getUTCFullYear(), 'emitted epoch').to.equal(1999);
expect(yearOf(el.value as string), 'public value').to.equal(1999);
expect(calOf(el).value.getFullYear(), 'calendar selection').to.equal(1999);
expect(yearText(el)).to.equal('99');
});
}
it('Y1b. YY seeded 1999, Apply without editing → 1999', async () => {
const el = await build({ value: SEED('1999') });
const [ev] = await apply(el);
expect(yearOf(ev.detail.value)).to.equal(1999);
expect(new Date(ev.detail.unixEpoch).getUTCFullYear()).to.equal(1999);
});
it('Y1c. YY seeded 2026, edit day → 2026', async () => {
const el = await build({ value: SEED('2026') });
caret(el, 0); typeKeys(el, '17');
await blur(el);
const [ev] = await apply(el);
expect(ev.detail.value).to.match(/^2026-06-17T/);
});
it('Y1d. YY empty, typing 99 → 2099 (D1: new two-digit years are 2000-2099)', async () => {
const el = await build();
typeKeys(el, '150699');
await blur(el);
expect(calOf(el).value.getFullYear()).to.equal(2099);
const [ev] = await apply(el);
expect(yearOf(ev.detail.value)).to.equal(2099);
});
it('Y1e. YYYY seeded 1999, edit day → 1999', async () => {
const el = await build({ fmt: 'DD/MM/YYYY', value: SEED('1999') });
caret(el, 0); typeKeys(el, '17');
await blur(el);
const [ev] = await apply(el);
expect(ev.detail.value).to.match(/^1999-06-17T/);
});
it('Y1f. YY seeded 1999, retyping the same visible 99 keeps 1999', async () => {
const el = await build({ value: SEED('1999') });
caret(el, 6); typeKeys(el, '99');
await blur(el);
expect(yearText(el)).to.equal('99');
expect(calOf(el).value.getFullYear()).to.equal(1999);
const [ev] = await apply(el);
expect(yearOf(ev.detail.value)).to.equal(1999);
expect(new Date(ev.detail.unixEpoch).getUTCFullYear()).to.equal(1999);
});
it('Y1f2. YY seeded 1999, explicitly changing 99 → 98 reads as 2098 (2000-2099 policy)', async () => {
const el = await build({ value: SEED('1999') });
caret(el, 6);
typeKeys(el, '9');
expect(calOf(el).value.getFullYear(), 'half-typed year does not reach the calendar').to.equal(1999);
typeKeys(el, '8');
expect(calOf(el).value.getFullYear(), 'completed year reaches the calendar').to.equal(2098);
await blur(el);
expect(yearText(el)).to.equal('98');
const [ev] = await apply(el);
expect(yearOf(ev.detail.value)).to.equal(2098);
expect(new Date(ev.detail.unixEpoch).getUTCFullYear()).to.equal(2098);
});
it('Y1f3. YY seeded 1999, change to 98 then restore 99 before committing → 1999', async () => {
const el = await build({ value: SEED('1999') });
caret(el, 6); typeKeys(el, '98');
expect(calOf(el).value.getFullYear()).to.equal(2098);
caret(el, 6); typeKeys(el, '99');
expect(calOf(el).value.getFullYear(), 'restored digits restore the century').to.equal(1999);
await blur(el);
const [ev] = await apply(el);
expect(yearOf(ev.detail.value)).to.equal(1999);
});
it('Y1f4. the restore also works across a blur, as long as nothing was applied', async () => {
const el = await build({ value: SEED('1999') });
caret(el, 6); typeKeys(el, '98');
await blur(el);
caret(el, 0); typeKeys(el, '17'); // an unrelated edit while the year sits at 2098
await blur(el);
expect(calOf(el).value.getFullYear()).to.equal(2098);
caret(el, 6); typeKeys(el, '99');
await blur(el);
const [ev] = await apply(el);
expect(ev.detail.value).to.match(/^1999-06-17T/);
});
it('Y1f5. after Apply the applied year is the new baseline (2098 stays 2098 through a day edit)', async () => {
const el = await build({ value: SEED('1999') });
caret(el, 6); typeKeys(el, '98');
await blur(el);
await apply(el);
caret(el, 0); typeKeys(el, '18');
await blur(el);
const [ev] = await apply(el);
expect(ev.detail.value).to.match(/^2098-06-18T/);
});
it('Y1f6. Backspace over the year, then retyping the same 99 → 1999; typing 98 → 2098', async () => {
const a = await build({ value: SEED('1999') });
caret(a, 6, 8); press(a, 'Backspace');
typeKeys(a, '99');
await blur(a);
expect(calOf(a).value.getFullYear()).to.equal(1999);
const b = await build({ value: SEED('1999') });
caret(b, 6, 8); press(b, 'Backspace');
typeKeys(b, '98');
await blur(b);
expect(calOf(b).value.getFullYear()).to.equal(2098);
});
it('Y1f7. YY paste over the year: 99 keeps 1999, 98 reads as 2098', async () => {
const a = await build({ value: SEED('1999') });
caret(a, 6, 8); paste(a, '99');
await blur(a);
expect(calOf(a).value.getFullYear()).to.equal(1999);
const b = await build({ value: SEED('1999') });
caret(b, 6, 8); paste(b, '98');
await blur(b);
expect(calOf(b).value.getFullYear()).to.equal(2098);
});
it('Y1g. YY seeded 1999, explicitly typing another year reads as 20xx', async () => {
const el = await build({ value: SEED('1999') });
caret(el, 6); typeKeys(el, '05');
await blur(el);
const [ev] = await apply(el);
expect(yearOf(ev.detail.value)).to.equal(2005);
});
it('Y1h. a programmatic value change does not retain the previous seeded century', async () => {
const el = await build({ value: SEED('1999') });
caret(el, 0); typeKeys(el, '17'); // edit → century 1999 is in play
el.value = SEED('2050'); // consumer replaces the value
await settle(el); await settle(el);
caret(el, 0); typeKeys(el, '18');
await blur(el);
const [ev] = await apply(el);
expect(ev.detail.value).to.match(/^2050-06-18T/);
});
it('Y1i. Apply re-seeds: after applying 1999, editing the day keeps 1999; Reset forgets it', async () => {
const el = await build({ value: SEED('1999') });
caret(el, 0); typeKeys(el, '17');
await blur(el);
await apply(el);
caret(el, 0); typeKeys(el, '18');
await blur(el);
const [ev] = await apply(el);
expect(ev.detail.value).to.match(/^1999-06-18T/);
(calOf(el).shadowRoot.querySelector('.clear-button') as HTMLElement).click();
await settle(el);
expect(textOf(el)).to.equal('');
typeKeys(el, '150699');
await blur(el);
const [ev2] = await apply(el);
expect(yearOf(ev2.detail.value), 'a cleared picker carries no century').to.equal(2099);
});
it('Y1j. changing dateFormat YY → YYYY → YY keeps 1999 through a later edit', async () => {
const el = await build({ value: SEED('1999') });
el.dateFormat = 'DD/MM/YYYY'; await settle(el); await settle(el);
expect(textOf(el)).to.match(/^15\/06\/1999/);
el.dateFormat = 'DD/MM/YY'; await settle(el); await settle(el);
caret(el, 0); typeKeys(el, '17');
await blur(el);
const [ev] = await apply(el);
expect(ev.detail.value).to.match(/^1999-06-17T/);
});
it('Y1k. a year 0-99 still round-trips in a four-digit format', async () => {
const el = await build({ fmt: 'DD/MM/YYYY', value: SEED('0050') });
expect(textOf(el)).to.match(/^15\/06\/0050/);
caret(el, 0); typeKeys(el, '17');
await blur(el);
expect(textOf(el)).to.match(/^17\/06\/0050/);
});
});
// ── Y1x. the remaining paths that carry or reset the seeded century ─────────
describe('Y1x. century across reflow, reseed, zones and allowedDates', () => {
it('Y1l. the beforeinput reflow path (IME, soft keyboard, cut/undo) keeps the century when the year digits are unchanged', async () => {
const el = await build({ value: SEED('1999') });
const native = nativeOf(el);
native.focus();
native.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: '7', bubbles: true, composed: true, cancelable: true }));
native.value = '17/06/99 - 10:20:30.000 - UTC'; // what the browser's own edit leaves behind
native.dispatchEvent(new InputEvent('input', { inputType: 'insertText', bubbles: true, composed: true }));
await new Promise(r => setTimeout(r, 30));
await blur(el);
expect(yearText(el)).to.equal('99');
const [ev] = await apply(el);
expect(ev.detail.value).to.match(/^1999-06-17T/);
});
it('Y1m. a programmatic value with the same last two digits but another century replaces the old century', async () => {
const el = await build({ value: SEED('1999') });
el.value = SEED('2099');
await settle(el); await settle(el);
caret(el, 0); typeKeys(el, '17');
await blur(el);
const [ev] = await apply(el);
expect(ev.detail.value).to.match(/^2099-06-17T/);
});
it('Y1n. a zone-encoded seed in another zone keeps its wall-clock century (1999-12-31 23:30 Asia/Kolkata)', async () => {
const el = await build({ value: '1999-12-31T23:30:00.000[GMT+05:30 Asia/Kolkata]' });
expect(textOf(el)).to.match(/^31\/12\/99 - 23:30:00\.000 - Asia\/Kolkata/);
caret(el, textOf(el).indexOf(':') + 1); typeKeys(el, '45');
await blur(el);
const [ev] = await apply(el);
expect(ev.detail.value).to.match(/^1999-12-31T23:45:00\.000\[GMT\+05:30 Asia\/Kolkata\]$/);
expect(new Date(ev.detail.unixEpoch).toISOString()).to.equal('1999-12-31T18:15:00.000Z');
});
it('Y1o. allowedDates: an in-range seeded 1999 is kept; a seeded 1999 outside the range snaps to the boundary year', async () => {
const inRange = await build({ value: SEED('1999') });
inRange.allowedDates = JSON.stringify({ startDate: '1998-01-01', endDate: '2001-12-31' });
await settle(inRange);
caret(inRange, 0); typeKeys(inRange, '17');
await blur(inRange);
const [a] = await apply(inRange);
expect(a.detail.value).to.match(/^1999-06-17T/);
const outside = await build({ value: SEED('1999') });
outside.allowedDates = JSON.stringify({ startDate: '2005-01-01', endDate: '2010-12-31' });
await settle(outside);
caret(outside, 0); typeKeys(outside, '17');
await blur(outside);
// the first keystroke already lands outside the range and snaps to the boundary, whose year is 2005
expect(yearText(outside)).to.equal('05');
const [b] = await apply(outside);
expect(yearOf(b.detail.value)).to.equal(2005);
expect(new Date(b.detail.unixEpoch).getUTCFullYear()).to.equal(2005);
});
});
// ── Y5. a YY year that no 2000-2099 reading can put inside the configured range ───
describe('Y5. YY with a range outside 2000-2099 never writes malformed digits or commits an out-of-range year', () => {
const wellFormed = (t: string) => expect(t, 'no sign or stray characters in the date part').to.match(/^[\d/]*$/);
it('Y5a. range 1990-1999, seeded 1999: typing 98 is rejected, the seeded 99 stays, and Apply emits 1999', async () => {
const el = await build({ fmt: 'DD/MM/YY', start: 1990, end: 1999, value: SEED('1999') });
caret(el, 6); typeKeys(el, '98');
await blur(el);
expect(textOf(el).slice(0, 8)).to.equal('15/06/99');
wellFormed(textOf(el).split(' - ')[0]);
expect(calOf(el).value.getFullYear()).to.equal(1999);
const [ev] = await apply(el);
expect(yearOf(ev.detail.value)).to.equal(1999);
expect(new Date(ev.detail.unixEpoch).getUTCFullYear()).to.equal(1999);
});
it('Y5b. range 2150-2200, seeded 2160: typing 05 is rejected (not 2050), and Apply emits 2160', async () => {
const el = await build({ fmt: 'DD/MM/YY', start: 2150, end: 2200, value: SEED('2160') });
caret(el, 6); typeKeys(el, '05');
await blur(el);
expect(textOf(el).slice(0, 8)).to.equal('15/06/60');
const [ev] = await apply(el);
expect(yearOf(ev.detail.value)).to.equal(2160);
});
it('Y5c. empty picker, range 1990-1999: the year is rejected, no date is left to Apply, nothing is emitted', async () => {
const el = await build({ fmt: 'DD/MM/YY', start: 1990, end: 1999 });
typeKeys(el, '150698');
await blur(el);
expect(textOf(el)).to.equal('15/06/');
wellFormed(textOf(el));
expect(calOf(el).value, 'no selection built from day and month alone').to.be.null;
const btn = calOf(el).shadowRoot.querySelector('.apply-button') as any;
await btn.updateComplete;
expect(btn.hasAttribute('disabled'), 'Apply is disabled').to.be.true;
const events = await apply(el);
expect(events).to.have.length(0);
expect(el.value ?? null).to.be.null;
});
it('Y5d. pasting such a year into an empty picker is rejected the same way', async () => {
const el = await build({ fmt: 'DD/MM/YY', start: 1990, end: 1999 });
paste(el, '150698');
await blur(el);
expect(textOf(el)).to.equal('15/06/');
expect(calOf(el).value).to.be.null;
});
it('Y5e. the same range with YYYY (the documented recommendation) types and commits 1995 normally', async () => {
const el = await build({ fmt: 'DD/MM/YYYY', start: 1990, end: 1999 });
typeKeys(el, '15061995');
await blur(el);
expect(textOf(el).slice(0, 10)).to.equal('15/06/1995');
const [ev] = await apply(el);
expect(yearOf(ev.detail.value)).to.equal(1995);
const above = await build({ fmt: 'DD/MM/YYYY', start: 1990, end: 1999 });
typeKeys(above, '15062005');
await blur(above);
expect(textOf(above).slice(0, 10), 'YYYY still clamps to the range').to.equal('15/06/1999');
});
it('Y5f. a YY range that does include 2000-2099 still clamps normally', async () => {
const el = await build({ fmt: 'DD/MM/YY', start: 2020, end: 2035, value: SEED('2026') });
caret(el, 6); typeKeys(el, '99');
await blur(el);
expect(yearText(el)).to.equal('35');
expect(calOf(el).value.getFullYear()).to.equal(2035);
});
it('Y5h. the rejection ends with the mask it belonged to: a new value, an unusable value, a format change, a type switch, Reset', async () => {
// observable: once the rejection is gone, a day and month typed on their own are selected as they always were
const selectedAfterTyping = async (el: any) => { typeKeys(el, '1506'); const v = calOf(el).value; return v === null || v === undefined ? null : v.getMonth(); };
const rejected = async (o: Opts = {}) => {
const el = await build({ fmt: 'DD/MM/YY', start: 1990, end: 1999, ...o });
typeKeys(el, '150698'); await blur(el);
expect(calOf(el).value, 'precondition: rejected, nothing selected').to.be.null;
return el;
};
const control = await rejected(); // still rejected: typing day and month selects nothing
typeKeys(control, '1506'); expect(calOf(control).value).to.be.null;
const byFormat = await rejected();
byFormat.dateFormat = 'DD/MM/YYYY'; await settle(byFormat); await settle(byFormat);
expect(await selectedAfterTyping(byFormat), 'format change').to.equal(5);
const byValue = await rejected();
byValue.value = 'not-a-date'; await settle(byValue); await settle(byValue);
expect(await selectedAfterTyping(byValue), 'unusable value').to.equal(5);
const byType = await rejected();
byType.type = 'relative'; await settle(byType);
byType.type = 'absolute'; await settle(byType); await settle(byType);
expect(await selectedAfterTyping(byType), 'type switch').to.equal(5);
const byReset = await rejected();
calOf(byReset).dispatchEvent(new CustomEvent('nile-clear', { bubbles: true, composed: true }));
await settle(byReset);
expect(await selectedAfterTyping(byReset), 'Reset').to.equal(5);
});
it('Y5i. a rejected year ends once a year can be typed: widening the range, then typing 26, commits 2026 on Apply', async () => {
const el = await build({ fmt: 'DD/MM/YY', start: 1990, end: 1999 });
typeKeys(el, '150698'); await blur(el);
el.endYear = 2050; await settle(el);
caret(el, 6); typeKeys(el, '26');
await blur(el);
expect(calOf(el).value.getFullYear()).to.equal(2026);
const [ev] = await apply(el);
expect(ev.detail.value).to.match(/^2026-06-15T/);
});
it('Y5j. while the year is rejected nothing can be committed from the mask: Apply is disabled and Enter / Space / click do nothing', async () => {
const el = await build({ fmt: 'DD/MM/YY', start: 1990, end: 1999 });
typeKeys(el, '150698'); await blur(el);
const btn = calOf(el).shadowRoot.querySelector('.apply-button') as any;
await btn.updateComplete;
expect(btn.hasAttribute('disabled')).to.be.true;
const events: CustomEvent[] = [];
el.addEventListener('nile-change', ((e: CustomEvent) => events.push(e)) as EventListener);
btn.click();
btn.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, composed: true }));
btn.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, composed: true }));
inputOf(el).dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, composed: true, cancelable: true }));
await settle(el);
expect(events).to.have.length(0);
expect(el.value ?? null).to.be.null;
});
it('Y5g. a seeded year outside the range stays untouched by an edit to another segment (no rejection, no clamp)', async () => {
const el = await build({ fmt: 'DD/MM/YY', start: 2150, end: 2200, value: SEED('1999') });
caret(el, 0); typeKeys(el, '17');
await blur(el);
expect(yearText(el)).to.equal('99');
const [ev] = await apply(el);
expect(ev.detail.value).to.match(/^1999-06-17T/);
});
});
// ── Y2. clamping and leap day while overtyping the year ─────────────────────
describe('Y2. year overtype is normalised once, when the year is complete', () => {
const win = { start: 2020, end: 2035 };
const cases: Array<[string, string, string]> = [
['1999', '2020', 'below the range → start'],
['2040', '2035', 'above the range → end'],
['2025', '2025', 'in range'],
['2028', '2028', 'in range'],
['2030', '2030', 'in range'],
['2022', '2022', 'in range'],
];
for (const [typed, expected, why] of cases) {
it(`Y2a. seeded 2026, window 2020-2035, overtype ${typed} → ${expected} (${why})`, async () => {
const el = await build({ fmt: 'DD/MM/YYYY', ...win, value: SEED('2026') });
caret(el, 6); typeKeys(el, typed);
await blur(el);
expect(yearText(el)).to.equal(expected);
const [ev] = await apply(el);
expect(yearOf(ev.detail.value)).to.equal(Number(expected));
expect(calOf(el).value.getFullYear()).to.equal(Number(expected));
});
it(`Y2b. empty picker, window 2020-2035, typing ${typed} → ${expected}`, async () => {
const el = await build({ fmt: 'DD/MM/YYYY', ...win });
typeKeys(el, '1506' + typed);
await blur(el);
expect(yearText(el)).to.equal(expected);
});
it(`Y2c. paste ${typed} over the seeded year, window 2020-2035 → ${expected}`, async () => {
const el = await build({ fmt: 'DD/MM/YYYY', ...win, value: SEED('2026') });
caret(el, 6, 10); paste(el, typed);
await blur(el);
expect(yearText(el)).to.equal(expected);
});
}
for (const [typed, expected] of [['99', '35'], ['19', '20'], ['28', '28'], ['40', '35']]) {
it(`Y2d. YY seeded 2026, window 2020-2035, overtype ${typed} → ${expected}`, async () => {
const el = await build({ fmt: 'DD/MM/YY', ...win, value: SEED('2026') });
caret(el, 6); typeKeys(el, typed);
await blur(el);
expect(yearText(el)).to.equal(expected);
const [ev] = await apply(el);
expect(yearOf(ev.detail.value)).to.equal(2000 + Number(expected));
});
}
it('Y2e. partial overtype never rewrites the digits: 2026 → 1026 → 1926 → 1996 → clamp on the 4th', async () => {
const el = await build({ fmt: 'DD/MM/YYYY', ...win, value: SEED('2026') });
caret(el, 6);
const seen: string[] = [];
for (const ch of '1999') { press(el, ch); seen.push(yearText(el)); }
expect(seen).to.deep.equal(['1026', '1926', '1996', '2020']);
});
it('Y2f. wide window (1900-3000): overtyping 1999 and 2030 shows no clamped intermediate (1900 / 2036 flicker)', async () => {
const a = await build({ fmt: 'DD/MM/YYYY', value: SEED('2026') });
caret(a, 6);
const seenA: string[] = [];
for (const ch of '1999') { press(a, ch); seenA.push(yearText(a)); }
expect(seenA).to.deep.equal(['1026', '1926', '1996', '1999']);
const b = await build({ fmt: 'DD/MM/YYYY', value: SEED('2026') });
caret(b, 6);
const seenB: string[] = [];
for (const ch of '2030') { press(b, ch); seenB.push(yearText(b)); }
expect(seenB).to.deep.equal(['2026', '2026', '2036', '2030']);
});
it('Y2g. a half-typed year is clamped on blur and does not reach the calendar while typing', async () => {
const el = await build({ fmt: 'DD/MM/YYYY', ...win, value: SEED('2026') });
caret(el, 6); typeKeys(el, '19'); // 1926: abandoned mid-edit
expect(calOf(el).currentYear, 'calendar not driven by 1926').to.equal(2026);
await blur(el);
expect(yearText(el)).to.equal('2020');
expect(calOf(el).currentYear).to.equal(2020);
});
it('Y2h. editing another segment first normalises an abandoned year edit', async () => {
const el = await build({ fmt: 'DD/MM/YYYY', ...win, value: SEED('2026') });
caret(el, 6); typeKeys(el, '19'); // 1926
caret(el, 0); typeKeys(el, '17');
expect(yearText(el)).to.equal('2020');
expect(calOf(el).currentYear).to.equal(2020);
});
it('Y2i2. focusing, selecting and blurring an unchanged out-of-range seeded year does not clamp it or emit', async () => {
const el = await build({ fmt: 'DD/MM/YYYY', ...win, value: SEED('2010') });
const events: string[] = [];
for (const n of ['nile-change', 'nile-changed', 'nile-input']) el.addEventListener(n, () => events.push(n));
caret(el, 0);
await blur(el);
caret(el, 6, 10); // select the whole year, change nothing
await blur(el);
caret(el, 6); press(el, 'ArrowRight');
await blur(el);
expect(yearText(el)).to.equal('2010');
expect(calOf(el).value.getFullYear()).to.equal(2010);
expect(events).to.deep.equal([]);
});
it('Y2i3. completing an explicit year edit clamps it, whichever digits were typed', async () => {
const el = await build({ fmt: 'DD/MM/YYYY', ...win, value: SEED('2010') });
caret(el, 6); typeKeys(el, '2011'); // changed digits, still out of range
await blur(el);
expect(yearText(el)).to.equal('2020');
const same = await build({ fmt: 'DD/MM/YYYY', ...win, value: SEED('2010') });
caret(same, 6); typeKeys(same, '2010'); // the same digits, completed by typing
await blur(same);
expect(yearText(same)).to.equal('2020');
});
it('Y2i4. YY: an out-of-range seeded 1999 survives an edit to another segment', async () => {
const el = await build({ fmt: 'DD/MM/YY', start: 2000, end: 2050, value: SEED('1999') });
caret(el, 0); typeKeys(el, '17');
await blur(el);
expect(yearText(el)).to.equal('99');
const [ev] = await apply(el);
expect(ev.detail.value).to.match(/^1999-06-17T/);
});
it('Y2i. a seeded year outside the range survives an edit to another segment (seedMask)', async () => {
const el = await build({ fmt: 'DD/MM/YYYY', ...win, value: SEED('2010') });
expect(yearText(el)).to.equal('2010');
caret(el, 0); typeKeys(el, '17');
await blur(el);
expect(yearText(el)).to.equal('2010');
});
const leap: Array<[string, string, string]> = [
['2032', '29/02/2032', '2032-02-29'],
['2036', '29/02/2036', '2036-02-29'],
['2031', '28/02/2031', '2031-02-28'],
];
for (const [typed, shown, iso] of leap) {
it(`Y2j. seeded 2028-02-29, retype ${typed} → ${shown}`, async () => {
const el = await build({ fmt: 'DD/MM/YYYY', value: SEED('2028', '02-29') });
caret(el, 6); typeKeys(el, typed);
expect(textOf(el).slice(0, 10), 'trigger').to.equal(shown);
await blur(el);
const [ev] = await apply(el);
expect(ev.detail.value).to.match(new RegExp(`^${iso}T`));
});
}
it('Y2k. the day is not clamped against an intermediate year (2028 → 203x keeps 29 until the year is final)', async () => {
const el = await build({ fmt: 'DD/MM/YYYY', value: SEED('2028', '02-29') });
caret(el, 6);
const days: string[] = [];
for (const ch of '2032') { press(el, ch); days.push(textOf(el).slice(0, 2)); }
expect(days).to.deep.equal(['29', '29', '29', '29']);
});
it('Y2l. YY seeded 2028-02-29, retype 32 → 29/02/32', async () => {
const el = await build({ fmt: 'DD/MM/YY', value: SEED('2028', '02-29') });
caret(el, 6); typeKeys(el, '32');
expect(textOf(el).slice(0, 8)).to.equal('29/02/32');
});
it('Y2m. segmented time input is unchanged: 25 into hours still yields 02', async () => {
const el = await build({ fmt: 'DD/MM/YYYY', value: SEED('2026') });
caret(el, textOf(el).indexOf('-') + 2); typeKeys(el, '25');
expect(textOf(el)).to.match(/ - 02:20:30\.000/);
});
});
// ── Y3. calendar view synchronisation ───────────────────────────────────────
describe('Y3. year/month dropdowns navigate only; reopening shows the selected date', () => {
const matrix: Array<[string, Opts]> = [
['DD/MM/YY', { fmt: 'DD/MM/YY' }],
['DD/MM/YYYY', { fmt: 'DD/MM/YYYY' }],
['DD/MM/YY, hours+minutes hidden', { fmt: 'DD/MM/YY', hide: ['hours', 'minutes'] }],
['DD/MM/YYYY, hours+minutes hidden', { fmt: 'DD/MM/YYYY', hide: ['hours', 'minutes'] }],
];
for (const [label, base] of matrix) {
const seeded = () => build({ ...base, dropdowns: true, value: '2026-10-13T00:00:00.000[GMT+00:00 UTC]' });
it(`Y3a. [${label}] picking a year without a day leaves the selection, value and trigger unchanged`, async () => {
const el = await seeded();
const before = textOf(el);
const events: string[] = [];
for (const n of ['nile-change', 'nile-changed']) el.addEventListener(n, () => events.push(n));
await pickYear(el, 2020);
expect(calOf(el).currentYear).to.equal(2020);
expect(calOf(el).value.getFullYear(), 'selected date').to.equal(2026);
expect(yearOf(el.value as string)).to.equal(2026);
expect(textOf(el)).to.equal(before);
expect(events, 'navigation emits no change').to.deep.equal([]);
});
it(`Y3b. [${label}] picking a year then Apply commits the selected date, once, without a navigation-induced extra change`, async () => {
const el = await seeded();
await pickYear(el, 2020);
const events = await apply(el);
expect(events).to.have.length(1);
expect(events[0].detail.value).to.match(/^2026-10-13T/);
expect(new Date(events[0].detail.unixEpoch).getUTCFullYear()).to.equal(2026);
expect(yearOf(el.value as string)).to.equal(2026);
});
it(`Y3c. [${label}] picking a year then a day selects that day in the picked year`, async () => {
const el = await seeded();
await pickYear(el, 2020);
const cal = calOf(el);
(cal.shadowRoot.querySelectorAll('.day_date:not(.filler)')[3] as HTMLElement).click();
await cal.updateComplete;
expect(cal.value.getFullYear()).to.equal(2020);
const [ev] = await apply(el);
expect(yearOf(ev.detail.value)).to.equal(2020);
expect(new Date(ev.detail.unixEpoch).getUTCFullYear()).to.equal(2020);
});
it(`Y3d. [${label}] picking a month without a day leaves the selected date unchanged`, async () => {
const el = await seeded();
await pickMonth(el, 2);
expect(calOf(el).currentMonth).to.equal(2);
expect(calOf(el).value.getMonth(), 'selected month').to.equal(9);
const [ev] = await apply(el);
expect(ev.detail.value).to.match(/^2026-10-13T/);
});
it(`Y3e. [${label}] Apply → close → reopen shows the committed month and year`, async () => {
const el = await seeded();
await openPanel(el);
await pickYear(el, 2020);
await pickMonth(el, 2);
await apply(el);
await closePanel(el);
await openPanel(el);
expect(calOf(el).currentYear, 'year').to.equal(2026);
expect(calOf(el).currentMonth, 'month').to.equal(9);
expect(yearOf(el.value as string), 'committed date unchanged').to.equal(2026);
});
}
it('Y3f. browsing while the panel is open is not reset; dismissing without Apply keeps the view as it was left', async () => {
const el = await build({ dropdowns: true, value: '2026-10-13T00:00:00.000[GMT+00:00 UTC]' });
await openPanel(el);
await pickYear(el, 2020);
await new Promise(r => setTimeout(r, 150));
expect(calOf(el).currentYear, 'still browsing').to.equal(2020);
await closePanel(el);
await openPanel(el);
expect(calOf(el).currentYear, 'existing dismissal semantics: the browsed view is kept').to.equal(2020);
expect(calOf(el).value.getFullYear(), 'selection untouched').to.equal(2026);
expect(yearOf(el.value as string), 'committed value untouched').to.equal(2026);
});
it('Y3i. a pending (unapplied) selection survives dismissal and reopening, and Apply then commits it', async () => {
const el = await build({ dropdowns: true, value: '2026-10-13T00:00:00.000[GMT+00:00 UTC]' });
const events: CustomEvent[] = [];
el.addEventListener('nile-change', ((e: CustomEvent) => events.push(e)) as EventListener);
await openPanel(el);
await pickYear(el, 2020);
const cal = calOf(el);
(cal.shadowRoot.querySelectorAll('.day_date:not(.filler)')[3] as HTMLElement).click();
await cal.updateComplete;
const pending = cal.value.getTime();
expect(cal.value.getFullYear()).to.equal(2020);
await closePanel(el); // dismissed, not applied
expect(events, 'dismissal emits nothing').to.have.length(0);
expect(yearOf(el.value as string), 'committed value unchanged').to.equal(2026);
await openPanel(el);
expect(cal.value.getTime(), 'pending selection kept').to.equal(pending);
expect(cal.currentYear, 'and still visible').to.equal(2020);
const [ev] = await apply(el);
expect(yearOf(ev.detail.value)).to.equal(2020);
});
it('Y3j. after Apply the next reopen shows the committed month and year even when another year was browsed first', async () => {
const el = await build({ dropdowns: true, value: '2026-10-13T00:00:00.000[GMT+00:00 UTC]' });
await openPanel(el);
await pickYear(el, 2020);
await apply(el); // commits the selected 2026-10-13
await closePanel(el);
await openPanel(el);
expect(calOf(el).currentYear).to.equal(2026);
expect(calOf(el).currentMonth).to.equal(9);
// the sync is one-shot: browsing and dismissing again keeps that view
await pickYear(el, 2021);
await closePanel(el);
await openPanel(el);
expect(calOf(el).currentYear).to.equal(2021);
});
it('Y3g. an initially empty picker: navigation commits nothing, and reopening does not invent a selection', async () => {
const el = await build({ dropdowns: true });
const events: string[] = [];
el.addEventListener('nile-change', () => events.push('nile-change'));
await openPanel(el);
await pickYear(el, 2020);
expect(calOf(el).value).to.be.null;
expect(el.value ?? null).to.be.null;
expect(textOf(el)).to.equal('');
await closePanel(el);
await openPanel(el);
expect(calOf(el).value).to.be.null;
expect(calOf(el).currentYear, 'empty picker keeps its own view').to.equal(2020);
expect(events).to.deep.equal([]);
});
it('Y3h. an empty picker: select a day in 2020, Apply, reopen → shows 2020', async () => {
const el = await build({ dropdowns: true });
await openPanel(el);
await pickYear(el, 2020);
const cal = calOf(el);
(cal.shadowRoot.querySelectorAll('.day_date:not(.filler)')[3] as HTMLElement).click();
await cal.updateComplete;
const [ev] = await apply(el);
expect(yearOf(ev.detail.value)).to.equal(2020);
await closePanel(el);
await openPanel(el);
expect(calOf(el).currentYear).to.equal(2020);
});
});
});
describe('NileDateTimePicker values: a clock face that is not a time of day is unparseable, never rolled into the next day', () => {
const textOf = (el: any) => (el.shadowRoot.querySelector('nile-input').input as HTMLInputElement).value;
const make = async (value: string, format = 'DD/MM/YYYY'): Promise => {
const el: any = await fixture(html``);
await el.updateComplete; await el.calendarElement.updateComplete;
await new Promise(r => setTimeout(r, 120));
return el;
};
const applyOnce = async (el: any) => {
const events: any[] = [];
el.addEventListener('nile-change', (e: CustomEvent) => events.push(e.detail));
el.calendarElement.shadowRoot.querySelector('.apply-button').dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await new Promise(r => setTimeout(r, 80));
return events;
};
for (const value of [
'2026-03-01T25:61:00', '2026-03-01T10:61:00', '2026-03-01T10:30:61', '2026-03-01T24:00:01',
'2026-03-01T25:61:00.000[GMT+00:00 UTC]', '2026-03-01T10:61:00.000[GMT+00:00 UTC]',
]) {
it(`V1. ${value} leaves the picker empty: no trigger text, no selection, and Apply emits nothing`, async () => {
const el = await make(value);
expect(textOf(el), 'trigger text').to.equal('');
expect(el.calendarElement.value ?? null, 'calendar selection').to.be.null;
expect(await applyOnce(el), 'Apply').to.have.length(0);
});
}
for (const [value, text] of [
['2026-03-01T24:00:00', '02/03/2026 - 00:00:00.000'],
['2026-03-01T24:00:00.000', '02/03/2026 - 00:00:00.000'],
['2026-03-01T24:00:00.000[GMT+00:00 UTC]', '02/03/2026 - 00:00:00.000'],
]) {
it(`V2. ${value} (ISO end-of-day) still resolves to the next midnight`, async () => {
const el = await make(value);
expect(textOf(el).startsWith(text), textOf(el)).to.be.true;
});
}
for (const value of ['2026-03-01T24:00:00', '2026-03-01T24:00:00.000', '2026-03-01T24:00:00.000[GMT+00:00 UTC]']) {
it(`V2b. ${value}: the trigger and the Calendar panel agree on the next day, and Apply emits it`, async () => {
const el = await make(value);
expect(textOf(el).startsWith('02/03/2026 - 00:00:00.000'), `trigger ${textOf(el)}`).to.be.true;
const sel = el.calendarElement.value as Date;
expect([sel.getFullYear(), sel.getMonth() + 1, sel.getDate()], 'panel selection').to.deep.equal([2026, 3, 2]);
expect(el.calendarElement.shadowRoot.querySelector('.apply-button').hasAttribute('disabled'), 'Apply enabled').to.be.false;
const events = await applyOnce(el);
expect(events).to.have.length(1);
expect(events[0].value).to.include('2026-03-02T00:00:00');
});
}
it('V3. valid values, date-only seeding and the month-end clamp are unchanged', async () => {
expect(textOf(await make('2026-03-01T23:59:59.999')).startsWith('01/03/2026 - 23:59:59.999')).to.be.true;
expect(textOf(await make('2026-03-01')).startsWith('01/03/2026 - 00:00:00.000')).to.be.true;
expect(textOf(await make('2026-02-31T10:00:00')).startsWith('28/02/2026 - 10:00:00.000')).to.be.true;
expect(textOf(await make('2026-06-15T10:20:30.000[GMT+00:00 UTC]')).startsWith('15/06/2026 - 10:20:30.000')).to.be.true;
});
it('V4. the same rule is shared: isClockTime accepts exactly what the picker accepts', () => {
expect(isClockTime(24, 0, 0, 0)).to.be.true;
expect(isClockTime(25, 61, 0, 0)).to.be.false;
});
it('V5. correcting a rejected value recovers normally', async () => {
const el = await make('2026-03-01T25:61:00');
expect(textOf(el)).to.equal('');
el.value = '2026-03-01T10:30:00';
await el.updateComplete; await el.calendarElement.updateComplete;
await new Promise(r => setTimeout(r, 150));
expect(textOf(el).startsWith('01/03/2026 - 10:30:00.000')).to.be.true;
const [ev] = await applyOnce(el);
expect(ev.value).to.match(/^2026-03-01T10:30:00\.000\[GMT\+00:00 UTC\]$/);
});
});
describe('NileDateTimePicker — Escape returns focus to the trigger', () => {
const make = async (): Promise => {
const el = await fixture(
html``,
);
await el.updateComplete;
return el;
};
const triggerNative = (el: NileDateTimePicker): HTMLInputElement =>
el.shadowRoot!.querySelector('nile-input')!.shadowRoot!.querySelector('input') as HTMLInputElement;
const triggerHasFocus = (el: NileDateTimePicker) =>
el.shadowRoot!.querySelector('nile-input')!.shadowRoot!.activeElement === triggerNative(el);
const open = async (el: NileDateTimePicker) => {
el.open = true;
await el.updateComplete;
await (el as any).calendarElement.updateComplete;
await aTimeout(300);
};
// the popper is portaled, so the calendar is a direct child of as seen by document.activeElement
const focusInPanel = (el: NileDateTimePicker) => {
const cal = (el as any).calendarElement;
(cal.shadowRoot.querySelector('[aria-label="Next month"]') as HTMLElement).focus();
expect(document.activeElement, 'focus is inside the panel').to.equal(cal);
};
const pressEscape = (target: Element) =>
target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, composed: true, cancelable: true }));
it('EF1. Escape with focus inside the panel closes it and focuses the trigger', async () => {
const el = await make();
await open(el);
focusInPanel(el);
pressEscape(document.activeElement as Element);
await aTimeout(400);
expect(el.open).to.be.false;
expect(triggerHasFocus(el), 'trigger input has focus').to.be.true;
});
it('EF2. Escape with focus on the trigger closes the panel and keeps focus there', async () => {
const el = await make();
triggerNative(el).focus();
await open(el);
triggerNative(el).focus();
pressEscape(triggerNative(el));
await aTimeout(400);
expect(el.open).to.be.false;
expect(triggerHasFocus(el)).to.be.true;
});
it('EF3. Escape does not pull focus from an unrelated element outside the picker', async () => {
const wrap = await fixture(
html`
`,
);
const el = wrap.querySelector('nile-date-time-picker') as NileDateTimePicker;
const other = wrap.querySelector('#other') as HTMLButtonElement;
await el.updateComplete;
await open(el);
other.focus();
pressEscape(other);
await aTimeout(400);
expect(el.open).to.be.false;
expect(document.activeElement).to.equal(other);
});
it('EF4. Escape only dismisses: no event, no commit, the pending selection survives', async () => {
const el = await make();
const events: string[] = [];
el.addEventListener('nile-change', () => events.push('change'));
el.addEventListener('nile-clear', () => events.push('clear'));
await open(el);
const cal = (el as any).calendarElement;
(cal.shadowRoot.querySelectorAll('.day_date:not(.filler)')[5] as HTMLElement).click();
await cal.updateComplete;
focusInPanel(el);
pressEscape(document.activeElement as Element);
await aTimeout(400);
expect(el.open).to.be.false;
expect(events).to.deep.equal([]);
expect(el.value ?? null).to.equal(null);
expect(cal.value, 'pending selection kept').to.be.instanceOf(Date);
});
it('EF5. other keys inside the panel do not move focus or close it', async () => {
const el = await make();
await open(el);
focusInPanel(el);
(document.activeElement as Element).dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, composed: true }));
await aTimeout(200);
expect(el.open).to.be.true;
expect(document.activeElement).to.equal((el as any).calendarElement);
});
});
describe('NileDateTimePicker — accessible name of the role="dialog" container', () => {
const make = async (attrs = ''): Promise => {
const wrap = await fixture(
html`
`}>
`,
);
const el = wrap.querySelector('nile-date-time-picker') as NileDateTimePicker;
await el.updateComplete;
return el;
};
const open = async (el: NileDateTimePicker) => {
el.open = true;
await el.updateComplete;
await (el as any).calendarElement.updateComplete;
await aTimeout(300);
};
// the element that actually carries role="dialog" around this picker's calendar (the panel is portaled)
const dialogOf = (el: NileDateTimePicker): HTMLElement =>
(el as any).calendarElement.closest('[role="dialog"]') as HTMLElement;
it('DN1. the portaled role="dialog" element has the default name', async () => {
const el = await make();
await open(el);
const dialog = dialogOf(el);
expect(dialog, 'a role=dialog ancestor of the calendar exists').to.exist;
expect(dialog.getAttribute('role')).to.equal('dialog');
expect(dialog.getRootNode(), 'portaled out of the picker shadow root').to.equal(document);
expect(el.shadowRoot!.contains(dialog)).to.be.false;
expect(dialog.getAttribute('aria-label')).to.equal('Date and time picker');
});
it('DN2. aria-label on the picker names the dialog', async () => {
const el = await make('aria-label="Start"');
await open(el);
expect(dialogOf(el).getAttribute('aria-label')).to.equal('Start picker');
});
it('DN3. a visible label names the dialog', async () => {
const el = await make('label="Due date"');
await open(el);
expect(dialogOf(el).getAttribute('aria-label')).to.equal('Due date picker');
});
it('DN4. changing the label while open renames the dialog', async () => {
const el = await make();
await open(el);
el.label = 'End';
await el.updateComplete;
await aTimeout(50);
expect(dialogOf(el).getAttribute('aria-label')).to.equal('End picker');
});
it('DN5. the name survives close and reopen', async () => {
const el = await make();
await open(el);
el.open = false;
await el.updateComplete;
await aTimeout(400);
await open(el);
expect(dialogOf(el).getAttribute('aria-label')).to.equal('Date and time picker');
});
it('DN6. the shared nile-floating-panel is unchanged: its own dialog stays unnamed', async () => {
const wrap = await fixture(
html`
content
`,
);
const fp = wrap.querySelector('nile-floating-panel') as any;
await fp.updateComplete;
fp.show();
await aTimeout(300);
const dialogs = [...document.querySelectorAll('[role="dialog"]')].filter(d => d.textContent!.includes('content'));
expect(dialogs.length).to.be.greaterThan(0);
dialogs.forEach(d => expect(d.hasAttribute('aria-label'), 'no aria-label added globally').to.be.false);
fp.hide();
});
});
describe('Y18. a `value` string is read only in the documented formats, never through engine-dependent Date parsing', () => {
const make = async (value: string, timezone = 'UTC'): Promise => {
const el: any = await fixture(html``);
await settle2(el);
return el;
};
const textOf = (el: any) => (el.shadowRoot.querySelector('nile-input').input as HTMLInputElement).value;
const p = (n: number, l = 2) => String(n).padStart(l, '0');
const SUPPORTED: Array<{ label: string; value: string; tz: string; text: string }> = [
{ label: 'date-only', value: '2026-03-05', tz: 'UTC', text: '05/03/2026 - 00:00:00.000' },
{ label: 'date-only, impossible day clamps to month end', value: '2026-02-31', tz: 'UTC', text: '28/02/2026 - 00:00:00.000' },
{ label: 'date-only, year 0-99 keeps its literal year', value: '0050-03-05', tz: 'UTC', text: '05/03/0050 - 00:00:00.000' },
{ label: 'offset-less, no seconds', value: '2026-03-05T10:20', tz: 'UTC', text: '05/03/2026 - 10:20:00.000' },
{ label: 'offset-less, seconds and milliseconds', value: '2026-03-05T10:20:30.250', tz: 'UTC', text: '05/03/2026 - 10:20:30.250' },
{ label: 'offset-less ignores the component zone', value: '2026-03-05T10:20:30.250', tz: 'Asia/Kolkata', text: '05/03/2026 - 10:20:30.250' },
{ label: 'UTC instant read in Asia/Kolkata', value: '2026-03-05T10:00:00Z', tz: 'Asia/Kolkata', text: '05/03/2026 - 15:30:00.000' },
{ label: 'UTC instant read in America/New_York', value: '2026-03-05T10:00:00Z', tz: 'America/New_York', text: '05/03/2026 - 05:00:00.000' },
{ label: 'offset instant read in UTC', value: '2026-03-05T10:00:00+05:30', tz: 'UTC', text: '05/03/2026 - 04:30:00.000' },
{ label: 'bracketed value keeps the digits as written', value: '2026-03-05T10:20:30.250[GMT+05:30 Asia/Kolkata]', tz: 'UTC', text: '05/03/2026 - 10:20:30.250' },
];
for (const c of SUPPORTED) {
it(`Y18 supported — ${c.label}: ${c.value} in ${c.tz}`, async () => {
const el = await make(c.value, c.tz);
expect(textOf(el)).to.equal(c.text);
}).timeout(60000);
}
it('Y18 a bracketed value adopts its own zone; an unknown bracket zone falls back to UTC with the digits kept', async () => {
const known = await make('2026-03-05T10:20:30.250[GMT+05:30 Asia/Kolkata]', 'UTC');
expect(known.timezone).to.equal('Asia/Kolkata');
const unknown = await make('2026-03-05T10:20:30.250[GMT+05:30 Mars/Phobos]', 'Asia/Kolkata');
expect(unknown.timezone).to.equal('UTC');
expect(textOf(unknown)).to.equal('05/03/2026 - 10:20:30.250');
}).timeout(60000);
it('Y18 an instant with an unusable zone reads in the browser zone, as before', async () => {
const host = new Date(Date.UTC(2026, 2, 5, 10, 0, 0, 0));
const want = `${p(host.getDate())}/${p(host.getMonth() + 1)}/${host.getFullYear()}` +
` - ${p(host.getHours())}:${p(host.getMinutes())}:${p(host.getSeconds())}.${p(host.getMilliseconds(), 3)}`;
const el = await make('2026-03-05T10:00:00Z', 'Mars/Phobos');
expect(textOf(el)).to.equal(want);
}).timeout(60000);
// strings a lenient engine (Date.parse) reads, in a browser-specific way; the documented formats are the only contract
const ENGINE_ONLY = [
'March 5 2026', '5 Mar 2026 10:00', '2026/03/05', '2026-3-5', '03/05/2026',
'2026-03-05 10:00', 'Thu, 05 Mar 2026 10:00:00 GMT',
];
const TRAILING_GARBAGE = [
'2026-03-05garbage',
'2026-03-05T10:20 garbage',
'2026-03-05T10:20:30.250xyz',
'2026-03-05T10:00:00Zjunk',
'2026-03-05T10:00:00+05:30junk',
'2026-03-05T10:20:30.250[GMT+05:30 Asia/Kolkata]junk',
];
it('Y18 premise: this browser does read the engine-only strings, so the rejections below are the picker\'s own', () => {
expect(isNaN(new Date('March 5 2026').getTime())).to.equal(false);
expect(isNaN(new Date('2026/03/05').getTime())).to.equal(false);
});
for (const bad of [...ENGINE_ONLY, ...TRAILING_GARBAGE]) {
it(`Y18 unsupported ${JSON.stringify(bad)}: the trigger is emptied, the value and zone are left alone`, async () => {
const el = await make('2026-06-15T10:20:30.000[GMT+05:30 Asia/Kolkata]', 'Asia/Kolkata');
expect(textOf(el), 'precondition').to.contain('15/06/2026');
el.value = bad;
await settle2(el);
expect(textOf(el), 'trigger text').to.equal('');
expect(el.value, 'the consumer\'s value is never rewritten').to.equal(bad);
expect(el.timezone).to.equal('Asia/Kolkata');
}).timeout(60000);
}
describe('the text of a calendar `nile-change` payload', () => {
const emit = async (el: any, value: string) => {
el.calendarElement.dispatchEvent(new CustomEvent('nile-change', {
detail: { value, timezone: 'UTC' }, bubbles: true, composed: true,
}));
await settle2(el);
};
const WANT = '15/06/2026 - 14:30:00.000';
for (const ok of ['2026-06-15T14:30:00.000[GMT+00:00 UTC]', '2026-06-15T14:30:00.000']) {
it(`Y18 payload accepted: ${ok}`, async () => {
const el = await make('2026-01-02T03:04:05.000');
await emit(el, ok);
expect(textOf(el)).to.equal(WANT);
}).timeout(60000);
}
// not a payload shape, but an absolute payload is also adopted as `value`, which re-seeds the trigger from it:
// these are documented `value` formats, so the text comes from that read (an instant in the component zone, a day at midnight)
for (const [adopted, text] of [
['2026-06-15T14:30:00.000Z', WANT],
['2026-06-15', '15/06/2026 - 00:00:00.000'],
]) {
it(`Y18 payload adopted as \`value\`: ${adopted}`, async () => {
const el = await make('2026-01-02T03:04:05.000');
await emit(el, adopted);
expect(el.value).to.equal(adopted);
expect(textOf(el)).to.equal(text);
}).timeout(60000);
}
for (const bad of [
'2026-06-15T14:30:00.000 junk',
'2026-06-15T14:30:00.000[GMT+00:00 UTC]junk',
'June 15 2026 14:30',
]) {
it(`Y18 payload rejected: ${JSON.stringify(bad)}`, async () => {
const el = await make('2026-01-02T03:04:05.000');
expect(textOf(el), 'precondition').to.contain('02/01/2026');
await emit(el, bad);
expect(textOf(el)).to.equal('');
}).timeout(60000);
}
});
});
describe('NileDateTimePicker — keyboard open, day navigation and Tab order', () => {
const make = async (value?: string): Promise => {
const el = await fixture(
html``,
);
await el.updateComplete;
return el;
};
const triggerNative = (el: NileDateTimePicker): HTMLInputElement =>
el.shadowRoot!.querySelector('nile-input')!.shadowRoot!.querySelector('input') as HTMLInputElement;
const cal = (el: NileDateTimePicker) => (el as any).calendarElement;
const key = (target: Element, k: string, init: KeyboardEventInit = {}) => {
const ev = new KeyboardEvent('keydown', { key: k, bubbles: true, composed: true, cancelable: true, ...init });
target.dispatchEvent(ev);
return ev;
};
const settle = async (el: NileDateTimePicker) => {
await el.updateComplete;
await cal(el).updateComplete;
await aTimeout(300);
};
const deepActive = (): Element | null => {
let a: Element | null = document.activeElement;
while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement;
return a;
};
const dayCells = (el: NileDateTimePicker) =>
[...cal(el).shadowRoot.querySelectorAll('.day_date[data-tp-date]')] as HTMLElement[];
const stops = (el: NileDateTimePicker) => dayCells(el).filter(c => c.getAttribute('tabindex') === '0');
const dayOf = (c: Element) => Number(c.getAttribute('data-tp-date')!.split('-')[2]);
describe('opening from the field', () => {
it('K1. ArrowDown opens the panel and moves focus to the day that owns the tab stop', async () => {
const el = await make('2026-06-15T10:00:00.000[GMT+00:00 UTC]');
triggerNative(el).focus();
const ev = key(triggerNative(el), 'ArrowDown');
expect(ev.defaultPrevented).to.be.true;
await settle(el);
expect(el.open).to.be.true;
const active = deepActive() as HTMLElement;
expect(active.classList.contains('day_date')).to.be.true;
expect(dayOf(active), 'the selected day').to.equal(15);
}).timeout(60000);
it('K2. Alt+ArrowDown opens it too', async () => {
const el = await make();
triggerNative(el).focus();
key(triggerNative(el), 'ArrowDown', { altKey: true });
await settle(el);
expect(el.open).to.be.true;
}).timeout(60000);
it('K3. Enter, Space and typed characters do not open it', async () => {
const el = await make();
triggerNative(el).focus();
for (const k of ['Enter', ' ', '1']) key(triggerNative(el), k);
await settle(el);
expect(el.open).to.be.false;
}).timeout(60000);
it('K4. Enter on the field is not swallowed (a form can still submit)', async () => {
const el = await make();
triggerNative(el).focus();
expect(key(triggerNative(el), 'Enter').defaultPrevented).to.be.false;
}).timeout(60000);
it('K5. ArrowDown does nothing when disabled or already open', async () => {
const el = await make();
el.disabled = true;
await el.updateComplete;
expect(key(triggerNative(el), 'ArrowDown').defaultPrevented).to.be.false;
expect(el.open).to.be.false;
el.disabled = false;
el.open = true;
await settle(el);
expect(key(triggerNative(el), 'ArrowDown').defaultPrevented, 'already open: left to the browser').to.be.false;
}).timeout(60000);
it('K6. ArrowDown opens in Relative mode and focuses a control inside the panel', async () => {
const el = await fixture(html``);
await el.updateComplete;
triggerNative(el).focus();
key(triggerNative(el), 'ArrowDown');
await settle(el);
expect(el.open).to.be.true;
expect(deepActive()).to.not.equal(triggerNative(el));
expect(document.activeElement, 'focus is inside the panel').to.equal(cal(el));
}).timeout(60000);
it('K7. opening by keyboard emits no event and commits nothing', async () => {
const el = await make();
const seen: string[] = [];
for (const n of ['nile-change', 'nile-clear']) el.addEventListener(n, () => seen.push(n));
triggerNative(el).focus();
key(triggerNative(el), 'ArrowDown');
await settle(el);
expect(seen).to.deep.equal([]);
expect(el.value || '').to.equal('');
}).timeout(60000);
});
describe('day grid', () => {
const openAt = async (value = '2026-06-15T10:00:00.000[GMT+00:00 UTC]') => {
const el = await make(value);
el.open = true;
await settle(el);
// the expanded grid shows the whole month
(cal(el).shadowRoot.querySelector('.tp-chevron') as HTMLElement).click();
await settle(el);
return el;
};
const press = async (el: NileDateTimePicker, k: string, init: KeyboardEventInit = {}) => {
key(deepActive() as Element, k, init);
await settle(el);
};
const focusDay = (el: NileDateTimePicker, d: number) =>
dayCells(el).find(c => dayOf(c) === d)!.focus();
it('K8. exactly one day is a tab stop (roving tabindex), on the selection', async () => {
const el = await openAt();
expect(stops(el).length).to.equal(1);
expect(dayOf(stops(el)[0])).to.equal(15);
}).timeout(60000);
it('K9. with no selection the tab stop is today when it is in view, else the first day', async () => {
const el = await make();
el.open = true;
await settle(el);
(cal(el).shadowRoot.querySelector('.tp-chevron') as HTMLElement).click();
await settle(el);
expect(stops(el).length).to.equal(1);
expect(dayOf(stops(el)[0])).to.equal(new Date().getDate());
cal(el).currentMonth = (new Date().getMonth() + 1) % 12;
cal(el).currentYear = new Date().getFullYear() + (new Date().getMonth() === 11 ? 1 : 0);
await settle(el);
expect(dayOf(stops(el)[0]), 'first day of the other month').to.equal(1);
}).timeout(60000);
it('K10. Arrow keys move by a day and a week and move the tab stop with focus', async () => {
const el = await openAt();
focusDay(el, 15);
await press(el, 'ArrowRight');
expect(dayOf(deepActive()!)).to.equal(16);
await press(el, 'ArrowLeft');
await press(el, 'ArrowLeft');
expect(dayOf(deepActive()!)).to.equal(14);
await press(el, 'ArrowDown');
expect(dayOf(deepActive()!)).to.equal(21);
await press(el, 'ArrowUp');
expect(dayOf(deepActive()!)).to.equal(14);
expect(stops(el).length).to.equal(1);
expect(dayOf(stops(el)[0]), 'roving tab stop follows focus').to.equal(14);
}).timeout(60000);
it('K11. Home and End go to the first and last day of the week', async () => {
const el = await openAt(); // 15 June 2026 is a Monday
focusDay(el, 15);
await press(el, 'Home');
expect(dayOf(deepActive()!)).to.equal(14);
await press(el, 'End');
expect(dayOf(deepActive()!)).to.equal(20);
}).timeout(60000);
it('K12. ArrowRight past the month end shows and focuses the next month', async () => {
const el = await openAt('2026-06-30T10:00:00.000[GMT+00:00 UTC]');
focusDay(el, 30);
await press(el, 'ArrowRight');
expect(cal(el).currentMonth).to.equal(6);
expect(dayOf(deepActive()!)).to.equal(1);
expect((deepActive() as HTMLElement).getAttribute('data-tp-date')).to.equal('2026-6-1');
}).timeout(60000);
it('K13. PageDown / PageUp move a month (clamped to its length), Shift moves a year', async () => {
const el = await openAt('2026-01-31T10:00:00.000[GMT+00:00 UTC]');
focusDay(el, 31);
await press(el, 'PageDown');
expect(cal(el).currentMonth).to.equal(1);
expect(dayOf(deepActive()!), '31 Jan -> 28 Feb').to.equal(28);
await press(el, 'PageUp');
expect(cal(el).currentMonth).to.equal(0);
await press(el, 'PageDown', { shiftKey: true });
expect(cal(el).currentYear).to.equal(2027);
}).timeout(60000);
it('K14. Enter and Space still select the focused day, and only Apply commits', async () => {
const el = await openAt();
const seen: any[] = [];
el.addEventListener('nile-change', ((e: CustomEvent) => seen.push(e.detail)) as EventListener);
focusDay(el, 15);
await press(el, 'ArrowRight');
await press(el, 'Enter');
expect(cal(el).value.getDate()).to.equal(16);
await press(el, ' ');
expect(cal(el).value.getDate()).to.equal(16);
expect(seen, 'selecting a day commits nothing').to.deep.equal([]);
}).timeout(60000);
it('K15. navigation keys are ignored while disabled, and Ctrl/Alt/Meta combinations are left alone', async () => {
const el = await openAt();
focusDay(el, 15);
expect(key(deepActive()!, 'ArrowRight', { ctrlKey: true }).defaultPrevented).to.be.false;
expect(key(deepActive()!, 'ArrowRight', { altKey: true }).defaultPrevented).to.be.false;
expect(key(deepActive()!, 'ArrowRight').defaultPrevented).to.be.true;
}).timeout(60000);
it('K16. in the collapsed three-week window an arrow past the visible weeks expands the calendar', async () => {
const el = await make('2026-06-15T10:00:00.000[GMT+00:00 UTC]');
el.open = true;
await settle(el);
expect(cal(el).expanded).to.be.false;
const first = dayCells(el)[0];
first.focus();
key(first, 'ArrowUp');
key(deepActive()!, 'ArrowUp');
await settle(el);
key(deepActive()!, 'ArrowUp');
await settle(el);
expect(deepActive()!.classList.contains('day_date')).to.be.true;
}).timeout(60000);
it('K17. the default nile-calendar is unchanged: every allowed day is still a tab stop, no arrow handling', async () => {
const c = await fixture(html``);
await c.updateComplete;
const cells = [...c.shadowRoot.querySelectorAll('.day_date')].filter((x: Element) => x.getAttribute('tabindex') === '0');
expect(cells.length).to.be.greaterThan(20);
const ev = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true });
cells[0].dispatchEvent(ev);
expect(ev.defaultPrevented).to.be.false;
}).timeout(60000);
});
describe('Tab order and focus restoration', () => {
const openByKey = async (el: NileDateTimePicker) => {
triggerNative(el).focus();
key(triggerNative(el), 'ArrowDown');
await settle(el);
};
it('K18. Tab from the field while open enters the panel (not the next page element)', async () => {
const el = await make('2026-06-15T10:00:00.000[GMT+00:00 UTC]');
el.open = true;
await settle(el);
triggerNative(el).focus();
const ev = key(triggerNative(el), 'Tab');
expect(ev.defaultPrevented, 'the browser must not skip to the end of ').to.be.true;
expect(document.activeElement).to.equal(cal(el));
expect(deepActive(), 'the first control, so Shift+Tab from it returns to the field').to.equal((el as any).panelTabbables()[0]);
}).timeout(60000);
it('K19. Shift+Tab from the field is left to the browser', async () => {
const el = await make();
el.open = true;
await settle(el);
triggerNative(el).focus();
expect(key(triggerNative(el), 'Tab', { shiftKey: true }).defaultPrevented).to.be.false;
expect(el.open).to.be.true;
}).timeout(60000);
it('K20. Shift+Tab from the first control returns to the field', async () => {
const el = await make();
await openByKey(el);
const tabbables = (el as any).panelTabbables() as HTMLElement[];
tabbables[0].focus();
const ev = key(deepActive()!, 'Tab', { shiftKey: true });
expect(ev.defaultPrevented).to.be.true;
expect(deepActive()).to.equal(triggerNative(el));
}).timeout(60000);
it('K21. Tab from the last control hands focus to the field (so the browser moves on) and closes the panel', async () => {
const el = await make('2026-06-15T10:00:00.000[GMT+00:00 UTC]');
await openByKey(el);
const tabbables = (el as any).panelTabbables() as HTMLElement[];
const last = tabbables[tabbables.length - 1];
last.focus();
const ev = key(deepActive()!, 'Tab');
expect(ev.defaultPrevented, 'default Tab then continues from the field').to.be.false;
expect(deepActive()).to.equal(triggerNative(el));
await aTimeout(400);
expect(el.open).to.be.false;
}).timeout(60000);
it('K22. Tab between controls in the middle of the panel is untouched', async () => {
const el = await make();
await openByKey(el);
const tabbables = (el as any).panelTabbables() as HTMLElement[];
expect(tabbables.length).to.be.greaterThan(2);
tabbables[1].focus();
expect(key(deepActive()!, 'Tab').defaultPrevented).to.be.false;
expect(el.open).to.be.true;
}).timeout(60000);
it('K23. Escape from a day returns focus to the field; Apply returns it too', async () => {
const el = await make('2026-06-15T10:00:00.000[GMT+00:00 UTC]');
await openByKey(el);
key(deepActive()!, 'Escape');
await aTimeout(400);
expect(el.open).to.be.false;
expect(deepActive()).to.equal(triggerNative(el));
await openByKey(el);
(cal(el).shadowRoot.querySelector('.apply-button') as HTMLElement).click();
await aTimeout(400);
expect(el.open).to.be.false;
expect(deepActive()).to.equal(triggerNative(el));
}).timeout(60000);
it('K24. panelTabbables skips disabled and non-visible controls', async () => {
const el = await make();
await openByKey(el);
const tabbables = (el as any).panelTabbables() as HTMLElement[];
for (const t of tabbables) {
expect(t.matches(':disabled'), 'enabled').to.be.false;
expect(t.getClientRects().length, 'rendered').to.be.greaterThan(0);
}
}).timeout(60000);
});
});