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 } 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 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 pop = h( 'div', { class: () => `ua-datepicker-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}` }, head, 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 { value(date); props.onChange?.(date); 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 cls = `ua-datepicker-day${date === value() ? ' is-selected' : ''}${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(date === value()), 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 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'}` }, () => value() || props.placeholder || '请选择日期'), 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; }