iter(g50): 新增 datePicker 日期选择组件

This commit is contained in:
HIsheR
2026-10-26 12:13:56 +08:00
parent e5c7778de5
commit e270e09903
12 changed files with 460 additions and 10 deletions
+150
View File
@@ -0,0 +1,150 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { Signal } from '@ui-arch/frame';
import { 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());
};
/*** 日期选择:单月网格 + 上下月翻页 + 今天快捷键,value 信号双向绑定,方向键/PageUp/PageDown 可导航 ***/
export function datePicker(props: DatePickerProps): HTMLElement {
// —— 状态集中声明 ——
const value: Signal<string> = 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<string>(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<string, number> = { 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()); }
});
document.addEventListener('click', (e) => {
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
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;
}