Files
UI/packages/skin/ui/src/tabs.ts
T

90 lines
5.2 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 } 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;
}