176 lines
8.3 KiB
TypeScript
176 lines
8.3 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, DatePreset } 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 end: Signal<string> = props.end ?? 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 presetRow = props.range && props.presets?.length // 快捷区间只在区间模式有意义
|
||
? h('div', { class: 'ua-datepicker-presets' }, ...props.presets.map((p) => h('button', {
|
||
type: 'button', class: 'ua-datepicker-preset', onClick: () => applyPreset(p),
|
||
}, p.label)))
|
||
: null;
|
||
const pop = h(
|
||
'div',
|
||
{ class: () => `ua-datepicker-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}` },
|
||
head,
|
||
...(presetRow ? [presetRow] : []),
|
||
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 {
|
||
if (!props.range) {
|
||
value(date);
|
||
props.onChange?.(date);
|
||
open(false);
|
||
trigger.focus();
|
||
return;
|
||
}
|
||
if (!value() || end()) { // 未选起点或上一段区间已完整:从这个日期重开一段
|
||
value(date);
|
||
end('');
|
||
render(); // 清掉上一段的区间底色
|
||
return;
|
||
}
|
||
const [start, stop] = date < value() ? [date, value()] : [value(), date]; // 点早了就自动交换
|
||
value(start);
|
||
end(stop);
|
||
props.onRangeChange?.(start, stop);
|
||
open(false);
|
||
trigger.focus();
|
||
}
|
||
|
||
/*** 应用快捷区间:days 优先(今天往前共 N 天),否则用绝对 from/to;按 min/max 钳制后写值并收起 ***/
|
||
function applyPreset(p: DatePreset): void {
|
||
const stop = p.days ? fmt(...today()) : (p.to || fmt(...today()));
|
||
const start = p.days ? shiftDay(stop, -(p.days - 1)) : (p.from || stop);
|
||
const [lo, hi] = start <= stop ? [start, stop] : [stop, start]; // 防御:from 晚于 to 时自动交换
|
||
const fit = (v: string): string => (props.min && v < props.min ? props.min : props.max && v > props.max ? props.max : v); // 钳进合法区间
|
||
value(fit(lo));
|
||
end(fit(hi));
|
||
props.onRangeChange?.(value(), end());
|
||
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 edge = date === value() || Boolean(props.range && end() && date === end()); // 区间两端
|
||
const between = Boolean(props.range && value() && end() && date > value() && date < end()); // 区间内部
|
||
const cls = `ua-datepicker-day${edge ? ' is-selected' : ''}${between ? ' is-in-range' : ''}${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(edge),
|
||
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 labelText = (): string => {
|
||
if (!props.range) return value() || props.placeholder || '请选择日期';
|
||
if (!value()) return props.placeholder || '请选择日期范围';
|
||
return end() ? `${value()} ~ ${end()}` : `${value()} ~ …`;
|
||
};
|
||
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'}` }, () => labelText()),
|
||
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;
|
||
}
|