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