133 lines
6.0 KiB
TypeScript
133 lines
6.0 KiB
TypeScript
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;
|
||
}
|