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 = 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 }; }; // 拆分当前值 /*** 分钟档对齐:值不在步长档上(如 09:07 + 15 分钟档)时取最近档,作为高亮与滚动锚点 ***/ const nearestMinute = (mm: string): string => { const n = Number(mm); if (!mm || Number.isNaN(n)) return ''; // 卫语句:没值或脏值不参与对齐 return minutes[Math.min(minutes.length - 1, Math.round(n / step))] ?? ''; }; /*** 写回时间:两段都补齐后调用回调;值未变时不重复派发(选时与选分各写一次) ***/ 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 activeText = () => (isHour ? picked().hh : nearestMinute(picked().mm)); // 本列该高亮的文案:分列按最近档对齐 const cells = list.map((text) => h('button', { type: 'button', class: () => `ua-timepicker-cell${activeText() === text ? ' is-active' : ''}`, role: 'option', 'aria-selected': () => String(activeText() === 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()) return; // 卫语句:展开时按键归列内导航 if (clearable && (e.key === 'Backspace' || e.key === 'Delete')) { // 收起态清空:不必伸手去点 × e.preventDefault(); clear(); return; } if (!value() || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return; // 卫语句:无值不猜、只管上下键 e.preventDefault(); const delta = (e.key === 'ArrowUp' ? 1 : -1) * (e.shiftKey ? 60 : step); // Shift + ↑↓ 按小时调,否则按步长调 const total = (Number(picked().hh) * 60 + Number(picked().mm) + delta + 1440) % 1440; // 环形:00:00 的上一档回到 23:xx commit(pad(Math.floor(total / 60)), pad(total % 60)); // 收起态改值同样派发 onChange }); bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听 effect(() => { if (open()) { releaseEsc ??= pushOverlay(() => { open(false); trigger.focus(); }); // 入 Esc 栈:与弹窗/抽屉共用逐层关闭 requestAnimationFrame(() => { const now = new Date(); // 无值时的锚点:打开就停在「此刻」附近,省得从 00:00 开始翻 const { hh, mm } = picked(); reveal(hourCol.col, hourCol.buttons, hh ? Number(hh) : now.getHours()); // 有值先滚到值上 reveal(minuteCol.col, minuteCol.buttons, mm ? minutes.indexOf(nearestMinute(mm)) : Math.floor(now.getMinutes() / step)); const enter = hourCol.buttons.find((el) => el.classList.contains('is-active')) ?? hourCol.buttons[0]; // 打开即把焦点送进时列 enter?.focus({ preventScroll: true }); // preventScroll:别把刚算好的列内滚动位置顶掉 }); // 布局就绪后再量 } else { releaseEsc?.(); releaseEsc = null; } }); effect(() => { if (open()) place(flip(trigger, pop, 'bottom')); }); // 空间不足自动翻到上方 return root; }