import { effect, h } from '@ui-arch/frame'; import type { TabItem, TabsProps } from './types.js'; import { trackSize } from './watch.js'; /*** 标签页:active 信号双向绑定 + 滑动指示条 + 方向键环形导航;closable 页签带关闭按钮(聚焦后 Delete 亦可关) ***/ export function tabs(props: TabsProps): HTMLElement { // —— 状态集中声明 —— const buttons: HTMLButtonElement[] = []; // 页签按钮,顺序与 tabs 一致(键盘导航与激活态都按它走) const indicator = h('span', { class: 'ua-tabs-indicator' }); const head = h('div', { class: 'ua-tabs-head', role: 'tablist', 'aria-label': '标签页' }, indicator); /*** 把激活页签滚进可视区:页签条溢出时只滚它自己这一层,不动页面与其它祖先容器 ***/ const revealActive = (wrap: HTMLElement) => { const pad = 8; // 两侧留白:贴边时激活页签的外框不至于被裁一半 const max = head.scrollWidth - head.clientWidth; if (max <= 0) return; // 卫语句:没有溢出(页签条放得下),不需要滚动 const left = wrap.offsetLeft; const right = left + wrap.offsetWidth; const viewLeft = head.scrollLeft; if (left >= viewLeft && right <= viewLeft + head.clientWidth) return; // 卫语句:已完整可见,不打断用户的手动滚动 const target = left < viewLeft ? left - pad : right - head.clientWidth + pad; // 就近对齐:只补被裁掉的那一段,不做居中 const smooth = !window.matchMedia('(prefers-reduced-motion: reduce)').matches; // 尊重减弱动效偏好:直接跳位 head.scrollTo({ left: Math.max(0, Math.min(max, target)), behavior: smooth ? 'smooth' : 'auto' }); }; /*** 同步激活态与指示条几何位置:量外壳而非按钮,可关闭页签的指示条才会盖住关闭按钮 ***/ const sync = () => { const value = props.active?.(); for (const btn of buttons) { const on = btn.dataset.value === value; btn.classList.toggle('ua-tab-active', on); btn.setAttribute('aria-selected', String(on)); btn.tabIndex = on ? 0 : -1; // 漫游 tabindex:仅激活页签可 Tab 聚焦 if (!on) continue; // 卫语句:只有激活项需要摆指示条 const wrap = btn.parentElement ?? btn; indicator.style.left = `${wrap.offsetLeft}px`; indicator.style.width = `${wrap.offsetWidth}px`; revealActive(wrap); // 激活项被滚出视口时拉回可见区(页签多到溢出才会真的动) } }; /*** 关闭页签:只回调,数据由调用方移除后重绘,组件不自己改 tabs 数组 ***/ const closeTab = (tab: TabItem, e: Event): void => { e.stopPropagation(); // 关闭按钮是页签按钮的兄弟节点,显式阻断以防外壳级监听误触 props.onClose?.(tab.value); }; for (const tab of props.tabs) { const btn = h('button', { class: 'ua-tab', role: 'tab', type: 'button' }, tab.label) as HTMLButtonElement; btn.dataset.value = tab.value; btn.disabled = tab.disabled ?? false; if (tab.disabled) btn.setAttribute('aria-disabled', 'true'); // 读屏提示不可选 btn.addEventListener('click', () => { if (btn.disabled) return; // 卫语句:禁用页签不响应 if (props.active) props.active(tab.value); // 用户切换写回信号 props.onChange?.(tab.value); }); btn.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key !== 'Delete' || !tab.closable) return; // 卫语句:只接管可关闭页签的 Delete e.preventDefault(); props.onClose?.(tab.value); // 键盘等价:不必伸手去点 × }); const wrap = h('span', { class: 'ua-tab-item', role: 'presentation' }, btn); // 外壳只做布局,tab 语义仍在按钮上 if (tab.closable) wrap.append(h('button', { type: 'button', class: 'ua-tab-close', 'aria-label': `关闭 ${tab.label}`, onClick: (e: Event) => closeTab(tab, e) }, '×')); buttons.push(btn); head.append(wrap); } head.addEventListener('keydown', (e: KeyboardEvent) => { const delta = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0; // 方向键步长 const edge = e.key === 'Home' ? 'first' : e.key === 'End' ? 'last' : ''; // 首/末页签直达 if (!delta && !edge) return; // 卫语句:非导航键放行 const list = buttons.filter((b) => !b.disabled); const i = list.indexOf(document.activeElement as HTMLButtonElement); if (i < 0) return; // 卫语句:焦点不在页签上 e.preventDefault(); const next = delta ? list[(i + delta + list.length) % list.length] : edge === 'first' ? list[0] : list[list.length - 1]; // 环形移动 / Home-End 直达 next?.focus(); next?.click(); }); const extra = props.extra ? h('div', { class: 'ua-tabs-extra' }, props.extra) : null; // 右侧操作区:与页签同一行 const bar = extra ? h('div', { class: 'ua-tabs-bar' }, head, extra) : head; // 有操作区才包一层,无操作区结构不变 const root = h('div', { class: 'ua-tabs' }, bar); effect(sync); requestAnimationFrame(sync); // 挂载后再量一次布局,否则指示条宽度为 0 trackSize(root, sync); // 容器变宽 / 窗口缩放后重算,指示条不会停在旧宽度 return root; }