import { effect, h, signal } from '@ui-arch/frame'; import type { Signal } from '@ui-arch/frame'; import { bindOutside, pushOverlay } from './overlay.js'; import { flip } from './position.js'; import type { DatePickerProps, DatePreset } from './types.js'; import { fmt, pad, parse, shiftDay, today } from './dateKit.js'; /*** 日期选择:单月网格 + 上下月翻页 + 今天快捷键,value 信号双向绑定,方向键/PageUp/PageDown 可导航 ***/ export function datePicker(props: DatePickerProps): HTMLElement { // —— 状态集中声明 —— const value: Signal = props.value ?? signal(''); // 已选日期(区间模式下是起点) const end: Signal = props.end ?? signal(''); // 区间终点,非区间模式恒为空 const open = signal(false); // 弹层开合 const place = signal<'top' | 'bottom'>('bottom'); // 实际方位 const initial = parse(value()) ?? today(); // 初始视图:跟随已选,否则今天 const view = signal<[number, number]>([initial[0], initial[1]]); // 当前展示的年月 const focus = signal(value() || fmt(...today())); // 键盘焦点日 let releaseEsc: (() => void) | null = null; // Esc 栈注销函数 const grid = h('div', { class: 'ua-datepicker-grid', role: 'grid' }) as HTMLElement; const label = h('span', { class: 'ua-datepicker-label' }) as HTMLElement; const head = h( 'div', { class: 'ua-datepicker-head' }, h('button', { type: 'button', class: 'ua-datepicker-nav', 'aria-label': '上一月', onClick: () => turn(-1) }, '‹'), label, h('button', { type: 'button', class: 'ua-datepicker-nav', 'aria-label': '下一月', onClick: () => turn(1) }, '›'), ); const presetRow = props.range && props.presets?.length // 快捷区间只在区间模式有意义 ? h('div', { class: 'ua-datepicker-presets' }, ...props.presets.map((p) => h('button', { type: 'button', class: 'ua-datepicker-preset', onClick: () => applyPreset(p), }, p.label))) : null; const pop = h( 'div', { class: () => `ua-datepicker-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}` }, head, ...(presetRow ? [presetRow] : []), h('div', { class: 'ua-datepicker-week' }, ...['日', '一', '二', '三', '四', '五', '六'].map((w) => h('span', {}, w))), grid, h('div', { class: 'ua-datepicker-foot' }, h('button', { type: 'button', class: 'ua-datepicker-today', onClick: () => commit(fmt(...today())) }, '今天')), ) as HTMLElement; /*** 翻月:只改视图,不改已选值;跨年自动进位 ***/ function turn(delta: number): void { const [y, m] = view(); const d = new Date(y, m + delta, 1); view([d.getFullYear(), d.getMonth()]); } /*** 提交:单日模式写值即收起;区间模式首点定起点并留在弹层,次点补终点后收起 ***/ function commit(date: string): void { if (!props.range) { value(date); props.onChange?.(date); open(false); trigger.focus(); return; } if (!value() || end()) { // 未选起点或上一段区间已完整:从这个日期重开一段 value(date); end(''); render(); // 清掉上一段的区间底色 return; } const [start, stop] = date < value() ? [date, value()] : [value(), date]; // 点早了就自动交换 value(start); end(stop); props.onRangeChange?.(start, stop); open(false); trigger.focus(); } /*** 应用快捷区间:days 优先(今天往前共 N 天),否则用绝对 from/to;按 min/max 钳制后写值并收起 ***/ function applyPreset(p: DatePreset): void { const stop = p.days ? fmt(...today()) : (p.to || fmt(...today())); const start = p.days ? shiftDay(stop, -(p.days - 1)) : (p.from || stop); const [lo, hi] = start <= stop ? [start, stop] : [stop, start]; // 防御:from 晚于 to 时自动交换 const fit = (v: string): string => (props.min && v < props.min ? props.min : props.max && v > props.max ? props.max : v); // 钳进合法区间 value(fit(lo)); end(fit(hi)); props.onRangeChange?.(value(), end()); open(false); trigger.focus(); } /*** 是否越界:min / max 为闭区间,缺省视为不限制 ***/ const outOfRange = (date: string): boolean => (props.min ? date < props.min : false) || (props.max ? date > props.max : false); /*** 重绘网格:前置空位对齐星期、今天/选中/焦点分别打标 ***/ function render(): void { const [y, m] = view(); const lead = new Date(y, m, 1).getDay(); // 当月 1 号是周几 const total = new Date(y, m + 1, 0).getDate(); // 当月天数 const cells: HTMLElement[] = []; for (let i = 0; i < lead; i++) cells.push(h('span', { class: 'ua-datepicker-blank' })); // 月初留白 for (let d = 1; d <= total; d++) { const date = fmt(y, m, d); const dead = outOfRange(date); const edge = date === value() || Boolean(props.range && end() && date === end()); // 区间两端 const between = Boolean(props.range && value() && end() && date > value() && date < end()); // 区间内部 const cls = `ua-datepicker-day${edge ? ' is-selected' : ''}${between ? ' is-in-range' : ''}${date === fmt(...today()) ? ' is-today' : ''}${dead ? ' is-disabled' : ''}`; cells.push( h('button', { type: 'button', class: cls, role: 'gridcell', tabindex: date === focus() ? '0' : '-1', // 漫游 tabindex:只有焦点日可 Tab 进入 'aria-selected': String(edge), disabled: dead, 'aria-label': date, onClick: () => commit(date), }, String(d)), ); } grid.replaceChildren(...cells); label.textContent = `${y} 年 ${m + 1} 月`; requestAnimationFrame(() => { if (!open()) return; // 卫语句:收起时不抢焦点 (grid.querySelector('.ua-datepicker-day[tabindex="0"]') as HTMLElement | null)?.focus(); }); } /*** 触发器文案:单日回显日期;区间回显「起 ~ 止」,只定了起点时后面跟省略号 ***/ const labelText = (): string => { if (!props.range) return value() || props.placeholder || '请选择日期'; if (!value()) return props.placeholder || '请选择日期范围'; return end() ? `${value()} ~ ${end()}` : `${value()} ~ …`; }; const trigger = h( 'button', { type: 'button', class: 'ua-datepicker-trigger', 'aria-haspopup': 'dialog', 'aria-expanded': () => String(open()), disabled: props.disabled, onClick: () => { if (!open()) { const p = parse(value()) ?? today(); // 打开时视图对准已选日期(无选则今天) view([p[0], p[1]]); focus(value() || fmt(...p)); } open(!open()); }, }, h('span', { class: () => `ua-datepicker-value${value() ? '' : ' is-empty'}` }, () => labelText()), h('span', { class: 'ua-datepicker-caret', 'aria-hidden': 'true' }, '▤'), ); const root = h('div', { class: () => `ua-datepicker${open() ? ' ua-open' : ''}` }, trigger, pop) as HTMLElement; // 键盘:←→ 逐日、↑↓ 逐周、PageUp/PageDown 逐月、Enter 提交,Esc 交给弹层栈 pop.addEventListener('keydown', (e: KeyboardEvent) => { const step: Record = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }; if (step[e.key]) { e.preventDefault(); const next = shiftDay(focus(), step[e.key]); const p = parse(next) ?? today(); focus(next); if (p[0] !== view()[0] || p[1] !== view()[1]) view([p[0], p[1]]); // 跨月自动翻页 render(); return; } if (e.key === 'PageUp' || e.key === 'PageDown') { e.preventDefault(); turn(e.key === 'PageUp' ? -1 : 1); return; } if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (!outOfRange(focus())) commit(focus()); } }); bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘 effect(render); // 依赖 view / value / focus effect(() => { if (open()) releaseEsc ??= pushOverlay(() => { open(false); trigger.focus(); }); // 入栈逐层关闭 else { releaseEsc?.(); releaseEsc = null; } }); effect(() => { if (open()) place(flip(trigger, pop, 'bottom')); }); // 空间不足自动翻转 return root; }