import { aTimeout, expect, fixture, html } from '@open-wc/testing';
import { utcFromFields, formatYear4 } from '../nile-calendar/timezones';
import { instantFromBracketedValue, wallClockFromOffsetLess } from '../nile-calendar/nile-calendar';
import './nile-date-time-picker';
import '../nile-calendar';
import '../nile-input';
import type { NileDateTimePicker } from './nile-date-time-picker';
import {
makePicker,
settle,
settle2,
applyButton,
apply,
roundTrip,
} from './nile-date-time-picker.test-helpers';
/*
* Regressions, type sync / paste, and the Y-series (Y, Y9-Y21).
* 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.
*/
/**
* ── R. REGRESSIONS (code-review findings #1, #2a, #3, #6) ───────────────────
*/
describe('NileDateTimePicker regressions', () => {
const VALUE = '2026-03-15T10:30:00';
const inputOf = (el: any) => el.shadowRoot.querySelector('nile-input') as any;
const textOf = (el: any) => (inputOf(el).input as HTMLInputElement).value;
const openPanel = async (el: any) => {
el.open = true;
await settle2(el);
await aTimeout(50);
return el.calendarElement as any;
};
const days = (cal: any): HTMLElement[] =>
[...cal.shadowRoot.querySelectorAll('.day_date:not(.filler)')] as HTMLElement[];
const pad = (n: number) => String(n).padStart(2, '0');
it('R1. setting value to null clears the trigger text (#1)', async () => {
const el = await fixture(
html``
);
await settle2(el);
expect(textOf(el), 'precondition').to.contain('2026-03-15');
el.value = null;
await settle2(el);
expect(textOf(el)).to.equal('');
// a later value still seeds normally
el.value = '2026-04-01T08:00:00';
await settle2(el);
expect(textOf(el)).to.contain('2026-04-01');
});
it('R2. the typing mask still works after the picker is moved in the DOM (#2a)', async () => {
const el = await fixture(
html``
);
await settle2(el);
const host = el.parentNode!;
el.remove();
host.appendChild(el);
await settle2(el);
await inputOf(el).updateComplete;
for (const key of '23') {
inputOf(el).dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true })
);
}
expect(textOf(el)).to.equal('23/');
});
it('R3a. a relative range is rendered in the zone it is labelled with, not the host zone (#3)', async () => {
const el = await fixture(
html``
);
await settle2(el);
const cal = el.calendarElement as any;
cal.dispatchEvent(new CustomEvent('nile-change', {
bubbles: true, composed: true,
detail: {
startDate: new Date(Date.UTC(2026, 5, 15, 3, 0, 0)),
endDate: new Date(Date.UTC(2026, 5, 15, 5, 0, 0)),
timezone: 'Asia/Kolkata',
},
}));
await settle2(el);
// 03:00Z / 05:00Z are 08:30 / 10:30 in Kolkata, whatever zone the browser runs in
expect(textOf(el)).to.contain('2026-06-15 - 08:30');
expect(textOf(el)).to.contain('2026-06-15 - 10:30');
expect(textOf(el)).to.match(/ - Asia\/Kolkata$/);
});
it('R3b. a relative Apply shows the UTC wall-clock it is labelled with (#3)', async () => {
const el = await fixture(
html``
);
await settle2(el);
const cal = await openPanel(el);
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
cal.handleTimeValueClick('hours', 2, new Event('click'));
await settle2(el);
applyButton(cal).click();
await settle2(el);
const end = new Date(detail.endDate);
expect(detail.timezone).to.equal('UTC');
expect(textOf(el)).to.contain(`${pad(end.getUTCHours())}:${pad(end.getUTCMinutes())}`);
expect(textOf(el)).to.match(/ - UTC$/);
expect(el.value, 'a relative apply does not write value').to.be.null;
});
it('R3c. with the timezone label hidden, a relative range keeps the host wall-clock (unchanged)', async () => {
const el = await fixture(
html``
);
await settle2(el);
const start = new Date(Date.UTC(2026, 5, 15, 3, 0, 0));
const cal = el.calendarElement as any;
cal.dispatchEvent(new CustomEvent('nile-change', {
bubbles: true, composed: true,
detail: { startDate: start, endDate: new Date(Date.UTC(2026, 5, 15, 5, 0, 0)), timezone: 'Asia/Kolkata' },
}));
await settle2(el);
expect(textOf(el)).to.contain(`${pad(start.getHours())}:${pad(start.getMinutes())}`);
expect(textOf(el)).to.not.contain('Asia/Kolkata');
});
it('R6. Apply updates value, and a later timezone change keeps the applied pick (#6)', async () => {
const el = await fixture(
html``
);
await settle2(el);
const cal = await openPanel(el);
days(cal)[20].click();
await settle2(el);
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
applyButton(cal).click();
await settle2(el);
const pickedDay = detail.value.slice(0, 10);
expect(pickedDay, 'precondition: a different day was applied').to.not.equal('2026-03-15');
expect(el.value).to.equal(detail.value);
expect(textOf(el)).to.contain(pickedDay);
el.timezone = 'Asia/Kolkata';
await settle2(el);
expect(textOf(el)).to.contain(pickedDay);
expect(textOf(el)).to.not.contain('2026-03-15');
});
});
// ── M. review fixes: type sync, Clear keeps the configured zone, non-keydown mask input ──
describe('NileDateTimePicker — type sync, timezone on Clear, paste / beforeinput', () => {
const dtpFixture = () =>
fixture(html``);
const innerCalendar = (el: NileDateTimePicker): any => (el as any).calendarElement;
const triggerInput = (el: NileDateTimePicker): any => el.shadowRoot!.querySelector('nile-input');
const settle = async (el: NileDateTimePicker) => {
await el.updateComplete;
const cal = innerCalendar(el);
if (cal) await cal.updateComplete;
return cal;
};
const settle2 = async (el: NileDateTimePicker) => { await settle(el); return settle(el); };
const dayCells = (cal: any): NodeListOf =>
cal.shadowRoot!.querySelectorAll('.day_date:not(.filler)');
const clickDay = (cal: any, i = 5) => (dayCells(cal)[i] as HTMLElement).click();
const resetBtn = (cal: any) => cal.shadowRoot!.querySelector('.clear-button') as HTMLElement;
const switchTab = async (el: NileDateTimePicker, value: string) => {
const cal = await settle(el);
cal.shadowRoot!.querySelector(`nile-tab[panel="${value}"]`)!.dispatchEvent(
new CustomEvent('nile-tab-show', { detail: { value }, bubbles: true, composed: true })
);
await cal.updateComplete;
await el.updateComplete;
};
const maskFixture = () =>
fixture(html``);
const nativeOf = (el: any) => (triggerInput(el) as any).input as HTMLInputElement;
const ready = async (el: any) => { await el.updateComplete; await triggerInput(el).updateComplete; };
const paste = (el: any, text: string) => {
const dt = new DataTransfer();
dt.setData('text', text);
const ev = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, composed: true, cancelable: true });
triggerInput(el).dispatchEvent(ev);
return ev;
};
it('M1. switching the calendar to Relative updates the picker type (and attribute)', async () => {
const el = await dtpFixture();
expect(el.type).to.equal('absolute');
await switchTab(el, 'relative');
expect(el.type).to.equal('relative');
expect(el.getAttribute('type')).to.equal('relative');
});
it('M2. switching back to Absolute restores type', async () => {
const el = await dtpFixture();
await switchTab(el, 'relative');
await switchTab(el, 'absolute');
expect(el.type).to.equal('absolute');
});
it('M3. nile-type-change is still forwarded after the type is synced', async () => {
const el = await dtpFixture();
let detail: any = null;
el.addEventListener('nile-type-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
await switchTab(el, 'relative');
expect(detail?.value).to.equal('relative');
});
it('M4. Reset in the calendar keeps the consumer-configured timezone', async () => {
const el = await fixture(
html``
);
const cal = await settle2(el);
clickDay(cal, 5);
await cal.updateComplete;
resetBtn(cal).click();
await settle2(el);
expect(el.timezone).to.equal('America/New_York');
expect(cal.timezone).to.equal('America/New_York');
});
it('M5. handleClear keeps the configured timezone and empties value', async () => {
const el = await fixture(
html``
);
await settle2(el);
(el as any).handleClear();
await settle2(el);
expect(el.timezone).to.equal('Asia/Kolkata');
expect(el.value == null).to.be.true;
});
it('M6. with no configured timezone, Clear still lands on UTC', async () => {
const el = await dtpFixture();
await settle2(el);
(el as any).handleClear();
await settle2(el);
expect(el.timezone).to.equal('UTC');
});
it('M8. a zone picked in the calendar dropdown is undone by Reset: wrapper and calendar both land on the configured zone', async () => {
const el = await fixture(
html``
);
const cal = await settle2(el);
cal.handleTimezoneChange(new CustomEvent('nile-change', { detail: { value: 'Asia/Tokyo' } }));
await cal.updateComplete;
resetBtn(cal).click();
await settle2(el);
expect(cal.timezone).to.equal('Europe/London');
expect(el.timezone).to.equal('Europe/London');
});
it('M9. a dynamic consumer timezone change is what Reset restores, in the wrapper and the calendar', async () => {
const el = await fixture(
html``
);
const cal = await settle2(el);
el.timezone = 'Asia/Kolkata';
await settle2(el);
cal.handleTimezoneChange(new CustomEvent('nile-change', { detail: { value: 'UTC' } }));
await cal.updateComplete;
resetBtn(cal).click();
await settle2(el);
expect(cal.timezone).to.equal('Asia/Kolkata');
expect(el.timezone).to.equal('Asia/Kolkata');
});
it('M10. with the timezone field hidden, Reset after Set to now restores the configured zone; Set to now still adopts the host zone', async () => {
const el = await fixture(
html``
);
const cal = await settle2(el);
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
(cal.shadowRoot!.querySelector('.tp-set-to-now-button') as HTMLElement).click();
await cal.updateComplete;
expect(cal.timezone).to.equal(host);
resetBtn(cal).click();
await settle2(el);
expect(cal.timezone).to.equal('Europe/London');
expect(el.timezone).to.equal('Europe/London');
});
it('M7. a date-only range seeded into the calendar keeps its days, and Reset still keeps the configured zone (default stays UTC)', async () => {
const el = await fixture(
html``
);
const cal = await settle2(el);
cal.range = true;
cal.valueAttribute = '{"startDate":"2025-12-31","endDate":"2026-01-02"}';
await settle2(el);
const ymd = (d: Date) => [d.getFullYear(), d.getMonth() + 1, d.getDate()];
expect(ymd(cal.startDate)).to.deep.equal([2025, 12, 31]);
expect(ymd(cal.endDate)).to.deep.equal([2026, 1, 2]);
resetBtn(cal).click();
await settle2(el);
expect(cal.startDate).to.equal(null);
expect(el.timezone).to.equal('Asia/Kolkata');
expect(cal.timezone).to.equal('Asia/Kolkata');
const plain = await dtpFixture();
expect(plain.timezone, 'unconfigured default').to.equal('UTC');
});
it('M6b. a timezone set by the consumer AFTER first render is what Clear restores', async () => {
const el = await dtpFixture();
await settle2(el);
el.timezone = 'Asia/Tokyo';
await settle2(el);
(el as any).handleClear();
await settle2(el);
expect(el.timezone).to.equal('Asia/Tokyo');
expect(innerCalendar(el).timezone).to.equal('Asia/Tokyo');
});
it('M6c. calendar Reset after a later consumer zone change keeps that zone (picker + calendar agree)', async () => {
const el = await dtpFixture();
await settle2(el);
el.timezone = 'Europe/Paris';
await settle2(el);
const cal = innerCalendar(el);
clickDay(cal, 5);
await cal.updateComplete;
resetBtn(cal).click();
await settle2(el);
expect(el.timezone).to.equal('Europe/Paris');
expect(cal.timezone).to.equal('Europe/Paris');
});
it('M6d. a zone picked in the calendar dropdown is NOT adopted as the default; Reset returns to the configured zone', async () => {
const el = await fixture(
html``
);
const cal = await settle2(el);
cal.handleTimezoneChange(new CustomEvent('nile-change', { detail: { value: 'America/Chicago' } }));
await settle2(el);
expect(cal.timezone).to.equal('America/Chicago');
resetBtn(cal).click();
await settle2(el);
expect(el.timezone).to.equal('Asia/Kolkata');
expect(cal.timezone).to.equal('Asia/Kolkata');
});
it('M6e. a zone adopted from a bracketed value is not mistaken for the configured default', async () => {
const el = await dtpFixture();
await settle2(el);
el.value = '2026-03-01T10:00:00.000[GMT+05:30 Asia/Kolkata]';
await settle2(el);
expect(el.timezone).to.equal('Asia/Kolkata');
(el as any).handleClear();
await settle2(el);
expect(el.timezone).to.equal('UTC');
});
it('M7. pasting a full datetime fills the mask like typing it', async () => {
const el = await maskFixture();
await ready(el);
const ev = paste(el, '230326172456426');
expect(ev.defaultPrevented).to.be.true;
expect(nativeOf(el).value).to.equal('23/03/26 - 17:24:56.426');
});
it('M8. pasting separator-formatted text uses only its digits', async () => {
const el = await maskFixture();
await ready(el);
paste(el, '23/03/26 - 17:24');
expect(nativeOf(el).value.startsWith('23/03/26 - 17:24')).to.be.true;
});
it('M9. pasting with no digits leaves the mask unchanged', async () => {
const el = await maskFixture();
await ready(el);
paste(el, 'hello');
expect(nativeOf(el).value).to.equal('');
});
it('M10. pasted out-of-range segments are clamped', async () => {
const el = await maskFixture();
await ready(el);
paste(el, '992026');
expect(nativeOf(el).value).to.not.contain('99');
});
it('M11. soft-keyboard style insertText (no usable keydown) is picked up by the mask', async () => {
const el = await maskFixture();
await ready(el);
const native = nativeOf(el);
native.value = '2303';
triggerInput(el).dispatchEvent(
new InputEvent('beforeinput', { inputType: 'insertText', data: '3', bubbles: true, composed: true })
);
await aTimeout(20);
expect(native.value).to.equal('23/03/');
});
it('M12. a cut (deleteByCut) rebuilds the slots from the remaining text', async () => {
const el = await maskFixture();
await ready(el);
paste(el, '230326');
await ready(el); // let the input's own re-render land before simulating the cut
const native = nativeOf(el);
native.value = '23/03/';
triggerInput(el).dispatchEvent(
new InputEvent('beforeinput', { inputType: 'deleteByCut', bubbles: true, composed: true })
);
await aTimeout(20);
expect(native.value).to.equal('23/03/');
});
it('M13. paste is ignored in Relative mode', async () => {
const el = await maskFixture();
await ready(el);
await switchTab(el, 'relative');
const ev = paste(el, '230326');
expect(ev.defaultPrevented).to.be.false;
});
});
/**
* ── Y. YEARS 0-99 (Date.UTC remap regression) ───────────────────────────────
* `Date.UTC(y, …)` treats years 0-99 as 1900-1999, so year 0001 used to become 1901: the bracketed offset was
* computed for the wrong year (`[GMT+16655040:00 UTC]`) and offset-less / bracketed values re-parsed a century off.
* Epochs here are built with setUTCFullYear — never Date.UTC — so the oracle does not share the bug.
*/
describe('Y. years 0-99 keep their literal year', () => {
const epochOf = (y: number, mo1: number, d: number, h = 0, mi = 0, s = 0, ms = 0) => {
const dt = new Date(0);
dt.setUTCFullYear(y, mo1 - 1, d);
dt.setUTCHours(h, mi, s, ms);
return dt.getTime();
};
const pad4 = (y: number) => String(y).padStart(4, '0');
const YEARS = [1, 99, 100];
it('Y1. utcFromFields equals Date.UTC for years >= 100 (incl. overflowing fields)', () => {
for (const args of [
[2026, 5, 15, 12, 30, 15, 250], [100, 0, 1], [1900, 2, 1], [2100, 1, 29], [2024, 12, 32, 25, 61, 61, 1001],
[2026, 2, 0],
] as number[][]) {
expect(utcFromFields(args[0], args[1], args[2], args[3], args[4], args[5], args[6]),
JSON.stringify(args)).to.equal(Date.UTC(args[0], args[1], args[2], args[3] ?? 0, args[4] ?? 0, args[5] ?? 0, args[6] ?? 0));
}
});
it('Y2. utcFromFields keeps years 0-99 literal and returns NaN for NaN input', () => {
expect(new Date(utcFromFields(1, 0, 1)).getUTCFullYear()).to.equal(1);
expect(new Date(utcFromFields(99, 11, 31, 23, 59, 59, 999)).getUTCFullYear()).to.equal(99);
expect(new Date(utcFromFields(0, 1, 29)).getUTCDate()).to.equal(29); // year 0 is a leap year
expect(utcFromFields(1, 0, 1)).to.equal(epochOf(1, 1, 1));
expect(utcFromFields(NaN, 0, 1)).to.be.NaN;
});
it('Y3. wallClockFromOffsetLess keeps the literal year, month length and digits', () => {
for (const y of YEARS) {
const w = wallClockFromOffsetLess(`${pad4(y)}-06-15T12:30:15.250`)!;
expect(w, `year ${y}`).to.deep.equal({ year: y, month: 6, day: 15, hours: 12, minutes: 30, seconds: 15, ms: 250 });
}
// year 0 is leap (400 | 0); the 1900 remap would clamp Feb 29 to 28
expect(wallClockFromOffsetLess('0000-02-29T10:00:00')!.day).to.equal(29);
// year 1 is not leap: Feb 29 clamps to 28
expect(wallClockFromOffsetLess('0001-02-29T10:00:00')!.day).to.equal(28);
});
it('Y4. instantFromBracketedValue returns the true epoch for years 0001, 0099 and 0100', () => {
for (const y of YEARS) {
const r = instantFromBracketedValue(`${pad4(y)}-06-15T12:30:15.250[GMT+00:00 UTC]`);
expect(r, `year ${y}`).to.deep.equal({ epochMs: epochOf(y, 6, 15, 12, 30, 15, 250), timezone: 'UTC' });
}
});
for (const y of YEARS) {
it(`Y5.${y}. Z instant in year ${pad4(y)} emits the true epoch, a sane bracket, and round-trips`, async () => {
const raw = `${pad4(y)}-06-15T12:30:15.250Z`;
const p = await apply(await makePicker({ value: raw, timezone: 'UTC' }));
expect(p, 'payload').to.exist;
expect(p!.unixEpoch).to.equal(epochOf(y, 6, 15, 12, 30, 15, 250));
expect(p!.value).to.equal(`${pad4(y)}-06-15T12:30:15.250[GMT+00:00 UTC]`);
const rt = await roundTrip(p!);
expect(rt.second && rt.second.unixEpoch, 'epoch after reload').to.equal(p!.unixEpoch);
expect(rt.second && rt.second.value).to.equal(p!.value);
}).timeout(60000);
it(`Y6.${y}. offset-less value in year ${pad4(y)} keeps its digits (no 19xx remap)`, async () => {
const p = await apply(await makePicker({ value: `${pad4(y)}-06-15T12:30:15.250`, timezone: 'UTC' }));
expect(p, 'payload').to.exist;
expect(p!.unixEpoch).to.equal(epochOf(y, 6, 15, 12, 30, 15, 250));
expect(p!.value).to.equal(`${pad4(y)}-06-15T12:30:15.250[GMT+00:00 UTC]`);
}).timeout(60000);
it(`Y7.${y}. bracketed value in year ${pad4(y)} reloads to the same epoch`, async () => {
const p = await apply(await makePicker({
value: `${pad4(y)}-06-15T12:30:15.250[GMT+00:00 UTC]`, timezone: 'UTC',
}));
expect(p, 'payload').to.exist;
expect(p!.unixEpoch).to.equal(epochOf(y, 6, 15, 12, 30, 15, 250));
}).timeout(60000);
}
it('Y8. a Feb-29 in year 0 (leap) survives; year 0001 shows no absurd offset', async () => {
const p = await apply(await makePicker({ value: '0001-01-01T00:00:00.000Z', timezone: 'UTC' }));
expect(p!.value).to.not.match(/GMT[+-]\d{3,}:/);
expect(p!.value).to.match(/\[GMT[+-]\d{2}:\d{2} UTC\]$/);
}).timeout(60000);
});
describe('Y9. typed date in years 0-99 (mask -> calendar)', () => {
it('Y9. a fully typed day in year 0050 selects year 50 (not 1950), and Feb 29 of year 0 is kept', async () => {
const el = await makePicker({});
el.dateFormat = 'DD/MM/YYYY'; // the default DD/MM/YY reads two digits as 20xx; a literal year 0050 needs YYYY
await settle(el);
const layout = (el as any).maskLayout();
const digitsFor = (mm: string, dd: string, yyyy: string) => {
const map: Record = { month: mm, day: dd, year: yyyy };
const idx: Record = { month: 0, day: 0, year: 0 };
return layout.slots.map((s: any) => (s.kind in map ? map[s.kind][idx[s.kind]++] : '0'));
};
const cal = el.calendarElement as any;
const run = async (mm: string, dd: string, yyyy: string) => {
(el as any).maskValues = digitsFor(mm, dd, yyyy);
(el as any).syncCalendarFromMask();
await settle(el);
};
await run('06', '15', '0050');
expect(cal.value.getFullYear(), 'year').to.equal(50);
expect(cal.value.getMonth(), 'month').to.equal(5);
expect(cal.value.getDate(), 'day').to.equal(15);
await run('02', '29', '0000');
expect(cal.value.getFullYear(), 'year 0').to.equal(0);
expect(cal.value.getDate(), 'Feb 29 exists in year 0 (leap)').to.equal(29);
await run('02', '29', '0100');
expect(cal.value.getFullYear(), 'year 100 unchanged').to.equal(100);
expect(cal.value.getDate(), 'year 100 is not leap: clamps to 28').to.equal(28);
}).timeout(60000);
});
describe('Y10. YYYY display in years 0-99 (picker input)', () => {
const wall = (year: number, month = 6, day = 15) =>
({ year, month, day, hours: 12, minutes: 30, seconds: 15, ms: 250 });
it('Y10a. formatYear4 pads only years 0-99', () => {
expect([0, 1, 50, 99].map(formatYear4)).to.deep.equal(['0000', '0001', '0050', '0099']);
for (const y of [100, 999, 1000, 2000, 9999, 10000]) expect(formatYear4(y)).to.equal(String(y));
});
it('Y10b. formatDatePartFields pads YYYY for 0-999, keeps >= 1000, and leaves YY alone', async () => {
const el = await makePicker({}) as any;
el.dateFormat = 'DD/MM/YYYY';
expect(el.formatDatePartFields(wall(50))).to.equal('15/06/0050');
expect(el.formatDatePartFields(wall(0, 2, 29))).to.equal('29/02/0000');
expect(el.formatDatePartFields(wall(1))).to.equal('15/06/0001');
expect(el.formatDatePartFields(wall(99))).to.equal('15/06/0099');
expect(el.formatDatePartFields(wall(100)), 'the mask has four year slots, so 100-999 are padded too').to.equal('15/06/0100');
expect(el.formatDatePartFields(wall(999))).to.equal('15/06/0999');
expect(el.formatDatePartFields(wall(1000))).to.equal('15/06/1000');
expect(el.formatDatePartFields(wall(2026))).to.equal('15/06/2026');
el.dateFormat = 'DD/MM/YY';
expect(el.formatDatePartFields(wall(1)), 'YY is always two characters (Y18)').to.equal('15/06/01');
expect(el.formatDatePartFields(wall(50))).to.equal('15/06/50');
expect(el.formatDatePartFields(wall(2026))).to.equal('15/06/26');
}).timeout(60000);
it('Y10c. the trigger input shows a year-0050 value as 0050 with time and timezone, and year 2026 is unchanged', async () => {
for (const [raw, shown] of [
['0050-06-15T12:30:15.250Z', '0050-06-15 - 12:30:15.250 - UTC'],
['2026-06-15T12:30:15.250Z', '2026-06-15 - 12:30:15.250 - UTC'],
]) {
const el = await fixture(
html``);
await settle(el);
expect((el.shadowRoot!.querySelector('nile-input') as any).value, raw).to.equal(shown);
}
}).timeout(60000);
it('Y10d. typed 0050 -> mask -> calendar -> display round-trips as 0050 (year 50, not 1950)', async () => {
const el = await makePicker({});
el.dateFormat = 'DD/MM/YYYY';
await settle(el);
const layout = (el as any).maskLayout();
const map: Record = { month: '06', day: '15', year: '0050' };
const idx: Record = { month: 0, day: 0, year: 0 };
(el as any).maskValues = layout.slots.map((s: any) => (s.kind in map ? map[s.kind][idx[s.kind]++] : '0'));
(el as any).syncCalendarFromMask();
await settle(el);
const cal = el.calendarElement as any;
expect(cal.value.getFullYear()).to.equal(50);
expect(cal.formatDate(cal.value)).to.equal('15/06/0050');
}).timeout(60000);
});
describe('Y11. years 0-999 in the picker input: four-digit display and a stable focus/blur round trip', () => {
const FORMATS: Array<[string, (y: string) => string]> = [
['YYYY-MM-DD', y => `${y}-06-15`],
['MM/DD/YYYY', y => `06/15/${y}`],
['DD/MM/YYYY', y => `15/06/${y}`],
];
const YEARS = ['0001', '0050', '0099', '0100', '0999', '1000', '2026'];
const DATE_ONLY = ['hours', 'minutes', 'seconds', 'milliseconds', 'timezone'];
const make = async (format: string, year: string): Promise => {
const el: any = await fixture(html``);
await settle2(el);
return el;
};
const textOf = (el: any) => el.shadowRoot.querySelector('nile-input').input.value as string;
const calDay = (el: any) => {
const c = el.calendarElement;
return [c.value.getFullYear(), c.value.getMonth(), c.value.getDate(), c.currentYear, c.currentMonth];
};
const focusBlur = async (el: any) => {
const ni = el.shadowRoot.querySelector('nile-input');
ni.input.focus();
ni.dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true }));
await settle2(el);
};
for (const [format, expectText] of FORMATS) {
for (const year of YEARS) {
it(`Y11 [${format}] year ${year}: four-digit text, and focus/blur x3 leaves text, calendar and value untouched`, async () => {
const el = await make(format, year);
const n = Number(year);
expect(textOf(el), 'seeded text').to.equal(expectText(year));
const before = calDay(el);
expect(before, 'seeded calendar').to.deep.equal([n, 5, 15, n, 5]);
const valueBefore = el.value;
let emitted = 0;
el.addEventListener('nile-change', () => { emitted++; });
el.addEventListener('nile-changed', () => { emitted++; });
for (let i = 0; i < 3; i++) {
await focusBlur(el);
expect(textOf(el), `text after cycle ${i + 1}`).to.equal(expectText(year));
expect(calDay(el), `calendar after cycle ${i + 1}`).to.deep.equal(before);
}
expect(el.value, 'value untouched').to.equal(valueBefore);
expect(emitted, 'no change event from focus/blur').to.equal(0);
}).timeout(60000);
}
}
});
describe('Y12. typed dates are clamped to the allowedDates range (user edits only)', () => {
const DMY = 'DD/MM/YYYY';
const make = async (range: [string, string] | null, value: string, tz = 'UTC'): Promise => {
const el: any = await fixture(html``);
await settle2(el);
return el;
};
const inputOf = (el: any) => el.shadowRoot.querySelector('nile-input') as any;
const nativeOf = (el: any) => inputOf(el).input as HTMLInputElement;
const press = (el: any, key: string) =>
inputOf(el).dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true }));
const textOf = (el: any) => nativeOf(el).value;
/** select the DD/MM/YYYY text, delete it, and type a fresh date digit by digit */
const typeDate = async (el: any, ddmmyyyy: string) => {
nativeOf(el).setSelectionRange(0, 10);
press(el, 'Backspace');
for (const ch of ddmmyyyy) press(el, ch);
await settle2(el);
};
const calDate = (el: any) => {
const c = el.calendarElement;
return [c.value.getFullYear(), c.value.getMonth() + 1, c.value.getDate()];
};
const focusBlur = async (el: any) => {
inputOf(el).input.focus();
inputOf(el).dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true }));
await settle2(el);
};
const applyAndCount = async (el: any) => {
const cal = el.calendarElement;
const events: string[] = [];
const details: any[] = [];
for (const n of ['nile-change']) {
el.addEventListener(n, (e: any) => { events.push(n); details.push(e.detail); });
}
(cal.shadowRoot.querySelector('.apply-button') as HTMLElement).click();
await settle2(el);
return { events, details };
};
const SEED = '2026-06-15T10:20:30.250[GMT+00:00 UTC]';
const JUNE: [string, string] = ['2026-06-10', '2026-06-20'];
const CASES: Array<[string, [string, string], string, string, string]> = [
['before range', JUNE, '05062026', '10/06/2026', '2026-06-10'],
['lower boundary', JUNE, '10062026', '10/06/2026', '2026-06-10'],
['inside range', JUNE, '15062026', '15/06/2026', '2026-06-15'],
['upper boundary', JUNE, '20062026', '20/06/2026', '2026-06-20'],
['after range', JUNE, '25062026', '20/06/2026', '2026-06-20'],
['month boundary, before', ['2026-12-29', '2027-01-03'], '28122026', '29/12/2026', '2026-12-29'],
['year boundary, after', ['2026-12-29', '2027-01-03'], '04012027', '03/01/2027', '2027-01-03'],
['year boundary, inside', ['2026-12-29', '2027-01-03'], '01012027', '01/01/2027', '2027-01-01'],
['leap: before 28 Feb', ['2024-02-28', '2024-03-01'], '27022024', '28/02/2024', '2024-02-28'],
['leap: 29 Feb is inside', ['2024-02-28', '2024-03-01'], '29022024', '29/02/2024', '2024-02-29'],
['leap: after 1 Mar', ['2024-02-28', '2024-03-01'], '02032024', '01/03/2024', '2024-03-01'],
['non-leap: before 27 Feb', ['2023-02-27', '2023-03-02'], '26022023', '27/02/2023', '2023-02-27'],
['non-leap: after 2 Mar', ['2023-02-27', '2023-03-02'], '03032023', '02/03/2023', '2023-03-02'],
];
for (const [name, range, typed, text, iso] of CASES) {
it(`Y12 ${name}: typing ${typed} gives ${text}; trigger, calendar and Apply agree, time and zone kept`, async () => {
const el = await make(range, SEED);
await typeDate(el, typed);
const [y, m, d] = iso.split('-').map(Number);
expect(textOf(el).slice(0, 10), 'trigger').to.equal(text);
expect(calDate(el), 'calendar').to.deep.equal([y, m, d]);
expect(el.calendarElement.timezone).to.equal('UTC');
const { events, details } = await applyAndCount(el);
expect([...events].sort(), 'exactly one change event').to.deep.equal(['nile-change']);
expect(details[0].value, 'emitted value').to.equal(`${iso}T10:20:30.250[GMT+00:00 UTC]`);
const typedIso = `${typed.slice(4)}-${typed.slice(2, 4)}-${typed.slice(0, 2)}`;
if (typedIso !== iso) expect(details[0].value, 'disallowed date never committed').to.not.contain(typedIso);
}).timeout(60000);
}
it('Y12 preserves a non-UTC zone and time of day while clamping', async () => {
const el = await make(JUNE, '2026-06-15T23:45:10.005[GMT+05:30 Asia/Kolkata]', 'Asia/Kolkata');
await typeDate(el, '25062026');
expect(textOf(el).slice(0, 10)).to.equal('20/06/2026');
expect(el.calendarElement.timezone).to.equal('Asia/Kolkata');
const { details } = await applyAndCount(el);
expect(details[0].value).to.equal('2026-06-20T23:45:10.005[GMT+05:30 Asia/Kolkata]');
}).timeout(60000);
it('Y12 focus/blur x3 after a clamp does not change the date again and emits nothing', async () => {
const el = await make(JUNE, SEED);
await typeDate(el, '25062026');
const text = textOf(el);
const cal = calDate(el);
let emitted = 0;
el.addEventListener('nile-change', () => { emitted++; });
el.addEventListener('nile-changed', () => { emitted++; });
for (let i = 0; i < 3; i++) {
await focusBlur(el);
expect(textOf(el), `text ${i + 1}`).to.equal(text);
expect(calDate(el), `calendar ${i + 1}`).to.deep.equal(cal);
}
expect(emitted).to.equal(0);
}).timeout(60000);
it('Y12 a programmatic value outside the range is not clamped by focus/blur', async () => {
const el = await make(JUNE, '2026-06-25T10:20:30.250[GMT+00:00 UTC]');
const text = textOf(el);
const cal = calDate(el);
const value = el.value;
let emitted = 0;
el.addEventListener('nile-change', () => { emitted++; });
el.addEventListener('nile-changed', () => { emitted++; });
for (let i = 0; i < 3; i++) {
await focusBlur(el);
expect(textOf(el)).to.equal(text);
expect(calDate(el)).to.deep.equal(cal);
}
expect(text.slice(0, 10)).to.equal('25/06/2026');
expect(el.value).to.equal(value);
expect(emitted).to.equal(0);
}).timeout(60000);
it('Y12 without allowedDates a typed date is never clamped', async () => {
const el = await make(null, SEED);
await typeDate(el, '25062026');
expect(textOf(el).slice(0, 10)).to.equal('25/06/2026');
expect(calDate(el)).to.deep.equal([2026, 6, 25]);
}).timeout(60000);
});
describe('Y13. changing dateFormat / hideTimeFields after render redraws the trigger from the committed value', () => {
const ALL_TIME = ['hours', 'minutes', 'seconds', 'milliseconds', 'timezone'];
const SEED = '2026-06-15T10:20:30.250[GMT+05:30 Asia/Kolkata]';
const make = async (extra: { allowed?: [string, string] } = {}): Promise => {
const el: any = await fixture(html``);
await settle2(el);
return el;
};
const inputOf = (el: any) => el.shadowRoot.querySelector('nile-input') as any;
const nativeOf = (el: any) => inputOf(el).input as HTMLInputElement;
const press = (el: any, key: string) =>
inputOf(el).dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true }));
const textOf = (el: any) => nativeOf(el).value;
const calDate = (el: any) => {
const c = el.calendarElement;
return [c.value.getFullYear(), c.value.getMonth() + 1, c.value.getDate()];
};
const focusBlur = async (el: any) => {
inputOf(el).input.focus();
inputOf(el).dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true }));
await settle2(el);
};
const watch = (el: any) => {
const seen: string[] = [];
for (const n of ['nile-change', 'nile-changed', 'nile-type-change']) el.addEventListener(n, () => seen.push(n));
return seen;
};
const apply = async (el: any) => {
(el.calendarElement.shadowRoot.querySelector('.apply-button') as HTMLElement).click();
await settle2(el);
};
it('Y13 DD/MM/YYYY -> YYYY-MM-DD -> back keeps the date, time and zone', async () => {
const el = await make();
const value = el.value;
const seen = watch(el);
expect(textOf(el).startsWith('15/06/2026')).to.be.true;
el.dateFormat = 'YYYY-MM-DD';
await settle2(el);
expect(textOf(el).startsWith('2026-06-15')).to.be.true;
expect(textOf(el)).to.contain('10:20:30.250').and.to.contain('Asia/Kolkata');
el.dateFormat = 'DD/MM/YYYY';
await settle2(el);
expect(textOf(el).startsWith('15/06/2026')).to.be.true;
expect(el.value).to.equal(value);
expect(el.timezone).to.equal('Asia/Kolkata');
expect(calDate(el)).to.deep.equal([2026, 6, 15]);
expect(seen).to.deep.equal([]);
}).timeout(60000);
it('Y13 hideTimeFields hides then restores time and zone without losing them', async () => {
const el = await make();
const value = el.value;
const seen = watch(el);
el.hideTimeFields = ALL_TIME;
await settle2(el);
expect(textOf(el)).to.equal('15/06/2026');
el.hideTimeFields = [];
await settle2(el);
expect(textOf(el)).to.contain('10:20:30.250').and.to.contain('Asia/Kolkata');
expect(el.value).to.equal(value);
expect(seen).to.deep.equal([]);
}).timeout(60000);
it('Y13 both properties changed in one update give one consistent trigger', async () => {
const el = await make();
const value = el.value;
el.dateFormat = 'YYYY-MM-DD';
el.hideTimeFields = ALL_TIME;
await settle2(el);
expect(textOf(el)).to.equal('2026-06-15');
el.dateFormat = 'DD/MM/YYYY';
el.hideTimeFields = [];
await settle2(el);
expect(textOf(el)).to.contain('15/06/2026').and.to.contain('10:20:30.250').and.to.contain('Asia/Kolkata');
expect(el.value).to.equal(value);
}).timeout(60000);
it('Y13 focus/blur after each change does not corrupt the date, time or zone', async () => {
const el = await make();
const value = el.value;
const seen = watch(el);
for (const [fmt, hide] of [['YYYY-MM-DD', []], ['YYYY-MM-DD', ALL_TIME], ['DD/MM/YYYY', ALL_TIME], ['DD/MM/YYYY', []]] as Array<[string, string[]]>) {
el.dateFormat = fmt;
el.hideTimeFields = hide;
await settle2(el);
const text = textOf(el);
for (let i = 0; i < 2; i++) {
await focusBlur(el);
expect(textOf(el), `${fmt} ${hide.length}`).to.equal(text);
}
expect(el.value).to.equal(value);
expect(el.timezone).to.equal('Asia/Kolkata');
expect(calDate(el)).to.deep.equal([2026, 6, 15]);
}
expect(seen).to.deep.equal([]);
}).timeout(60000);
it('Y13 an uncommitted draft is discarded and the committed value is redrawn', async () => {
const el = await make();
const value = el.value;
nativeOf(el).setSelectionRange(0, 10);
press(el, 'Backspace');
for (const ch of '01012030') press(el, ch);
await settle2(el);
expect(textOf(el).startsWith('01/01/2030')).to.be.true;
const seen = watch(el);
el.dateFormat = 'YYYY-MM-DD';
await settle2(el);
expect(textOf(el).startsWith('2026-06-15')).to.be.true;
await focusBlur(el);
expect(textOf(el).startsWith('2026-06-15')).to.be.true;
expect(el.value).to.equal(value);
expect(seen).to.deep.equal([]);
}).timeout(60000);
it('Y13 repeated toggles do not drift', async () => {
const el = await make();
const value = el.value;
const first = textOf(el);
for (let i = 0; i < 4; i++) {
el.dateFormat = 'YYYY-MM-DD';
el.hideTimeFields = ALL_TIME;
await settle2(el);
el.dateFormat = 'DD/MM/YYYY';
el.hideTimeFields = [];
await settle2(el);
}
expect(textOf(el)).to.equal(first);
expect(el.value).to.equal(value);
}).timeout(60000);
it('Y13 a programmatic value after a format change shows in the new format', async () => {
const el = await make();
el.dateFormat = 'YYYY-MM-DD';
await settle2(el);
el.value = '2027-03-04T05:06:07.008[GMT+05:30 Asia/Kolkata]';
await settle2(el);
expect(textOf(el).startsWith('2027-03-04')).to.be.true;
await focusBlur(el);
expect(textOf(el).startsWith('2027-03-04')).to.be.true;
expect(calDate(el)).to.deep.equal([2027, 3, 4]);
}).timeout(60000);
it('Y13 Apply after a format change emits the committed date once', async () => {
const el = await make();
el.dateFormat = 'YYYY-MM-DD';
await settle2(el);
const seen = watch(el);
await apply(el);
expect(seen).to.deep.equal(['nile-change']);
expect(el.value.startsWith('2026-06-15T10:20:30.250')).to.be.true;
}).timeout(60000);
it('Y13 with no value a format change leaves the trigger empty and emits nothing', async () => {
const el: any = await fixture(html``);
await settle2(el);
const seen = watch(el);
el.dateFormat = 'YYYY-MM-DD';
el.hideTimeFields = ALL_TIME;
await settle2(el);
expect(textOf(el)).to.equal('');
expect(el.value ?? '').to.equal('');
expect(seen).to.deep.equal([]);
}).timeout(60000);
it('Y13 a format change that discards a typed draft (no committed value) emits no nile-input', async () => {
const el: any = await fixture(html``);
await settle2(el);
nativeOf(el).setSelectionRange(0, 0);
for (const ch of '01012030') press(el, ch);
await settle2(el);
expect(textOf(el).startsWith('01/01/2030')).to.be.true;
const seen = watch(el);
let inputs = 0;
inputOf(el).addEventListener('nile-input', () => { inputs++; });
el.dateFormat = 'YYYY-MM-DD';
await settle2(el);
el.hideTimeFields = ALL_TIME;
await settle2(el);
expect(textOf(el)).to.equal('');
expect(el.value ?? '').to.equal('');
expect(inputs).to.equal(0);
expect(seen).to.deep.equal([]);
}).timeout(60000);
it('Y13 a user Clear still emits nile-input and nile-clear', async () => {
const el = await make();
let inputs = 0;
let clears = 0;
inputOf(el).addEventListener('nile-input', () => { inputs++; });
el.addEventListener('nile-clear', () => { clears++; });
el.handleClear();
await settle2(el);
expect(inputs).to.equal(1);
expect(clears).to.equal(1);
}).timeout(60000);
it('Y13 relative mode ignores a format change', async () => {
const el: any = await fixture(html``);
await settle2(el);
const before = el.type;
el.dateFormat = 'YYYY-MM-DD';
el.hideTimeFields = ALL_TIME;
await settle2(el);
expect(el.type).to.equal(before);
expect(el.dateFormat).to.equal('YYYY-MM-DD');
}).timeout(60000);
it('Y13 allowedDates clamping still works after a format change', async () => {
const el = await make({ allowed: ['2026-06-10', '2026-06-20'] });
el.dateFormat = 'DD/MM/YYYY';
el.hideTimeFields = [];
await settle2(el);
nativeOf(el).setSelectionRange(0, 10);
press(el, 'Backspace');
for (const ch of '25062026') press(el, ch);
await settle2(el);
expect(textOf(el).startsWith('20/06/2026')).to.be.true;
expect(calDate(el)).to.deep.equal([2026, 6, 20]);
}).timeout(60000);
});
describe('Y14. switching Relative -> Absolute redraws the trigger from the retained absolute value', () => {
const SEED = '2026-06-15T10:20:30.250[GMT+05:30 Asia/Kolkata]';
const make = async (withValue = true): Promise => {
const el: any = await fixture(html``);
await settle2(el);
return el;
};
const inputOf = (el: any) => el.shadowRoot.querySelector('nile-input') as any;
const nativeOf = (el: any) => inputOf(el).input as HTMLInputElement;
const textOf = (el: any) => nativeOf(el).value;
const calDate = (el: any) => {
const c = el.calendarElement;
return [c.value.getFullYear(), c.value.getMonth() + 1, c.value.getDate()];
};
const focusBlur = async (el: any) => {
inputOf(el).input.focus();
inputOf(el).dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true }));
await settle2(el);
};
/** user-style mode switch: the calendar's tab group fires nile-tab-show */
const switchTab = async (el: any, value: string) => {
const cal = el.calendarElement;
cal.shadowRoot.querySelector(`nile-tab[panel="${value}"]`).dispatchEvent(
new CustomEvent('nile-tab-show', { detail: { value }, bubbles: true, composed: true })
);
await settle2(el);
};
const relativeApply = async (el: any) => {
const cal = el.calendarElement;
(cal.shadowRoot.querySelector('.duration__value') as HTMLElement).click();
await cal.updateComplete;
(cal.shadowRoot.querySelector('.apply-button') as HTMLElement).click();
await settle2(el);
};
const watch = (el: any) => {
const seen: string[] = [];
for (const n of ['nile-change', 'nile-changed']) el.addEventListener(n, () => seen.push(n));
const types: any[] = [];
el.addEventListener('nile-type-change', (e: any) => types.push(e.detail?.value));
let inputs = 0;
inputOf(el).addEventListener('nile-input', () => { inputs++; });
return { seen, types, inputs: () => inputs };
};
it('Y14 Relative -> Absolute restores the retained absolute value, never the relative text', async () => {
const el = await make();
const value = el.value;
await switchTab(el, 'relative');
await relativeApply(el);
expect(textOf(el)).to.contain(' - ');
expect(el.value, 'relative apply leaves value alone').to.equal(value);
const w = watch(el);
await switchTab(el, 'absolute');
expect(el.type).to.equal('absolute');
expect(textOf(el).startsWith('15/06/2026')).to.be.true;
expect(textOf(el)).to.contain('10:20:30.250').and.to.contain('Asia/Kolkata');
expect(el.value).to.equal(value);
expect(el.timezone).to.equal('Asia/Kolkata');
expect(w.seen).to.deep.equal([]);
expect(w.inputs()).to.equal(0);
expect(w.types).to.deep.equal(['absolute']);
}).timeout(60000);
it('Y14 with no absolute value the trigger is cleared silently', async () => {
const el = await make(false);
await switchTab(el, 'relative');
await relativeApply(el);
expect(textOf(el)).to.contain(' - ');
const w = watch(el);
await switchTab(el, 'absolute');
expect(textOf(el)).to.equal('');
expect(el.value ?? '').to.equal('');
expect(w.inputs()).to.equal(0);
expect(w.seen).to.deep.equal([]);
expect(w.types).to.deep.equal(['absolute']);
}).timeout(60000);
it('Y14 focus/blur after returning to Absolute does not corrupt date, time or zone', async () => {
const el = await make();
const value = el.value;
await switchTab(el, 'relative');
await relativeApply(el);
await switchTab(el, 'absolute');
const text = textOf(el);
for (let i = 0; i < 3; i++) {
await focusBlur(el);
expect(textOf(el), `blur ${i + 1}`).to.equal(text);
}
expect(el.value).to.equal(value);
expect(el.timezone).to.equal('Asia/Kolkata');
expect(calDate(el)).to.deep.equal([2026, 6, 15]);
expect((el as any).maskValues.length).to.be.greaterThan(0);
}).timeout(60000);
it('Y14 with no absolute value, focus/blur after returning leaves the trigger empty', async () => {
const el = await make(false);
await switchTab(el, 'relative');
await relativeApply(el);
await switchTab(el, 'absolute');
await focusBlur(el);
expect(textOf(el)).to.equal('');
expect(el.value ?? '').to.equal('');
}).timeout(60000);
it('Y14 Absolute -> Relative -> Absolute and repeated switching show no stale text', async () => {
const el = await make();
const first = textOf(el);
const w = watch(el);
for (let i = 0; i < 3; i++) {
await switchTab(el, 'relative');
expect(el.type).to.equal('relative');
await switchTab(el, 'absolute');
expect(textOf(el), `round ${i + 1}`).to.equal(first);
expect(textOf(el)).to.not.contain(' - Asia/Kolkata - ');
}
expect(w.types).to.deep.equal(['relative', 'absolute', 'relative', 'absolute', 'relative', 'absolute']);
expect(w.seen).to.deep.equal([]);
expect(w.inputs()).to.equal(0);
}).timeout(60000);
it('Y14 a programmatic type change back to Absolute also redraws', async () => {
const el = await make();
const first = textOf(el);
el.type = 'relative';
await settle2(el);
await relativeApply(el);
el.type = 'absolute';
await settle2(el);
expect(textOf(el)).to.equal(first);
}).timeout(60000);
it('Y14 Absolute -> Relative does not change the absolute value', async () => {
const el = await make();
const value = el.value;
await switchTab(el, 'relative');
expect(el.value).to.equal(value);
expect(el.timezone).to.equal('Asia/Kolkata');
}).timeout(60000);
it('Y14 Absolute Apply and Relative Apply still work after switching back and forth', async () => {
const el = await make();
await switchTab(el, 'relative');
let relative: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { relative = e.detail; }) as EventListener);
await relativeApply(el);
expect(relative?.startDate, 'relative apply emits a range').to.exist;
await switchTab(el, 'absolute');
const events: string[] = [];
let detail: any = null;
for (const n of ['nile-change', 'nile-changed']) el.addEventListener(n, (e: any) => { events.push(n); detail = e.detail; });
(el.calendarElement.shadowRoot.querySelector('.apply-button') as HTMLElement).click();
await settle2(el);
expect(events).to.deep.equal(['nile-change']);
expect(detail.value.startsWith('2026-06-15T10:20:30.250')).to.be.true;
expect(el.value.startsWith('2026-06-15T10:20:30.250')).to.be.true;
}).timeout(60000);
it('Y14 dateFormat and hideTimeFields updates still work after returning to Absolute', async () => {
const el = await make();
await switchTab(el, 'relative');
await relativeApply(el);
await switchTab(el, 'absolute');
el.dateFormat = 'YYYY-MM-DD';
el.hideTimeFields = ['hours', 'minutes', 'seconds', 'milliseconds', 'timezone'];
await settle2(el);
expect(textOf(el)).to.equal('2026-06-15');
el.hideTimeFields = [];
el.dateFormat = 'DD/MM/YYYY';
await settle2(el);
expect(textOf(el).startsWith('15/06/2026')).to.be.true;
}).timeout(60000);
});
describe('Y15. focus/blur never clamps a seeded year outside the default startYear/endYear range', () => {
// no .startYear/.endYear here on purpose: the default navigation range (2000-2050) is what used to clamp the seed
const DATE_ONLY = ['hours', 'minutes', 'seconds', 'milliseconds', 'timezone'];
const make = async (year: string, format = 'DD/MM/YYYY'): Promise => {
const el: any = await fixture(html``);
await settle2(el);
return el;
};
const inputOf = (el: any) => el.shadowRoot.querySelector('nile-input') as any;
const textOf = (el: any) => (inputOf(el).input as HTMLInputElement).value;
const calState = (el: any) => {
const c = el.calendarElement;
return [c.value.getFullYear(), c.value.getMonth(), c.value.getDate(), c.currentYear, c.currentMonth];
};
const focusBlur = async (el: any) => {
inputOf(el).input.focus();
inputOf(el).dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true }));
await settle2(el);
};
for (const year of ['0050', '1985', '1999', '2051', '2100', '9999']) {
for (const format of ['DD/MM/YYYY', 'YYYY-MM-DD']) {
it(`Y15 [${format}] seeded year ${year}: focus/blur x3 leaves text, calendar, value and events untouched`, async () => {
const el = await make(year, format);
const text = textOf(el);
const cal = calState(el);
const value = el.value;
expect(text, 'seeded text carries the literal year').to.contain(year);
expect(cal.slice(0, 1), 'seeded calendar year').to.deep.equal([Number(year)]);
let emitted = 0;
el.addEventListener('nile-change', () => { emitted++; });
el.addEventListener('nile-changed', () => { emitted++; });
for (let i = 0; i < 3; i++) {
await focusBlur(el);
expect(textOf(el), `text after cycle ${i + 1}`).to.equal(text);
expect(calState(el), `calendar after cycle ${i + 1}`).to.deep.equal(cal);
}
expect(el.value, 'value untouched').to.equal(value);
expect(emitted, 'no change event from focus/blur').to.equal(0);
}).timeout(60000);
}
}
it('Y15 a year the USER types outside the range is still clamped (user edits only)', async () => {
const el = await make('2026');
const native = inputOf(el).input as HTMLInputElement;
native.setSelectionRange(0, 10);
inputOf(el).dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, composed: true, cancelable: true }));
for (const ch of '15061850') {
inputOf(el).dispatchEvent(new KeyboardEvent('keydown', { key: ch, bubbles: true, composed: true, cancelable: true }));
}
await settle2(el);
await focusBlur(el);
expect(textOf(el)).to.equal('15/06/2000');
}).timeout(60000);
it('Y15 a year the USER types above the range clamps to the range end', async () => {
const el = await make('2026');
const native = inputOf(el).input as HTMLInputElement;
native.setSelectionRange(0, 10);
inputOf(el).dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, composed: true, cancelable: true }));
for (const ch of '15062100') {
inputOf(el).dispatchEvent(new KeyboardEvent('keydown', { key: ch, bubbles: true, composed: true, cancelable: true }));
}
await settle2(el);
await focusBlur(el);
expect(textOf(el)).to.equal('15/06/2050');
}).timeout(60000);
});
describe('Y16. an unparseable value after a valid one does not leave the old selection on screen', () => {
const SEED = '2026-06-15T10:20:30.000[GMT+05:30 Asia/Kolkata]';
const BAD = ['not-a-date', '2026-13-45T10:00:00', 'garbage[GMT+00:00 UTC]', ' '];
const make = async (value = SEED): 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;
for (const bad of BAD) {
it(`Y16 ${JSON.stringify(bad)}: trigger and calendar empty (as for an invalid initial value), zone and events untouched`, async () => {
const el = await make();
expect(textOf(el), 'precondition').to.contain('15/06/2026');
let changes = 0;
el.addEventListener('nile-change', () => { changes++; });
el.addEventListener('nile-changed', () => { changes++; });
el.value = bad;
await settle2(el);
expect(textOf(el), 'trigger text').to.equal('');
expect(el.calendarElement.value, 'calendar selection').to.equal(null);
expect(el.timezone, 'zone is not touched').to.equal('Asia/Kolkata');
expect(el.value, 'value is the consumer\'s, never rewritten').to.equal(bad);
expect(changes, 'no change event').to.equal(0);
// and a later valid value seeds normally
el.value = '2027-01-02T03:04:05.000[GMT+05:30 Asia/Kolkata]';
await settle2(el);
expect(textOf(el)).to.contain('02/01/2027');
expect(el.calendarElement.value.getFullYear()).to.equal(2027);
}).timeout(60000);
}
it('Y16 an invalid INITIAL value shows an empty trigger (the behaviour the after-valid case must match)', async () => {
const el = await make('not-a-date');
expect(textOf(el)).to.equal('');
expect(el.calendarElement.value ?? null).to.equal(null);
}).timeout(60000);
});
describe('Y17. focus/blur with a two-digit (YY) format never moves the calendar off a seeded year outside 2000-2099', () => {
const DATE_ONLY = ['hours', 'minutes', 'seconds', 'milliseconds', 'timezone'];
const make = async (year: string): Promise => {
const el: any = await fixture(html``);
await settle2(el);
return el;
};
const inputOf = (el: any) => el.shadowRoot.querySelector('nile-input') as any;
const calState = (el: any) => {
const c = el.calendarElement;
return [c.value.getFullYear(), c.value.getMonth(), c.value.getDate(), c.currentYear, c.currentMonth];
};
for (const year of ['1985', '2026', '2099', '2100']) {
it(`Y17 seeded year ${year}: focus/blur x3 leaves calendar, text, value and events untouched`, async () => {
const el = await make(year);
const text = (inputOf(el).input as HTMLInputElement).value;
const cal = calState(el);
const value = el.value;
let emitted = 0;
el.addEventListener('nile-change', () => { emitted++; });
for (let i = 0; i < 3; i++) {
inputOf(el).input.focus();
inputOf(el).dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true }));
await settle2(el);
expect(calState(el), `calendar after cycle ${i + 1}`).to.deep.equal(cal);
expect((inputOf(el).input as HTMLInputElement).value, `text after cycle ${i + 1}`).to.equal(text);
}
expect(el.value).to.equal(value);
expect(emitted).to.equal(0);
}).timeout(60000);
}
});
describe('Y18. a YY year is always two characters', () => {
const DATE_ONLY = ['hours', 'minutes', 'seconds', 'milliseconds', 'timezone'];
for (const [year, yy] of [['0005', '05'], ['0009', '09'], ['0010', '10'], ['2005', '05'], ['2026', '26']]) {
it(`Y18 year ${year} renders as ${yy} under DD/MM/YY`, async () => {
const el: any = await fixture(html``);
await settle2(el);
expect((el.shadowRoot.querySelector('nile-input').input as HTMLInputElement).value).to.equal(`15/06/${yy}`);
}).timeout(60000);
}
});
describe('Y19. blur clamps only the segments the user actually edited', () => {
// default navigation range (2000-2050) on purpose: every seed below is outside it (1999 and 2051 sit just beyond each end)
const DATE_ONLY = ['hours', 'minutes', 'seconds', 'milliseconds', 'timezone'];
const make = async (value: string, format = 'DD/MM/YYYY'): Promise => {
const el: any = await fixture(html``);
await settle2(el);
return el;
};
const inputOf = (el: any) => el.shadowRoot.querySelector('nile-input') as any;
const nativeOf = (el: any) => inputOf(el).input as HTMLInputElement;
const press = (el: any, key: string) =>
inputOf(el).dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true }));
const blur = async (el: any) => {
nativeOf(el).focus();
inputOf(el).dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true }));
await settle2(el);
};
const calDate = (el: any) => {
const c = el.calendarElement;
return [c.value.getFullYear(), c.value.getMonth() + 1, c.value.getDate()];
};
/** select [from, to) of the text, delete it, and type digits */
const retype = async (el: any, from: number, to: number, digits: string) => {
nativeOf(el).setSelectionRange(from, to);
press(el, 'Backspace');
for (const ch of digits) press(el, ch);
await settle2(el);
};
for (const year of ['1985', '1999', '2051', '2100', '0050']) {
it(`Y19 seeded ${year}: editing only the day, then blur, leaves the untouched year alone`, async () => {
const el = await make(`${year}-06-15T00:00:00[GMT+00:00 UTC]`);
await retype(el, 0, 2, '20');
await blur(el);
expect(nativeOf(el).value).to.equal(`20/06/${year}`);
expect(calDate(el)).to.deep.equal([Number(year), 6, 20]);
}).timeout(60000);
it(`Y19 seeded ${year}: editing only the month, then blur, leaves the untouched year alone`, async () => {
const el = await make(`${year}-06-15T00:00:00[GMT+00:00 UTC]`);
await retype(el, 3, 5, '08');
await blur(el);
expect(nativeOf(el).value).to.equal(`15/08/${year}`);
expect(calDate(el)).to.deep.equal([Number(year), 8, 15]);
}).timeout(60000);
}
// the approved default range is 2000-2050: a year the user completes is clamped into it, a year inside it is kept
for (const [typed, expected, why] of [
['1990', '2000', 'below the range → 2000'],
['1999', '2000', 'just below the range → 2000'],
['2100', '2050', 'above the range → 2050'],
['2051', '2050', 'just above the range → 2050'],
['2000', '2000', 'the range start is kept'],
['2050', '2050', 'the range end is kept'],
['2030', '2030', 'inside the range is kept'],
]) {
it(`Y19 the year IS clamped when the user edits the year itself: ${typed} → ${expected} (${why})`, async () => {
const el = await make('1985-06-15T00:00:00[GMT+00:00 UTC]');
await retype(el, 6, 10, typed);
await blur(el);
expect(nativeOf(el).value).to.equal(`15/06/${expected}`);
expect(calDate(el)).to.deep.equal([Number(expected), 6, 15]);
}).timeout(60000);
}
it('Y19 an in-range seeded year is not changed by editing another segment', async () => {
const el = await make('2030-06-15T00:00:00[GMT+00:00 UTC]');
await retype(el, 0, 2, '20');
await blur(el);
expect(nativeOf(el).value).to.equal('20/06/2030');
}).timeout(60000);
it('Y19 a second edit after a blur still leaves the untouched year alone', async () => {
const el = await make('1985-06-15T00:00:00[GMT+00:00 UTC]');
await retype(el, 0, 2, '20');
await blur(el);
await retype(el, 3, 5, '08');
await blur(el);
expect(nativeOf(el).value).to.equal('20/08/1985');
}).timeout(60000);
it('Y19 an edited month/year still clamps the day to the month length (e.g. 31 -> 28 after choosing February)', async () => {
const el = await make('1985-01-31T00:00:00[GMT+00:00 UTC]');
await retype(el, 3, 5, '02');
await blur(el);
expect(nativeOf(el).value).to.equal('28/02/1985');
}).timeout(60000);
});
describe('Y19b. typed-year clamping: explicit startYear / endYear win, an unset side falls back to 2000 / 2050', () => {
const DATE_ONLY = ['hours', 'minutes', 'seconds', 'milliseconds', 'timezone'];
const make = async (bounds: { start?: number; end?: number }, format = 'DD/MM/YYYY'): Promise => {
const el: any = await fixture(html``);
await settle2(el);
return el;
};
const inputOf = (el: any) => el.shadowRoot.querySelector('nile-input') as any;
const nativeOf = (el: any) => inputOf(el).input as HTMLInputElement;
const press = (el: any, key: string) =>
inputOf(el).dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true }));
/** retype the year segment (the last four characters of DD/MM/YYYY) and blur */
const typeYear = async (el: any, digits: string, from = 6, to = 10) => {
nativeOf(el).setSelectionRange(from, to);
press(el, 'Backspace');
for (const ch of digits) press(el, ch);
await settle2(el);
nativeOf(el).focus();
inputOf(el).dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true }));
await settle2(el);
};
const calYear = (el: any) => el.calendarElement.value.getFullYear();
type Case = [string, { start?: number; end?: number }, string, string];
const cases: Case[] = [
// no bounds: the 2000-2050 default applies on both sides
['no bounds, before 2000', {}, '1850', '2000'],
['no bounds, 1999', {}, '1999', '2000'],
['no bounds, after 2050', {}, '2100', '2050'],
['no bounds, 2051', {}, '2051', '2050'],
['no bounds, year 0050', {}, '0050', '2000'],
['no bounds, 2000 and 2050 are kept', {}, '2000', '2000'],
['no bounds, 2050 is kept', {}, '2050', '2050'],
// explicit bounds win over the default, including outside 2000-2050
['explicit bounds before 2000 are respected', { start: 1850, end: 2100 }, '1850', '1850'],
['explicit bounds after 2050 are respected', { start: 1850, end: 2100 }, '2100', '2100'],
['explicit bounds still clamp below', { start: 1850, end: 2100 }, '1800', '1850'],
['explicit bounds still clamp above', { start: 1850, end: 2100 }, '2200', '2100'],
['explicit 0-9999 keeps year 0050', { start: 0, end: 9999 }, '0050', '0050'],
['explicit 0-9999 keeps 9999', { start: 0, end: 9999 }, '9999', '9999'],
// custom bounds inside the default range
['custom bounds, below', { start: 1990, end: 2010 }, '1980', '1990'],
['custom bounds, above', { start: 1990, end: 2010 }, '2020', '2010'],
['custom bounds, inside', { start: 1990, end: 2010 }, '2000', '2000'],
// one-sided: the unset side falls back to 2000 / 2050
['startYear only, below it', { start: 1990 }, '1980', '1990'],
['startYear only, before 2000 but above it', { start: 1990 }, '1995', '1995'],
['startYear only, above the default end', { start: 1990 }, '2100', '2050'],
['endYear only, above it', { end: 2010 }, '2020', '2010'],
['endYear only, below the default start', { end: 2010 }, '1850', '2000'],
['endYear only, past 2050 but within it', { end: 2100 }, '2080', '2080'],
['endYear only, past it', { end: 2100 }, '2101', '2100'],
];
for (const [name, bounds, typed, expected] of cases) {
it(`Y19b ${name}: typing ${typed} gives ${expected}`, async () => {
const el = await make(bounds);
await typeYear(el, typed);
expect(nativeOf(el).value).to.equal(`15/06/${expected}`);
expect(calYear(el)).to.equal(Number(expected));
}).timeout(60000);
}
it('Y19b no bounds: a typed YY year past 2050 is pulled back to 2050 (75 becomes 50)', async () => {
const el = await make({}, 'DD/MM/YY');
await typeYear(el, '75', 6, 8);
expect(nativeOf(el).value).to.equal('15/06/50');
expect(calYear(el)).to.equal(2050);
}).timeout(60000);
it('Y19b explicit endYear 2099: a typed YY year 75 is kept (2075)', async () => {
const el = await make({ end: 2099 }, 'DD/MM/YY');
await typeYear(el, '75', 6, 8);
expect(nativeOf(el).value).to.equal('15/06/75');
expect(calYear(el)).to.equal(2075);
}).timeout(60000);
it('Y19b endYear only: the calendar the picker renders gets only that bound', async () => {
const el = await make({ end: 2010 });
const cal = el.calendarElement;
expect(cal.startYear).to.be.undefined;
expect(Number(cal.endYear)).to.equal(2010);
}).timeout(60000);
});
describe('Y20. Set to now uses the machine zone; value = null keeps the configured timezone', () => {
const FROZEN = Date.UTC(2026, 5, 15, 18, 42, 37, 456);
const textOf = (el: any) => (el.shadowRoot.querySelector('nile-input').input as HTMLInputElement).value;
it('Y20 Set to now shows the machine clock and zone in the trigger, switches picker and calendar to the machine zone, and emits it', async () => {
const HOST = Intl.DateTimeFormat().resolvedOptions().timeZone;
const el: any = await fixture(html``);
const cal = await settle2(el);
const RealDate = Date;
class FrozenDate extends RealDate {
constructor(...args: any[]) { if (args.length === 0) super(FROZEN); else super(...(args as [any])); }
static now() { return FROZEN; }
}
let detail: any = null;
el.addEventListener('nile-change', (e: CustomEvent) => { detail = e.detail; });
(globalThis as any).Date = FrozenDate;
try { (cal.shadowRoot!.querySelector('.tp-set-to-now-button') as HTMLElement).click(); }
finally { (globalThis as any).Date = RealDate; }
await settle2(el);
(cal.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
await settle2(el);
// the machine's own reading of the frozen instant (host zone differs between runs)
const t = new RealDate(FROZEN);
const p2 = (n: number) => String(n).padStart(2, '0');
expect(textOf(el)).to.equal(
`${p2(t.getDate())}/${p2(t.getMonth() + 1)}/${t.getFullYear()} - ${p2(t.getHours())}:${p2(t.getMinutes())}:${p2(t.getSeconds())}.456 - ${HOST}`);
expect(el.timezone, 'picker zone becomes the machine zone').to.equal(HOST);
expect(cal.timezone).to.equal(HOST);
expect(detail.timezone).to.equal(HOST);
expect(detail.unixEpoch).to.equal(FROZEN);
}).timeout(60000);
it('Y20 value = null clears value and display and keeps the configured timezone', async () => {
const el: any = await fixture(html``);
await settle2(el);
expect(textOf(el), 'precondition').to.contain('15/06/2026');
el.value = null;
await settle2(el);
expect(el.value).to.equal(null);
expect(textOf(el)).to.equal('');
expect(el.calendarElement.value).to.equal(null);
expect(el.timezone).to.equal('Asia/Kolkata');
expect(el.calendarElement.timezone).to.equal('Asia/Kolkata');
}).timeout(60000);
});
describe('Y21. an overflow Relative duration never reaches the picker (Finding #8)', () => {
const fire = (target: HTMLElement, value: string) =>
target.dispatchEvent(new CustomEvent('nile-change', { detail: { value }, bubbles: false, composed: true }));
const make = async (): Promise => {
const el: any = await fixture(html``);
await settle2(el);
return el;
};
const tab = async (el: any, value: string) => {
el.calendarElement.shadowRoot.querySelector(`nile-tab[panel="${value}"]`).dispatchEvent(
new CustomEvent('nile-tab-show', { detail: { value }, bubbles: true, composed: true })
);
await settle2(el);
};
const enter = async (el: any, unit: string, n: number) => {
const cal = el.calendarElement;
fire(cal.shadowRoot.querySelector('.time-input') as HTMLElement, unit);
fire(cal.shadowRoot.querySelector('.duration-input') as HTMLElement, String(n));
await settle2(el);
};
const apply = (el: any) => el.calendarElement.shadowRoot.querySelector('.apply-button') as HTMLElement;
const textOf = (el: any) => (el.shadowRoot.querySelector('nile-input') as any).input.value as string;
for (const [unit, n] of [['minutes', 999999999999], ['hours', 99999999999], ['days', 999999999], ['weeks', 999999999], ['months', 999999999]] as Array<[string, number]>) {
it(`Y21 ${unit}: ${n} -> Apply is disabled, nothing is thrown, emitted or changed; correcting it applies normally`, async () => {
const el = await make();
const value = el.value;
await tab(el, 'relative');
const errors: string[] = [];
const onError = (e: ErrorEvent) => { errors.push(e.message); e.preventDefault(); };
window.addEventListener('error', onError);
const seen: string[] = [];
for (const name of ['nile-change', 'nile-changed']) el.addEventListener(name, () => seen.push(name));
try {
const before = textOf(el);
await enter(el, unit, n);
expect(apply(el).hasAttribute('disabled'), 'Apply disabled').to.be.true;
apply(el).click();
await settle2(el);
expect(errors, 'no uncaught RangeError').to.deep.equal([]);
expect(seen, 'no change events').to.deep.equal([]);
expect(textOf(el), 'trigger unchanged').to.equal(before);
expect(el.value, 'value unchanged').to.equal(value);
expect(el.timezone).to.equal('UTC');
await enter(el, unit, 2);
expect(apply(el).hasAttribute('disabled'), 'Apply enabled again').to.be.false;
apply(el).click();
await settle2(el);
expect(seen).to.deep.equal(['nile-change']);
expect(textOf(el)).to.contain(' - UTC');
expect(errors).to.deep.equal([]);
} finally {
window.removeEventListener('error', onError);
}
}).timeout(60000);
}
});