s; without a role they are announced as bare day numbers, hiding
* month, year and selected state. Covers both the default grid and the date-time-picker variant. */
describe('V0. day cell accessibility', () => {
const firstReal = (el: NileCalendar) =>
el.shadowRoot!.querySelector('.day_date:not(.filler)') as HTMLElement;
for (const [name, make] of [
['default', () => fixture
(html``)],
['variant', () => fixture(html``)],
] as const) {
it(`V0-${name}. a focusable day exposes a role and a full-date accessible name`, async () => {
const el = await make();
await el.updateComplete;
const cell = firstReal(el);
expect(cell, 'a real day cell exists').to.exist;
if (name === 'default') {
// published default markup: a day cell carries no role, name or state attributes
for (const attr of ['role', 'aria-label', 'aria-pressed', 'aria-current', 'aria-disabled', 'aria-hidden']) {
expect(cell.hasAttribute(attr), `default day cell has no ${attr}`).to.be.false;
}
return;
}
expect(cell.getAttribute('role'), 'interactive role').to.equal('button');
const label = cell.getAttribute('aria-label') ?? '';
expect(label, 'names more than the bare day number').to.not.equal(cell.textContent!.trim());
// must carry a year, so the name is unambiguous across months
expect(label, `aria-label "${label}" should include a year`).to.match(/\d{4}/);
expect(cell.getAttribute('aria-pressed'), 'selected state is exposed').to.be.oneOf(['true', 'false']);
});
it(`V0-${name}. filler days from adjacent months are hidden, not announced`, async () => {
const el = await make();
await el.updateComplete;
const filler = el.shadowRoot!.querySelector('.day_date.filler') as HTMLElement | null;
if (!filler) return; // a month can start exactly on the first grid column
if (name === 'default') {
expect(filler.hasAttribute('aria-hidden'), 'published default filler: not aria-hidden').to.be.false;
return;
}
expect(filler.getAttribute('aria-hidden')).to.equal('true');
expect(filler.hasAttribute('role'), 'not a button — it does nothing').to.be.false;
expect(filler.getAttribute('tabindex'), 'not tabbable').to.equal('-1');
});
}
});
// ---- variant gate ----
it('V1. default variant renders no picker panel', async () => {
const el = await fixture(html``);
expect(el.shadowRoot!.querySelector('.base--date-time-picker')).to.be.null;
expect(el.shadowRoot!.querySelector('.tp-footer')).to.be.null;
expect(el.shadowRoot!.querySelector('.tp-time-fields')).to.be.null;
});
// ---- structure ----
it('V2. renders footer, chevron, time fields and timezone', async () => {
const el = await dtpFixture();
expect(el.shadowRoot!.querySelector('.base--date-time-picker')).to.exist;
expect(el.shadowRoot!.querySelector('.tp-footer')).to.exist;
expect(el.shadowRoot!.querySelector('.tp-chevron')).to.exist;
expect(el.shadowRoot!.querySelector('.tp-time-fields')).to.exist;
expect(el.shadowRoot!.querySelector('.tp-timezone')).to.exist;
expect(el.shadowRoot!.querySelector('.tp-set-to-now-button')).to.exist;
});
it('V3. renders four time fields', async () => {
const el = await dtpFixture();
expect(el.shadowRoot!.querySelectorAll('.tp-time-field').length).to.equal(4);
});
it('V4. no manual From/To date inputs', async () => {
const el = await dtpFixture();
expect(el.shadowRoot!.querySelector('.calender-input')).to.be.null;
expect(el.shadowRoot!.querySelector('.date-input')).to.be.null;
});
// ---- expand / collapse ----
it('V5. collapsed by default with 3 week rows (21 day cells)', async () => {
const el = await dtpFixture();
expect(el.shadowRoot!.querySelector('.base--tp-expanded')).to.be.null;
expect(el.shadowRoot!.querySelectorAll('.day_date').length).to.equal(21);
});
it('V6. chevron expands to the full month in place and collapses back', async () => {
const el = await dtpFixture();
const chevron = el.shadowRoot!.querySelector('.tp-chevron') as HTMLElement;
chevron.click();
await el.updateComplete;
expect(el.shadowRoot!.querySelector('.base--tp-expanded')).to.exist;
// the month is rendered by this element itself -- no nested calendar is composed
expect(el.shadowRoot!.querySelector('nile-calendar')).to.be.null;
const expandedCells = el.shadowRoot!.querySelectorAll('.day_date').length;
expect(expandedCells).to.be.greaterThan(27);
expect(expandedCells % 7).to.equal(0);
// every day of the displayed month is present
const daysInMonth = new Date(el.currentYear, el.currentMonth + 1, 0).getDate();
expect(el.shadowRoot!.querySelectorAll('.day_date:not(.filler)').length).to.equal(daysInMonth);
chevron.click();
await el.updateComplete;
expect(el.shadowRoot!.querySelector('.base--tp-expanded')).to.be.null;
expect(el.shadowRoot!.querySelectorAll('.day_date').length).to.equal(21);
});
it('V7. expanded: day clicks select silently, no nile-change leaks before Apply', async () => {
const el = await dtpFixture();
let leaked = false;
el.addEventListener('nile-change', () => { leaked = true; });
(el.shadowRoot!.querySelector('.tp-chevron') as HTMLElement).click();
await el.updateComplete;
(el.shadowRoot!.querySelectorAll('.day_date:not(.filler)')[5] as HTMLElement).click();
await el.updateComplete;
expect(leaked).to.be.false;
// ...but the selection was captured: apply is enabled
const apply = el.shadowRoot!.querySelector('.apply-button') as HTMLElement;
expect(apply.hasAttribute('disabled')).to.be.false;
// and Apply is what actually emits
apply.click();
await el.updateComplete;
expect(leaked).to.be.true;
});
it('V8. collapsed view contains today', async () => {
const el = await dtpFixture();
expect(el.shadowRoot!.querySelector('.current__date__dot')).to.exist;
});
it('V9. collapsed window centers today: previous week on top, next week below', async () => {
const el = await dtpFixture();
const cells = el.shadowRoot!.querySelectorAll('.day_date');
expect(cells.length).to.equal(21);
// first visible cell is the Sunday of the week BEFORE today's week
const today = new Date();
const windowStart = new Date(today);
windowStart.setDate(today.getDate() - today.getDay() - 7);
expect((cells[0] as HTMLElement).textContent!.trim()).to.contain(String(windowStart.getDate()));
// today sits in the middle row (cells 7..13)
const dotCell = el.shadowRoot!.querySelector('.current__date__dot')!.closest('.day_date');
const idx = Array.prototype.indexOf.call(cells, dotCell);
expect(idx).to.be.greaterThan(6);
expect(idx).to.be.lessThan(14);
});
// ---- selection / apply ----
it('V10. apply is disabled until a date is selected', async () => {
const el = await dtpFixture();
const apply = el.shadowRoot!.querySelector('.apply-button') as HTMLElement;
expect(apply.hasAttribute('disabled')).to.be.true;
clickDay(el, 5);
await el.updateComplete;
expect(apply.hasAttribute('disabled')).to.be.false;
});
it('V11. day click does NOT emit nile-change (batched until apply)', async () => {
const el = await dtpFixture();
let emitted = false;
el.addEventListener('nile-change', () => { emitted = true; });
clickDay(el, 5);
await el.updateComplete;
expect(emitted).to.be.false;
});
it('V12. apply emits nile-change with value and timezone', async () => {
const el = await dtpFixture();
clickDay(el, 5);
await el.updateComplete;
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
expect(detail).to.not.be.null;
// value is now a zone-encoded string: "YYYY-MM-DDTHH:mm:ss.SSS[GMT±HH:MM Zone]"
expect(detail.value).to.be.a('string');
expect(detail.value).to.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}\[GMT[+-]\d{2}:\d{2} .+\]$/);
expect(detail.value).to.include('[GMT+00:00 UTC]');
expect(detail.timezone).to.equal('UTC');
});
it('V13. time fields are merged into the applied value (selected-zone wall-clock)', async () => {
const el = await dtpFixture() as any;
clickDay(el, 5);
el.tpHours = 14; el.tpMinutes = 30; el.tpSeconds = 5; el.tpMilliseconds = 250;
await el.updateComplete;
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
// value shows the picked wall-clock as-entered (the selected-zone time)
expect(detail.value).to.include('T14:30:05.250');
expect(detail.value).to.include('[GMT+00:00 UTC]');
});
it('V14. unset time fields apply as 00:00:00.000', async () => {
const el = await dtpFixture();
clickDay(el, 5);
await el.updateComplete;
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
expect(detail.value).to.include('T00:00:00.000');
});
it('V14a. variant absolute payload includes localTimezoneValue (the browser-zone instant)', async () => {
const el = await dtpFixture() as any;
clickDay(el, 5);
el.tpHours = 9; el.tpMinutes = 0;
await el.updateComplete;
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
expect(detail.localTimezoneValue).to.be.instanceOf(Date);
// `value` is the entered wall clock (09:00); `localTimezoneValue` is that same moment through the
// selected zone, so the two are equal only when the selected zone matches the browser zone.
expect(detail.value).to.include('T09:00:00');
});
// ---- relative tab ----
it('V15. relative selection applies startDate/endDate with timezone', async () => {
const el = await dtpFixture() as any;
el.type = 'relative';
await el.updateComplete;
(el.shadowRoot!.querySelector('.duration__value') as HTMLElement).click();
await el.updateComplete;
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
expect(detail).to.not.be.null;
expect(detail.startDate).to.be.instanceOf(Date);
expect(detail.endDate).to.be.instanceOf(Date);
expect(detail.timezone).to.equal('UTC');
});
// ---- timezone ----
it('V16. timezone defaults to UTC', async () => {
const el = await dtpFixture();
expect(el.timezone).to.equal('UTC');
});
it('V17. timezone property can be preset', async () => {
const el = await fixture(html``);
expect(el.timezone).to.equal('Asia/Kolkata');
});
it('V17a. restores wall-clock and zone from an emitted (zone-encoded) value', async () => {
const el = await fixture(html``) as any;
await el.updateComplete;
expect(el.timezone).to.equal('Asia/Kolkata');
expect(el.value).to.be.instanceOf(Date);
expect(el.value.getFullYear()).to.equal(2026);
expect(el.value.getMonth()).to.equal(6); // July (0-indexed)
expect(el.value.getDate()).to.equal(15);
expect(el.tpHours).to.equal(14);
expect(el.tpMinutes).to.equal(30);
expect(el.tpSeconds).to.equal(5);
expect(el.tpMilliseconds).to.equal(250);
});
it('V17b. an emitted value fed back in re-emits identically (round-trip)', async () => {
const original = '2026-07-15T14:30:05.250[GMT+05:30 Asia/Kolkata]';
const el = await fixture(html``) as any;
await el.updateComplete;
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
expect(detail).to.not.be.null;
expect(detail.value).to.equal(original);
expect(detail.timezone).to.equal('Asia/Kolkata');
});
it('V17c. a non-zoned value still parses and leaves timezone at default', async () => {
const el = await fixture(html``) as any;
await el.updateComplete;
expect(el.value).to.be.instanceOf(Date);
expect(el.value.getDate()).to.equal(15);
expect(el.timezone).to.equal('UTC');
});
it('V17d. an unrecognized zone in the value falls back to UTC (wall-clock kept)', async () => {
const el = await fixture(html``) as any;
await el.updateComplete;
expect(el.timezone).to.equal('UTC');
expect(el.value).to.be.instanceOf(Date);
expect(el.value.getDate()).to.equal(15);
expect(el.tpHours).to.equal(14);
expect(el.tpMinutes).to.equal(30);
});
it('V17e. an unrecognized-zone fallback keeps the month (March stays March)', async () => {
// Mars/Phobos fails instantFromBracketedValue, so this seeds through the parseZonedValue fallback.
const el = await fixture(html``) as any;
await el.updateComplete;
expect(el.timezone).to.equal('UTC');
expect(el.value.getFullYear()).to.equal(2026);
expect(el.value.getMonth()).to.equal(2); // March (0-indexed)
expect(el.value.getDate()).to.equal(15);
expect(el.currentMonth).to.equal(2);
const march = new Date(2026, 2).toLocaleString('default', { month: 'long' });
expect(el.shadowRoot!.querySelector('.calendar-header__center')!.textContent).to.contain(march);
});
it('V17f. externally clearing the value attribute also clears the time spinners', async () => {
const el = await fixture(html``) as any;
await el.updateComplete;
expect(el.tpHours).to.equal(14);
el.setAttribute('value', '');
await el.updateComplete;
expect(el.value).to.be.null;
expect(el.tpHours).to.be.null;
expect(el.tpMinutes).to.be.null;
expect(el.tpSeconds).to.be.null;
expect(el.tpMilliseconds).to.be.null;
expect(el.timezone, 'clearing the value must not reset the zone').to.equal('Asia/Kolkata');
});
it('V17g. clicking the already-selected seeded day keeps the seeded time', async () => {
// seeding stores the day at noon; a click builds midnight — same day, so the time must survive
const el = await fixture(html``) as any;
await el.updateComplete;
(el.shadowRoot!.querySelector('.day_date.selected-date:not(.filler)') as HTMLElement).click();
await el.updateComplete;
expect(el.value.getDate()).to.equal(15);
expect(el.tpHours).to.equal(14);
expect(el.tpMinutes).to.equal(30);
expect(el.tpSeconds).to.equal(5);
expect(el.tpMilliseconds).to.equal(250);
});
it('V17h. doubleClickUnselect: one click on the seeded selected day unselects it', async () => {
const el = await fixture(html``) as any;
await el.updateComplete;
expect(el.doubleClickUnselect).to.be.true;
let cleared = false;
el.addEventListener('nile-clear', () => { cleared = true; });
(el.shadowRoot!.querySelector('.day_date.selected-date:not(.filler)') as HTMLElement).click();
await el.updateComplete;
expect(el.value).to.be.null;
expect(el.tpHours).to.be.null;
expect(cleared).to.be.true;
});
it('V18. use local timezone link sets the browser timezone', async () => {
const el = await dtpFixture();
(el.shadowRoot!.querySelector('.tp-timezone-local-link') as HTMLElement).click();
await el.updateComplete;
expect(el.timezone).to.equal(Intl.DateTimeFormat().resolvedOptions().timeZone);
});
// The dropdown is built from Intl.supportedValuesOf while the parser validated against a static list;
// they disagree on ~70 zones, so a picked zone was silently downgraded to UTC. These cover that seam.
describe('timezone alias round-trip (validated via Intl, not the static list)', () => {
// `Asia/Calcutta` is what Chrome's Intl reports for India and is deliberately absent from the static
// TIMEZONES list, so a membership validator drops +05:30. `isValidTimezone` asks Intl and echoes it back.
const ALIAS = 'Asia/Calcutta';
const ALIAS_VALUE = `2026-07-15T14:30:05.250[GMT+05:30 ${ALIAS}]`;
// 14:30:05.250 at +05:30 → 09:00:05.250Z. Derived from the ZONE, so this number is
// correct on every host.
const ALIAS_EPOCH = Date.UTC(2026, 6, 15, 9, 0, 5, 250);
const applyAndCapture = async (el: any) => {
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
await el.updateComplete;
return detail;
};
it('TZ1. an aliased zone in `value` survives Apply → reload → Apply', async () => {
const el = await fixture(
html``
) as any;
await el.updateComplete;
expect(el.timezone, 'a VALID alias must not be downgraded to UTC').to.equal(ALIAS);
const first = await applyAndCapture(el);
expect(first.timezone).to.equal(ALIAS);
expect(first.unixEpoch, 'resolved through the alias at +05:30').to.equal(ALIAS_EPOCH);
expect(first.localTimezoneValue.getTime(), 'localTimezoneValue === unixEpoch')
.to.equal(first.unixEpoch);
expect(first.value, 'the supplied spelling is echoed back, not canonicalized')
.to.contain(`[GMT+05:30 ${ALIAS}]`);
expect(first.value, 'and the zone reading of the instant').to.contain('2026-07-15T14:30:05.250');
// emit → store → reload: feed the emitted value into a FRESH component
const el2 = await fixture(
html``
) as any;
await el2.updateComplete;
expect(el2.timezone, 'still not downgraded on the second pass').to.equal(ALIAS);
const second = await applyAndCapture(el2);
expect(second.unixEpoch, 'the instant survives emit → store → reload → Apply')
.to.equal(first.unixEpoch);
expect(second.value, 'and the serialization is stable').to.equal(first.value);
expect(second.timezone).to.equal(ALIAS);
});
// NOTE: TZ2 does NOT exercise `isValidTimezone` — assigning `timezone` directly bypasses validation.
// Kept as a contract test for the SELECT path; TZ1 and TZ3 are the ones with teeth against a regression.
it('TZ2. an aliased zone SELECTED on the panel resolves through the alias', async () => {
const el = await dtpFixture() as any;
el.timezone = ALIAS;
el.currentYear = 2026;
el.currentMonth = 6; // July, 0-indexed
await el.updateComplete;
clickDay(el, 14); // the 15th non-filler cell → 2026-07-15
el.tpHours = 14; el.tpMinutes = 30; el.tpSeconds = 5; el.tpMilliseconds = 250;
await el.updateComplete;
const detail = await applyAndCapture(el);
expect(detail.timezone, 'the selected spelling is kept').to.equal(ALIAS);
expect(detail.unixEpoch, 'the picked wall clock read in the ALIAS zone')
.to.equal(ALIAS_EPOCH);
expect(detail.value).to.contain(`[GMT+05:30 ${ALIAS}]`);
});
it('TZ3. the alias and its canonical spelling denote the SAME instant', async () => {
// Same digits, two valid spellings of one zone. A downgrade to UTC would move
// only the aliased one, so this pins the two together.
const seen: { zone: string; epoch: number }[] = [];
for (const zone of [ALIAS, 'Asia/Kolkata']) {
const el = await fixture(
html``
) as any;
await el.updateComplete;
expect(el.timezone, `${zone}: spelling preserved`).to.equal(zone);
seen.push({ zone, epoch: (await applyAndCapture(el)).unixEpoch });
}
expect(seen[0].epoch, 'alias and canonical must resolve identically')
.to.equal(seen[1].epoch);
expect(seen[0].epoch).to.equal(ALIAS_EPOCH);
});
});
// allowedDates used to be cosmetic in this variant: a .not-allowed cell was still selectable. Now
// enforced in tpSelectDate; the DEFAULT calendar keeps its behaviour, as changing it would move nile-date-picker.
describe('allowedDates enforcement', () => {
const ALLOWED = JSON.stringify({ startDate: '2026-06-10', endDate: '2026-06-20' });
const june = async () => {
const el = await fixture(html`
`);
el.currentYear = 2026; el.currentMonth = 5; el.expanded = true;
await el.updateComplete;
return el;
};
const blockedCell = (el: NileCalendar) =>
el.shadowRoot!.querySelector('.day_date.not-allowed:not(.filler)') as HTMLElement;
it('V21. a disallowed day cannot be selected by click', async () => {
const el = await june();
const cell = blockedCell(el);
expect(cell, 'fixture should render a blocked cell').to.exist;
expect(cell.getAttribute('aria-disabled')).to.equal('true');
cell.click();
await el.updateComplete;
expect(el.value, 'disallowed date must not be selected').to.be.null;
expect(
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).hasAttribute('disabled'),
'Apply must stay disabled'
).to.be.true;
});
it('V22. a disallowed day cannot be selected by keyboard either', async () => {
const el = await june();
const cell = blockedCell(el);
for (const key of ['Enter', ' ']) {
cell.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
await el.updateComplete;
expect(el.value, `${key} must not select a disallowed date`).to.be.null;
}
});
it('V23. an allowed day is still selectable', async () => {
const el = await june();
const allowed = Array.from(
el.shadowRoot!.querySelectorAll('.day_date:not(.filler):not(.not-allowed)')
) as HTMLElement[];
expect(allowed.length, 'some days must remain selectable').to.be.greaterThan(0);
allowed[0].click();
await el.updateComplete;
expect(el.value).to.not.be.null;
expect(el.value!.getMonth()).to.equal(5);
});
it('V24. an aria-hidden filler cell is not keyboard-selectable', async () => {
const el = await june();
const filler = el.shadowRoot!.querySelector('.day_date.filler') as HTMLElement;
if (filler) {
filler.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
await el.updateComplete;
expect(el.value).to.be.null;
}
});
it('V25. the DEFAULT calendar keeps its existing (unguarded) behaviour', async () => {
const el = await fixture(html`
`);
el.currentYear = 2026; el.currentMonth = 5;
await el.updateComplete;
const cell = el.shadowRoot!.querySelector('.day_date.not-allowed:not(.filler)') as HTMLElement;
expect(cell, 'default should still render blocked cells').to.exist;
cell.click();
await el.updateComplete;
// documents the deliberate scope of the fix: the variant only
expect(el.value, 'default calendar behaviour is intentionally unchanged').to.not.be.null;
});
});
it('V19a. every zone offered in the dropdown survives a value round-trip', async () => {
const el = await dtpFixture();
const select = el.shadowRoot!.querySelector('.tp-timezone-select') as any;
const offered = Array.from(
select.querySelectorAll('nile-option') as NodeListOf,
(opt: HTMLElement) => opt.getAttribute('value') as string
);
expect(offered.length).to.be.greaterThan(300);
const downgraded: string[] = [];
for (const zone of offered) {
const probe = await fixture(
html``
);
if (probe.timezone !== zone) downgraded.push(`${zone} -> ${probe.timezone}`);
}
expect(downgraded, `zones lost on read-back: ${downgraded.slice(0, 5).join(', ')}`).to.deep.equal([]);
});
it('V19b. the host zone survives "use local timezone" -> Apply -> re-read', async () => {
const hostZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const el = await dtpFixture();
clickDay(el, 5);
await el.updateComplete;
(el.shadowRoot!.querySelector('.tp-timezone-local-link') as HTMLElement).click();
await el.updateComplete;
expect(el.timezone).to.equal(hostZone);
let emitted: any = null;
el.addEventListener('nile-change', (e: any) => { emitted = e.detail; });
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
await el.updateComplete;
expect(emitted.timezone).to.equal(hostZone);
// feeding the emitted value back in must not shift the instant
const back = await fixture(
html``
);
expect(back.timezone, 'host zone must not be downgraded to UTC').to.equal(hostZone);
let reapplied: any = null;
back.addEventListener('nile-change', (e: any) => { reapplied = e.detail; });
(back.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
await back.updateComplete;
expect(reapplied.unixEpoch, 'instant must be stable across a round-trip').to.equal(emitted.unixEpoch);
});
it('V19c. an invalid zone in a value still falls back to UTC', async () => {
for (const bad of ['Foo/Bar', 'GMT+5', 'Not A Zone']) {
const el = await fixture(
html``
);
expect(el.timezone, `${bad} should not be accepted`).to.equal('UTC');
}
});
it('V19. timezone options come from the browser and include UTC and the local zone', async () => {
const el = await dtpFixture();
const select = el.shadowRoot!.querySelector('.tp-timezone-select') as any;
const options: string[] = Array.from(
select.querySelectorAll('nile-option') as NodeListOf,
(opt: HTMLElement) => opt.getAttribute('value') as string
);
expect(options).to.include('UTC');
// the local zone must always be selectable — the reason for using Intl
expect(options).to.include(Intl.DateTimeFormat().resolvedOptions().timeZone);
expect(options.length).to.be.greaterThan(300);
});
// ---- set to now ----
it('V20. set to now fills time fields and selects today when nothing selected', async () => {
const el = await dtpFixture() as any;
(el.shadowRoot!.querySelector('.tp-set-to-now-button') as HTMLElement).click();
await el.updateComplete;
expect(el.tpHours).to.be.a('number');
expect(el.tpMinutes).to.be.a('number');
expect(el.tpSeconds).to.be.a('number');
expect(el.tpMilliseconds).to.be.a('number');
expect(el.value).to.be.instanceOf(Date);
const today = new Date();
expect(el.value.getDate()).to.equal(today.getDate());
expect(el.value.getMonth()).to.equal(today.getMonth());
});
it('V21. set to now overrides an already-selected date with today and sets the local timezone', async () => {
const el = await dtpFixture() as any;
clickDay(el, 2);
await el.updateComplete;
(el.shadowRoot!.querySelector('.tp-set-to-now-button') as HTMLElement).click();
await el.updateComplete;
const today = new Date();
expect(el.value.getDate()).to.equal(today.getDate());
expect(el.value.getMonth()).to.equal(today.getMonth());
expect(el.timezone).to.equal(Intl.DateTimeFormat().resolvedOptions().timeZone);
});
it('V21b. disabled: Set to now ignores click and Enter/Space, and its glyph uses the disabled token', async () => {
const el = await dtpFixture() as any;
el.disabled = true;
await el.updateComplete;
const btn = el.shadowRoot!.querySelector('.tp-set-to-now-button') as HTMLElement;
btn.click();
for (const key of ['Enter', ' ']) btn.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, composed: true }));
await el.updateComplete;
expect(el.value, 'no date selected').to.be.null;
expect(el.tpHours, 'no time filled').to.be.null;
expect(btn.hasAttribute('disabled')).to.be.true;
expect(btn.querySelector('nile-glyph')!.getAttribute('color')).to.equal('var(--nile-colors-dark-500, var(--ng-colors-fg-disabled))');
el.disabled = false;
await el.updateComplete;
expect(btn.querySelector('nile-glyph')!.hasAttribute('color'), 'enabled keeps the default color').to.be.false;
});
// ---- reset ----
it('V22. reset clears date, time fields and restores the configured timezone (default UTC) and emits nile-clear', async () => {
const el = await dtpFixture() as any;
clickDay(el, 5);
el.tpHours = 9;
el.handleTimezoneChange(new CustomEvent('nile-change', { detail: { value: 'Asia/Kolkata' } })); // picked in the dropdown, not configured
await el.updateComplete;
expect(el.timezone).to.equal('Asia/Kolkata');
let cleared = false;
el.addEventListener('nile-clear', () => { cleared = true; });
(el.shadowRoot!.querySelector('.clear-button') as HTMLElement).click();
await el.updateComplete;
expect(el.value).to.be.null;
expect(el.tpHours).to.be.null;
expect(el.timezone).to.equal('UTC');
expect(cleared).to.be.true;
});
it('V23. relative tab has no Set to now; absolute tab does', async () => {
const el = await dtpFixture() as any;
expect(el.shadowRoot!.querySelector('.tp-set-to-now-button')).to.exist;
el.type = 'relative';
await el.updateComplete;
expect(el.shadowRoot!.querySelector('.tp-set-to-now-button')).to.be.null;
expect(el.shadowRoot!.querySelector('.tp-footer--relative')).to.exist;
// reset/apply still present, like the default variant footer
expect(el.shadowRoot!.querySelector('.clear-button')).to.exist;
expect(el.shadowRoot!.querySelector('.apply-button')).to.exist;
});
// The date-time-picker grid is a 21-cell window around today that re-flows after a selection, so
// cell INDICES are not stable (and depend on today's date). Select by the date a click actually
// produces instead: try cells until `value` lands on a different calendar day. Uses only local
// y/m/d from `value`, so it is independent of the host timezone.
const dayKey = (d: Date | null) => (d ? `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` : null);
const clickDifferentDay = async (el: any): Promise => {
const before = dayKey(el.value);
const cells = Array.from(el.shadowRoot!.querySelectorAll('.day_date:not(.filler)')) as HTMLElement[];
for (const cell of cells) {
cell.click();
await el.updateComplete;
const now = dayKey(el.value);
if (now !== before) return now!;
}
throw new Error('no selectable day produced a different value');
};
const setTime = async (el: any) => {
el.tpHours = 9; el.tpMinutes = 45; el.tpSeconds = 30; el.tpMilliseconds = 500;
await el.updateComplete;
};
it('V24. picking a different date resets the time fields', async () => {
const el = await dtpFixture() as any;
const first = await clickDifferentDay(el);
await setTime(el);
const second = await clickDifferentDay(el);
expect(second, 'precondition: a genuinely different day was selected').to.not.equal(first);
expect(el.tpHours).to.be.null;
expect(el.tpMinutes).to.be.null;
expect(el.tpSeconds).to.be.null;
expect(el.tpMilliseconds).to.be.null;
});
it('V24b. selecting the SAME date again keeps the time fields', async () => {
const el = await dtpFixture() as any;
const picked = await clickDifferentDay(el);
await setTime(el);
const selected = el.shadowRoot!.querySelector('.day_date.selected-date') as HTMLElement;
expect(selected, 'precondition: the picked day is rendered as selected').to.exist;
selected.click();
await el.updateComplete;
expect(dayKey(el.value), 'still the same day').to.equal(picked);
expect(el.tpHours).to.equal(9);
expect(el.tpMinutes).to.equal(45);
expect(el.tpSeconds).to.equal(30);
expect(el.tpMilliseconds).to.equal(500);
});
it('V24c. a different date, then back to a new one, resets the time each time', async () => {
const el = await dtpFixture() as any;
await clickDifferentDay(el);
await setTime(el);
await clickDifferentDay(el);
expect(el.tpHours).to.be.null;
await setTime(el);
await clickDifferentDay(el);
expect(el.tpHours).to.be.null;
expect(el.tpMilliseconds).to.be.null;
});
// ---- time-field auto-correction (over-max -> 0 + first digit, no error) ----
const typeTimeField = async (el: any, index: number, raw: string) => {
const field = el.shadowRoot!.querySelectorAll('.tp-time-field')[index] as any;
field.dispatchEvent(new CustomEvent('nile-focus', { detail: {} }));
field.value = raw;
field.dispatchEvent(new CustomEvent('nile-input', { detail: { value: raw } }));
await el.updateComplete;
return field;
};
it('V25. an over-max hour auto-corrects to 0 + first digit (67 -> 06)', async () => {
const el = await dtpFixture() as any;
clickDay(el, 5);
await el.updateComplete;
const hours = await typeTimeField(el, 0, '67');
expect(el.tpHours).to.equal(6);
expect(hours.value).to.equal('06');
});
it('V26. 28 -> 02 keeps the first digit; a valid value passes through', async () => {
const el = await dtpFixture() as any;
clickDay(el, 5);
await el.updateComplete;
await typeTimeField(el, 0, '28');
expect(el.tpHours).to.equal(2);
const hours = await typeTimeField(el, 0, '21');
expect(el.tpHours).to.equal(21);
expect(hours.value).to.equal('21');
});
it('V27. the same correction applies to minutes (67 -> 06)', async () => {
const el = await dtpFixture() as any;
clickDay(el, 5);
await el.updateComplete;
const minutes = await typeTimeField(el, 1, '67');
expect(el.tpMinutes).to.equal(6);
expect(minutes.value).to.equal('06');
});
it('V28. no error state is set on the time fields', async () => {
const el = await dtpFixture() as any;
clickDay(el, 5);
await el.updateComplete;
const hours = await typeTimeField(el, 0, '99');
expect(hours.error).to.not.be.ok; // never flagged red
});
it('V29. time fields strip non-digits (-, ., letters)', async () => {
const el = await dtpFixture() as any;
clickDay(el, 5);
await el.updateComplete;
let hours = await typeTimeField(el, 0, '0-4');
expect(el.tpHours).to.equal(4);
expect(hours.value).to.equal('04');
hours = await typeTimeField(el, 0, '09.');
expect(el.tpHours).to.equal(9);
expect(hours.value).to.equal('09');
});
it('V30. odometer entry: digits shift in from the right over "00"', async () => {
const el = await dtpFixture() as any;
clickDay(el, 5); // all time fields show 00
await el.updateComplete;
// simulate typing "1" then "4" into the pre-filled "00" hours field
let hours = await typeTimeField(el, 0, '001'); // 00 + 1
expect(hours.value).to.equal('01');
hours = await typeTimeField(el, 0, '0014'); // ...then 4
expect(hours.value).to.equal('14');
expect(el.tpHours).to.equal(14);
});
});
/** Default ↔ date-time-picker payload parity: the variant emits the SAME events carrying a SUPERSET of
* the default payload (plus its own `timezone`), and adding it changes nothing the default emits or renders. */
describe('NileCalendar default ↔ date-time-picker payload parity & zero-breakage', () => {
const defaultSingle = () => fixture(html``);
const defaultRange = () => fixture(html``);
const variant = () => fixture(html``);
const realDays = (root: NileCalendar | any) =>
root.shadowRoot!.querySelectorAll('.day_date:not(.filler)') as NodeListOf;
const clickDay = (root: any, i = 5) => (realDays(root)[i] as HTMLElement).click();
// capture BOTH the canonical and the deprecated event in one go
const listen = (el: HTMLElement) => {
const seen: { change?: any; changed?: any; changeCount: number } = { changeCount: 0 };
el.addEventListener('nile-change', ((e: CustomEvent) => { seen.change = e.detail; seen.changeCount += 1; }) as EventListener);
el.addEventListener('nile-changed', ((e: CustomEvent) => { seen.changed = e.detail; }) as EventListener);
return seen;
};
// ── default calendar baseline (the contract the variant must not break) ──────
it('P1. default single emits nile-change + nile-changed with exactly { value: Date }', async () => {
const el = await defaultSingle();
const seen = listen(el);
clickDay(el, 5);
await el.updateComplete;
expect(seen.change).to.not.be.undefined;
expect(seen.change.value).to.be.instanceOf(Date);
// deprecated alias fires with the identical detail
expect(seen.changed).to.equal(seen.change);
// no extra keys leaked onto the default payload
expect(Object.keys(seen.change)).to.deep.equal(['value']);
expect(seen.change.timezone).to.be.undefined;
});
it('P2. default single emits immediately on day click (no Apply button exists)', async () => {
const el = await defaultSingle();
expect(el.shadowRoot!.querySelector('.apply-button')).to.be.null;
const seen = listen(el);
clickDay(el, 3);
await el.updateComplete;
expect(seen.changeCount).to.equal(1);
});
it('P3. default range emits { startDate, endDate } on Apply — no timezone key', async () => {
const el = await defaultRange();
clickDay(el, 3);
await el.updateComplete;
clickDay(el, 9);
await el.updateComplete;
const seen = listen(el);
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
expect(seen.change.startDate).to.be.instanceOf(Date);
expect(seen.change.endDate).to.be.instanceOf(Date);
expect(seen.changed).to.equal(seen.change);
expect(Object.keys(seen.change).sort()).to.deep.equal(['endDate', 'startDate']);
expect(seen.change.timezone).to.be.undefined;
});
// ── variant emits the default payload (superset) + its own timezone ──────────
it('P4. variant absolute payload is a SUPERSET of the default single payload', async () => {
// default baseline
const def = await defaultSingle();
const defSeen = listen(def);
clickDay(def, 5);
await def.updateComplete;
// variant
const el = await variant();
clickDay(el, 5);
await el.updateComplete;
const seen = listen(el);
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
// the default emits a Date under .value; the variant emits a zone-encoded
// string under the same key (carries the selected timezone)
expect(defSeen.change.value).to.be.instanceOf(Date);
expect(seen.change.value).to.be.a('string');
// every key the default emits is still present on the variant payload
Object.keys(defSeen.change).forEach(k => expect(seen.change).to.have.property(k));
// ...plus its own timezone
expect(seen.change.timezone).to.equal('UTC');
// the deprecated alias is dropped for the variant
expect(seen.changed).to.be.undefined;
});
it('P5. variant relative payload is a SUPERSET of the default range payload', async () => {
// default range baseline keys
const def = await defaultRange();
clickDay(def, 3);
await def.updateComplete;
clickDay(def, 9);
await def.updateComplete;
const defSeen = listen(def);
(def.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
// variant relative
const el = await variant() as any;
el.type = 'relative';
await el.updateComplete;
(el.shadowRoot!.querySelector('.duration__value') as HTMLElement).click();
await el.updateComplete;
const seen = listen(el);
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
expect(seen.change.startDate).to.be.instanceOf(Date);
expect(seen.change.endDate).to.be.instanceOf(Date);
Object.keys(defSeen.change).forEach(k => expect(seen.change).to.have.property(k));
expect(seen.change.timezone).to.equal('UTC');
expect(seen.changed).to.be.undefined;
});
// ── value equality: the SAME picked DATE produces the SAME payload value ─────
// The two render different day grids, so match by day-of-month number rather than by cell index.
const clickDayNum = (root: any, n: number) => {
const cell = ([...realDays(root)] as HTMLElement[]).find(
d => d.textContent!.trim() === String(n)
);
if (!cell) throw new Error(`day ${n} not found in current month`);
cell.click();
};
// The variant renders a collapsed 21-day window anchored on "today", so a hard-coded day of month is only on the grid
// for part of each month. These tests freeze `new Date()` (the component does not go through `Date.now`) at local
// noon on 2026-06-15, a Monday whose window (Jun 7-27) always contains day 15, in every host timezone.
const withFrozenToday = async (fn: () => Promise): Promise => {
const RealDate = Date;
const FROZEN = new RealDate(2026, 5, 15, 12, 0, 0, 0).getTime();
class FrozenDate extends RealDate {
constructor(...args: any[]) {
if (args.length === 0) super(FROZEN);
else super(...(args as [any]));
}
static now() { return FROZEN; }
}
(globalThis as any).Date = FrozenDate;
try { return await fn(); } finally { (globalThis as any).Date = RealDate; }
};
it('P5a. single — same picked date with no time entered: variant `value` string names the same wall-clock day as the default Date', () => withFrozenToday(async () => {
// default: emits on click, browser-local midnight (a Date)
const def = await defaultSingle();
const defSeen = listen(def);
clickDayNum(def, 15);
await def.updateComplete;
// variant `value` is a zone-encoded string carrying the picked wall-clock
// as-entered — its date/time portion names the same calendar day at midnight.
const el = await variant();
const seen = listen(el);
clickDayNum(el, 15);
await el.updateComplete;
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
const d = defSeen.change.value as Date;
const p = (n: number) => String(n).padStart(2, '0');
const wallClock = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T00:00:00.000`;
expect(seen.change.value).to.be.a('string');
expect(seen.change.value).to.include(wallClock);
}));
it('P5b. single — entering a time only layers time-of-day on the SAME calendar day', () => withFrozenToday(async () => {
const def = await defaultSingle();
const defSeen = listen(def);
clickDayNum(def, 15);
await def.updateComplete;
const el = await variant() as any;
const seen = listen(el);
clickDayNum(el, 15);
await el.updateComplete;
// enter 09:30:15 before applying
el.tpHours = 9; el.tpMinutes = 30; el.tpSeconds = 15;
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
const d = defSeen.change.value as Date, v = seen.change.value as string;
const p = (n: number) => String(n).padStart(2, '0');
// the date the old calendar resolves is unchanged — only the clock moves
const day = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
expect(v).to.include(`${day}T09:30:15`);
}));
it('P5d. single — localTimezoneValue honors the selected timezone (same wall-clock in two zones differs by the zone offset)', () => withFrozenToday(async () => {
const pickMidnight = async (tz: string) => {
const el = await variant() as any;
el.timezone = tz;
const seen = listen(el);
clickDayNum(el, 15);
await el.updateComplete;
el.tpHours = 0; el.tpMinutes = 0; el.tpSeconds = 0; el.tpMilliseconds = 0;
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
return seen.change as any;
};
const utc = await pickMidnight('UTC');
const kolkata = await pickMidnight('Asia/Kolkata'); // UTC+5:30, no DST
// `value` shows the entered wall-clock (00:00) regardless of zone, and each
// carries its own selected zone name
expect(utc.value).to.include('T00:00:00');
expect(kolkata.value).to.include('T00:00:00');
expect(utc.value).to.include('[GMT+00:00 UTC]');
expect(kolkata.value).to.include('[GMT+05:30 Asia/Kolkata]');
// `localTimezoneValue` is the absolute instant: midnight UTC reads as 00:00Z,
// and midnight Kolkata is 5h30m earlier in absolute terms than midnight UTC.
expect(utc.localTimezoneValue.getUTCHours()).to.equal(0);
expect(utc.localTimezoneValue.getTime() - kolkata.localTimezoneValue.getTime())
.to.equal((5 * 60 + 30) * 60 * 1000);
}));
it('P5c. range — default emits an ordered, distinct { startDate, endDate } and the variant range stays ordered too', async () => {
// default range: two day clicks → ordered pair (the old contract)
const def = await defaultRange();
clickDay(def, 3);
await def.updateComplete;
clickDay(def, 9);
await def.updateComplete;
const defSeen = listen(def);
(def.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
expect(defSeen.change.startDate.getTime()).to.be.lessThan(defSeen.change.endDate.getTime());
// variant range (relative tab) → still an ordered Date pair
const el = await variant() as any;
el.type = 'relative';
await el.updateComplete;
(el.shadowRoot!.querySelector('.duration__value') as HTMLElement).click();
await el.updateComplete;
const seen = listen(el);
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
expect(seen.change.startDate.getTime()).to.be.at.most(seen.change.endDate.getTime());
});
it('P6. variant fires nile-change only; the deprecated nile-changed is dropped for the variant', async () => {
const el = await variant();
clickDay(el, 5);
await el.updateComplete;
let change = false, changed = false;
el.addEventListener('nile-change', () => { change = true; });
el.addEventListener('nile-changed', () => { changed = true; });
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
expect(change).to.be.true;
expect(changed).to.be.false;
});
// ── zero breakage: the default render/behaviour is untouched by the variant ──
it('P7. variant="default" renders identically to no variant (no picker panel)', async () => {
const implicit = await defaultSingle();
const explicit = await fixture(html``);
for (const el of [implicit, explicit]) {
expect(el.shadowRoot!.querySelector('.base')).to.exist;
expect(el.shadowRoot!.querySelector('.base--date-time-picker')).to.be.null;
expect(el.shadowRoot!.querySelector('.tp-footer')).to.be.null;
expect(el.shadowRoot!.querySelector('.tp-time-fields')).to.be.null;
expect(el.shadowRoot!.querySelector('.tp-chevron')).to.be.null;
expect(el.shadowRoot!.querySelector('.tp-timezone')).to.be.null;
}
});
it('P8. default calendar exposes no timezone-related public state', async () => {
const el = await defaultSingle() as any;
// timezone defaults to UTC as a property but the default UI never surfaces it
expect(el.shadowRoot!.querySelector('.tp-timezone-select')).to.be.null;
expect(el.shadowRoot!.querySelector('.tp-set-to-now-button')).to.be.null;
});
it('P9. default single fires exactly one nile-change per click — variant batches until Apply', async () => {
const def = await defaultSingle();
const defSeen = listen(def);
clickDay(def, 5);
await def.updateComplete;
expect(defSeen.changeCount).to.equal(1);
const el = await variant();
const seen = listen(el);
clickDay(el, 5);
await el.updateComplete;
expect(seen.changeCount).to.equal(0); // nothing emitted until Apply
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
expect(seen.changeCount).to.equal(1);
});
it('P10. both variants emit nile-clear on reset/clear (same clear contract)', async () => {
// default range reset
const def = await defaultRange() as any;
clickDay(def, 3); await def.updateComplete;
clickDay(def, 9); await def.updateComplete;
let defCleared = false;
def.addEventListener('nile-clear', () => { defCleared = true; });
def.clearDate();
expect(defCleared).to.be.true;
// variant reset
const el = await variant();
clickDay(el, 5); await el.updateComplete;
let cleared = false;
el.addEventListener('nile-clear', () => { cleared = true; });
(el.shadowRoot!.querySelector('.clear-button') as HTMLElement).click();
await el.updateComplete;
expect(cleared).to.be.true;
});
it('P11. switching variant at runtime back to default restores the default render', async () => {
const el = await variant() as any;
expect(el.shadowRoot!.querySelector('.base--date-time-picker')).to.exist;
el.variant = 'default';
await el.updateComplete;
expect(el.shadowRoot!.querySelector('.base--date-time-picker')).to.be.null;
expect(el.shadowRoot!.querySelector('.tp-footer')).to.be.null;
expect(el.shadowRoot!.querySelector('.base')).to.exist;
});
// tab support differs by variant in SINGLE mode
it('P12. both variants hide the Absolute/Relative tabs until asked; showTypes reveals them', async () => {
// default single: the switcher is hidden (tabs are range-only)
const def = await defaultSingle();
const defConfig = def.shadowRoot!.querySelector('.calendar-config');
expect(defConfig, '.calendar-config should render').to.exist;
expect(defConfig!.classList.contains('hidden')).to.be.true;
// variant single: hidden by default too -- showTypes is opt-in
const el = await variant();
expect(el.showTypes).to.be.false;
expect(
el.shadowRoot!.querySelector('.calendar-config')!.classList.contains('hidden')
).to.be.true;
// ...and setting it reveals the tabs
el.showTypes = true;
await el.updateComplete;
expect(
el.shadowRoot!.querySelector('.calendar-config')!.classList.contains('hidden')
).to.be.false;
expect(el.shadowRoot!.querySelector('nile-tab[panel="absolute"]')).to.exist;
expect(el.shadowRoot!.querySelector('nile-tab[panel="relative"]')).to.exist;
});
it('P13. variant single emits the absolute payload on Apply, then the relative payload after switching tabs', () => withFrozenToday(async () => {
const el = await variant() as any;
const seen = listen(el);
// Absolute tab (default): pick a day, Apply -> { value, localTimezoneValue, timezone }
clickDayNum(el, 15);
await el.updateComplete;
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
expect(seen.change.value).to.be.a('string');
expect(seen.change.localTimezoneValue).to.be.instanceOf(Date);
expect(seen.change).to.not.have.property('startDate');
// Relative tab: pick a duration, Apply -> { startDate, endDate, timezone }
el.type = 'relative';
await el.updateComplete;
(el.shadowRoot!.querySelector('.duration__value') as HTMLElement).click();
await el.updateComplete;
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
expect(seen.change.startDate).to.be.instanceOf(Date);
expect(seen.change.endDate).to.be.instanceOf(Date);
expect(seen.change.timezone).to.exist;
}));
it('P14. relative payload timezone is always UTC, even after picking a non-UTC zone on Absolute', async () => {
const el = await variant() as any;
el.timezone = 'Asia/Kolkata'; // as if a zone was chosen on the Absolute tab
await el.updateComplete;
el.type = 'relative';
await el.updateComplete;
const seen = listen(el);
(el.shadowRoot!.querySelector('.duration__value') as HTMLElement).click();
await el.updateComplete;
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
expect(seen.change.timezone).to.equal('UTC');
});
it('P15. variant relative shows the manual Duration/Unit input without range; non-range default does not', async () => {
// variant (no range): the relative tab exposes the manual Duration input
const el = await variant() as any;
el.type = 'relative';
await el.updateComplete;
expect(el.shadowRoot!.querySelector('.duration-input')).to.exist;
// default, no range: the manual Duration input stays hidden (range-only)
const def = await defaultSingle() as any;
def.type = 'relative';
await def.updateComplete;
expect(def.shadowRoot!.querySelector('.duration-input')).to.be.null;
});
});
/** structuredInput — masked/structured entry for the in-panel manual inputs. Only exercised in range
* mode with showManualInputs; when false the inputs remain free-form. */
describe('NileCalendar structuredInput masked manual entry', () => {
const rangeFixture = (dateFormat = 'MM/DD/YYYY', structured = true) =>
fixture(html`
`);
const nativeOf = async (el: NileCalendar, selector: string) => {
const nileInput = el.shadowRoot!.querySelector(selector) as any;
await nileInput.updateComplete;
return { nileInput, native: nileInput.input as HTMLInputElement };
};
const typeDigits = async (el: NileCalendar, native: HTMLInputElement, keys: string) => {
for (const key of keys) {
native.dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true })
);
}
await el.updateComplete;
};
it('S1. structuredInput defaults to false', async () => {
const el = await fixture(html``);
expect(el.structuredInput).to.be.false;
});
it('S2. with structuredInput false the manual date input is free-form (no key blocking)', async () => {
const el = await rangeFixture('MM/DD/YYYY', false);
const { native } = await nativeOf(el, '.calender-input .date-input');
// a non-digit keystroke is NOT prevented (no mask interception)
const ev = new KeyboardEvent('keydown', { key: 'a', bubbles: true, composed: true, cancelable: true });
native.dispatchEvent(ev);
expect(ev.defaultPrevented).to.be.false;
// free-form text stays exactly as written
native.value = 'free/text';
expect(native.value).to.equal('free/text');
});
it('S3. masked date input auto-inserts dateFormat separators (DD/MM/YYYY)', async () => {
const el = await rangeFixture('DD/MM/YYYY', true);
const { native } = await nativeOf(el, '.calender-input .date-input');
await typeDigits(el, native, '15062026');
expect(native.value).to.equal('15/06/2026');
});
it('S4. masked date input follows a different dateFormat (MM/DD/YYYY)', async () => {
const el = await rangeFixture('MM/DD/YYYY', true);
const { native } = await nativeOf(el, '.calender-input .date-input');
await typeDigits(el, native, '06152026');
expect(native.value).to.equal('06/15/2026');
});
it('S5. out-of-range hour digit is clamped (99 -> 09)', async () => {
const el = await rangeFixture('MM/DD/YYYY', true);
const { native } = await nativeOf(el, '.calender-input .time-input');
await typeDigits(el, native, '99');
// 99 > 23 -> keeps the first digit -> "09" (with the auto separator)
expect(native.value.startsWith('09')).to.be.true;
expect(native.value).to.not.contain('99');
});
it('S6. out-of-range month digit is clamped (13 -> 01)', async () => {
const el = await rangeFixture('MM/DD/YYYY', true);
const { native } = await nativeOf(el, '.calender-input .date-input');
await typeDigits(el, native, '13');
// 13 > 12 -> keeps the first digit -> "01"
expect(native.value.startsWith('01')).to.be.true;
});
it('S7. typing beyond the mask length is ignored', async () => {
const el = await rangeFixture('DD/MM/YYYY', true);
const { native } = await nativeOf(el, '.calender-input .date-input');
await typeDigits(el, native, '150620269'); // one extra digit
expect(native.value).to.equal('15/06/2026');
});
it('S8. a completed masked date commits through the start-date handler', async () => {
const el = await rangeFixture('DD/MM/YYYY', true) as any;
const { nileInput, native } = await nativeOf(el, '.calender-input .date-input');
await typeDigits(el, native, '15062026');
// blur commits the value into the calendar via nile-change
nileInput.dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true }));
await el.updateComplete;
expect(el.startDate).to.be.instanceOf(Date);
expect(el.startDate.getFullYear()).to.equal(2026);
expect(el.startDate.getMonth()).to.equal(5); // June (0-based)
expect(el.startDate.getDate()).to.equal(15);
});
it('S9. default variant keeps its legacy rollover for an impossible day (31/02/2026 -> Mar 3)', async () => {
// Characterization only: the invalid-day clamp is scoped to the date-time-picker variant, so the
// default variant's parseDate path must stay exactly as it was.
const el = await rangeFixture('DD/MM/YYYY', true) as any;
const { nileInput, native } = await nativeOf(el, '.calender-input .date-input');
await typeDigits(el, native, '31022026');
expect(native.value).to.equal('31/02/2026');
nileInput.dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true }));
await el.updateComplete;
expect(el.startDate.getFullYear()).to.equal(2026);
expect(el.startDate.getMonth()).to.equal(2); // March (0-based)
expect(el.startDate.getDate()).to.equal(3);
});
});
/** Preset `value` seeding must land on the SAME calendar day it names in every browser timezone, and
* Apply must emit that exact day/time. Run under `TZ=America/New_York` to exercise the off-by-one path. */
describe('NileCalendar preset value seeding + emitted payload', () => {
const dtpPreset = (value: string) =>
fixture(html``);
const apply = (el: NileCalendar) =>
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
it('PS1. preset date-only value → Apply (no edits) emits that exact day at midnight (no off-by-one)', async () => {
const el = await dtpPreset('2026-06-15');
await el.updateComplete;
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
apply(el);
expect(detail, 'Apply must emit even without a manual edit').to.not.be.null;
expect(detail.value).to.be.a('string');
// the day the preset NAMES — never shifted by the UTC boundary — at midnight
expect(detail.value).to.match(/^2026-06-15T00:00:00\.000\[GMT[+-]\d{2}:\d{2} .+\]$/);
});
it('PS2. preset date-time value seeds the time spinners and Apply emits that wall-clock', async () => {
const el = await dtpPreset('2026-06-15T09:30:45') as any; // zoneless → local wall-clock
await el.updateComplete;
expect(el.tpHours).to.equal(9);
expect(el.tpMinutes).to.equal(30);
expect(el.tpSeconds).to.equal(45);
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
apply(el);
expect(detail.value).to.include('2026-06-15T09:30:45');
});
it('PS3. seeding a preset value is SILENT — it must NOT emit nile-change on its own', async () => {
const el = await fixture(html``);
await el.updateComplete;
let emitted = false;
el.addEventListener('nile-change', () => { emitted = true; });
el.setAttribute('value', '2026-06-15');
await el.updateComplete;
expect(emitted, 'presetting a value should not fire a change event').to.be.false;
});
it('PS4. changing timezone after a preset keeps the wall-clock day and emits the new zone on Apply', async () => {
const el = await dtpPreset('2026-06-15') as any;
await el.updateComplete;
el.timezone = 'Asia/Kolkata';
await el.updateComplete;
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
apply(el);
expect(detail.value).to.include('2026-06-15'); // wall-clock day is unchanged
expect(detail.value).to.include('[GMT+05:30 Asia/Kolkata]');
expect(detail.timezone).to.equal('Asia/Kolkata');
expect(detail.localTimezoneValue).to.be.instanceOf(Date);
});
it('PS5. default (non-picker) single preset is seeded exactly as published (host-offset shifted, not the written day on every host)', async () => {
const el = await fixture(html``) as any;
await el.updateComplete;
expect(el.value).to.be.instanceOf(Date);
expect(el.value.getTime()).to.equal(published.seed('2026-06-15')!.getTime());
});
it('PS6. an invalid preset value leaves the calendar unselected (no bogus emit)', async () => {
const el = await dtpPreset('not-a-date') as any;
await el.updateComplete;
expect(el.value == null).to.be.true;
});
});
describe('NileCalendar structuredInput listener lifecycle (#2b)', () => {
const rangeFixture = (structured: boolean) =>
fixture(html`
`);
const moveInDom = async (el: NileCalendar) => {
const host = el.parentNode!;
el.remove();
host.appendChild(el);
await el.updateComplete;
};
const firstNative = async (el: NileCalendar) => {
const nileInput = el.shadowRoot!.querySelector('.calender-input .date-input') as any;
await nileInput.updateComplete;
return nileInput.input as HTMLInputElement;
};
const keydown = (native: HTMLInputElement, key: string) => {
const ev = new KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true });
native.dispatchEvent(ev);
return ev;
};
it('L1. the date mask still applies after the calendar is moved in the DOM', async () => {
const el = await rangeFixture(true);
await moveInDom(el);
const native = await firstNative(el);
for (const key of '03152026') keydown(native, key);
await el.updateComplete;
expect(native.value).to.equal('03/15/2026');
});
it('L2. without structuredInput a moved calendar stays free-form (no listeners added)', async () => {
const el = await rangeFixture(false);
await moveInDom(el);
const native = await firstNative(el);
expect(keydown(native, 'a').defaultPrevented).to.be.false;
});
});
describe('NileCalendar time stepper focus lifecycle', () => {
const dtp = () =>
fixture(html``);
const stepperBtns = (el: NileCalendar) =>
Array.from(el.shadowRoot!.querySelectorAll('.tp-stepper-btn')) as HTMLElement[];
it('S1. in a rendered calendar, a stepper click steps the value AND moves focus to that field', async () => {
const el = await dtp();
(el as any).tpHours = 9;
await el.updateComplete;
const up = stepperBtns(el)[0];
const nileInput = up.closest('nile-input') as any;
await nileInput.updateComplete;
expect(nileInput.input, 'inner input rendered').to.exist;
up.click();
await el.updateComplete;
expect((el as any).tpHours).to.equal(10);
expect(nileInput.shadowRoot.activeElement, 'focus moved to the stepped field').to.equal(nileInput.input);
});
it('S2. a stepper whose nile-input has not rendered (no inner input) steps without throwing', async () => {
const el = await dtp();
(el as any).tpHours = 9;
await el.updateComplete;
const up = stepperBtns(el)[0];
const nileInput = up.closest('nile-input') as any;
await nileInput.updateComplete;
// simulate an input that never rendered (calendar parked in a closed floating panel)
Object.defineProperty(nileInput, 'input', { value: null, configurable: true });
const errors: string[] = [];
const onError = (e: ErrorEvent) => { errors.push(e.message); e.preventDefault(); };
window.addEventListener('error', onError);
try {
up.click();
await el.updateComplete;
} finally {
window.removeEventListener('error', onError);
}
expect(errors, 'no uncaught error from the click handler').to.deep.equal([]);
expect((el as any).tpHours).to.equal(10);
});
});
/**
* ── Z. YEARS 0-99 in the calendar grid, selection, allowedDates and typed dates ─────────────────────
* `new Date(y, …)` and `Date.UTC(y, …)` remap years 0-99 to 1900-1999. Every oracle below is built with
* `setUTCFullYear` (never the remapping constructors), so it does not share the bug. Years >= 100 are
* pinned too, so the fix cannot change them.
*/
describe('NileCalendar years 0-99 (Date remap regression)', () => {
const oracleDow = (y: number, m0: number, d: number) => {
const dt = new Date(0); dt.setUTCFullYear(y, m0, d); return dt.getUTCDay();
};
const oracleDim = (y: number, m0: number) => {
const dt = new Date(0); dt.setUTCFullYear(y, m0 + 1, 0); return dt.getUTCDate();
};
const make = (variant: 'default' | 'dtp', year: number, month0: number) =>
fixture(variant === 'dtp'
? html``
: html``).then(async (el) => {
el.currentYear = year; el.currentMonth = month0;
if (variant === 'dtp') el.expanded = true;
await el.updateComplete;
return el;
});
const cells = (el: NileCalendar) =>
Array.from(el.shadowRoot!.querySelectorAll('.day_date')) as HTMLElement[];
const realCells = (el: NileCalendar) => cells(el).filter((c) => !c.classList.contains('filler'));
// (year, month0): a weekday/leap sample inside 0-99, plus 100/1900/2000 unchanged-behaviour anchors
const SAMPLES: Array<[number, number]> = [[1, 0], [50, 5], [99, 11], [0, 1], [4, 1], [100, 0], [1900, 1], [2000, 1]];
for (const variant of ['default', 'dtp'] as const) {
it(`Z1-${variant}. the grid lays out the real weekdays and month length of the displayed year`, async () => {
for (const [y, m] of SAMPLES) {
const el = await make(variant, y, m);
const all = cells(el);
const first = all.findIndex((c) => !c.classList.contains('filler'));
// published default grid: `new Date(y, m, ...)`, which remaps years 0-99 to 19xx; only the variant shows the real year
const dow = variant === 'default' ? new Date(y, m, 1).getDay() : oracleDow(y, m, 1);
const dim = variant === 'default' ? new Date(y, m + 1, 0).getDate() : oracleDim(y, m);
expect(first, `year ${y} month ${m + 1}: leading blanks = weekday of the 1st`).to.equal(dow);
expect(realCells(el).length, `year ${y} month ${m + 1}: days in month`).to.equal(dim);
}
});
it(`Z2-${variant}. a day cell's accessible name carries the true year and weekday, not 19xx`, async () => {
const el = await make(variant, 50, 5);
if (variant === 'default') {
// published markup: the default grid has no accessible day names at all
expect(realCells(el)[14].hasAttribute('aria-label'), 'no aria-label on a default day cell').to.be.false;
return;
}
const label = realCells(el)[14].getAttribute('aria-label') ?? '';
const wd = new Intl.DateTimeFormat('en-US', { weekday: 'long', timeZone: 'UTC' }).format(
(() => { const d = new Date(0); d.setUTCFullYear(50, 5, 15); return d; })());
expect(label, `aria-label "${label}"`).to.contain(wd);
expect(label, `aria-label "${label}"`).to.not.match(/19\d\d/);
});
it(`Z3-${variant}. clicking a day in year 50 selects year 50`, async () => {
const el = await make(variant, 50, 5);
const seen: Date[] = [];
el.addEventListener('nile-change', ((e: CustomEvent) => { if (e.detail?.value instanceof Date) seen.push(e.detail.value); }) as EventListener);
realCells(el)[14].click();
await el.updateComplete;
expect(el.value, 'selected value').to.be.instanceOf(Date);
if (variant === 'default') {
// published: selectDate builds `new Date(year, month, day)`, which remaps year 50 to 1950
const exp = new Date(50, 5, 15);
expect(el.value!.getTime(), 'selected value').to.equal(exp.getTime());
expect(seen[0]?.getTime(), 'emitted value').to.equal(exp.getTime());
return;
}
expect(el.value!.getFullYear(), 'year').to.equal(50);
expect(el.value!.getMonth(), 'month').to.equal(5);
expect(el.value!.getDate(), 'day').to.equal(15);
});
}
it('Z4. the selected day is highlighted in the year-50 grid (variant); the default grid keeps its published selection', async () => {
for (const variant of ['default', 'dtp'] as const) {
const el = await make(variant, 50, 5);
realCells(el)[14].click();
await el.updateComplete;
if (variant === 'default') {
expect(el.value!.getTime(), 'published selection (year 50 remapped to 1950)').to.equal(new Date(50, 5, 15).getTime());
continue;
}
const sel = cells(el).filter((c) => c.classList.contains('selected-date'));
expect(sel.length, `${variant}: one highlighted day`).to.equal(1);
expect(sel[0].textContent!.trim(), `${variant}: it is the 15th`).to.equal('15');
}
});
describe('allowedDates / startYear-endYear validation', () => {
const allowed = async (startDate: string, endDate: string, variant: 'default' | 'date-time-picker' = 'default') =>
fixture(html``);
for (const variant of ['default', 'date-time-picker'] as const) {
// published (default): Date.UTC remaps years 0-99 to 19xx, so membership is whatever the published formula says;
// the variant keeps the literal year
const verdict = (el: NileCalendar, start: string, end: string, day: number, month: number, year: number, literal: boolean) =>
variant === 'default' ? published.allowed(start, end, day, month, year) : literal;
it(`Z5 [${variant}]. a range that spans year 1..100 and the year-50 / year-100 / year-101 checks`, async () => {
const el = await allowed('0001-01-01', '0100-12-31', variant);
if (variant !== 'default') expect(el.allowedDatesLocal, 'range kept (start must not look later than end)').to.not.be.null;
expect(el.isAllowedDate(15, 5, 50)).to.equal(verdict(el, '0001-01-01', '0100-12-31', 15, 5, 50, true));
expect(el.isAllowedDate(15, 5, 100)).to.equal(verdict(el, '0001-01-01', '0100-12-31', 15, 5, 100, true));
expect(el.isAllowedDate(1, 0, 101)).to.equal(verdict(el, '0001-01-01', '0100-12-31', 1, 0, 101, false));
});
it(`Z6 [${variant}]. a range inside year 1950 versus a range inside year 50`, async () => {
const a = await allowed('1950-01-01', '1950-12-31', variant);
expect(a.isAllowedDate(15, 5, 1950)).to.equal(verdict(a, '1950-01-01', '1950-12-31', 15, 5, 1950, true));
expect(a.isAllowedDate(15, 5, 50), 'year 50 against a 1950-only range').to.equal(verdict(a, '1950-01-01', '1950-12-31', 15, 5, 50, false));
const b = await allowed('0050-01-01', '0050-12-31', variant);
expect(b.isAllowedDate(15, 5, 50)).to.equal(verdict(b, '0050-01-01', '0050-12-31', 15, 5, 50, true));
expect(b.isAllowedDate(15, 5, 1950), 'year 1950 against a year-50-only range').to.equal(verdict(b, '0050-01-01', '0050-12-31', 15, 5, 1950, false));
});
it(`Z7 [${variant}]. a year-1 start / year-99 end, and year 100`, async () => {
const el = await allowed('0001-06-10', '0099-06-20', variant);
for (const [d, m, y, literal] of [[9, 5, 1, false], [10, 5, 1, true], [20, 5, 99, true], [21, 5, 99, false], [15, 5, 100, false]] as Array<[number, number, number, boolean]>) {
expect(el.isAllowedDate(d, m, y), `${d}/${m + 1}/${y}`).to.equal(verdict(el, '0001-06-10', '0099-06-20', d, m, y, literal));
}
});
it(`Z8 [${variant}]. years >= 100 keep their behaviour (inclusive bounds)`, async () => {
const el = await allowed('2026-06-10', '2026-06-20', variant);
expect(el.isAllowedDate(9, 5, 2026)).to.be.false;
expect(el.isAllowedDate(10, 5, 2026)).to.be.true;
expect(el.isAllowedDate(20, 5, 2026)).to.be.true;
expect(el.isAllowedDate(21, 5, 2026)).to.be.false;
const old = await allowed('0100-01-01', '0100-12-31', variant);
expect(old.isAllowedDate(15, 5, 100)).to.be.true;
expect(old.isAllowedDate(15, 5, 101)).to.be.false;
});
}
it('Z9. an allowedDates range that starts in year 1 blocks a year-50 grid cell correctly', async () => {
const el = await fixture(html``);
el.currentYear = 50; el.currentMonth = 5; el.expanded = true;
await el.updateComplete;
const open = Array.from(el.shadowRoot!.querySelectorAll('.day_date:not(.filler):not(.not-allowed)'))
.map((c) => c.textContent!.trim());
expect(open[0]).to.equal('10');
expect(open[open.length - 1]).to.equal('20');
expect(open.length).to.equal(11);
});
});
for (const variant of ['default', 'date-time-picker'] as const) {
it(`Z10 [${variant}]. typed range input (YYYY): the variant keeps the literal year, the default keeps the published remap`, async () => {
const el = await fixture(html``);
for (const y of [1, 50, 99, 100, 2026]) {
const text = `${String(y).padStart(4, '0')}-06-15`;
const d = (el as any).parseDate(text) as Date;
const expectedYear = variant === 'default' ? published.parse(text, 'YYYY-MM-DD')!.getFullYear() : y;
expect(d.getFullYear(), `year ${y}`).to.equal(expectedYear);
expect(d.getMonth()).to.equal(5);
expect(d.getDate()).to.equal(15);
}
});
}
it('Z11. Feb 29 of year 0 (a leap year) is not clamped by a zoned value', async () => {
const el = await fixture(html``);
await el.updateComplete;
expect(el.value!.getFullYear(), 'year').to.equal(0);
expect(el.value!.getMonth(), 'month').to.equal(1);
expect(el.value!.getDate(), 'Feb 29 exists in year 0').to.equal(29);
const non = await fixture(html``);
expect(non.value!.getDate(), 'year 1 is not leap: clamps to 28').to.equal(28);
});
});
describe('NileCalendar YYYY formatting (years 0-99 padded to four digits)', () => {
const mk = (fmt: string, variant: 'default' | 'date-time-picker' = 'default') =>
fixture(html``);
const fmt = (el: NileCalendar, y: number, m0: number, d: number) =>
(el as any).formatDate(localDateFromFields(y, m0, d));
const parse = (el: NileCalendar, s: string) => (el as any).parseDate(s, null) as Date | null;
it('F1. formatYear4 pads only years 0-99 and leaves every other year as String(year)', () => {
expect([0, 1, 50, 99].map(formatYear4)).to.deep.equal(['0000', '0001', '0050', '0099']);
for (const y of [100, 999, 1000, 1950, 2000, 9999, 10000]) expect(formatYear4(y), `year ${y}`).to.equal(String(y));
expect(formatYear4(-5)).to.equal('-5');
});
for (const variant of ['default', 'date-time-picker'] as const) {
it(`F2 [${variant}]. YYYY for years 0-99: the variant pads to four digits, the default prints the published year`, async () => {
for (const [f, padded] of [
['MM/DD/YYYY', '06/15/0050'], ['DD/MM/YYYY', '15/06/0050'], ['YYYY-MM-DD', '0050-06-15'],
] as const) {
const el = await mk(f, variant);
const exp = variant === 'default' ? published.format(localDateFromFields(50, 5, 15), f) : padded;
expect(fmt(el, 50, 5, 15), f).to.equal(exp);
}
const el = await mk('MM/DD/YYYY', variant);
for (const [y, m0, d] of [[0, 0, 1], [1, 0, 1], [99, 11, 31]] as Array<[number, number, number]>) {
const exp = variant === 'default'
? published.format(localDateFromFields(y, m0, d), 'MM/DD/YYYY')
: `${String(m0 + 1).padStart(2, '0')}/${String(d).padStart(2, '0')}/${String(y).padStart(4, '0')}`;
expect(fmt(el, y, m0, d), `year ${y}`).to.equal(exp);
}
});
}
it('F3. years >= 100 are byte-identical to before (100 and 999 pinned)', async () => {
const el = await mk('MM/DD/YYYY');
for (const y of [100, 999, 1000, 1950, 2000, 9999]) {
expect(fmt(el, y, 5, 15), `year ${y}`).to.equal(`06/15/${y}`);
}
});
it('F4. YY is unchanged (last two digits of the year)', async () => {
const el = await mk('DD/MM/YY');
expect(fmt(el, 1, 0, 1), 'pre-existing: YY is slice(-2) of the bare year, so year 1 is "1"').to.equal('01/01/1');
expect(fmt(el, 50, 5, 15)).to.equal('15/06/50');
expect(fmt(el, 2026, 5, 15)).to.equal('15/06/26');
});
for (const variant of ['default', 'date-time-picker'] as const) {
it(`F5 [${variant}]. format -> parseDate: the variant round-trips years 0, 1, 50, 99, 2000; the default matches the published pair`, async () => {
for (const f of ['MM/DD/YYYY', 'DD/MM/YYYY', 'YYYY-MM-DD']) {
const el = await mk(f, variant);
for (const y of [0, 1, 50, 99, 2000]) {
const text = fmt(el, y, 5, 15);
const d = parse(el, text);
if (variant === 'default') {
const exp = published.parse(published.format(localDateFromFields(y, 5, 15), f), f);
expect(text, `${f} year ${y} text`).to.equal(published.format(localDateFromFields(y, 5, 15), f));
expect(d?.getTime() ?? null, `${f} year ${y}`).to.equal(exp?.getTime() ?? null);
continue;
}
expect(d, `${f} year ${y}`).to.not.be.null;
expect([d!.getFullYear(), d!.getMonth(), d!.getDate()], `${f} year ${y}`).to.deep.equal([y, 5, 15]);
}
}
});
it(`F6 [${variant}]. Feb 29 of year 0: the variant formats it as 0000 and round-trips it; the default matches the published pair`, async () => {
const el = await mk('MM/DD/YYYY', variant);
if (variant === 'default') {
const text = fmt(el, 0, 1, 29);
expect(text).to.equal(published.format(localDateFromFields(0, 1, 29), 'MM/DD/YYYY'));
expect(parse(el, text)?.getTime() ?? null).to.equal(published.parse(text, 'MM/DD/YYYY')?.getTime() ?? null);
expect(fmt(el, 100, 1, 29), 'year 100 Feb 29 overflows to Mar 1').to.equal('03/01/100');
return;
}
expect(fmt(el, 0, 1, 29)).to.equal('02/29/0000');
const d = parse(el, '02/29/0000')!;
expect([d.getFullYear(), d.getMonth(), d.getDate()]).to.deep.equal([0, 1, 29]);
expect(fmt(el, 100, 1, 29), 'year 100 Feb 29 overflows to Mar 1').to.equal('03/01/100');
});
}
it('F7. the default range trigger inputs display a year-50 selection exactly as published', async () => {
const el = await fixture(html``);
el.startDate = localDateFromFields(50, 5, 15);
el.endDate = localDateFromFields(50, 5, 20);
await el.updateComplete;
const vals = Array.from(el.shadowRoot!.querySelectorAll('.date-input')).map((i: any) => i.value);
expect(vals).to.deep.equal([
published.format(localDateFromFields(50, 5, 15), 'MM/DD/YYYY'),
published.format(localDateFromFields(50, 5, 20), 'MM/DD/YYYY'),
]);
});
it('F8. selecting a day in year 50 in the default calendar selects and displays what the published calendar does', async () => {
const el = await fixture(html``);
el.currentYear = 50; el.currentMonth = 5;
await el.updateComplete;
(el.shadowRoot!.querySelectorAll('.day_date:not(.filler)')[14] as HTMLElement).click();
await el.updateComplete;
const exp = new Date(50, 5, 15); // published: year 50 is remapped to 1950
expect(el.startDate!.getTime()).to.equal(exp.getTime());
expect((el.shadowRoot!.querySelector('.date-input') as any).value).to.equal(published.format(exp, 'MM/DD/YYYY'));
});
});
describe('NileCalendar default variant: value seeding is exactly the published conversion', () => {
const ymd = (d: Date) => [d.getFullYear(), d.getMonth() + 1, d.getDate()];
const seed = async (value: string, extra = '') => {
const el = (extra === 'variant-default'
? await fixture(html``)
: await fixture(html``)) as any;
await el.updateComplete;
return el;
};
// every kind of string a consumer can pass: the default calendar must keep the published (host-offset shifted) result
const RAWS = [
'2026-06-15', '2026-01-01', '2026-12-31', '2025-12-31', '2024-02-29', '2026-03-08', '2026-11-01', // date-only
'2026-02-31', '2026-02-30', '2026-04-31', '2026-02-99', // overflowing day
'0050-06-15', '0001-01-01', '0099-12-31', // years 0-99
'2026-06-15T10:00:00Z', '2026-01-01T00:00:00.000Z', '2026-12-31T23:59:59Z', // instants
'2026-06-15T23:30:00+05:30', '2026-06-15T20:00:00-04:00', // explicit offsets
'2026-06-15T10:00:00', '2026-06-15T12:00:00', '2025-12-31T23:30:00', '2026-01-01T00:30:00', // offset-less
];
it('D1. the value attribute is seeded exactly as published, for every kind of string', async () => {
for (const raw of RAWS) {
const el = await seed(raw);
expectPublishedSeed(el, raw, `initial ${raw}`);
const exp = published.seed(raw);
if (exp) expect([el.currentYear, el.currentMonth], `${raw} grid`).to.deep.equal([exp.getFullYear(), exp.getMonth()]);
}
});
it('D2. the same when the value is set after first render (attribute and property)', async () => {
const el = await seed('2026-06-15T12:00:00');
for (const raw of RAWS) {
el.setAttribute('value', raw);
await el.updateComplete;
const exp = published.seed(raw);
if (exp) expect(el.value.getTime(), `attribute ${raw}`).to.equal(exp.getTime());
}
el.valueAttribute = '2027-01-01T10:00:00Z';
await el.updateComplete;
expect(el.value.getTime(), 'property').to.equal(published.seed('2027-01-01T10:00:00Z')!.getTime());
});
it('D3. variant="default" seeds exactly like no variant attribute', async () => {
for (const raw of RAWS) {
const plain = await seed(raw);
const explicit = await seed(raw, 'variant-default');
expect(explicit.value?.getTime() ?? null, raw).to.equal(plain.value?.getTime() ?? null);
}
});
it('D4. a day clicked after seeding is the clicked day, and the emitted payload is a plain { value: Date }', async () => {
const el = await seed('2026-06-15T12:00:00');
expect(ymd(el.value)).to.deep.equal([2026, 6, 15]);
const seen: Array<[string, any]> = [];
for (const n of ['nile-change', 'nile-changed']) el.addEventListener(n, ((e: CustomEvent) => seen.push([n, e.detail])) as EventListener);
(el.shadowRoot!.querySelectorAll('.day_date:not(.filler)')[19] as HTMLElement).click();
await el.updateComplete;
expect(ymd(el.value), 'selected').to.deep.equal([2026, 6, 20]);
expect(seen.map(x => x[0]), 'events, in the published order').to.deep.equal(['nile-changed', 'nile-change']);
for (const [, detail] of seen) {
expect(Object.keys(detail), 'payload keys').to.deep.equal(['value']);
expect(ymd(detail.value), 'payload day').to.deep.equal([2026, 6, 20]);
}
});
it('D8. an invalid value string still leaves the default calendar unselected', async () => {
for (const raw of ['not-a-date', '2026-13-01', '2026-02-00']) {
const el = await seed(raw);
expect(el.value == null, raw).to.be.true;
}
});
it('D9. the date-time-picker variant is untouched: date-only still clamps and keeps the day', async () => {
const el = await fixture(html``) as any;
await el.updateComplete;
expect(ymd(el.value), 'clamped to Feb 28').to.deep.equal([2026, 2, 28]);
const el2 = await fixture(html``) as any;
await el2.updateComplete;
expect(ymd(el2.value)).to.deep.equal([2026, 6, 15]);
});
});
describe('NileCalendar range: date-only YYYY-MM-DD values keep their calendar day in every host timezone', () => {
const ymd = (d: Date) => [d.getFullYear(), d.getMonth() + 1, d.getDate()];
const hms = (d: Date) => [d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()];
const json = (s: string, e: string) => JSON.stringify({ startDate: s, endDate: e });
const seed = async (value: string, variant: string) => {
const el = await fixture(
html``
) as any;
await el.updateComplete;
return el;
};
const RANGES: Array<[string, string, number[], number[]]> = [
['2026-06-10', '2026-06-20', [2026, 6, 10], [2026, 6, 20]],
['2025-12-31', '2026-01-02', [2025, 12, 31], [2026, 1, 2]],
['2024-02-28', '2024-03-01', [2024, 2, 28], [2024, 3, 1]],
['2024-02-29', '2024-02-29', [2024, 2, 29], [2024, 2, 29]],
['2026-03-07', '2026-03-09', [2026, 3, 7], [2026, 3, 9]],
['2026-10-31', '2026-11-02', [2026, 10, 31], [2026, 11, 2]],
['2026-03-28', '2026-03-30', [2026, 3, 28], [2026, 3, 30]],
];
for (const variant of ['default', 'date-time-picker']) {
it(`R1 [${variant}]. initial date-only range lands on the written days: start 00:00:00, end 23:59:59.999`, async () => {
for (const [s, e, es, ee] of RANGES) {
const el = await seed(json(s, e), variant);
if (variant === 'default') {
// published: each bound is `new Date(raw)` (a date-only string is UTC midnight) and the end is not end-of-day
expect(el.startDate.getTime(), `${s} start`).to.equal(published.bound(s).getTime());
expect(el.endDate.getTime(), `${e} end`).to.equal(published.bound(e).getTime());
continue;
}
expect(ymd(el.startDate), `${s} start`).to.deep.equal(es);
expect(ymd(el.endDate), `${e} end`).to.deep.equal(ee);
expect(hms(el.startDate), `${s} start time`).to.deep.equal([0, 0, 0, 0]);
expect(hms(el.endDate), `${e} end time`).to.deep.equal([23, 59, 59, 999]);
}
});
it(`R2 [${variant}]. a date-only range set after first render (attribute and property) lands on the written days`, async () => {
const el = await seed(json('2026-06-10', '2026-06-20'), variant);
for (const [s, e, es, ee] of RANGES) {
el.setAttribute('value', json(s, e));
await el.updateComplete;
if (variant === 'default') {
expect(el.startDate.getTime(), `attr ${s}`).to.equal(published.bound(s).getTime());
expect(el.endDate.getTime(), `attr ${e}`).to.equal(published.bound(e).getTime());
continue;
}
expect(ymd(el.startDate), `attr ${s}`).to.deep.equal(es);
expect(ymd(el.endDate), `attr ${e}`).to.deep.equal(ee);
}
el.valueAttribute = json('2027-01-01', '2027-01-31');
await el.updateComplete;
if (variant === 'default') {
expect(el.startDate.getTime(), 'property start').to.equal(published.bound('2027-01-01').getTime());
expect(el.endDate.getTime(), 'property end').to.equal(published.bound('2027-01-31').getTime());
return;
}
expect(ymd(el.startDate)).to.deep.equal([2027, 1, 1]);
expect(ymd(el.endDate)).to.deep.equal([2027, 1, 31]);
});
it(`R3 [${variant}]. a range emitted by a day click and re-seeded as date-only shows the same days`, async () => {
const el = await seed('', variant);
el.selectDate(10, 5, 2026);
el.selectDate(20, 5, 2026);
const s = ymd(el.startDate);
const e = ymd(el.endDate);
const again = await seed(json('2026-06-10', '2026-06-20'), variant);
if (variant === 'default') {
// published: a date-only string re-seeds as `new Date(raw)` (UTC midnight), not as the clicked local days
expect(again.startDate.getTime()).to.equal(published.bound('2026-06-10').getTime());
expect(again.endDate.getTime()).to.equal(published.bound('2026-06-20').getTime());
return;
}
expect(ymd(again.startDate)).to.deep.equal(s);
expect(ymd(again.endDate)).to.deep.equal(e);
expect(hms(again.endDate)).to.deep.equal(hms(el.endDate));
});
it(`R4 [${variant}]. years 0-99 in a date-only range keep their literal year (no 19xx remap)`, async () => {
const el = await seed(json('0001-01-05', '0099-12-31'), variant);
if (variant === 'default') {
expect(el.startDate.getTime()).to.equal(published.bound('0001-01-05').getTime());
expect(el.endDate.getTime()).to.equal(published.bound('0099-12-31').getTime());
return;
}
expect(ymd(el.startDate)).to.deep.equal([1, 1, 5]);
expect(ymd(el.endDate)).to.deep.equal([99, 12, 31]);
const el2 = await seed(json('0050-06-10', '0050-06-20'), variant);
expect(ymd(el2.startDate)).to.deep.equal([50, 6, 10]);
expect(ymd(el2.endDate)).to.deep.equal([50, 6, 20]);
});
it(`R5 [${variant}]. non-date-only range strings are unchanged (offset-less, Z, explicit offset)`, async () => {
for (const [s, e] of [
['2026-06-10T08:30:00', '2026-06-20T17:45:10'],
['2026-06-10T08:30:00Z', '2026-06-20T17:45:10Z'],
['2026-06-10T08:30:00+05:30', '2026-06-20T17:45:10-04:00'],
]) {
const el = await seed(json(s, e), variant);
expect(el.startDate.getTime(), s).to.equal(new Date(s).getTime());
expect(el.endDate.getTime(), e).to.equal(new Date(e).getTime());
}
});
}
it('R6. an unparsable date-only range leaves the range empty instead of throwing', async () => {
const el = await seed(json('2026-13-45', '2026-99-99'), 'default');
expect(el.startDate === null || isNaN(el.startDate.getTime())).to.equal(true);
});
});
describe('NileCalendar range seed: visible month', () => {
const json = (s: string, e: string) => JSON.stringify({ startDate: s, endDate: e });
const seed = async (variant: string, value: string) => {
const el = await fixture(
html``
) as any;
await el.updateComplete;
return el;
};
const now = () => [new Date().getFullYear(), new Date().getMonth()];
const SPANS: Array<[string, string, number[]]> = [
['2026-06-10', '2026-06-20', [2026, 5]],
['2026-06-28', '2026-07-03', [2026, 5]],
['2025-12-20', '2026-01-05', [2025, 11]],
['2024-02-29', '2024-03-01', [2024, 1]],
['2026-03-07', '2026-03-09', [2026, 2]],
['2026-01-31', '2027-02-01', [2026, 0]],
['0050-06-10', '0050-06-20', [50, 5]],
];
describe('date-time-picker variant: a seeded range shows the start month', () => {
it('B3-1. initial range seed navigates to the start month and year (spans across months and years)', async () => {
for (const [s, e, expected] of SPANS) {
const el = await seed('date-time-picker', json(s, e));
expect([el.currentYear, el.currentMonth], s).to.deep.equal(expected);
}
});
it('B3-2. a dynamically updated range (attribute and property) navigates to the new start month', async () => {
const el = await seed('date-time-picker', json('2026-06-10', '2026-06-20'));
for (const [s, e, expected] of SPANS) {
el.setAttribute('value', json(s, e));
await el.updateComplete;
expect([el.currentYear, el.currentMonth], `attr ${s}`).to.deep.equal(expected);
}
el.valueAttribute = json('2027-09-01', '2027-10-05');
await el.updateComplete;
expect([el.currentYear, el.currentMonth]).to.deep.equal([2027, 8]);
});
it('B3-3. non-date-only start strings navigate to the start instant\'s local month', async () => {
const el = await seed('date-time-picker', json('2026-06-10T08:30:00', '2026-08-20T17:45:10'));
expect([el.currentYear, el.currentMonth]).to.deep.equal([2026, 5]);
});
it('B3-4. start/end dates and times are unchanged by the navigation', async () => {
const el = await seed('date-time-picker', json('2026-06-28', '2026-07-03'));
expect(el.startDate.getTime()).to.equal(new Date(2026, 5, 28, 0, 0, 0, 0).getTime());
expect(el.endDate.getTime()).to.equal(new Date(2026, 6, 3, 23, 59, 59, 999).getTime());
});
it('B3-5. an unparsable range does not move the month', async () => {
const el = await seed('date-time-picker', json('nope', 'nope'));
expect([el.currentYear, el.currentMonth]).to.deep.equal(now());
});
it('B3-6. a typed end date still navigates to the end month, and clicks/next-month still work', async () => {
const el = await seed('date-time-picker', json('2026-06-10', '2026-08-20'));
expect(el.currentMonth).to.equal(5);
el.handleEndDateInput({ stopPropagation() {}, detail: { value: '08/20/2026' } } as any);
await el.updateComplete;
expect([el.currentYear, el.currentMonth]).to.deep.equal([2026, 7]);
el.currentMonth = 11; el.currentYear = 2026;
el.nextMonth();
expect([el.currentYear, el.currentMonth]).to.deep.equal([2027, 0]);
});
it('B3-7. a single (non-range) date-time-picker value navigation is unchanged', async () => {
const el = await fixture(html``) as any;
await el.updateComplete;
expect([el.currentYear, el.currentMonth]).to.deep.equal([2025, 11]);
});
});
describe('default variant is unchanged: a seeded range does not navigate', () => {
it('B3d-1. initial range seed keeps the current month and year', async () => {
for (const [s, e] of SPANS) {
const el = await seed('default', json(s, e));
expect([el.currentYear, el.currentMonth], s).to.deep.equal(now());
}
});
it('B3d-2. a dynamically updated range keeps the current month and year', async () => {
const el = await seed('default', json('2026-06-10', '2026-06-20'));
for (const [s, e] of SPANS) {
el.setAttribute('value', json(s, e));
await el.updateComplete;
expect([el.currentYear, el.currentMonth], s).to.deep.equal(now());
}
});
it('B3d-3. user navigation still moves the default calendar from where it is', async () => {
const el = await seed('default', json('2026-06-10', '2026-06-20'));
const [y, m] = now();
el.currentYear = 2020; el.currentMonth = 11;
el.nextMonth?.();
await el.updateComplete;
expect([el.currentYear, el.currentMonth]).to.deep.equal([2021, 0]);
expect(y).to.be.a('number'); expect(m).to.be.a('number');
});
});
});
describe('NileCalendar date-only overflow days clamp to the month end (same in every browser engine)', () => {
const ymd = (d: Date) => [d.getFullYear(), d.getMonth() + 1, d.getDate()];
const hms = (d: Date) => [d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()];
const json = (s: string, e: string) => JSON.stringify({ startDate: s, endDate: e });
const make = async (variant: string, attrs: Record = {}) => {
const el = document.createElement('nile-calendar') as any;
el.setAttribute('variant', variant);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
document.body.appendChild(el);
await el.updateComplete;
return el;
};
const CASES: Array<[string, number[]]> = [
['2026-02-31', [2026, 2, 28]], ['2026-02-30', [2026, 2, 28]], ['2026-02-29', [2026, 2, 28]],
['2024-02-31', [2024, 2, 29]], ['2024-02-30', [2024, 2, 29]],
['2026-04-31', [2026, 4, 30]], ['2026-06-31', [2026, 6, 30]], ['2026-09-31', [2026, 9, 30]], ['2026-11-31', [2026, 11, 30]],
['2000-02-30', [2000, 2, 29]], ['1900-02-29', [1900, 2, 28]],
['0050-02-31', [50, 2, 28]], ['0004-02-31', [4, 2, 29]],
];
const VALID: Array<[string, number[]]> = [
['2026-02-28', [2026, 2, 28]], ['2024-02-29', [2024, 2, 29]], ['2026-04-30', [2026, 4, 30]], ['2026-01-31', [2026, 1, 31]],
['2026-12-31', [2026, 12, 31]], ['2026-03-08', [2026, 3, 8]], ['2026-11-01', [2026, 11, 1]], ['0050-06-15', [50, 6, 15]],
];
let made: HTMLElement[] = [];
afterEach(() => { made.forEach(e => e.remove()); made = []; });
const track = (e: T) => { made.push(e); return e; };
for (const variant of ['default', 'date-time-picker']) {
it(`C1 [${variant}]. single value: overflow days clamp (initial and dynamic attribute/property updates)`, async () => {
if (variant === 'default') {
// published: no clamp; an overflowing day is whatever `new Date(raw)` makes of it, shifted by the host offset
for (const [raw] of CASES) {
expectPublishedSeed(track(await make(variant, { value: raw })), raw, `initial ${raw}`);
const dyn = track(await make(variant));
dyn.setAttribute('value', raw);
await dyn.updateComplete;
expectPublishedSeed(dyn, raw, `attr ${raw}`);
}
return;
}
for (const [raw, expected] of CASES) {
const init = track(await make(variant, { value: raw }));
expect(ymd(init.value), `initial ${raw}`).to.deep.equal(expected);
const dyn = track(await make(variant));
dyn.setAttribute('value', raw);
await dyn.updateComplete;
expect(ymd(dyn.value), `attr ${raw}`).to.deep.equal(expected);
const prop = track(await make(variant, { value: '2026-06-15' }));
prop.valueAttribute = raw;
await prop.updateComplete;
expect(ymd(prop.value), `prop ${raw}`).to.deep.equal(expected);
expect([prop.currentYear, prop.currentMonth + 1], `${raw} grid`).to.deep.equal([expected[0], expected[1]]);
}
});
it(`C2 [${variant}]. single value: valid date-only values are unchanged`, async () => {
if (variant === 'default') {
for (const [raw] of VALID) expectPublishedSeed(track(await make(variant, { value: raw })), raw, raw);
return;
}
for (const [raw, expected] of VALID) {
const el = track(await make(variant, { value: raw }));
expect(ymd(el.value), raw).to.deep.equal(expected);
expect(hms(el.value).slice(0, 3), `${raw} midnight`).to.deep.equal([0, 0, 0]);
}
});
it(`C3 [${variant}]. range bounds: overflow days clamp for start and end (initial and dynamic); end stays 23:59:59.999`, async () => {
if (variant === 'default') {
for (const [raw] of CASES) {
const init = track(await make(variant, { range: '', value: json(raw, raw) }));
expect(init.startDate.getTime(), `start ${raw}`).to.equal(published.bound(raw).getTime());
expect(init.endDate.getTime(), `end ${raw}`).to.equal(published.bound(raw).getTime());
const dyn = track(await make(variant, { range: '' }));
dyn.setAttribute('value', json('2026-01-10', raw));
await dyn.updateComplete;
expect(dyn.endDate.getTime(), `dyn end ${raw}`).to.equal(published.bound(raw).getTime());
}
return;
}
for (const [raw, expected] of CASES) {
const init = track(await make(variant, { range: '', value: json(raw, raw) }));
expect(ymd(init.startDate), `start ${raw}`).to.deep.equal(expected);
expect(ymd(init.endDate), `end ${raw}`).to.deep.equal(expected);
expect(hms(init.startDate)).to.deep.equal([0, 0, 0, 0]);
expect(hms(init.endDate)).to.deep.equal([23, 59, 59, 999]);
const dyn = track(await make(variant, { range: '' }));
dyn.setAttribute('value', json('2026-01-10', raw));
await dyn.updateComplete;
expect(ymd(dyn.startDate), 'dyn start').to.deep.equal([2026, 1, 10]);
expect(ymd(dyn.endDate), `dyn end ${raw}`).to.deep.equal(expected);
}
});
it(`C4 [${variant}]. range bounds: valid date-only ranges are unchanged`, async () => {
if (variant === 'default') {
for (const [raw] of VALID) {
const el = track(await make(variant, { range: '', value: json(raw, raw) }));
expect(el.startDate.getTime(), raw).to.equal(published.bound(raw).getTime());
expect(el.endDate.getTime(), raw).to.equal(published.bound(raw).getTime());
}
return;
}
for (const [raw, expected] of VALID) {
const el = track(await make(variant, { range: '', value: json(raw, raw) }));
expect(ymd(el.startDate), raw).to.deep.equal(expected);
expect(ymd(el.endDate), raw).to.deep.equal(expected);
}
});
it(`C5 [${variant}]. genuinely invalid date-only strings (month 13, month 0, day 0) are still not seeded`, async () => {
for (const raw of ['2026-13-01', '2026-00-10', '2026-06-00']) {
const single = track(await make(variant, { value: raw }));
expect(single.value ?? null, `single ${raw}`).to.equal(null);
const rng = track(await make(variant, { range: '', value: json(raw, raw) }));
expect(isNaN(rng.startDate.getTime()), `range ${raw}`).to.equal(true);
}
});
}
const BIG_DAYS: Array<[string, number[]]> = [
['2026-02-32', [2026, 2, 28]], ['2026-02-45', [2026, 2, 28]], ['2026-02-99', [2026, 2, 28]],
['2024-02-32', [2024, 2, 29]], ['2024-02-45', [2024, 2, 29]], ['2024-02-99', [2024, 2, 29]],
['2026-04-32', [2026, 4, 30]], ['2026-04-45', [2026, 4, 30]], ['2026-04-99', [2026, 4, 30]],
['2026-01-32', [2026, 1, 31]], ['2026-01-45', [2026, 1, 31]], ['2026-01-99', [2026, 1, 31]],
['2026-12-99', [2026, 12, 31]], ['0050-02-45', [50, 2, 28]],
];
for (const variant of ['default', 'date-time-picker']) {
it(`C8 [${variant}]. days 32, 45 and 99 clamp to the month end: single (initial, attribute, property) and range bounds`, async () => {
if (variant === 'default') {
for (const [raw] of BIG_DAYS) {
expectPublishedSeed(track(await make(variant, { value: raw })), raw, `initial ${raw}`);
const rng = track(await make(variant, { range: '', value: json(raw, raw) }));
const exp = published.bound(raw);
expect(Number.isNaN(rng.startDate.getTime()) ? NaN : rng.startDate.getTime(), `range start ${raw}`)
.to.deep.equal(Number.isNaN(exp.getTime()) ? NaN : exp.getTime());
}
return;
}
for (const [raw, expected] of BIG_DAYS) {
const init = track(await make(variant, { value: raw }));
expect(ymd(init.value), `initial ${raw}`).to.deep.equal(expected);
const dyn = track(await make(variant));
dyn.setAttribute('value', raw);
await dyn.updateComplete;
expect(ymd(dyn.value), `attr ${raw}`).to.deep.equal(expected);
const prop = track(await make(variant, { value: '2026-06-15' }));
prop.valueAttribute = raw;
await prop.updateComplete;
expect(ymd(prop.value), `prop ${raw}`).to.deep.equal(expected);
const rng = track(await make(variant, { range: '', value: json(raw, raw) }));
expect(ymd(rng.startDate), `range start ${raw}`).to.deep.equal(expected);
expect(ymd(rng.endDate), `range end ${raw}`).to.deep.equal(expected);
expect(hms(rng.startDate)).to.deep.equal([0, 0, 0, 0]);
expect(hms(rng.endDate)).to.deep.equal([23, 59, 59, 999]);
const rd = track(await make(variant, { range: '' }));
rd.setAttribute('value', json('2026-01-10', raw));
await rd.updateComplete;
expect(ymd(rd.endDate), `range dyn ${raw}`).to.deep.equal(expected);
}
});
}
it('C6. non-date-only strings keep exactly their previous parse (offset-less, Z, offset) in the default variant', async () => {
for (const raw of ['2026-02-28T10:15:00', '2026-06-10T08:30:00Z', '2026-06-10T08:30:00+05:30']) {
const el = track(await make('default', { value: raw }));
expectPublishedSeed(el, raw, raw); // published: shifted by the host offset (the variant keeps its own parsers)
}
});
it('C7. the clamped bound still feeds B3: only the date-time-picker variant navigates to the start month', async () => {
const dtp = track(await make('date-time-picker', { range: '', value: json('2026-02-31', '2026-04-31') }));
expect([dtp.currentYear, dtp.currentMonth]).to.deep.equal([2026, 1]);
const def = track(await make('default', { range: '', value: json('2026-02-31', '2026-04-31') }));
expect([def.currentYear, def.currentMonth]).to.deep.equal([new Date().getFullYear(), new Date().getMonth()]);
});
});
describe('NileCalendar Reset restores the developer-configured timezone', () => {
const make = (attrs = '') => {
const host = document.createElement('div');
host.innerHTML = ``;
document.body.appendChild(host);
const el = host.firstElementChild as any;
return { host, el };
};
const created: HTMLElement[] = [];
const mount = async (attrs = '') => {
const { host, el } = make(attrs);
created.push(host);
await el.updateComplete;
return el;
};
afterEach(() => { created.splice(0).forEach(h => h.remove()); });
const pick = async (el: any, tz: string) => {
el.handleTimezoneChange(new CustomEvent('nile-change', { detail: { value: tz } }));
await el.updateComplete;
};
const reset = async (el: any) => {
(el.shadowRoot!.querySelector('.clear-button') as HTMLElement).click();
await el.updateComplete;
};
const firstDay = (el: any) => (el.shadowRoot!.querySelector('.day_date:not(.filler)') as HTMLElement).click();
it('R1. default UTC -> pick London -> Reset -> UTC', async () => {
const el = await mount();
await pick(el, 'Europe/London');
expect(el.timezone).to.equal('Europe/London');
await reset(el);
expect(el.timezone).to.equal('UTC');
});
it('R2. configured London -> pick Tokyo -> Reset -> London', async () => {
const el = await mount('timezone="Europe/London"');
expect(el.timezone).to.equal('Europe/London');
await pick(el, 'Asia/Tokyo');
expect(el.timezone).to.equal('Asia/Tokyo');
await reset(el);
expect(el.timezone).to.equal('Europe/London');
});
it('R3. configured Kolkata -> pick UTC -> Reset -> Kolkata', async () => {
const el = await mount('timezone="Asia/Kolkata"');
await pick(el, 'UTC');
expect(el.timezone).to.equal('UTC');
await reset(el);
expect(el.timezone).to.equal('Asia/Kolkata');
});
it('R4. a dynamic consumer change is the new configured zone; Reset restores the latest', async () => {
const el = await mount('timezone="Europe/London"');
el.timezone = 'America/New_York';
await el.updateComplete;
await pick(el, 'Asia/Tokyo');
await reset(el);
expect(el.timezone).to.equal('America/New_York');
el.setAttribute('timezone', 'Asia/Kolkata');
await el.updateComplete;
await reset(el);
expect(el.timezone).to.equal('Asia/Kolkata');
});
it('R5. Reset after Set to now (host zone) restores the configured zone; Set to now still adopts the host zone', async () => {
const el = await mount('timezone="Europe/London"');
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
(el.shadowRoot!.querySelector('.tp-set-to-now-button') as HTMLElement).click();
await el.updateComplete;
expect(el.timezone).to.equal(host);
await reset(el);
expect(el.timezone).to.equal('Europe/London');
});
it('R6. Use local timezone does not overwrite the configured zone', async () => {
const el = await mount('timezone="Asia/Kolkata"');
(el.shadowRoot!.querySelector('.tp-timezone-local-link') as HTMLElement).click();
await el.updateComplete;
await reset(el);
expect(el.timezone).to.equal('Asia/Kolkata');
});
it('R7. a bracketed value seeds its zone internally; Reset restores the configured zone', async () => {
const el = await mount('timezone="Europe/London" value="2026-07-15T14:30:05.250[GMT+09:00 Asia/Tokyo]"');
expect(el.timezone).to.equal('Asia/Tokyo');
await reset(el);
expect(el.timezone).to.equal('Europe/London');
});
it('R8. doubleClickUnselect uses the Reset path and restores the configured zone', async () => {
const el = await mount('timezone="Europe/London" value="2026-07-15T14:30:05.250[GMT+01:00 Europe/London]"');
el.doubleClickUnselect = true;
await pick(el, 'Asia/Tokyo');
await el.updateComplete;
const sel = el.shadowRoot!.querySelector('.day_date.selected-date:not(.filler)') as HTMLElement | null;
if (sel) {
sel.click();
await el.updateComplete;
expect(el.value).to.be.null;
} else {
firstDay(el); await el.updateComplete;
el.handleReset(); await el.updateComplete;
}
expect(el.timezone).to.equal('Europe/London');
});
it('R9. works with the timezone field hidden via hide-time-fields', async () => {
const host = document.createElement('div');
host.innerHTML = ``;
document.body.appendChild(host);
created.push(host);
const el = host.firstElementChild as any;
await el.updateComplete;
expect(el.shadowRoot!.querySelector('.tp-timezone-select')).to.not.exist;
(el.shadowRoot!.querySelector('.tp-set-to-now-button') as HTMLElement | null)?.click();
await el.updateComplete;
await reset(el);
expect(el.timezone).to.equal('Europe/London');
});
it('R10. Set to now still adopts the host zone and absolute Apply emits the selected zone', async () => {
const el = await mount('timezone="Europe/London"');
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
(el.shadowRoot!.querySelector('.tp-set-to-now-button') as HTMLElement).click();
await el.updateComplete;
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
expect(detail.timezone).to.equal(host);
});
it('R11. relative Apply still emits UTC with a configured non-UTC zone', async () => {
const el = await mount('timezone="Asia/Tokyo"');
el.type = 'relative';
await el.updateComplete;
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
(el.shadowRoot!.querySelector('.duration__value') as HTMLElement).click();
await el.updateComplete;
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
expect(detail.timezone).to.equal('UTC');
expect(el.timezone).to.equal('Asia/Tokyo');
});
});
describe('NileCalendar hideTimeFields is the only time-field visibility API', () => {
const created: HTMLElement[] = [];
const mount = async (attrs = '') => {
const host = document.createElement('div');
host.innerHTML = ``;
document.body.appendChild(host);
created.push(host);
const el = host.firstElementChild as any;
await el.updateComplete;
return el;
};
afterEach(() => { created.splice(0).forEach(h => h.remove()); });
const labels = (el: any) =>
Array.from(el.shadowRoot!.querySelectorAll('.tp-time-field')).map((n: any) => n.getAttribute('label'));
const ALL = ['Hours (24H)', 'Minutes', 'Seconds', 'Milliseconds'];
const has = (el: any, sel: string) => !!el.shadowRoot!.querySelector(sel);
it('H1. there is no timeFields property any more', async () => {
const el = await mount();
expect('timeFields' in el).to.be.false;
expect(NileCalendarCtor().elementProperties.has('timeFields')).to.be.false;
});
it('H2. by default every field and the timezone block render', async () => {
const el = await mount();
expect(labels(el)).to.deep.equal(ALL);
expect(has(el, '.tp-timezone')).to.be.true;
expect(has(el, '.tp-time-fields--stacked')).to.be.false;
expect(has(el, '.base--tp-fewfields')).to.be.false;
});
it('H3. each single hidden field disappears alone', async () => {
const el = await mount();
const map: Record = { hours: 'Hours (24H)', minutes: 'Minutes', seconds: 'Seconds', milliseconds: 'Milliseconds' };
for (const key of Object.keys(map)) {
el.hideTimeFields = [key];
await el.updateComplete;
expect(labels(el), key).to.deep.equal(ALL.filter(l => l !== map[key]));
expect(has(el, '.tp-timezone'), key).to.be.true;
}
el.hideTimeFields = ['timezone'];
await el.updateComplete;
expect(labels(el)).to.deep.equal(ALL);
expect(has(el, '.tp-timezone')).to.be.false;
});
it('H4. fewer than three time fields with no timezone stacks the layout (few-fields classes)', async () => {
const el = await mount(`hide-time-fields='["seconds","milliseconds","timezone"]'`);
expect(labels(el)).to.deep.equal(['Hours (24H)', 'Minutes']);
expect(has(el, '.tp-time-fields--stacked')).to.be.true;
expect(has(el, '.base--tp-fewfields')).to.be.true;
el.hideTimeFields = ['seconds', 'milliseconds']; // timezone back: not stacked
await el.updateComplete;
expect(has(el, '.tp-time-fields--stacked')).to.be.false;
expect(has(el, '.base--tp-fewfields')).to.be.false;
});
it('H5. all fields hidden: empty time row, no timezone, same layout classes as before', async () => {
const el = await mount(`hide-time-fields='["hours","minutes","seconds","milliseconds","timezone"]'`);
expect(labels(el)).to.deep.equal([]);
expect(has(el, '.tp-timezone')).to.be.false;
expect(has(el, '.tp-time-fields')).to.be.true;
expect(has(el, '.tp-time-fields--stacked')).to.be.true;
expect(has(el, '.base--tp-fewfields')).to.be.true;
});
it('H6. the four time fields hidden but timezone shown renders an empty, non-stacked row', async () => {
const el = await mount(`hide-time-fields='["hours","minutes","seconds","milliseconds"]'`);
expect(labels(el)).to.deep.equal([]);
expect(has(el, '.tp-timezone')).to.be.true;
expect(has(el, '.tp-time-fields--stacked')).to.be.false;
});
it('H7. HTML attribute and property forms agree, and dynamic updates re-render both ways', async () => {
const attr = await mount(`hide-time-fields='["seconds"]'`);
const prop = await mount();
prop.hideTimeFields = ['seconds'];
await prop.updateComplete;
expect(labels(attr)).to.deep.equal(labels(prop));
expect(labels(attr)).to.deep.equal(['Hours (24H)', 'Minutes', 'Milliseconds']);
attr.setAttribute('hide-time-fields', '["minutes"]');
await attr.updateComplete;
expect(labels(attr)).to.deep.equal(['Hours (24H)', 'Seconds', 'Milliseconds']);
attr.hideTimeFields = [];
await attr.updateComplete;
expect(labels(attr)).to.deep.equal(ALL);
});
it('H8. unknown keys in the list are ignored', async () => {
const el = await mount(`hide-time-fields='["bogus"]'`);
expect(labels(el)).to.deep.equal(ALL);
expect(has(el, '.tp-timezone')).to.be.true;
});
it('H9. invalid JSON and valid-JSON non-arrays count as an empty list and never crash rendering', async () => {
for (const raw of ['{seconds:false}', 'seconds', '5', '{"a":1}', 'null', 'true', '"seconds"']) {
const el = await mount(`hide-time-fields='${raw}'`);
expect(labels(el), raw).to.deep.equal(ALL);
expect(has(el, '.tp-timezone'), raw).to.be.true;
}
const el = await mount();
for (const bad of [5, { a: 1 }, 'seconds', null, undefined]) {
el.hideTimeFields = bad;
await el.updateComplete;
expect(labels(el), String(bad)).to.deep.equal(ALL);
}
});
it('H10. the default variant ignores hideTimeFields', async () => {
const host = document.createElement('div');
host.innerHTML = ``;
document.body.appendChild(host);
created.push(host);
const el = host.firstElementChild as any;
await el.updateComplete;
expect(has(el, '.tp-time-fields')).to.be.false;
});
});
function NileCalendarCtor(): any { return customElements.get('nile-calendar') as any; }
describe('NileCalendar structuredInput scope and variant switching', () => {
const created: HTMLElement[] = [];
const mount = async (attrs: string) => {
const host = document.createElement('div');
host.innerHTML = ``;
document.body.appendChild(host);
created.push(host);
const el = host.firstElementChild as any;
await el.updateComplete;
return el;
};
afterEach(() => { created.splice(0).forEach(h => h.remove()); });
const manual = (el: any) => Array.from(el.shadowRoot!.querySelectorAll('.calender-input nile-input')) as any[];
const bound = (el: any) => el.boundMaskInputs as any[];
const settle = async (el: any) => { await el.updateComplete; await el.updateComplete; };
/** keydown-only typing: a masked input blocks the key (preventDefault) and writes the display itself */
const type = async (ni: any, text: string) => {
await ni.updateComplete;
for (const ch of text) {
ni.dispatchEvent(new KeyboardEvent('keydown', { key: ch, bubbles: true, composed: true, cancelable: true }));
}
return ni.input.value as string;
};
const RANGE = 'range showManualInputs structuredInput dateFormat="MM/DD/YYYY"';
it('SI1. default variant with range + showManualInputs + structuredInput masks all four manual inputs', async () => {
const el = await mount(RANGE);
expect(manual(el).length).to.equal(4);
expect(bound(el).length).to.equal(4);
expect(await type(el.shadowRoot!.querySelector('.date-input'), '12312025')).to.equal('12/31/2025');
expect(await type(el.shadowRoot!.querySelector('.time-input'), '123045')).to.equal('12:30:45');
});
it('SI2. the date-time-picker variant has no manual inputs, so structuredInput binds nothing', async () => {
const el = await mount(`variant="date-time-picker" ${RANGE}`);
expect(el.shadowRoot!.querySelector('.calender-input')).to.be.null;
expect(manual(el).length).to.equal(0);
expect(bound(el).length).to.equal(0);
});
it('SI3. structuredInput on a single (non-range) default calendar is a no-op: there are no manual inputs to mask', async () => {
const el = await mount('structuredInput');
expect(manual(el).length).to.equal(0);
expect(bound(el).length).to.equal(0);
});
it('SI4. switching date-time-picker -> default at runtime binds the mask to the newly rendered manual inputs', async () => {
const el = await mount(`variant="date-time-picker" ${RANGE}`);
expect(bound(el).length).to.equal(0);
el.variant = 'default';
await settle(el);
expect(manual(el).length).to.equal(4);
expect(bound(el).length).to.equal(4);
expect(await type(el.shadowRoot!.querySelector('.date-input'), '12312025')).to.equal('12/31/2025');
});
it('SI5. switching default -> date-time-picker unbinds: nothing is left bound, and no detached input is retained', async () => {
const el = await mount(RANGE);
const before = bound(el).slice();
expect(before.length).to.equal(4);
el.variant = 'date-time-picker';
await settle(el);
expect(bound(el).length).to.equal(0);
expect(manual(el).length).to.equal(0);
expect(before.every(n => !n.isConnected)).to.be.true; // the old inputs are gone and nothing references them
});
it('SI6. repeated switches never duplicate or leak bindings, and typing is masked exactly once', async () => {
const el = await mount(RANGE);
for (let i = 0; i < 4; i++) {
el.variant = 'date-time-picker'; await settle(el);
expect(bound(el).length, `round ${i} dtp`).to.equal(0);
el.variant = 'default'; await settle(el);
expect(bound(el).length, `round ${i} default`).to.equal(4);
expect(bound(el).every(n => n.isConnected), `round ${i} connected`).to.be.true;
}
const di = el.shadowRoot!.querySelector('.date-input');
expect(await type(di, '12312025')).to.equal('12/31/2025'); // a duplicate listener would double every digit
});
it('SI7. turning structuredInput off at runtime leaves the manual inputs free-form', async () => {
const el = await mount(RANGE);
el.structuredInput = false;
await settle(el);
expect(bound(el).length).to.equal(0);
expect(await type(el.shadowRoot!.querySelector('.date-input'), '1')).to.equal(''); // unmasked: keydown is not intercepted
});
});
describe('NileCalendar collapsed-height ResizeObserver lifecycle', () => {
type Tracked = { cal: boolean; live: boolean; targets: Element[] };
let all: Tracked[] = [];
let loopErrors = 0;
let Orig: typeof ResizeObserver;
const onError = (e: ErrorEvent) => { if (/ResizeObserver loop/i.test(e.message || '')) loopErrors++; };
const created: HTMLElement[] = [];
beforeEach(() => {
all = [];
loopErrors = 0;
Orig = window.ResizeObserver;
window.addEventListener('error', onError);
// counts only the calendar's own observer: the one that watches a `.base--date-time-picker`
(window as any).ResizeObserver = class extends Orig {
info: Tracked = { cal: false, live: false, targets: [] };
constructor(cb: ResizeObserverCallback) { super(cb); all.push(this.info); }
observe(t: Element, o?: ResizeObserverOptions) {
if (t.classList.contains('base--date-time-picker')) { this.info.cal = true; this.info.live = true; }
this.info.targets.push(t);
return super.observe(t, o);
}
disconnect() { this.info.live = false; return super.disconnect(); }
};
});
afterEach(() => {
window.ResizeObserver = Orig;
window.removeEventListener('error', onError);
created.splice(0).forEach(h => h.remove());
});
const live = () => all.filter(o => o.cal && o.live);
const mount = async (attrs = 'variant="date-time-picker"') => {
const host = document.createElement('div');
host.innerHTML = ``;
document.body.appendChild(host);
created.push(host);
const el = host.firstElementChild as any;
await el.updateComplete;
await frames();
return { host, el };
};
const frames = async (n = 3) => { for (let i = 0; i < n; i++) await new Promise(r => requestAnimationFrame(() => r(null))); await new Promise(r => setTimeout(r, 30)); };
const baseOf = (el: any) => el.shadowRoot!.querySelector('.base--date-time-picker') as HTMLElement | null;
const widen = async (el: any, w: string | null) => {
for (const k of ['--ng-width-312', '--nile-width-288px']) w ? el.style.setProperty(k, w) : el.style.removeProperty(k);
await frames();
};
const follows = (el: any) => el.tpLockedHeight === `${baseOf(el)!.offsetHeight}px`;
it('O1. initial mount: one observer on the rendered base, and the collapsed height is measured', async () => {
const { el } = await mount();
expect(live().length).to.equal(1);
expect(live()[0].targets[0]).to.equal(baseOf(el));
expect(el.tpLockedHeight).to.equal(`${baseOf(el)!.offsetHeight}px`);
});
it('O2. the default variant never creates a picker-panel observer', async () => {
await mount('range');
expect(all.filter(o => o.cal).length).to.equal(0);
});
it('O3. remove then re-attach: the observer is dropped while detached and restored, and layout changes are followed again', async () => {
const { host, el } = await mount();
const parent = host.parentElement!;
parent.removeChild(host);
expect(live().length).to.equal(0);
parent.appendChild(host);
await el.updateComplete;
await frames();
expect(live().length).to.equal(1);
expect(live()[0].targets.slice(-1)[0]).to.equal(baseOf(el));
await widen(el, '180px');
expect(follows(el), `locked=${el.tpLockedHeight} real=${baseOf(el)!.offsetHeight}`).to.be.true;
await widen(el, null);
expect(follows(el)).to.be.true;
});
it('O4. date-time-picker -> default -> date-time-picker, repeated: never an observer on a detached base, and layout is followed after each round', async () => {
const { el } = await mount();
for (let i = 0; i < 3; i++) {
el.variant = 'default';
await el.updateComplete; await frames();
expect(live().length, `round ${i} default`).to.equal(0);
expect(baseOf(el)).to.be.null;
el.variant = 'date-time-picker';
await el.updateComplete; await frames();
expect(live().length, `round ${i} dtp`).to.equal(1);
expect(live()[0].targets.slice(-1)[0], `round ${i} target`).to.equal(baseOf(el));
expect(live()[0].targets.slice(-1)[0].isConnected).to.be.true;
await widen(el, '180px');
expect(follows(el), `round ${i} follows`).to.be.true;
await widen(el, null);
}
});
it('O5. rapid expand/collapse toggles never recreate the observer or raise a ResizeObserver loop error', async () => {
const { el } = await mount();
const before = all.filter(o => o.cal).length;
const lockedBefore = el.tpLockedHeight;
for (let i = 0; i < 40; i++) el.toggleExpanded();
await el.updateComplete; await frames();
for (let i = 0; i < 40; i++) { el.toggleExpanded(); await new Promise(r => requestAnimationFrame(() => r(null))); }
await el.updateComplete; await frames();
if (el.expanded) { el.toggleExpanded(); await el.updateComplete; await frames(); }
expect(all.filter(o => o.cal).length).to.equal(before);
expect(live().length).to.equal(1);
expect(loopErrors).to.equal(0);
expect(el.tpLockedHeight).to.equal(lockedBefore);
expect(follows(el)).to.be.true;
});
it('O6. the existing guard still holds: nothing is measured while expanded', async () => {
const { el } = await mount();
const locked = el.tpLockedHeight;
el.toggleExpanded();
await el.updateComplete; await frames();
expect(el.expanded).to.be.true;
await widen(el, '180px'); // the panel is height-locked while expanded, and the locked value is not re-measured
expect(el.tpLockedHeight).to.equal(locked);
el.toggleExpanded();
await el.updateComplete; await frames();
await widen(el, null);
expect(follows(el)).to.be.true;
});
it('O7. 60 mount/unmount cycles leave no live observer', async () => {
for (let i = 0; i < 60; i++) {
const { host } = await mount();
host.remove();
}
expect(all.filter(o => o.cal).length).to.equal(60);
expect(live().length).to.equal(0);
}).timeout(60000);
it('O8. repeated reparenting keeps exactly one observer on the current base', async () => {
const { host, el } = await mount();
const other = document.createElement('div');
document.body.appendChild(other);
created.push(other);
const origin = host.parentElement!;
for (let i = 0; i < 10; i++) {
(i % 2 ? origin : other).appendChild(host);
await el.updateComplete; await frames(1);
expect(live().length, `move ${i}`).to.equal(1);
}
await frames();
expect(live()[0].targets.slice(-1)[0]).to.equal(baseOf(el));
await widen(el, '180px');
expect(follows(el)).to.be.true;
expect(loopErrors).to.equal(0);
});
it('O9. removing the element finally disconnects the observer (no retained watcher)', async () => {
const { host } = await mount();
expect(live().length).to.equal(1);
host.remove();
expect(live().length).to.equal(0);
});
});
describe('NileCalendar date-time-picker variant: an unparseable value after a valid one clears the selection', () => {
const GOOD = '2026-06-15T10:20:30.000[GMT+05:30 Asia/Kolkata]';
for (const bad of ['not-a-date', '2026-13-45T10:00:00', ' ']) {
it(`V ${JSON.stringify(bad)}: value and time fields reset, timezone and events untouched`, async () => {
const el = await fixture(html``) as any;
await el.updateComplete;
expect(el.value.getDate(), 'precondition').to.equal(15);
let changes = 0;
el.addEventListener('nile-change', () => { changes++; });
el.setAttribute('value', bad);
await el.updateComplete;
await el.updateComplete;
expect(el.value).to.equal(null);
expect([el.tpHours, el.tpMinutes, el.tpSeconds, el.tpMilliseconds]).to.deep.equal([null, null, null, null]);
expect(el.timezone, 'zone is not touched').to.equal('Asia/Kolkata');
expect(changes).to.equal(0);
});
}
it('V the DEFAULT variant keeps its selection on an unparseable value (nile-date-picker relies on it while the user types)', async () => {
const el = await fixture(html``) as any;
await el.updateComplete;
el.setAttribute('value', 'not-a-date');
await el.updateComplete;
await el.updateComplete;
expect(el.value.getDate()).to.equal(15);
});
});
describe('NileCalendar Set to now uses the machine local date, time and timezone', () => {
const FROZEN = Date.UTC(2026, 5, 15, 18, 42, 37, 456); // 2026-06-15T18:42:37.456Z
const HOST = Intl.DateTimeFormat().resolvedOptions().timeZone;
const created: HTMLElement[] = [];
afterEach(() => { created.splice(0).forEach(h => h.remove()); });
const mount = async (attrs: string): Promise => {
const host = document.createElement('div');
host.innerHTML = ``;
document.body.appendChild(host);
created.push(host);
const el = host.firstElementChild as any;
await el.updateComplete;
return el;
};
/** click Set to now with the global clock frozen (`new Date()` does not go through `Date.now`) */
const setToNowAtFrozen = async (el: any) => {
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; }
}
(globalThis as any).Date = FrozenDate;
try { (el.shadowRoot!.querySelector('.tp-set-to-now-button') as HTMLElement).click(); }
finally { (globalThis as any).Date = RealDate; }
await el.updateComplete;
};
const apply = (el: any) => {
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener, { once: true });
(el.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
return detail;
};
const p2 = (n: number) => String(n).padStart(2, '0');
// the selected zone is replaced by the machine zone, whatever it was (incl. one picked in the dropdown)
for (const zone of ['UTC', 'Asia/Kolkata', 'America/New_York', 'Pacific/Kiritimati', 'Pacific/Pago_Pago']) {
it(`S1. selected ${zone}: Set to now fills the machine's clock and day, switches to the machine zone, and Apply commits exactly now`, async () => {
const el = await mount(`timezone="${zone}"`);
await setToNowAtFrozen(el);
const t = new Date(FROZEN); // read through the machine's own zone
expect(el.timezone, 'zone becomes the machine zone').to.equal(HOST);
expect([el.tpHours, el.tpMinutes, el.tpSeconds, el.tpMilliseconds]).to.deep.equal([t.getHours(), t.getMinutes(), t.getSeconds(), 456]);
expect([el.value.getFullYear(), el.value.getMonth() + 1, el.value.getDate()], 'the machine day').to.deep.equal([t.getFullYear(), t.getMonth() + 1, t.getDate()]);
expect([el.currentYear, el.currentMonth + 1]).to.deep.equal([t.getFullYear(), t.getMonth() + 1]);
const detail = apply(el);
expect(detail.timezone).to.equal(HOST);
expect(detail.unixEpoch, 'the committed instant is exactly now').to.equal(FROZEN);
expect(detail.value).to.contain(` ${HOST}]`);
expect(detail.value.startsWith(
`${String(t.getFullYear()).padStart(4, '0')}-${p2(t.getMonth() + 1)}-${p2(t.getDate())}T${p2(t.getHours())}:${p2(t.getMinutes())}:${p2(t.getSeconds())}.456`
)).to.be.true;
});
}
it('S2. a zone picked in the dropdown is replaced by the machine zone too, and Reset still restores the configured zone', async () => {
const el = await mount('timezone="Europe/London"');
el.handleTimezoneChange(new CustomEvent('nile-change', { detail: { value: 'America/New_York' } }));
await el.updateComplete;
await setToNowAtFrozen(el);
expect(el.timezone).to.equal(HOST);
expect(apply(el).unixEpoch).to.equal(FROZEN);
(el.shadowRoot!.querySelector('.clear-button') as HTMLElement).click();
await el.updateComplete;
expect(el.timezone).to.equal('Europe/London');
});
it('S3. Set to now over a seeded instant commits now, not the old instant', async () => {
const el = await mount('timezone="UTC" value="2026-01-02T03:04:05.000Z"');
await setToNowAtFrozen(el);
expect(apply(el).unixEpoch).to.equal(FROZEN);
});
it('S4. "Use local timezone" adopts the machine zone', async () => {
const el = await mount('timezone="Pacific/Kiritimati"');
(el.shadowRoot!.querySelector('.tp-timezone-local-link') as HTMLElement).click();
await el.updateComplete;
expect(el.timezone).to.equal(HOST);
});
});
describe('NileCalendar relative range arithmetic (createRelativePeriod)', () => {
const RealDate = Date;
/** run `fn` with the global clock frozen at `ts` (`new Date()` does not go through `Date.now`) */
const frozen = (ts: number, fn: () => T): T => {
class FrozenDate extends RealDate {
constructor(...args: any[]) { if (args.length === 0) super(ts); else super(...(args as [any])); }
static now() { return ts; }
}
(globalThis as any).Date = FrozenDate;
try { return fn(); } finally { (globalThis as any).Date = RealDate; }
};
const make = async (): Promise => {
const el = await fixture(html``) as any;
await el.updateComplete;
return el;
};
const local = (y: number, m1: number, d: number, h = 12, mi = 0) => localDateFromFields(y, m1 - 1, d, h, mi).getTime();
const ymdhm = (d: Date) => [d.getFullYear(), d.getMonth() + 1, d.getDate(), d.getHours(), d.getMinutes()];
/** every host-zone offset change in 2026, as the first instant on the new offset (empty on a host without DST) */
const hostTransitions = (): number[] => {
const out: number[] = [];
let prev = new RealDate(Date.UTC(2026, 0, 1)).getTimezoneOffset();
for (let t = Date.UTC(2026, 0, 1); t < Date.UTC(2027, 0, 1); t += 15 * 60000) {
const off = new RealDate(t).getTimezoneOffset();
if (off !== prev) { out.push(t); prev = off; }
}
return out;
};
it('A1. minutes and hours are elapsed durations, also across the host DST transitions (host-zone independent)', async () => {
const el = await make();
const transitions = hostTransitions();
// eslint-disable-next-line no-console
console.warn(`[relative-arith] host DST transitions in 2026: ${transitions.length}`);
const bad: string[] = [];
for (const tr of transitions) {
for (const offsetMin of [-45, -5, 5, 30, 90, 150, 200, 300]) {
const now = tr + offsetMin * 60000;
for (const [unit, ms] of [['minutes', 60000], ['hours', 3600000]] as Array<[string, number]>) {
for (const n of [1, 2, 3, 6, 30, 90, 120, 180]) {
const { startDate, endDate } = frozen(now, () => el.createRelativePeriod(unit, n));
const elapsed = endDate.getTime() - startDate.getTime();
if (elapsed !== n * ms) bad.push(`${new RealDate(now).toISOString()} last ${n} ${unit}: elapsed ${elapsed / ms} ${unit}`);
}
}
}
}
expect(bad.slice(0, 12), `${bad.length} wrong ranges`).to.deep.equal([]);
});
it('A2. minutes and hours away from any transition are exact elapsed durations', async () => {
const el = await make();
const now = Date.UTC(2026, 5, 15, 12, 0, 0, 0);
for (const [unit, ms] of [['minutes', 60000], ['hours', 3600000]] as Array<[string, number]>) {
const { startDate, endDate } = frozen(now, () => el.createRelativePeriod(unit, 5));
expect(endDate.getTime()).to.equal(now);
expect(endDate.getTime() - startDate.getTime()).to.equal(5 * ms);
}
});
it('A3. months never overflow past the end of the shorter target month (clamped, time of day kept)', async () => {
const el = await make();
const cases: Array<[number[], number, number[]]> = [
[[2026, 3, 31], 1, [2026, 2, 28, 12, 0]], // 31 Mar - 1 month: 28 Feb, not 3 Mar
[[2028, 3, 31], 1, [2028, 2, 29, 12, 0]], // leap year
[[2026, 10, 31], 1, [2026, 9, 30, 12, 0]], // 31 Oct - 1 month: 30 Sep, not 1 Oct
[[2026, 1, 31], 11, [2025, 2, 28, 12, 0]], // across a year boundary
[[2026, 5, 30], 3, [2026, 2, 28, 12, 0]],
[[2026, 3, 29], 1, [2026, 2, 28, 12, 0]],
];
for (const [[y, m, d], n, expected] of cases) {
const now = local(y, m, d);
const { startDate, endDate } = frozen(now, () => el.createRelativePeriod('months', n));
expect(ymdhm(startDate), `${y}-${m}-${d} minus ${n} month(s)`).to.deep.equal(expected);
expect(endDate.getTime(), 'end is now').to.equal(now);
}
});
it('A4. months that do not overflow are unchanged (same day and time of day)', async () => {
const el = await make();
for (const [[y, m, d], n, expected] of [
[[2026, 6, 15], 3, [2026, 3, 15, 12, 0]],
[[2026, 6, 15], 6, [2025, 12, 15, 12, 0]],
[[2026, 1, 15], 1, [2025, 12, 15, 12, 0]],
[[2026, 3, 28], 1, [2026, 2, 28, 12, 0]],
] as Array<[number[], number, number[]]>) {
const { startDate } = frozen(local(y, m, d), () => el.createRelativePeriod('months', n));
expect(ymdhm(startDate), `${y}-${m}-${d} minus ${n}`).to.deep.equal(expected);
}
});
it('A5. days and weeks keep their calendar-day meaning (same wall-clock time N calendar days ago)', async () => {
const el = await make();
const now = local(2026, 6, 15);
expect(ymdhm(frozen(now, () => el.createRelativePeriod('days', 7)).startDate)).to.deep.equal([2026, 6, 8, 12, 0]);
expect(ymdhm(frozen(now, () => el.createRelativePeriod('weeks', 2)).startDate)).to.deep.equal([2026, 6, 1, 12, 0]);
expect(ymdhm(frozen(local(2026, 3, 3), () => el.createRelativePeriod('days', 5)).startDate)).to.deep.equal([2026, 2, 26, 12, 0]);
});
});
describe('NileCalendar relative duration overflow (Finding #8): a duration that cannot be a Date never becomes a selection', () => {
type Variant = 'default' | 'date-time-picker';
const VARIANTS: Variant[] = ['default', 'date-time-picker'];
const UNITS = ['minutes', 'hours', 'days', 'weeks', 'months'] as const;
/** far past the representable range for every unit (the largest valid value per unit is at most ~1.4e11 minutes) */
const OVERFLOW: Record = { minutes: 999999999999, hours: 99999999999, days: 999999999, weeks: 999999999, months: 999999999 };
const make = async (variant: Variant): Promise => {
const el: any = variant === 'default'
? await fixture(html``)
: await fixture(html``);
await el.updateComplete;
return el;
};
const durationInput = (el: any) => el.shadowRoot.querySelector('.duration-input') as HTMLElement;
const unitSelect = (el: any) => el.shadowRoot.querySelector('.time-input') as HTMLElement;
const applyBtn = (el: any) => el.shadowRoot.querySelector('.apply-button') as HTMLElement;
const isDisabled = (el: any) => applyBtn(el).hasAttribute('disabled');
const fire = (target: HTMLElement, value: string) =>
target.dispatchEvent(new CustomEvent('nile-change', { detail: { value }, bubbles: false, composed: true }));
/** the user's path: choose the unit, then type the duration */
const enter = async (el: any, unit: string, value: number | string) => {
fire(unitSelect(el), unit);
fire(durationInput(el), String(value));
await el.updateComplete;
};
const watch = (el: any) => {
const seen: any[] = [];
for (const n of ['nile-change', 'nile-changed']) el.addEventListener(n, (e: any) => seen.push([n, e.detail]));
return seen;
};
const finite = (d: any) => d instanceof Date && !isNaN(d.getTime());
/** valid = the calculation produced a usable range (works for the stored dates before and after the fix) */
const valid = (el: any, unit: string, n: number) => {
const p = el.createRelativePeriod(unit, n);
return finite(p.startDate) && finite(p.endDate);
};
const largestValid = (el: any, unit: string): number => {
let lo = 1, hi = 1;
while (valid(el, unit, hi)) { lo = hi; hi *= 2; if (hi > 1e13) throw new Error('no upper limit found'); }
while (hi - lo > 1) { const mid = Math.floor((lo + hi) / 2); if (valid(el, unit, mid)) lo = mid; else hi = mid; }
return lo;
};
for (const variant of VARIANTS) {
describe(`[${variant}]`, () => {
for (const unit of UNITS) {
it(`F8 ${unit}: an overflow duration stores no dates, disables Apply and emits nothing`, async () => {
const el = await make(variant);
if (variant === 'default') {
// published default: no overflow guard; the calculation yields an Invalid Date
expect(finite(el.createRelativePeriod(unit, OVERFLOW[unit]).startDate), 'published: invalid start').to.be.false;
return;
}
const seen = watch(el);
await enter(el, unit, OVERFLOW[unit]);
expect(el.startDate, 'startDate').to.be.null;
expect(el.endDate, 'endDate').to.be.null;
expect(isDisabled(el), 'Apply disabled').to.be.true;
applyBtn(el).click();
await el.updateComplete;
expect(seen, 'no nile-change / nile-changed').to.deep.equal([]);
});
it(`F8 ${unit}: valid -> overflow clears the previous selection, correcting it restores Apply`, async () => {
const el = await make(variant);
if (variant === 'default') {
await enter(el, unit, 3);
expect(finite(el.startDate) && finite(el.endDate), 'valid range stored').to.be.true;
expect(isDisabled(el), 'Apply enabled for a valid duration').to.be.false;
return;
}
await enter(el, unit, 3);
expect(finite(el.startDate) && finite(el.endDate), 'valid range stored').to.be.true;
expect(isDisabled(el), 'Apply enabled for a valid duration').to.be.false;
await enter(el, unit, OVERFLOW[unit]);
expect(el.startDate, 'previous selection cleared').to.be.null;
expect(el.endDate).to.be.null;
expect(isDisabled(el)).to.be.true;
await enter(el, unit, 3);
expect(finite(el.startDate) && finite(el.endDate), 'recovered').to.be.true;
expect(isDisabled(el)).to.be.false;
const seen = watch(el);
applyBtn(el).click();
await el.updateComplete;
const changes = seen.filter(([n]) => n === 'nile-change');
expect(changes.length, 'exactly one nile-change').to.equal(1);
const detail = changes[0][1];
expect(finite(detail.startDate) && finite(detail.endDate), 'emitted dates are valid').to.be.true;
expect(detail.endDate.getTime()).to.be.greaterThan(detail.startDate.getTime());
});
it(`F8 ${unit}: the largest representable duration is accepted, one more is rejected (no invented limit)`, async () => {
const el = await make(variant);
if (variant === 'default') {
// published default: validity boundary is whatever Date arithmetic yields; no extra guard
const max = largestValid(el, unit);
expect(max).to.be.greaterThan(1e6);
expect(valid(el, unit, max + 1)).to.be.false;
return;
}
const max = largestValid(el, unit);
expect(max, 'the representable range is huge, not an arbitrary cap').to.be.greaterThan(1e6);
await enter(el, unit, max);
expect(finite(el.startDate) && finite(el.endDate), `${max} ${unit} is accepted`).to.be.true;
expect(isDisabled(el)).to.be.false;
await enter(el, unit, max + 1);
expect(el.startDate, `${max + 1} ${unit} is rejected`).to.be.null;
expect(isDisabled(el)).to.be.true;
});
}
it('F8 valid durations are unchanged: minutes/hours are exact elapsed time, days/weeks/months are calendar steps', async () => {
const el = await make(variant);
if (variant === 'default') {
// published wall-clock arithmetic (setMinutes/setHours/setMonth)
for (const [unit, n] of [['minutes', 45], ['hours', 5], ['months', 1], ['months', 18], ['days', 10]] as Array<[string, number]>) {
const { startDate } = el.createRelativePeriod(unit, n);
const e = new Date();
if (unit === 'minutes') e.setMinutes(e.getMinutes() - n);
else if (unit === 'hours') e.setHours(e.getHours() - n);
else if (unit === 'months') e.setMonth(e.getMonth() - n);
else e.setDate(e.getDate() - n);
expect(Math.abs(startDate.getTime() - e.getTime()), `${n} ${unit}`).to.be.lessThan(5000);
}
return;
}
const MS = { minutes: 60000, hours: 3600000 } as Record;
for (const unit of ['minutes', 'hours']) {
for (const n of [1, 45, 500]) {
const { startDate, endDate } = el.createRelativePeriod(unit, n);
expect(endDate.getTime() - startDate.getTime(), `${n} ${unit}`).to.equal(n * MS[unit]);
}
}
for (const [unit, n] of [['days', 1], ['days', 90], ['weeks', 2], ['months', 1], ['months', 18]] as Array<[string, number]>) {
const { startDate, endDate } = el.createRelativePeriod(unit, n);
expect(finite(startDate) && finite(endDate), `${n} ${unit}`).to.be.true;
expect(startDate.getTime()).to.be.lessThan(endDate.getTime());
}
const { startDate } = el.createRelativePeriod('days', 10);
const expected = new Date(); expected.setDate(expected.getDate() - 10);
expect(Math.abs(startDate.getTime() - expected.getTime())).to.be.lessThan(5000);
});
it('F8 an overflow does not emit nile-clear / nile-type-change and keeps the unit and typed value', async () => {
const el = await make(variant);
if (variant === 'default') return; // published default has no overflow handling
const other: string[] = [];
for (const n of ['nile-clear', 'nile-type-change']) el.addEventListener(n, () => other.push(n));
await enter(el, 'days', 999999999);
expect(el.selectedUnit).to.equal('days');
expect(el.selectedValue).to.equal(999999999);
expect(el.type).to.equal('relative');
expect(other).to.deep.equal([]);
});
});
}
});
describe('NileCalendar year/month dropdowns only navigate', () => {
for (const variant of ['default', 'date-time-picker'] as const) {
describe(`${variant} variant`, () => {
const make = async () => {
const el = await fixture(
html``
);
await el.updateComplete;
return el as any;
};
const record = (el: any) => {
const seen: Array<{ name: string; detail: any }> = [];
for (const name of ['nile-change', 'nile-changed', 'nile-year-change', 'nile-month-change']) {
el.addEventListener(name, (e: CustomEvent) => seen.push({ name, detail: e.detail }));
}
return seen;
};
const pickYear = async (el: any, y: number) => {
const item = [...el.shadowRoot.querySelectorAll('.year-menu-item')]
.find((m: any) => m.textContent.trim() === String(y)) as HTMLElement;
item.click();
await el.updateComplete;
};
const pickMonth = async (el: any, m0: number) => {
(el.shadowRoot.querySelectorAll('.month-menu-item')[m0] as HTMLElement).click();
await el.updateComplete;
};
const countOf = (seen: Array<{ name: string }>, name: string) => seen.filter(s => s.name === name).length;
it('picking a year moves the view, keeps the selected date, and emits only nile-year-change', async () => {
const el = await make();
const seen = record(el);
await pickYear(el, 2020);
expect(el.currentYear).to.equal(2020);
expect(el.value.getFullYear(), 'selected date').to.equal(2026);
expect(el.value.getMonth()).to.equal(9);
expect(el.value.getDate()).to.equal(13);
expect(seen.map(s => s.name)).to.deep.equal(['nile-year-change']);
expect(seen[0].detail).to.deep.equal({ year: 2020 });
});
it('picking a month moves the view, keeps the selected date, and emits only nile-month-change', async () => {
const el = await make();
const seen = record(el);
await pickMonth(el, 2);
expect(el.currentMonth).to.equal(2);
expect(el.value.getMonth(), 'selected month').to.equal(9);
expect(seen.map(s => s.name)).to.deep.equal(['nile-month-change']);
});
it('picking a year and then a day selects that day in the picked year', async () => {
const el = await make();
await pickYear(el, 2020);
(el.shadowRoot.querySelectorAll('.day_date:not(.filler)')[3] as HTMLElement).click();
await el.updateComplete;
expect(el.value.getFullYear()).to.equal(2020);
expect(el.value.getMonth()).to.equal(9);
});
});
}
it('date-time-picker variant: Apply after navigation only emits the original selected date, once', async () => {
const el: any = await fixture(
html``
);
await el.updateComplete;
const item = [...el.shadowRoot.querySelectorAll('.year-menu-item')]
.find((m: any) => m.textContent.trim() === '2020') as HTMLElement;
item.click();
await el.updateComplete;
const changes: any[] = [];
el.addEventListener('nile-change', (e: CustomEvent) => changes.push(e.detail));
const btn = el.shadowRoot.querySelector('.apply-button') as any;
await btn.updateComplete;
btn.click();
expect(changes).to.have.length(1);
expect(changes[0].value).to.match(/^2026-10-13T/);
expect(new Date(changes[0].unixEpoch).getUTCFullYear()).to.equal(2026);
});
});
describe('NileCalendar manual From/To inputs: the default reads a two-digit year as published; only the date-time-picker variant keeps an existing bound\'s century', () => {
const make = async (format: string, range?: { startDate: string; endDate: string }) => {
const el: any = await fixture(
html``
);
await el.updateComplete;
await new Promise(r => setTimeout(r, 50));
return el;
};
// the four manual inputs are: start date, start time, end date, end time
const START = 0;
const END = 2;
const fire = async (el: any, i: number, v: string) => {
el.shadowRoot.querySelectorAll('nile-input')[i]
.dispatchEvent(new CustomEvent('nile-change', { detail: { value: v }, bubbles: true, composed: true }));
await el.updateComplete;
};
const SEEDED = { startDate: '1999-06-10', endDate: '1999-06-20' };
it('R1. default calendar, seeded 1999 start: editing its day reads 99 as 2099, exactly as published', async () => {
const el = await make('DD/MM/YY', SEEDED);
const existing = el.startDate;
await fire(el, START, '11/06/99');
const exp = published.parse('11/06/99', 'DD/MM/YY', existing)!;
const ymd = (d: Date) => [d.getFullYear(), d.getMonth(), d.getDate()];
expect(ymd(el.startDate)).to.deep.equal(ymd(exp));
expect(el.startDate.getFullYear(), 'published: no century preservation').to.equal(2099);
});
it('R2. default calendar, seeded 1999 end: editing its day reads 99 as 2099, exactly as published', async () => {
const el = await make('DD/MM/YY', SEEDED);
const existing = el.endDate;
await fire(el, END, '21/06/99');
const exp = published.parse('21/06/99', 'DD/MM/YY', existing)!;
expect([el.endDate.getFullYear(), el.endDate.getMonth(), el.endDate.getDate()]).to.deep.equal([exp.getFullYear(), exp.getMonth(), exp.getDate()]);
expect(el.endDate.getFullYear()).to.equal(2099);
});
it('R8. date-time-picker variant: an existing bound keeps its century while the two digits are unchanged', async () => {
const el: any = await fixture(html``);
const existing = new Date(1999, 5, 10);
expect(el.parseDate('11/06/99', existing).getFullYear(), 'unchanged digits keep 1999').to.equal(1999);
expect(el.parseDate('11/06/98', existing).getFullYear(), 'changed digits read as 20xx').to.equal(2098);
expect(el.parseDate('11/06/99', null).getFullYear(), 'no existing bound reads as 20xx').to.equal(2099);
});
it('R3. changed digits follow the 2000-2099 reading (start 98 → 2098, end 05 → 2005)', async () => {
const el = await make('DD/MM/YY', SEEDED);
await fire(el, START, '10/06/98');
expect(el.startDate.getFullYear()).to.equal(2098);
await fire(el, END, '20/06/05');
expect(el.endDate.getFullYear()).to.equal(2005);
});
it('R4. empty input: a new 99 is 2099 and 00 is 2000, as before', async () => {
const el = await make('DD/MM/YY');
await fire(el, START, '10/06/99');
expect(el.startDate.getFullYear()).to.equal(2099);
await fire(el, END, '20/06/00');
expect(el.endDate.getFullYear()).to.equal(2000);
});
it('R5. a 2026 bound still edits to 2026 and moves to 2027', async () => {
const el = await make('DD/MM/YY', { startDate: '2026-06-10', endDate: '2026-06-20' });
await fire(el, START, '11/06/26');
expect(el.startDate.getFullYear()).to.equal(2026);
await fire(el, START, '11/06/27');
expect(el.startDate.getFullYear()).to.equal(2027);
});
it('R6. YYYY is unaffected: 1999 stays 1999 and a typed 2099 is 2099', async () => {
const el = await make('DD/MM/YYYY', SEEDED);
await fire(el, START, '11/06/1999');
expect(el.startDate.getFullYear()).to.equal(1999);
await fire(el, START, '11/06/2099');
expect(el.startDate.getFullYear()).to.equal(2099);
});
it('R7. the preserved century leaves the time and the other bound alone', async () => {
const el = await make('DD/MM/YY', SEEDED);
const endBefore = el.endDate.getTime();
await fire(el, START, '12/06/99');
expect(el.endDate.getTime()).to.equal(endBefore);
expect(el.startDate.getHours()).to.equal(0);
expect(el.startDate.getMinutes()).to.equal(0);
});
});
describe('NileCalendar offset-less values: a clock face that is not a time of day is unparseable, never rolled into the next day', () => {
const VALID: Array<[string, [number, number, number, number, number, number, number]]> = [
['2026-03-01T10:30', [2026, 3, 1, 10, 30, 0, 0]],
['2026-03-01T00:00:00', [2026, 3, 1, 0, 0, 0, 0]],
['2026-03-01T23:59:59.999', [2026, 3, 1, 23, 59, 59, 999]],
['2026-02-31T10:00:00', [2026, 2, 28, 10, 0, 0, 0]], // an impossible day still clamps to the month end
];
for (const [raw, [y, mo, d, h, mi, s, ms]] of VALID) {
it(`T1. ${raw} is read as written`, () => {
expect(wallClockFromOffsetLess(raw)).to.deep.equal({ year: y, month: mo, day: d, hours: h, minutes: mi, seconds: s, ms });
});
}
for (const raw of [
'2026-03-01T25:61:00', '2026-03-01T25:00:00', '2026-03-01T10:61:00', '2026-03-01T10:30:61',
'2026-03-01T99:00', '2026-03-01T24:00:01', '2026-03-01T24:01:00', '2026-03-01T24:00:00.001',
]) {
it(`T2. ${raw} is rejected (null), not rolled over`, () => {
expect(wallClockFromOffsetLess(raw)).to.be.null;
});
}
it('T3. 24:00:00 is ISO end-of-day: it is kept and resolves to the next midnight', () => {
expect(wallClockFromOffsetLess('2026-03-01T24:00:00')).to.deep.equal({ year: 2026, month: 3, day: 2, hours: 0, minutes: 0, seconds: 0, ms: 0 });
expect(wallClockFromOffsetLess('2026-03-01T24:00:00.000')).to.deep.equal({ year: 2026, month: 3, day: 2, hours: 0, minutes: 0, seconds: 0, ms: 0 });
});
it('T4. isClockTime: the shared rule, edge by edge', () => {
for (const [h, m, s, ms] of [[0, 0, 0, 0], [23, 59, 59, 999], [24, 0, 0, 0], [12, 0, 0, 0]]) expect(isClockTime(h, m, s, ms), `${h}:${m}:${s}.${ms}`).to.be.true;
for (const [h, m, s, ms] of [[24, 0, 0, 1], [24, 0, 1, 0], [24, 1, 0, 0], [25, 0, 0, 0], [0, 60, 0, 0], [0, 0, 60, 0], [0, 0, 0, 1000], [-1, 0, 0, 0], [NaN, 0, 0, 0]]) expect(isClockTime(h, m, s, ms), `${h}:${m}:${s}.${ms}`).to.be.false;
});
for (const variant of ['default', 'date-time-picker']) {
it(`T5. [${variant}] the element shows no selection for 2026-03-01T25:61:00, and a valid sibling still selects`, async () => {
const bad: any = await fixture(html``);
await bad.updateComplete;
expect(bad.value ?? null, 'no selection from an impossible clock face').to.be.null;
const good: any = await fixture(html``);
await good.updateComplete;
expect([good.value.getFullYear(), good.value.getMonth() + 1, good.value.getDate()]).to.deep.equal([2026, 3, 1]);
});
}
});
describe('NileCalendar bracketed values: ISO end-of-day 24:00:00.000 is the next day 00:00:00', () => {
const NEXT_MIDNIGHT_UTC = Date.UTC(2026, 2, 2);
it('B1. 24:00:00(.000) in a bracketed value is the next day 00:00:00 in that zone', () => {
expect(instantFromBracketedValue('2026-03-01T24:00:00.000[GMT+00:00 UTC]')).to.deep.equal({ epochMs: NEXT_MIDNIGHT_UTC, timezone: 'UTC' });
expect(instantFromBracketedValue('2026-03-01T24:00:00[GMT+00:00 UTC]')).to.deep.equal({ epochMs: NEXT_MIDNIGHT_UTC, timezone: 'UTC' });
expect(instantFromBracketedValue('2026-03-01T24:00:00.000[GMT+05:30 Asia/Kolkata]'))
.to.deep.equal({ epochMs: NEXT_MIDNIGHT_UTC - 330 * 60000, timezone: 'Asia/Kolkata' });
expect(instantFromBracketedValue('2026-12-31T24:00:00.000[GMT+00:00 UTC]')!.epochMs, 'year rollover').to.equal(Date.UTC(2027, 0, 1));
expect(instantFromBracketedValue('2028-02-28T24:00:00.000[GMT+00:00 UTC]')!.epochMs, 'leap day').to.equal(Date.UTC(2028, 1, 29));
});
it('B2. anything past 24:00:00.000, or hours above 24, is not a time and stays unparseable', () => {
for (const bad of [
'2026-03-01T24:00:01.000[GMT+00:00 UTC]', '2026-03-01T24:01:00.000[GMT+00:00 UTC]', '2026-03-01T24:00:00.001[GMT+00:00 UTC]',
'2026-03-01T25:00:00.000[GMT+00:00 UTC]', '2026-03-01T99:00:00.000[GMT+00:00 UTC]', '2026-03-01T24:00:01[GMT+00:00 UTC]',
]) expect(instantFromBracketedValue(bad), bad).to.be.null;
});
it('B3. offset and zone validation is unchanged: a wrong offset for the zone, an invalid zone, and ordinary hours', () => {
expect(instantFromBracketedValue('2026-03-01T24:00:00.000[GMT+09:00 Asia/Kolkata]'), 'offset does not match the zone').to.be.null;
expect(instantFromBracketedValue('2026-03-01T24:00:00.000[GMT+00:00 Mars/Phobos]'), 'unknown zone').to.be.null;
expect(instantFromBracketedValue('2026-03-01T23:59:59.999[GMT+00:00 UTC]')!.epochMs).to.equal(NEXT_MIDNIGHT_UTC - 1);
expect(instantFromBracketedValue('2026-03-01T00:00:00.000[GMT+00:00 UTC]')!.epochMs).to.equal(Date.UTC(2026, 2, 1));
});
for (const [value, zoneName, offset, epoch] of [
['2026-03-01T24:00:00.000[GMT+00:00 UTC]', 'UTC', '+00:00', NEXT_MIDNIGHT_UTC],
['2026-03-01T24:00:00[GMT+05:30 Asia/Kolkata]', 'Asia/Kolkata', '+05:30', NEXT_MIDNIGHT_UTC - 330 * 60000],
] as const) {
it(`B4. the panel selects the next day for ${value}, and Apply emits it`, async () => {
const el: any = await fixture(html``);
await el.updateComplete;
expect([el.value.getFullYear(), el.value.getMonth() + 1, el.value.getDate()], 'selected day').to.deep.equal([2026, 3, 2]);
expect(el.shadowRoot.querySelector('.selected-date').textContent.trim(), 'highlighted cell').to.equal('2');
const apply = el.shadowRoot.querySelector('.apply-button') as HTMLElement;
expect(apply.hasAttribute('disabled'), 'Apply enabled').to.be.false;
let detail: any;
el.addEventListener('nile-change', (e: CustomEvent) => { detail = e.detail; });
apply.click();
expect(detail.value).to.include('2026-03-02T00:00:00');
expect(detail.value).to.include(`[GMT${offset} ${zoneName}]`);
expect(detail.timezone).to.equal(zoneName);
expect((detail.localTimezoneValue as Date).getTime()).to.equal(epoch);
});
}
it('B5. a rejected bracketed 24:00 value still leaves no selection and Apply disabled', async () => {
const el: any = await fixture(html``);
await el.updateComplete;
expect(el.value ?? null).to.be.null;
expect(el.shadowRoot.querySelector('.apply-button').hasAttribute('disabled')).to.be.true;
});
});
describe('NileCalendar default variant: published DOM, defaults and inert additive properties', () => {
const dom = (el: any) =>
el.shadowRoot.innerHTML
.replace(//g, '')
.replace(/\b(id|aria-controls|aria-labelledby|for)="([a-z-]+)-\d+"/g, '$1="$2-#"');
const build = async (tpl: any) => {
const el = (await fixture(tpl)) as any;
await el.updateComplete;
await new Promise(r => setTimeout(r, 20));
return el;
};
it('PB1. public property defaults are the published ones', async () => {
const el = await build(html``);
expect(el.range).to.be.false;
expect(el.type).to.equal('absolute');
expect(el.dateFormat).to.equal('MM/DD/YYYY');
expect(el.hideTypes).to.be.false;
expect(el.showManualInputs).to.be.false;
expect(el.allowClear).to.be.false;
expect(el.doubleClickUnselect).to.be.false;
expect(el.disabled).to.be.false;
expect(el.valueAttribute).to.be.null;
expect(el.allowedDates).to.equal('{}');
expect(el.startYear).to.be.undefined;
expect(el.endYear).to.be.undefined;
expect(el.structuredInput).to.be.false;
expect(el.variant).to.equal('default');
});
it('PB2. the default renders none of the date-time-picker markup and no day-cell ARIA', async () => {
for (const tpl of [html``, html``]) {
const el = await build(tpl);
const root = el.shadowRoot;
expect(root.querySelector('.base--date-time-picker')).to.be.null;
expect(root.querySelector('[class*="tp-"]')).to.be.null;
expect(root.querySelector('.base')).to.not.be.null;
root.querySelectorAll('[part^="calendar-day"]').forEach((c: Element) => {
for (const a of ['role', 'aria-label', 'aria-pressed', 'aria-current', 'aria-disabled', 'aria-hidden'])
expect(c.hasAttribute(a), `default day cell has no ${a}`).to.be.false;
});
}
});
it('PB3. showTypes / timezone / hideTimeFields / structuredInput do not change the default DOM, state or events', async () => {
const plain = await build(html``);
const extra = await build(html``);
expect(dom(extra)).to.equal(dom(plain));
expect(extra.value?.getTime()).to.equal(plain.value?.getTime());
expect([extra.currentMonth, extra.currentYear]).to.deep.equal([plain.currentMonth, plain.currentYear]);
});
it('PB4. clearing the value attribute leaves the relative unit/value untouched, as published', async () => {
const el = await build(html``);
el.handleTimeValueClick('days', 7, new CustomEvent('x'));
el.valueAttribute = '';
await el.updateComplete;
expect(el.selectedUnit).to.equal('days');
expect(el.selectedValue).to.equal(7);
expect(el.tpHours).to.be.null;
});
it('PB5. the default relative branch is the published one: wall-clock arithmetic, no overflow guard', async () => {
const el = await build(html``);
const r = el.createRelativePeriod('months', 1e12);
expect(isNaN(el.startDate.getTime()), 'an overflowing duration stores an Invalid Date, as published').to.be.true;
expect(r).to.not.be.null;
const m = el.createRelativePeriod('minutes', 90);
const expected = new Date(); expected.setMinutes(expected.getMinutes() - 90);
expect(Math.abs(el.startDate.getTime() - expected.getTime())).to.be.lessThan(2000);
expect(m).to.not.be.undefined;
});
});
describe('NileCalendar day-cell Space/Enter keyboard handling', () => {
const dayCell = (el: NileCalendar, n: number): HTMLElement =>
[...el.shadowRoot!.querySelectorAll('.day_date:not(.filler)')].find(c => c.textContent!.trim() === String(n))!;
// dispatchEvent returns false when a listener called preventDefault, i.e. the browser would not scroll on Space
const press = (target: Element, key: string) =>
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, composed: true }));
const variant = async () => {
const el = await fixture(
html``,
);
(el as any).expanded = true;
await el.updateComplete;
return el;
};
const pressed = (el: NileCalendar) =>
[...el.shadowRoot!.querySelectorAll('.day_date[aria-pressed="true"]')].map(c => c.textContent!.trim());
it('SP1. variant: Space selects the day and is default-prevented (no page scroll)', async () => {
const el = await variant();
const cell = dayCell(el, 20);
expect(press(cell, ' '), 'Space is default-prevented').to.be.false;
await el.updateComplete;
expect(pressed(el)).to.deep.equal(['20']);
});
it('SP2. variant: Enter still selects the day', async () => {
const el = await variant();
press(dayCell(el, 21), 'Enter');
await el.updateComplete;
expect(pressed(el)).to.deep.equal(['21']);
});
it('SP3. variant: Space/Enter on a disabled (not allowed) day select nothing', async () => {
const el = await fixture(
html``,
);
(el as any).expanded = true;
await el.updateComplete;
const cell = dayCell(el, 25);
expect(cell.getAttribute('aria-disabled')).to.equal('true');
press(cell, ' ');
press(cell, 'Enter');
await el.updateComplete;
expect(pressed(el)).to.deep.equal(['15']);
});
it('SP4. variant: Tab, letters and Escape are not default-prevented and select nothing; arrow keys now move focus', async () => {
const el = await variant();
for (const key of ['Tab', 'a', 'Escape']) {
expect(press(dayCell(el, 22), key), key).to.be.true;
}
// day navigation (ArrowLeft/Right/Up/Down, Home, End, PageUp/PageDown) is handled and prevented so the page does not scroll
expect(press(dayCell(el, 22), 'ArrowRight'), 'ArrowRight is handled').to.be.false;
await el.updateComplete;
expect(pressed(el), 'moving focus selects nothing').to.deep.equal(['15']);
});
it('SP5. default calendar: published handler unchanged — Space still selects and is NOT default-prevented', async () => {
const el = await fixture(html``);
await el.updateComplete;
const cell = dayCell(el, 20);
expect(press(cell, ' '), 'published behaviour: not prevented').to.be.true;
await el.updateComplete;
expect([...el.shadowRoot!.querySelectorAll('.day_date.selected-date')].map(c => c.textContent!.trim())).to.deep.equal(['20']);
});
});
describe('YR. startYear / endYear bound month navigation: explicit bounds win, the variant falls back to 2000-2050, the default calendar stays open', () => {
const variants = ['default', 'date-time-picker'] as const;
const arrows = (el: NileCalendar) =>
Array.from(el.shadowRoot!.querySelectorAll('.calendar-header__month-navigation'));
const prevArrow = (el: NileCalendar) => arrows(el)[0];
const nextArrow = (el: NileCalendar) => arrows(el)[arrows(el).length - 1];
const make = async (variant: string, bounds: { start?: number; end?: number }, year: number, month: number) => {
const el = await fixture(html``);
el.currentYear = year;
el.currentMonth = month;
await el.updateComplete;
return el;
};
const at = (el: NileCalendar) => [el.currentYear, el.currentMonth];
const step = async (el: NileCalendar, arrow: HTMLElement) => {
arrow.click();
await el.updateComplete;
};
/** 'open' = the default calendar as published (an unset bound is unlimited); 'dtp' = the variant (2000-2050 fallback) */
const openOrFallback = (variant: string) => (variant === 'default' ? 'open' : 'dtp');
for (const variant of variants) {
it(`YR1 [${variant}]. no bounds: ${variant === 'default' ? 'the arrows stay open past 2000 and 2050 (as published)' : 'the arrows stop at Jan 2000 and Dec 2050'}`, async () => {
const lo = await make(variant, {}, 2000, 0);
await step(lo, prevArrow(lo));
expect(at(lo)).to.deep.equal(openOrFallback(variant) === 'open' ? [1999, 11] : [2000, 0]);
expect(prevArrow(lo).getAttribute('tabindex')).to.equal(openOrFallback(variant) === 'open' ? '0' : '-1');
const hi = await make(variant, {}, 2050, 11);
await step(hi, nextArrow(hi));
expect(at(hi)).to.deep.equal(openOrFallback(variant) === 'open' ? [2051, 0] : [2050, 11]);
expect(nextArrow(hi).getAttribute('tabindex')).to.equal(openOrFallback(variant) === 'open' ? '0' : '-1');
});
it(`YR2 [${variant}]. no bounds: inside 2000-2050 both arrows work, including December to January`, async () => {
const el = await make(variant, {}, 2026, 11);
await step(el, nextArrow(el));
expect(at(el)).to.deep.equal([2027, 0]);
await step(el, prevArrow(el));
expect(at(el)).to.deep.equal([2026, 11]);
});
it(`YR3 [${variant}]. both bounds: the arrows stop at the first and last month`, async () => {
const lo = await make(variant, { start: 2020, end: 2030 }, 2020, 0);
expect(prevArrow(lo).getAttribute('tabindex')).to.equal('-1');
await step(lo, prevArrow(lo));
expect(at(lo)).to.deep.equal([2020, 0]);
const hi = await make(variant, { start: 2020, end: 2030 }, 2030, 11);
expect(nextArrow(hi).getAttribute('tabindex')).to.equal('-1');
await step(hi, nextArrow(hi));
expect(at(hi)).to.deep.equal([2030, 11]);
await step(hi, prevArrow(hi));
expect(at(hi)).to.deep.equal([2030, 10]);
});
it(`YR4 [${variant}]. explicit bounds outside 2000-2050 take precedence over the fallback`, async () => {
const lo = await make(variant, { start: 1990, end: 2060 }, 2000, 0);
await step(lo, prevArrow(lo));
expect(at(lo), 'passes 2000 down to the explicit start').to.deep.equal([1999, 11]);
const hi = await make(variant, { start: 1990, end: 2060 }, 2050, 11);
await step(hi, nextArrow(hi));
expect(at(hi), 'passes 2050 up to the explicit end').to.deep.equal([2051, 0]);
const start = await make(variant, { start: 1990, end: 2060 }, 1990, 0);
await step(start, prevArrow(start));
expect(at(start)).to.deep.equal([1990, 0]);
const end = await make(variant, { start: 1990, end: 2060 }, 2060, 11);
await step(end, nextArrow(end));
expect(at(end)).to.deep.equal([2060, 11]);
});
it(`YR5 [${variant}]. only startYear: it bounds backwards; forwards is ${variant === 'default' ? 'open' : 'capped at the 2050 fallback'}`, async () => {
const lo = await make(variant, { start: 2020 }, 2020, 0);
await step(lo, prevArrow(lo));
expect(at(lo)).to.deep.equal([2020, 0]);
const hi = await make(variant, { start: 2020 }, 2050, 11);
await step(hi, nextArrow(hi));
expect(at(hi)).to.deep.equal(variant === 'default' ? [2051, 0] : [2050, 11]);
});
it(`YR6 [${variant}]. only endYear: it bounds forwards; backwards is ${variant === 'default' ? 'open' : 'capped at the 2000 fallback'}`, async () => {
const hi = await make(variant, { end: 2030 }, 2030, 11);
await step(hi, nextArrow(hi));
expect(at(hi)).to.deep.equal([2030, 11]);
const lo = await make(variant, { end: 2030 }, 2000, 0);
await step(lo, prevArrow(lo));
expect(at(lo)).to.deep.equal(variant === 'default' ? [1999, 11] : [2000, 0]);
});
it(`YR7 [${variant}]. a bound set after the first render applies from the next click`, async () => {
const el = await make(variant, {}, 2030, 11);
el.endYear = 2030;
await el.updateComplete;
await step(el, nextArrow(el));
expect(at(el)).to.deep.equal([2030, 11]);
el.endYear = undefined as any;
await el.updateComplete;
await step(el, nextArrow(el));
expect(at(el), '2030 is inside the fallback and the open default').to.deep.equal([2031, 0]);
});
}
it('YR8. the variant without bounds: the year dropdown list and the arrow limits are the same 2000-2050', async () => {
const el = await make('date-time-picker', {}, 2000, 0);
const options = (el as any).yearOptions as number[];
expect([options[0], options.at(-1), options.length]).to.deep.equal([2000, 2050, 51]);
await step(el, prevArrow(el));
expect(el.currentYear).to.equal(options[0]);
});
it('YR9. the year dropdown fallback (2000-2050) is unchanged in both variants', async () => {
for (const variant of variants) {
const el = await fixture(html``);
expect((el as any).yearOptions[0], variant).to.equal(2000);
expect((el as any).yearOptions.at(-1), variant).to.equal(2050);
expect((el as any).yearOptions.length, variant).to.equal(51);
}
});
it('YR10. a bound replaces only its own side of the dropdown fallback', async () => {
const el = await fixture(html``);
expect((el as any).yearOptions[0]).to.equal(1990);
expect((el as any).yearOptions.at(-1)).to.equal(2050);
});
it('YR11. date-time-picker, explicit startYear 0: year 0001 steps back to 0000 without the 1900 remap, then stops', async () => {
const el = await make('date-time-picker', { start: 0, end: 9999 }, 1, 0);
await step(el, prevArrow(el));
expect(at(el)).to.deep.equal([0, 11]);
el.currentMonth = 0;
await el.updateComplete;
await step(el, prevArrow(el));
expect(at(el)).to.deep.equal([0, 0]);
});
it('YR12. the default calendar is never given the fallback: no bounds means open on both sides far from 2000-2050', async () => {
const lo = await make('default', {}, 1850, 0);
await step(lo, prevArrow(lo));
expect(at(lo)).to.deep.equal([1849, 11]);
const hi = await make('default', {}, 2100, 11);
await step(hi, nextArrow(hi));
expect(at(hi)).to.deep.equal([2101, 0]);
});
});
describe('CS. variant day-label part and the height-lock class (no inline presentation in the variant template)', () => {
const baseOf = (el: any) => el.shadowRoot!.querySelector('.base--date-time-picker') as HTMLElement;
const settle = async (el: NileCalendar) => {
await el.updateComplete;
await new Promise(r => requestAnimationFrame(() => r(null)));
await el.updateComplete;
};
const make = async (extra = '') => {
const el = await fixture(html``);
await settle(el);
return el;
};
const expand = async (el: NileCalendar) => {
el.expanded = true;
await settle(el);
};
it('CS1. every current-month day cell has one span.day-label exposed as part="calendar-day-label", and no inline style', async () => {
const el = await make();
await expand(el);
const cells = Array.from(el.shadowRoot!.querySelectorAll('.day_date[role="button"]'));
expect(cells.length).to.be.greaterThan(27);
for (const c of cells) {
const labels = c.querySelectorAll('span.day-label');
expect(labels.length, c.textContent!.trim()).to.equal(1);
expect(labels[0].getAttribute('part')).to.equal('calendar-day-label');
expect(labels[0].hasAttribute('style'), 'no inline style').to.be.false;
expect(getComputedStyle(labels[0]).position).to.equal('relative');
}
});
it('CS2. the day number is still the label text, and the today dot sits inside the positioned label', async () => {
const el = await make();
el.currentMonth = new Date().getMonth();
el.currentYear = new Date().getFullYear();
await settle(el);
const dot = el.shadowRoot!.querySelector('.current__date__dot') as HTMLElement | null;
if (dot) {
const label = dot.closest('.day-label') as HTMLElement;
expect(label, 'dot is inside a day-label').to.exist;
expect(dot.offsetParent, 'the label is the dot\'s positioning parent').to.equal(label);
}
const first = el.shadowRoot!.querySelector('.day_date[role="button"] .day-label') as HTMLElement;
expect(first.textContent!.trim().startsWith('1')).to.be.true;
});
it('CS3. a consumer can style the label from outside through the part', async () => {
const el = await make();
const style = document.createElement('style');
style.textContent = 'nile-calendar::part(calendar-day-label){ letter-spacing: 3px; }';
document.head.appendChild(style);
try {
const label = el.shadowRoot!.querySelector('.day_date[role="button"] .day-label') as HTMLElement;
expect(getComputedStyle(label).letterSpacing).to.equal('3px');
} finally {
style.remove();
}
});
it('CS4. the default calendar keeps its published day markup: inline position:relative, no class, no part', async () => {
const el = await fixture(html``);
await el.updateComplete;
const span = el.shadowRoot!.querySelector('.day_date span') as HTMLElement;
expect(span.getAttribute('style')).to.equal('position:relative;');
expect(span.hasAttribute('part')).to.be.false;
expect(span.classList.contains('day-label')).to.be.false;
expect(el.shadowRoot!.querySelector('[part="calendar-day-label"]')).to.be.null;
expect(el.shadowRoot!.querySelector('.base--date-time-picker')).to.be.null;
});
it('CS5. collapsed: no height-lock class and no style attribute on the picker base', async () => {
const el = await make();
const base = baseOf(el);
expect(el.expanded).to.be.false;
expect(base.classList.contains('base--tp-height-locked')).to.be.false;
expect(base.hasAttribute('style')).to.be.false;
});
it('CS6. expanded: the class and the custom property carry the measured collapsed height; no inline height', async () => {
const el: any = await make();
const collapsed = baseOf(el).offsetHeight;
await expand(el);
const base = baseOf(el);
expect(base.classList.contains('base--tp-height-locked')).to.be.true;
expect(base.style.getPropertyValue('--nile-calendar-tp-locked-height')).to.equal(el.tpLockedHeight);
expect(base.style.height, 'no inline height').to.equal('');
expect(el.tpLockedHeight).to.equal(`${collapsed}px`);
expect(base.offsetHeight, 'rendered height equals the locked collapsed height').to.equal(collapsed);
expect(getComputedStyle(base).height).to.equal(`${collapsed}px`);
});
it('CS7. collapsing again removes the class and the style attribute', async () => {
const el = await make();
await expand(el);
el.expanded = false;
await settle(el);
const base = baseOf(el);
expect(base.classList.contains('base--tp-height-locked')).to.be.false;
expect(base.hasAttribute('style')).to.be.false;
});
it('CS8. relative type is never height-locked', async () => {
const el = await make();
await expand(el);
el.type = 'relative';
await settle(el);
const base = baseOf(el);
expect(base.classList.contains('base--tp-height-locked'), 'expanded may linger on a direct type change; the lock must not').to.be.false;
expect(base.hasAttribute('style')).to.be.false;
});
it('CS9. the locked height can be overridden by a consumer through ::part(calendar-container)', async () => {
const el = await make();
await expand(el);
const style = document.createElement('style');
style.textContent = 'nile-calendar::part(calendar-container){ height: 500px; }';
document.head.appendChild(style);
try {
expect(getComputedStyle(baseOf(el)).height).to.equal('500px');
} finally {
style.remove();
}
});
});