Files
UI/packages/skin/ui/src/timePicker.ts
T
2026-10-26 20:03:10 +08:00

144 lines
7.4 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 type { TimePickerProps } from './types.js';
import { flip } from './position.js';
import { bindOutside, pushOverlay } from './overlay.js';
/*** 两位补零:时钟显示固定宽度,避免 9:5 这类不对齐的写法 ***/
const pad = (n: number): string => String(n).padStart(2, '0');
/*** 时间选择:时 / 分两列自绘弹层,value 信号双向绑定(HH:mm);Esc 收起,↑↓/Home/End 在列内移动 ***/
export function timePicker(props: TimePickerProps): HTMLElement {
// —— 状态集中声明 ——
const value: Signal<string> = props.value ?? signal(''); // 选中时间(HH:mm),未传信号则内部持有
const open = signal(false); // 弹层开合
const place = signal<'top' | 'bottom'>('bottom'); // 实际生效方位:打开时按视口空间自动翻转
const step = props.minuteStep && props.minuteStep > 0 ? Math.min(props.minuteStep, 30) : 5; // 分钟步长,越界回退 5
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数:展开期间占一层,收起即释放
const hours = Array.from({ length: 24 }, (_, i) => pad(i)); // 00..23
const minutes = Array.from({ length: Math.ceil(60 / step) }, (_, i) => pad(i * step)); // 00..55(按步长取)
const picked = () => { const [hh = '', mm = ''] = value().split(':'); return { hh, mm }; }; // 拆分当前值
/*** 写回时间:两段都补齐后调用回调;值未变时不重复派发(选时与选分各写一次) ***/
const commit = (hh: string, mm: string): void => {
const next = `${hh}:${mm}`;
if (next === value()) return; // 卫语句:没变化不重复派发回调
value(next);
props.onChange?.(next);
};
/*** 清空:回到「未选」态,收起弹层并写回空串(与 select / multiSelect 的清空语义一致) ***/
const clear = () => {
if (!value()) return; // 卫语句:本来就没选,不重复派发回调
value('');
props.onChange?.('');
open(false);
};
/*** 列内聚焦导航:↑↓ 环形移动、Home/End 直达首末(列内按钮顺序即时间顺序) ***/
const navigate = (col: HTMLElement, cells: HTMLButtonElement[]) => {
col.addEventListener('keydown', (e: KeyboardEvent) => {
const at = cells.indexOf(document.activeElement as HTMLButtonElement);
if (at < 0) return; // 卫语句:焦点不在本列
const dir = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0; // 单步方向
if (!dir && e.key !== 'Home' && e.key !== 'End') return; // 卫语句:其它键放行(Enter/空格 由按钮自己响应)
e.preventDefault(); // 阻止页面滚动
const next = dir ? cells[(at + dir + cells.length) % cells.length] : e.key === 'Home' ? cells[0] : cells[cells.length - 1];
next.focus();
});
};
/*** 造一列时间格:点击写回,激活格加 is-active 供样式与初始滚动定位 ***/
const column = (label: string, list: string[], isHour: boolean) => {
const cells = list.map((text) =>
h('button', {
type: 'button',
class: () => `ua-timepicker-cell${(isHour ? picked().hh : picked().mm) === text ? ' is-active' : ''}`,
role: 'option',
'aria-selected': () => String((isHour ? picked().hh : picked().mm) === text),
onClick: () => {
const cur = picked();
if (isHour) return commit(text, cur.mm || minutes[0]); // 先选时:分默认落到首个步长值,值立即可用
commit(cur.hh || hours[0], text); // 选分即视为选完
open(false); // 分选完收起,指针交回触发器
trigger.focus();
},
}, text),
);
const col = h('div', { class: 'ua-timepicker-col', role: 'listbox', 'aria-label': label }, ...cells);
const buttons = cells as HTMLButtonElement[];
navigate(col, buttons);
return { col, buttons };
};
const hourCol = column('时', hours, true);
const minuteCol = column('分', minutes, false);
// —— 触发器 ——
const trigger = h(
'button',
{
type: 'button',
class: `ua-timepicker-trigger${props.size && props.size !== 'md' ? ` is-${props.size}` : ''}`,
'aria-haspopup': 'dialog',
'aria-expanded': () => String(open()),
disabled: props.disabled, // 布尔或信号禁用,h() 直接透传
onClick: () => open(!open()),
},
h('span', { class: () => `ua-timepicker-value${value() ? '' : ' is-empty'}` }, () => value() || props.placeholder || '请选择时间'),
h('span', { class: 'ua-timepicker-arrow', 'aria-hidden': 'true' }, '▾'),
);
// —— 清空按钮:clearable 且有值时出现,压在触发器右缘(与箭头错开)——
const clearable = props.clearable ?? false; // 缺省不带清空:没选的场景多,按钮会白占位置
const clearBtn = h('button', {
type: 'button',
class: 'ua-timepicker-clear',
'aria-label': '清空时间',
tabindex: '-1', // 不进 Tab 序:清空是鼠标 / 触屏便利,键盘用 Delete / Backspace
onClick: () => clear(),
}, '×') as HTMLElement;
const pop = h(
'div',
{ class: () => `ua-timepicker-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}`, role: 'dialog', 'aria-label': '选择时间' },
hourCol.col,
minuteCol.col,
);
const root = h('div', { class: () => `ua-timepicker${open() ? ' is-open' : ''}${props.clearable && value() ? ' has-clear' : ''}` }, trigger, clearable ? clearBtn : null, pop);
/*** 把目标格滚到列中部:优先当前值所在格,还没选过时退到「此刻」的格,打开即可就近选 ***/
const reveal = (col: HTMLElement, cells: HTMLButtonElement[], fallback: number) => {
const at = cells.findIndex((el) => el.classList.contains('is-active'));
const cell = cells[at >= 0 ? at : Math.min(Math.max(fallback, 0), cells.length - 1)];
if (!cell) return; // 卫语句:空列(步长异常)不滚
const target = cell.offsetTop - (col.clientHeight - cell.offsetHeight) / 2; // 居中,上下都留出邻近项
col.scrollTop = Math.max(0, target);
};
effect(() => { clearBtn.hidden = !clearable || !value(); }); // 无值或未开启时藏起来(hidden 一并移出点击区)
root.addEventListener('keydown', (e: KeyboardEvent) => {
if (open() || !clearable) return; // 卫语句:展开时按键归列内导航,未开启清空则不接管
if (e.key !== 'Backspace' && e.key !== 'Delete') return; // 卫语句:只接管这两个键
e.preventDefault();
clear(); // 键盘等价:不必伸手去点 ×
});
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听
effect(() => {
if (open()) {
releaseEsc ??= pushOverlay(() => { open(false); trigger.focus(); }); // 入 Esc 栈:与弹窗/抽屉共用逐层关闭
requestAnimationFrame(() => {
const now = new Date(); // 未选值时的锚点:打开就停在「此刻」附近,省得从 00:00 开始翻
reveal(hourCol.col, hourCol.buttons, now.getHours());
reveal(minuteCol.col, minuteCol.buttons, Math.floor(now.getMinutes() / step));
}); // 布局就绪后再量
} else {
releaseEsc?.();
releaseEsc = null;
}
});
effect(() => { if (open()) place(flip(trigger, pop, 'bottom')); }); // 空间不足自动翻到上方
return root;
}