iter(g115): 新增月历组件 calendar

This commit is contained in:
HIsheR
2026-10-26 14:08:58 +08:00
parent b4ab92ed98
commit c2900a4852
15 changed files with 378 additions and 26 deletions
+89
View File
@@ -0,0 +1,89 @@
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);
}
+30
View File
@@ -0,0 +1,30 @@
/*** 日期工具:datePicker 与 calendar 共用一套格式化与网格语义,避免两处各写一遍 ***/
export const pad = (n: number): string => String(n).padStart(2, '0'); // 两位补零
/*** 拼成 YYYY-MM-DD ***/
export const fmt = (y: number, m: number, d: number): string => `${y}-${pad(m + 1)}-${pad(d)}`;
/*** 解析 YYYY-MM-DD:非法串返回 null,调用方自行回落 ***/
export const parse = (s: string): [number, number, number] | null => {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
return m ? [Number(m[1]), Number(m[2]) - 1, Number(m[3])] : null;
};
/*** 今天 ***/
export const today = (): [number, number, number] => {
const d = new Date();
return [d.getFullYear(), d.getMonth(), d.getDate()];
};
/*** 按天偏移:跨月 / 跨年自动进位,非法输入回落到今天 ***/
export const shiftDay = (key: string, delta: number): string => {
const p = parse(key) ?? today();
const d = new Date(p[0], p[1], p[2] + delta);
return fmt(d.getFullYear(), d.getMonth(), d.getDate());
};
/*** 单月网格元数据:月初留白格数与当月天数 ***/
export const monthGrid = (y: number, m: number): { lead: number; total: number } => ({
lead: new Date(y, m, 1).getDay(), // 1 号是周几 → 前面留几个空位
total: new Date(y, m + 1, 0).getDate(), // 下月第 0 天即本月最后一天
});
+1 -16
View File
@@ -3,22 +3,7 @@ import type { Signal } from '@ui-arch/frame';
import { bindOutside, pushOverlay } from './overlay.js';
import { flip } from './position.js';
import type { DatePickerProps } from './types.js';
const pad = (n: number): string => String(n).padStart(2, '0'); // 两位补零
const fmt = (y: number, m: number, d: number): string => `${y}-${pad(m + 1)}-${pad(d)}`; // 拼成 YYYY-MM-DD
const parse = (s: string): [number, number, number] | null => {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
return m ? [Number(m[1]), Number(m[2]) - 1, Number(m[3])] : null; // 非法串返回 null,调用方回落到今天
};
const today = (): [number, number, number] => {
const d = new Date();
return [d.getFullYear(), d.getMonth(), d.getDate()];
};
const shiftDay = (key: string, delta: number): string => {
const p = parse(key) ?? today();
const d = new Date(p[0], p[1], p[2] + delta);
return fmt(d.getFullYear(), d.getMonth(), d.getDate());
};
import { fmt, pad, parse, shiftDay, today } from './dateKit.js';
/*** 日期选择:单月网格 + 上下月翻页 + 今天快捷键,value 信号双向绑定,方向键/PageUp/PageDown 可导航 ***/
export function datePicker(props: DatePickerProps): HTMLElement {
+1
View File
@@ -59,3 +59,4 @@ export { watermark } from './watermark.js';
export { affix } from './affix.js';
export { anchor } from './anchor.js';
export { popconfirm } from './popconfirm.js';
export { calendar } from './calendar.js';
+9
View File
@@ -725,4 +725,13 @@ export interface PopconfirmProps {
open?: Signal<boolean>; // 受控开合信号;不传则内部维护
}
/*** 月历入参 ***/
export interface CalendarProps {
value?: Signal<string>; // 选中日期(YYYY-MM-DD)信号;不传则内部维护
month?: Signal<string>; // 展示月份(YYYY-MM);不传则内部维护,初始跟随 value 或今天
marker?: (date: string) => string | null; // 每日标记:返回文案即渲染小徽标(如「3 项」),返回 null 不标记
onSelect?: (date: string) => void; // 选中日期回调(点击或 Enter / 空格)
onMonthChange?: (month: string) => void; // 翻月回调,参数为 YYYY-MM
}
export type { ColumnDef, RowKey }; // 再导出常用内核类型,方便调用方