import { act, createElement as h } from 'react' import assert from 'node:assert/strict' import { afterEach, describe, test } from 'vitest' import { makeDateRange, monthLabelOf, type DateRangeProps, } from '../../../src/client/components/dateRange' import type { DayRange } from '../../../src/client/overview' import { click, hover, keydown, makeKit, mount, query, queryAll, text, type Mounted } from '../helpers/kit' /** The specs' calendar: a Friday, so the month opens mid-week and ends mid-week. */ const TODAY = '2026-10-09' const DateRange = makeDateRange(makeKit()) /** Every tree this spec mounted, torn down after each test (the panel portals to ). */ const mounted: Mounted[] = [] function panel(): HTMLElement | null { return document.querySelector('.lc-dp-panel') } function trigger(m: Mounted): HTMLElement { return query(m.container, '.lc-dp-trigger') } function cell(m: Mounted, key: string): HTMLElement { const found = queryAll(document.body, '.lc-dp-cell').find(el => el.getAttribute('aria-label') === key) if (found === undefined) throw new Error(`no day cell: ${key}`) return found } /** Dispatch a raw event on a window listener, act-wrapped like the kit's own relays. */ async function fireWindow(ev: Event): Promise { await act(async () => { window.dispatchEvent(ev) }) } interface Harness { m: Mounted changes: (DayRange | null)[] /** Re-render with new props, as a controlled parent would. */ set: (next: Partial) => Promise } async function open(over: Partial = {}): Promise { const changes: (DayRange | null)[] = [] let props: DateRangeProps = { value: null, picked: false, onChange: (range) => { changes.push(range) }, today: TODAY, locale: 'en', ...over, } const m = await mount(h(DateRange, props)) mounted.push(m) return { m, changes, async set(next) { props = { ...props, ...next } await m.update(h(DateRange, props)) }, } } async function toggle(m: Mounted): Promise { await click(trigger(m)) } afterEach(async () => { while (mounted.length > 0) await mounted.pop()?.unmount() }) describe('monthLabelOf', () => { test('the panel header names the month in the active locale', () => { assert.match(monthLabelOf('2026-10', 'en'), /2026/) assert.match(monthLabelOf('2026-10', 'zh'), /2026/) assert.equal(monthLabelOf('not-a-month', 'en'), 'not-a-month', 'a key that will not convert falls back to itself') }) }) describe('the trigger', () => { test('carries the scope’s two ends, or the two placeholders', async () => { const { m, set } = await open() const halves = () => queryAll(m.container, '.lc-dp-half').map(el => el.textContent) assert.deepEqual(halves(), ['Start date', 'End date'], 'an unbounded scope has no span to print') assert.match(query(m.container, '.lc-dp-trigger').getAttribute('aria-label') ?? '', /custom date range/) assert.equal(trigger(m).getAttribute('aria-expanded'), 'false') await toggle(m) assert.equal(trigger(m).getAttribute('aria-expanded'), 'true') assert.ok(panel() !== null, 'the panel is portaled to the body') // A preset's window rides the chip too — linked, but never in the brand // voice and with no way back, because a preset is not a pick. await set({ value: { from: '2026-09-10', to: '2026-10-09' } }) assert.deepEqual(halves(), ['2026-09-10', '2026-10-09']) assert.ok(!trigger(m).className.includes('lc-dp-trigger-on')) assert.equal(queryAll(panel()!, '.lc-dp-clear').length, 0, 'nothing to clear while a preset is in charge') await set({ picked: true }) assert.match(trigger(m).className, /lc-dp-trigger-on/) assert.equal(queryAll(panel()!, '.lc-dp-clear').length, 1, 'a pick grows the way back') }) test('a press while open closes it again', async () => { const { m } = await open() await toggle(m) await toggle(m) assert.ok(panel() === null) }) }) describe('picking a range', () => { test('two clicks in order commit the pair and close the panel', async () => { const { m, changes } = await open() await toggle(m) assert.match(text(query(panel()!, '.lc-dp-hint')), /start date/) await click(cell(m, '2026-10-03')) assert.deepEqual(changes, [], 'the first click only anchors') assert.match(text(query(panel()!, '.lc-dp-hint')), /end date/) assert.match(cell(m, '2026-10-03').className, /lc-dp-edge/, 'the anchor is the start until the end lands') assert.match(text(query(m.container, '.lc-dp-half')), /2026-10-03/, 'the head reads the anchor as it lands') await hover(cell(m, '2026-10-09')) assert.match(text(queryAll(m.container, '.lc-dp-half')[1]), /2026-10-09/, 'the head previews the end under the cursor') await click(cell(m, '2026-10-09')) assert.deepEqual(changes, [{ from: '2026-10-03', to: '2026-10-09' }]) assert.ok(panel() === null, 'the pick answers the page immediately') }) test('the end may come first: the pair is sorted, never inverted', async () => { const { m, changes } = await open() await toggle(m) await click(cell(m, '2026-10-09')) await click(cell(m, '2026-10-03')) assert.deepEqual(changes, [{ from: '2026-10-03', to: '2026-10-09' }]) }) test('the same day twice is a one-day range', async () => { const { m, changes } = await open() await toggle(m) await click(cell(m, '2026-10-05')) await click(cell(m, '2026-10-05')) assert.deepEqual(changes, [{ from: '2026-10-05', to: '2026-10-05' }]) }) test('the band previews under the cursor once the start is anchored', async () => { const { m } = await open() await toggle(m) await click(cell(m, '2026-10-03')) assert.match(cell(m, '2026-10-03').className, /lc-dp-edge/, 'the anchor alone lights up as an edge') await hover(cell(m, '2026-10-07')) assert.match(cell(m, '2026-10-05').className, /lc-dp-in/, 'the band runs to the hovered day') assert.match(cell(m, '2026-10-07').className, /lc-dp-edge/, 'the hovered day previews where the end would land') await hover(cell(m, '2026-10-01')) assert.match(cell(m, '2026-10-02').className, /lc-dp-in/, 'hovering back past the anchor previews the swapped band') assert.match(cell(m, '2026-10-01').className, /lc-dp-edge/, 'the swap moves the previewed end to the near side') }) test('a hover before any anchor previews nothing', async () => { const { m } = await open() await toggle(m) const before = cell(m, '2026-10-07').className await hover(cell(m, '2026-10-07')) assert.equal(cell(m, '2026-10-07').className, before) }) test('the live scope is highlighted when the panel opens, and a click restarts the pick', async () => { const { m } = await open({ value: { from: '2026-10-03', to: '2026-10-06' }, picked: true }) await toggle(m) assert.match(cell(m, '2026-10-04').className, /lc-dp-in/) assert.match(cell(m, '2026-10-03').className, /lc-dp-edge/) assert.match(cell(m, '2026-10-06').className, /lc-dp-edge/) assert.match(cell(m, '2026-10-09').className, /lc-dp-today/) assert.match(cell(m, '2026-10-07').textContent ?? '', /^7$/, 'each cell prints its own day of the month') // Clicking anywhere restarts the pick, so the old band releases. await click(cell(m, '2026-10-08')) assert.equal(cell(m, '2026-10-04').className.includes('lc-dp-in'), false) assert.match(text(query(panel()!, '.lc-dp-hint')), /end date/) }) test('a preset’s window is the band on show, with no second copy of its dates', async () => { const { m } = await open({ value: { from: '2026-10-03', to: '2026-10-09' } }) await toggle(m) assert.match(cell(m, '2026-10-05').className, /lc-dp-in/, 'the preset’s own days show as the live band') assert.equal(queryAll(panel()!, '.lc-dp-box').length, 0, 'the chip above is the only place the ends are printed') assert.equal(queryAll(panel()!, '.lc-dp-pair').length, 0) }) test('a future day is inert and a blank pad cell is inert', async () => { const { m, changes } = await open() await toggle(m) const future = cell(m, '2026-10-10') assert.match(future.className, /lc-dp-future/) assert.equal(future.getAttribute('aria-disabled'), 'true') await click(future) assert.deepEqual(changes, []) assert.match(text(query(panel()!, '.lc-dp-hint')), /start date/, 'the future day did not anchor') await hover(future) assert.ok(queryAll(panel()!, '.lc-dp-blank').length > 0, 'the month pads its weeks') }) test('the clear button hands the scope back to the presets', async () => { const { m, changes } = await open({ value: { from: '2026-10-03', to: '2026-10-06' }, picked: true }) await toggle(m) await click(query(panel()!, '.lc-dp-clear')) assert.deepEqual(changes, [null]) }) test('no clear button while nothing is picked', async () => { const { m } = await open() await toggle(m) assert.equal(queryAll(panel()!, '.lc-dp-clear').length, 0) }) }) describe('the month nav', () => { test('steps a month at a time and stops at this month', async () => { const { m } = await open() await toggle(m) assert.match(query(panel()!, '.lc-dp-month').textContent ?? '', /2026/) assert.equal(queryAll(panel()!, '.lc-dp-navbtn')[1].hasAttribute('disabled'), true, 'the panel opens on this month, so next is spent') await click(queryAll(panel()!, '.lc-dp-navbtn')[0]) assert.equal(queryAll(panel()!, '.lc-dp-navbtn')[1].hasAttribute('disabled'), false, 'back a month, next serves again') await click(queryAll(panel()!, '.lc-dp-navbtn')[1]) assert.equal(queryAll(panel()!, '.lc-dp-navbtn')[1].hasAttribute('disabled'), true, 'no month past this one') assert.equal(queryAll(panel()!, '.lc-dp-cell').length > 0, true) }) test('the panel always comes back on this month, not the browsed one', async () => { const { m } = await open() await toggle(m) await click(queryAll(panel()!, '.lc-dp-navbtn')[0]) const away = query(panel()!, '.lc-dp-month').textContent await toggle(m) await toggle(m) assert.notEqual(query(panel()!, '.lc-dp-month').textContent, away, 'the panel came back on today, not the browsed month') }) test('the earliest representable month cannot step back', async () => { const { m } = await open({ today: '1970-01-05' }) await toggle(m) await click(queryAll(panel()!, '.lc-dp-navbtn')[0]) assert.match(query(panel()!, '.lc-dp-month').textContent ?? '', /1970/) }) test('a malformed today opens no panel and claims no expansion', async () => { const { m } = await open({ today: 'garbage' }) await toggle(m) assert.ok(panel() === null) assert.equal(trigger(m).getAttribute('aria-expanded'), 'false', 'nothing rendered, so nothing reported as open') }) }) describe('the keyboard cursor', () => { test('arrows walk the shown month and Enter picks the day under them', async () => { const { m, changes } = await open() await toggle(m) const grid = query(panel()!, '.lc-dp-grid') assert.equal(grid.getAttribute('aria-activedescendant'), 'lc-dp-day') assert.match(cell(m, '2026-10-01').className, /lc-dp-cursor/, 'the cursor opens on the month’s first day') await keydown('ArrowRight', grid) assert.match(cell(m, '2026-10-02').className, /lc-dp-cursor/) await keydown('ArrowDown', grid) assert.match(cell(m, '2026-10-09').className, /lc-dp-cursor/) await keydown('ArrowUp', grid) assert.match(cell(m, '2026-10-02').className, /lc-dp-cursor/) await keydown('ArrowLeft', grid) assert.match(cell(m, '2026-10-01').className, /lc-dp-cursor/) await keydown('Enter', grid) await keydown(' ', grid) assert.deepEqual(changes, [{ from: '2026-10-01', to: '2026-10-01' }], 'Enter anchors, Space closes the pair') }) test('the cursor never leaves the shown month or enters the future', async () => { const { m } = await open() await toggle(m) const grid = () => query(panel()!, '.lc-dp-grid') // The 1st is a Thursday; a week up from it would leave October. await keydown('ArrowUp', grid()) assert.match(cell(m, '2026-10-01').className, /lc-dp-cursor/, 'the cursor stays inside the shown month') // Rightward it walks, and today is the last stop — the future is not a seat. for (let i = 0; i < 40; i++) await keydown('ArrowRight', grid()) assert.match(cell(m, TODAY).className, /lc-dp-cursor/) await keydown('ArrowRight', grid()) assert.match(cell(m, TODAY).className, /lc-dp-cursor/) await click(queryAll(panel()!, '.lc-dp-navbtn')[0]) assert.match(cell(m, '2026-09-01').className, /lc-dp-cursor/, 'a month step re-seats the cursor on the 1st') await keydown('ArrowDown', grid()) assert.match(cell(m, '2026-09-08').className, /lc-dp-cursor/) await keydown('ArrowDown', grid()) assert.match(cell(m, '2026-09-15').className, /lc-dp-cursor/) }) test('any other key is left to the page', async () => { const { m } = await open() await toggle(m) const grid = query(panel()!, '.lc-dp-grid') await keydown('Tab', grid) await keydown('a', grid) assert.match(cell(m, '2026-10-01').className, /lc-dp-cursor/) }) }) describe('the panel seat', () => { test('sits under its trigger, inside the viewport, and flips when it would fall off', async () => { // jsdom measures every element as 0; drive the panel's own box, then put // the prototype's own descriptors back (deleting them would leave every later measurement NaN). const size = { w: 240, h: 300 } const own = (name: string): PropertyDescriptor | undefined => Object.getOwnPropertyDescriptor(HTMLElement.prototype, name) const width = own('offsetWidth') const height = own('offsetHeight') const restore = (name: string, was: PropertyDescriptor | undefined): void => { if (was === undefined) Reflect.deleteProperty(HTMLElement.prototype, name) else Object.defineProperty(HTMLElement.prototype, name, was) } Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => size.w }) Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => size.h }) try { const { m } = await open() // The panel measures its anchor wrapper (the trigger's own box): jsdom's rects are all zeros, so drive the one it reads. const rect = { left: 800, right: 900, top: 680, bottom: 700 } query(m.container, '.lc-dp').getBoundingClientRect = () => ({ ...rect, x: rect.left, y: rect.top, width: 100, height: 20, toJSON: () => '' }) as DOMRect await toggle(m) const seated = panel()! assert.equal(seated.style.left, '660px', 'right-aligned to the trigger') assert.equal(seated.style.top, '374px', 'flipped above: below the trigger there is no room for 300px') rect.left = 10 rect.right = 110 await toggle(m) await toggle(m) const clamped = panel()! assert.equal(clamped.style.left, '8px', 'clamped inside the viewport') assert.equal(clamped.style.top, '374px') } finally { restore('offsetWidth', width) restore('offsetHeight', height) } }) test('renders hidden until it has measured itself', async () => { const { m } = await open() await toggle(m) // The layout effect measures before paint, so the seated panel is what a // reader ever sees; a fresh mount would flash at (0, 0) without it. assert.equal(panel()!.style.visibility, '') assert.notEqual(panel()!.style.left, '0px') }) }) describe('leaving the panel', () => { test('Escape closes it and puts focus back on the trigger', async () => { const { m } = await open() await toggle(m) trigger(m).focus() await keydown('Escape') assert.ok(panel() === null) assert.equal(document.activeElement?.className, trigger(m).className, 'focus comes back to the trigger') }) test('a press outside closes it; a press inside does not', async () => { const { m } = await open() await toggle(m) await act(async () => { query(m.container, '.lc-dp').dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) }) assert.ok(panel() !== null, 'the tree the panel was opened from stays open') await act(async () => { document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) }) assert.ok(panel() === null) }) test('a press inside the panel is not an outside press', async () => { const { m } = await open() await toggle(m) await act(async () => { cell(m, '2026-10-03').dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) }) assert.ok(panel() !== null) }) test('a press on the trigger itself does not race the toggle', async () => { const { m } = await open() await toggle(m) await act(async () => { trigger(m).dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) }) assert.ok(panel() !== null) }) test('an event with no node target is ignored', async () => { const { m } = await open() await toggle(m) // Dispatched at window itself, so the target is not a node at all. await fireWindow(new MouseEvent('mousedown')) assert.ok(panel() !== null) }) test('a scroll or a resize retracts it rather than floating off the anchor', async () => { const { m } = await open() await toggle(m) await fireWindow(new Event('scroll')) assert.ok(panel() === null) await toggle(m) await fireWindow(new Event('resize')) assert.ok(panel() === null) }) })