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

133 lines
6.0 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 { 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<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()); }
});
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;
}