import { effect, h, signal } from '@ui-arch/frame'; import type { CalendarProps } from './types.js'; import { fmt, monthGrid, pad, parse, shiftDay, today } from './dateKit.js'; /*** 月历:整月网格直接内嵌在页面里(排期表 / 值班表 / 日程选择),每日可带标记文案,方向键漫游 + Enter 选中 ***/ export function calendar(props: CalendarProps): HTMLElement { // —— 状态集中声明 —— const value = props.value ?? signal(''); // 选中日期(YYYY-MM-DD) const from = parse(value()) ?? today(); // 初始定位:跟随选中值,否则今天 const month = props.month ?? signal(`${from[0]}-${pad(from[1] + 1)}`); // 展示月份(YYYY-MM) const focus = signal(value() || fmt(...today())); // 键盘漫游焦点日 const grid = h('div', { class: 'ua-calendar-grid', role: 'grid' }); const label = h('span', { class: 'ua-calendar-label' }); /*** 当前展示的年月:月份串非法时回落到初始定位 ***/ const view = (): [number, number] => { const hit = /^(\d{4})-(\d{2})$/.exec(month()); return hit ? [Number(hit[1]), Number(hit[2]) - 1] : [from[0], from[1]]; }; /*** 翻月:只改展示,不动选中值;跨年自动进位 ***/ const turn = (delta: number): void => { const [y, m] = view(); const d = new Date(y, m + delta, 1); const next = `${d.getFullYear()}-${pad(d.getMonth() + 1)}`; month(next); props.onMonthChange?.(next); }; /*** 选择:写回信号 + 回调,焦点同步到该日 ***/ const pick = (date: string): void => { value(date); focus(date); props.onSelect?.(date); }; /*** 重绘网格:月初留白、今天与选中分别打标,漫游 tabindex 保证 Tab 只进一次 ***/ const render = (): void => { const [y, m] = view(); const { lead, total } = monthGrid(y, m); const cells: HTMLElement[] = Array.from({ length: lead }, () => h('span', { class: 'ua-calendar-blank' })); const nowKey = fmt(...today()); for (let d = 1; d <= total; d++) { const date = fmt(y, m, d); const mark = props.marker?.(date) ?? null; const cls = `ua-calendar-day${date === value() ? ' is-selected' : ''}${date === nowKey ? ' is-today' : ''}`; cells.push(h('button', { type: 'button', class: cls, role: 'gridcell', 'aria-selected': String(date === value()), tabindex: date === focus() ? '0' : '-1', 'aria-label': mark ? `${date},${mark}` : date, // 标记文案同时进无障碍名,读屏也能听到「几项」 onClick: () => pick(date), }, h('span', { class: 'ua-calendar-num' }, String(d)), mark ? h('span', { class: 'ua-calendar-mark' }, mark) : null)); } grid.replaceChildren(...cells); label.textContent = `${y} 年 ${m + 1} 月`; }; // —— 键盘:←→ 逐日、↑↓ 逐周、Enter / 空格 选中;跨月自动翻页后把焦点交给新网格 —— grid.addEventListener('keydown', (e: KeyboardEvent) => { const step: Record = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }; if (!step[e.key]) { if (e.key !== 'Enter' && e.key !== ' ') return; // 卫语句:只接管选中键 e.preventDefault(); pick(focus()); return; } e.preventDefault(); const next = shiftDay(focus(), step[e.key]); const p = parse(next) ?? today(); focus(next); const [y, m] = view(); if (p[0] !== y || p[1] !== m) month(`${p[0]}-${pad(p[1] + 1)}`); // 跨月:先翻页,重绘交给 effect requestAnimationFrame(() => (grid.querySelector('.ua-calendar-day[tabindex="0"]') as HTMLElement | null)?.focus()); }); const head = h( 'div', { class: 'ua-calendar-head' }, h('button', { type: 'button', class: 'ua-calendar-nav', 'aria-label': '上一月', onClick: () => turn(-1) }, '‹'), label, h('button', { type: 'button', class: 'ua-calendar-nav', 'aria-label': '下一月', onClick: () => turn(1) }, '›'), ); const week = h('div', { class: 'ua-calendar-week' }, ...['日', '一', '二', '三', '四', '五', '六'].map((w) => h('span', {}, w))); effect(render); // month / value / focus 任一变化即重绘 return h('div', { class: 'ua-calendar' }, head, week, grid); }