import { expect, fixture, html } from '@open-wc/testing'; import './nile-calendar'; import '../nile-input'; import type { NileCalendar } from './nile-calendar'; import { formatYear4, localDateFromFields, isClockTime } from './timezones'; import { wallClockFromOffsetLess, instantFromBracketedValue } from './nile-calendar'; /** * The PUBLISHED default-calendar algorithms (@aquera/nile-elements 2.0.16), kept as oracles. The default calendar * (`` and `variant="default"`) must keep behaving exactly like this on every host timezone; only * `variant="date-time-picker"` may differ. Tests that need "what the published calendar does" compare against these, * so no expected value has to be hard-coded against a host zone. */ const published = { /** `value` seeding: `new Date(raw)` shifted by the host offset of that instant; null when it is not a date */ seed(raw: string): Date | null { const d = new Date(raw); const shifted = new Date(d.getTime() - d.getTimezoneOffset() * 60000); return isNaN(shifted.getTime()) ? null : shifted; }, /** one bound of a `range` value */ bound(raw: string): Date { return new Date(raw); }, /** manual From/To input text */ format(date: Date, dateFormat: string): string { const yyyy = date.getFullYear(); const mm = String(date.getMonth() + 1).padStart(2, '0'); const dd = String(date.getDate()).padStart(2, '0'); let formatted = dateFormat; formatted = formatted.replace(/YYYY/g, String(yyyy)); formatted = formatted.replace(/MM/g, mm); formatted = formatted.replace(/DD/g, dd); formatted = formatted.replace(/YY/g, String(yyyy).slice(-2)); return formatted; }, /** manual From/To input parsing */ parse(input: string, dateFormat: string, existingDate?: Date | null): Date | null { if (!input || !dateFormat) return null; const formatPattern = dateFormat .replace(/YYYY/, '(?\\d{4})') .replace(/YY/, '(?\\d{2})') .replace(/MM/, '(?\\d{1,2})') .replace(/DD/, '(?\\d{1,2})'); const match = input.match(new RegExp(`^${formatPattern}$`)); if (!match || !match.groups) return null; const year = match.groups.year.length === 2 ? Number(`20${match.groups.year}`) : Number(match.groups.year); const month = Number(match.groups.month) - 1; const day = Number(match.groups.day); const date = new Date(year, month, day); if (existingDate) date.setHours(existingDate.getHours(), existingDate.getMinutes(), existingDate.getSeconds()); else date.setHours(0, 0, 0); return date; }, /** allowedDates membership (Date.UTC remaps years 0-99 to 1900-1999) */ allowed(startDate: string, endDate: string, day: number, month: number, year: number): boolean { const utc = (str: string) => new Date(Date.UTC(str.slice(0, 4) as any, (str.slice(5, 7) as any) - 1, str.slice(8, 10) as any)); // published: a range whose start reads later than its end is rejected, leaving every day allowed if (utc(startDate) > utc(endDate)) return true; const end = utc(endDate); end.setUTCHours(23, 59, 59, 999); const d = new Date(Date.UTC(year, month, day)); return d >= utc(startDate) && d <= end; }, }; /** the default calendar seeded `raw` exactly as published: the shifted Date, or no selection when `raw` is not a date */ const expectPublishedSeed = (el: any, raw: string, label: string) => { const exp = published.seed(raw); if (!exp) { expect(el.value ?? null, label).to.equal(null); return; } expect(el.value.getTime(), label).to.equal(exp.getTime()); }; describe('NileCalendar', () => { it('1. renders', async () => { const el = await fixture(html``); expect(el).to.exist; }); it('2. shadow root', async () => { const el = await fixture(html``); expect(el.shadowRoot).to.not.be.null; }); it('3. tag name', async () => { const el = await fixture(html``); expect(el.tagName.toLowerCase()).to.equal('nile-calendar'); }); it('4. is defined', async () => { expect(customElements.get('nile-calendar')).to.exist; }); it('5. has styles', async () => { expect((await import('./nile-calendar')).NileCalendar.styles).to.exist; }); it('6. shadow mode', async () => { const el = await fixture(html``); expect(el.shadowRoot!.mode).to.equal('open'); }); it('7. isConnected', async () => { const el = await fixture(html``); expect(el.isConnected).to.be.true; }); it('8. removal', async () => { const el = await fixture(html``); el.remove(); expect(el.isConnected).to.be.false; }); it('9. outerHTML', async () => { const el = await fixture(html``); expect(el.outerHTML).to.contain('nile-calendar'); }); it('10. matches', async () => { const el = await fixture(html``); expect(el.matches('.x')).to.be.true; }); it('11. closest', async () => { const el = await fixture(html``); expect(el.closest('nile-calendar')).to.equal(el); }); it('12. cloneNode', async () => { const el = await fixture(html``); expect((el.cloneNode(true) as Element).tagName.toLowerCase()).to.equal('nile-calendar'); }); it('13. dispatchEvent', async () => { const el = await fixture(html``); let f = false; el.addEventListener('c', () => (f = true)); el.dispatchEvent(new Event('c')); expect(f).to.be.true; }); it('14. updateComplete', async () => { const el = await fixture(html``); const r = await el.updateComplete; expect(r).to.not.be.undefined; }); it('15. render method', async () => { const el = await fixture(html``); expect(el.render).to.be.a('function'); }); it('16. shadowRoot host', async () => { const el = await fixture(html``); expect(el.shadowRoot!.host).to.equal(el); }); it('17. class attr', async () => { const el = await fixture(html``); expect(el.classList.contains('cl')).to.be.true; }); it('18. id attr', async () => { const el = await fixture(html``); expect(el.id).to.equal('cl1'); }); it('19. hidden', async () => { const el = await fixture(html``); expect(el.hidden).to.be.true; }); it('20. nodeType', async () => { const el = await fixture(html``); expect(el.nodeType).to.equal(1); }); it('21. localName', async () => { const el = await fixture(html``); expect(el.localName).to.equal('nile-calendar'); }); it('22. namespaceURI', async () => { const el = await fixture(html``); expect(el.namespaceURI).to.equal('http://www.w3.org/1999/xhtml'); }); it('23. ownerDocument', async () => { const el = await fixture(html``); expect(el.ownerDocument).to.equal(document); }); it('24. multiple instances', async () => { const c = await fixture(html`
`); expect(c.querySelectorAll('nile-calendar').length).to.equal(2); }); it('25. parent-child', async () => { const c = await fixture(html`
`); expect(c.querySelector('nile-calendar')!.parentElement).to.equal(c); }); it('26. createElement', async () => { const el = document.createElement('nile-calendar') as NileCalendar; document.body.appendChild(el); await el.updateComplete; expect(el.shadowRoot).to.not.be.null; document.body.removeChild(el); }); it('27. dataset', async () => { const el = await fixture(html``); expect(el.dataset.idx).to.equal('0'); }); it('28. classList add', async () => { const el = await fixture(html``); el.classList.add('z'); expect(el.classList.contains('z')).to.be.true; }); it('29. getBoundingClientRect', async () => { const el = await fixture(html``); expect(el.getBoundingClientRect()).to.exist; }); it('30. style attr', async () => { const el = await fixture(html``); expect(el.style.color).to.equal('red'); }); it('31. aria-label', async () => { const el = await fixture(html``); expect(el.getAttribute('aria-label')).to.equal('Cal'); }); it('32. dir', async () => { const el = await fixture(html``); expect(el.dir).to.equal('rtl'); }); it('33. lang attr', async () => { const el = await fixture(html``); expect(el.lang).to.equal('en'); }); it('34. tabindex', async () => { const el = await fixture(html``); expect(el.getAttribute('tabindex')).to.equal('0'); }); it('35. requestUpdate', async () => { const el = await fixture(html``); el.requestUpdate(); await el.updateComplete; expect(el.shadowRoot).to.not.be.null; }); it('36. shadow childNodes', async () => { const el = await fixture(html``); expect(el.shadowRoot!.childNodes.length).to.be.greaterThan(0); }); it('37. scrollIntoView', async () => { const el = await fixture(html``); expect(el.scrollIntoView).to.be.a('function'); }); it('38. focus method', async () => { const el = await fixture(html``); expect(el.focus).to.be.a('function'); }); it('39. blur method', async () => { const el = await fixture(html``); expect(el.blur).to.be.a('function'); }); it('40. class toggle', async () => { const el = await fixture(html``); el.classList.toggle('a'); expect(el.classList.contains('a')).to.be.true; }); it('41. toggle hidden', async () => { const el = await fixture(html``); el.hidden = true; expect(el.hidden).to.be.true; }); it('42. dispatchCustomEvent', async () => { const el = await fixture(html``); let d: any; el.addEventListener('t', ((e: CustomEvent) => { d = e.detail; }) as EventListener); el.dispatchEvent(new CustomEvent('t', { detail: 'x' })); expect(d).to.equal('x'); }); it('43. nested in div', async () => { const c = await fixture(html`
`); expect(c.querySelector('nile-calendar')).to.exist; }); it('44. no canvas', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('canvas')).to.be.null; }); it('45. no video', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('video')).to.be.null; }); it('46. no audio', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('audio')).to.be.null; }); it('47. no nav', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nav')).to.be.null; }); it('48. no aside', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('aside')).to.be.null; }); it('49. no table', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('table')).to.be.null; }); it('50. no img', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('img')).to.be.null; }); it('51. no anchor', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('a')).to.be.null; }); it('52. no svg', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('svg')).to.be.null; }); it('53. no form', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('form')).to.be.null; }); it('54. title attr', async () => { const el = await fixture(html``); expect(el.title).to.equal('CL'); }); it('55. no h1', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('h1')).to.be.null; }); it('56. no pre', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('pre')).to.be.null; }); it('57. no code', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('code')).to.be.null; }); it('58. no hr', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('hr')).to.be.null; }); it('59. no p', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('p')).to.be.null; }); it('60. no ul', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('ul')).to.be.null; }); it('61. no textarea', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('textarea')).to.be.null; }); it('62. no select', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('select')).to.be.null; }); it('63. no nile-badge', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-badge')).to.be.null; }); it('64. no nile-input', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-input')).to.be.null; }); it('65. no nile-checkbox', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-checkbox')).to.be.null; }); it('66. no nile-radio', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-radio')).to.be.null; }); it('67. no nile-dialog', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-dialog')).to.be.null; }); it('68. no nile-drawer', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-drawer')).to.be.null; }); it('69. no nile-slider', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-slider')).to.be.null; }); it('70. no nile-accordion', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-accordion')).to.be.null; }); it('71. setAttribute data', async () => { const el = await fixture(html``); el.setAttribute('data-test', '1'); expect(el.dataset.test).to.equal('1'); }); it('72. removeAttribute hidden', async () => { const el = await fixture(html``); el.removeAttribute('hidden'); expect(el.hidden).to.be.false; }); it('73. setAttribute class', async () => { const el = await fixture(html``); el.setAttribute('class', 'test'); expect(el.classList.contains('test')).to.be.true; }); it('74. aria-describedby', async () => { const el = await fixture(html``); expect(el.getAttribute('aria-describedby')).to.equal('d'); }); it('75. multiple re-renders', async () => { const el = await fixture(html``); for (let i = 0; i < 3; i++) { el.requestUpdate(); await el.updateComplete; } expect(el.shadowRoot).to.not.be.null; }); it('76. contains', async () => { const el = await fixture(html`C`); expect(el.contains(el.querySelector('#s1'))).to.be.true; }); it('77. data attr', async () => { const el = await fixture(html``); expect(el.getAttribute('data-x')).to.equal('1'); }); it('78. role attr', async () => { const el = await fixture(html``); expect(el.getAttribute('role')).to.equal('grid'); }); it('79. no section', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('section')).to.be.null; }); it('80. no header', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('header')).to.be.null; }); it('81. no footer', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('footer')).to.be.null; }); it('82. no main', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('main')).to.be.null; }); it('83. no article', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('article')).to.be.null; }); it('84. no nile-toolbar', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-toolbar')).to.be.null; }); it('85. no nile-link', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-link')).to.be.null; }); it('86. no nile-menu', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-menu')).to.be.null; }); it('87. no nile-select', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-select')).to.be.null; }); it('88. no nile-textarea', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-textarea')).to.be.null; }); it('89. no nile-button', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-button')).to.be.null; }); it('90. no nile-card', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-card')).to.be.null; }); it('91. has nile-tab', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-tab')).to.exist; }); it('92. no nile-stepper', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-stepper')).to.be.null; }); it('93. no nile-pagination', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-pagination')).to.be.null; }); it('94. nile-tab-group exists', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-tab-group')).to.exist; }); it('95. has calendar wrapper', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('.calendar')).to.exist; }); it('96. has day name elements', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelectorAll('.day_name').length).to.be.greaterThan(0); }); it('97. shows month/year', async () => { const el = await fixture(html``); expect(el.shadowRoot!.textContent).to.not.be.empty; }); it('98. no nile-progress-bar', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-progress-bar')).to.be.null; }); it('99. no nile-loader', async () => { const el = await fixture(html``); expect(el.shadowRoot!.querySelector('nile-loader')).to.be.null; }); it('100. full integration', async () => { const el = await fixture(html``); expect(el.id).to.equal('cl1'); expect(el.shadowRoot).to.not.be.null; expect(el.shadowRoot!.querySelector('.calendar')).to.exist; expect(el.shadowRoot!.querySelectorAll('.day_name').length).to.be.greaterThan(0); }); }); describe('NileCalendar date-time-picker variant', () => { const dtpFixture = () => fixture(html``); const clickDay = (el: NileCalendar, index = 0) => { const days = el.shadowRoot!.querySelectorAll('.day_date:not(.filler)'); (days[index] as HTMLElement).click(); }; /** Day cells are focusable
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(); } }); });