Files
UI/packages/skin/ui/src/tabs.ts
T
2026-10-26 15:38:50 +08:00

75 lines
4.0 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 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`;
}
};
/*** 关闭页签:只回调,数据由调用方移除后重绘,组件不自己改 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;
}