Files
UI/packages/skin/ui/src/calendar.ts
T
2026-10-26 14:08:58 +08:00

90 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, number> = { 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);
}