iter(g105): 标签页支持可关闭页签
This commit is contained in:
@@ -1196,6 +1196,14 @@
|
||||
"acceptance": "ua lint-iterations 额外做双向核对:每行 commit 必须能在 git 历史里按前缀命中,且该提交主题以 iter(<id>) 开头(兼容早期无 scope 的 iter: <id>);伪造 / 抄错的哈希报违规并退出码 1;非 git 环境(打包分发)自动跳过该维度;ITERATION.md 留痕章节写明该校验;npm run check 全绿",
|
||||
"notes": "自补:门禁工具链;一次 git log 建索引,150 行留痕不额外起进程",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"id": "g105",
|
||||
"title": "标签页支持可关闭页签",
|
||||
"motivation": "多开详情页 / 编辑器类界面需要「关掉这个页签」,tabs 只有 label + disabled,调用方得自己在页签头外面挂按钮,关闭按钮的 hover 显现、指示条宽度、键盘等价全得重写一遍",
|
||||
"acceptance": "TabItem 新增 closable(缺省不可关),渲染 × 关闭按钮(aria-label=关闭 <label>),点击或页签聚焦后按 Delete 触发 onClose(value);指示条改为量外壳宽度,可关闭页签的高亮能盖住 ×;触摸设备(hover: none)常显 ×;表单化入参不变(closable 在项上),文档参数表/手把手/示例与演示(三张可关订单页签,关完显示空态)同步;npm run check 全绿",
|
||||
"notes": "自补:组件可用性;关闭只回调不改数据,符合受控约定",
|
||||
"status": "open"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+17
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "ui-arch-registry-index/0.1",
|
||||
"generatedAt": "2026-10-26T05:49:43.206Z",
|
||||
"generatedAt": "2026-10-26T05:56:43.149Z",
|
||||
"repoRoot": ".",
|
||||
"packages": [
|
||||
{
|
||||
@@ -167,7 +167,7 @@
|
||||
"pagination(props):独立分页器,page 信号双向绑定,页码过多折叠省略号,首末页禁用翻页,jumpable 时末尾可输入页码直接跳转,键盘可达;pageSizes 可切每页条数",
|
||||
"spinner(props):加载指示器,size 控制直径",
|
||||
"progress(props):进度条,value 信号驱动 0-100(满格切 success 色);不传 value 退化为不确定态无限往复",
|
||||
"tabs(props):标签页,active 信号双向绑定,滑动指示条 + 方向键环形导航,支持禁用页签",
|
||||
"tabs(props):标签页,active 信号双向绑定,滑动指示条 + 方向键环形导航,支持禁用页签;页签项 closable 时带关闭按钮(聚焦后 Delete 同样可关),关闭只回调 onClose 由调用方摘数据",
|
||||
"stepper(props):步骤条,done/current/upcoming 三态,active 信号双向绑定,clickable 时已完成步骤可回跳",
|
||||
"rate(props):评分星标,value 信号双向绑定,hover 预览移出还原,键盘方向键调节,max 可配",
|
||||
"empty(props):空状态占位,title/description 可选,action 放操作区",
|
||||
@@ -1304,9 +1304,9 @@
|
||||
"props": [
|
||||
{
|
||||
"n": "tabs",
|
||||
"t": "OptionItem[]",
|
||||
"t": "TabItem[]",
|
||||
"r": 1,
|
||||
"d": "页签列表,每项 { value, label }"
|
||||
"d": "页签列表,每项 { value, label, disabled?, closable? }"
|
||||
},
|
||||
{
|
||||
"n": "active",
|
||||
@@ -1318,13 +1318,25 @@
|
||||
"t": "(value: string) => void",
|
||||
"k": 1,
|
||||
"d": "切换回调"
|
||||
},
|
||||
{
|
||||
"n": "onClose",
|
||||
"t": "(value: string) => void",
|
||||
"k": 1,
|
||||
"d": "关闭页签回调:组件不自行移除,凭 value 从数据源里摘掉后重绘"
|
||||
},
|
||||
{
|
||||
"n": "closable",
|
||||
"t": "boolean",
|
||||
"d": "页签项上的开关:true 时渲染关闭按钮(×),关闭按钮不进 Tab 序但可用鼠标点;缺省不可关"
|
||||
}
|
||||
],
|
||||
"example": "const tab = signal('a'); tabs({ tabs: [{ value: 'a', label: '概览' }, { value: 'b', label: '明细', disabled: true }], active: tab })",
|
||||
"example": "const tab = signal('a'); tabs({ tabs: [{ value: 'a', label: '概览', closable: true }, { value: 'b', label: '明细', disabled: true }], active: tab, onClose: (v) => toast.info(`关闭 ${v}`) })",
|
||||
"steps": [
|
||||
"创建信号 tab = signal('a'),初值为默认页签",
|
||||
"tabs 只渲染页签头;页签内容用响应式函数按信号切换,如 () => tab() === 'a' ? panelA : panelB",
|
||||
"点击或方向键(←→ 环形)、Home/End(直达首/末页签)切换,滑动指示条自动跟随;disabled: true 的页签置灰不可点",
|
||||
"需要可关闭页签时给该项加 closable: true,× 或聚焦后按 Delete 触发 onClose(value);组件不改数据源,调用方从数组里摘掉该项再重绘",
|
||||
"键盘可访问:Tab 聚焦激活页签,←→ 在启用页签间环形移动,Home/End 跳首个/末个启用页签"
|
||||
]
|
||||
},
|
||||
|
||||
+21
-1
@@ -153,6 +153,13 @@ setInterval(() => percent((percent() + 5) % 105), 400); // 自动推进演示进
|
||||
|
||||
// —— 标签页 / 弹窗状态 ——
|
||||
const activeTab = signal('a');
|
||||
// —— 可关闭页签:数据源为信号数组,onClose 里摘掉元素即复现 ——
|
||||
const closableTabs = signal([
|
||||
{ value: 'n1', label: '订单 #1024', closable: true },
|
||||
{ value: 'n2', label: '订单 #1025', closable: true },
|
||||
{ value: 'n3', label: '订单 #1026', closable: true },
|
||||
]);
|
||||
const closableActive = signal('n1');
|
||||
const modalOpen = signal(false);
|
||||
const modalPick = signal('a'); // 弹窗内下拉的取值,用于演示 Esc 逐层关闭
|
||||
|
||||
@@ -447,9 +454,22 @@ const content = h(
|
||||
section(
|
||||
'sec-tabs-modal',
|
||||
'标签页与弹窗',
|
||||
'tabs 滑动指示条 + 方向键切换(←→),禁用页签置灰;modal 支持 Esc / 遮罩 / × 关闭(closeOnOverlay: false 时点遮罩不关、防填一半误触),弹窗内的下拉展开时 Esc 先收下拉、再按才关弹窗(逐层关闭)。',
|
||||
'tabs 滑动指示条 + 方向键切换(←→),禁用页签置灰,closable 页签带 ×(聚焦后 Delete 也能关,关闭只回调、由数据源摘除后重绘);modal 支持 Esc / 遮罩 / × 关闭(closeOnOverlay: false 时点遮罩不关、防填一半误触),弹窗内的下拉展开时 Esc 先收下拉、再按才关弹窗(逐层关闭)。',
|
||||
tabs({ tabs: [{ value: 'a', label: '概览' }, { value: 'b', label: '明细' }, { value: 'c', label: '设置' }, { value: 'd', label: '已停用', disabled: true }], active: activeTab }),
|
||||
h('span', { class: 'out' }, () => `当前页签:${activeTab()}`),
|
||||
renderList(h('div', { class: 'row' }), () => {
|
||||
const items = closableTabs();
|
||||
if (!items.length) return [h('span', { class: 'desc' }, '页签全关掉了 —— onClose 里从数据源摘掉,重绘自然只剩空态')];
|
||||
return [tabs({
|
||||
tabs: items,
|
||||
active: closableActive,
|
||||
onClose: (v) => {
|
||||
const left = closableTabs().filter((t) => t.value !== v); // 数据源唯一来源:摘掉即重绘
|
||||
closableTabs(left);
|
||||
if (closableActive() === v) closableActive(left[0]?.value ?? ''); // 关掉的正好是当前页:顺位激活下一个
|
||||
},
|
||||
})];
|
||||
}),
|
||||
button({ label: '打开弹窗', onClick: () => modalOpen(true) }),
|
||||
button({ label: '打开防误关弹窗', variant: 'ghost', onClick: () => stickyOpen(true) }),
|
||||
modal(
|
||||
|
||||
+8
-5
@@ -1,6 +1,6 @@
|
||||
# @ui-arch 组件库 Agent 文档(无图形版)
|
||||
|
||||
> 自动生成于 2026-10-26T05:49:43.206Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
|
||||
> 自动生成于 2026-10-26T05:56:43.149Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
|
||||
> 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。
|
||||
|
||||
## 快速上手
|
||||
@@ -672,14 +672,16 @@ const pct = signal(0); progress({ value: pct }); setInterval(() => pct((pct() +
|
||||
|
||||
参数:
|
||||
|
||||
- tabs(必填):OptionItem[] —— 页签列表,每项 { value, label }
|
||||
- tabs(必填):TabItem[] —— 页签列表,每项 { value, label, disabled?, closable? }
|
||||
- active:Signal<string> —— 当前激活页签 value 信号,双向绑定
|
||||
- onChange(回调):(value: string) => void —— 切换回调
|
||||
- onClose(回调):(value: string) => void —— 关闭页签回调:组件不自行移除,凭 value 从数据源里摘掉后重绘
|
||||
- closable:boolean —— 页签项上的开关:true 时渲染关闭按钮(×),关闭按钮不进 Tab 序但可用鼠标点;缺省不可关
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
const tab = signal('a'); tabs({ tabs: [{ value: 'a', label: '概览' }, { value: 'b', label: '明细', disabled: true }], active: tab })
|
||||
const tab = signal('a'); tabs({ tabs: [{ value: 'a', label: '概览', closable: true }, { value: 'b', label: '明细', disabled: true }], active: tab, onClose: (v) => toast.info(`关闭 ${v}`) })
|
||||
```
|
||||
|
||||
手把手:
|
||||
@@ -687,7 +689,8 @@ const tab = signal('a'); tabs({ tabs: [{ value: 'a', label: '概览' }, { value:
|
||||
1. 创建信号 tab = signal('a'),初值为默认页签
|
||||
2. tabs 只渲染页签头;页签内容用响应式函数按信号切换,如 () => tab() === 'a' ? panelA : panelB
|
||||
3. 点击或方向键(←→ 环形)、Home/End(直达首/末页签)切换,滑动指示条自动跟随;disabled: true 的页签置灰不可点
|
||||
4. 键盘可访问:Tab 聚焦激活页签,←→ 在启用页签间环形移动,Home/End 跳首个/末个启用页签
|
||||
4. 需要可关闭页签时给该项加 closable: true,× 或聚焦后按 Delete 触发 onClose(value);组件不改数据源,调用方从数组里摘掉该项再重绘
|
||||
5. 键盘可访问:Tab 聚焦激活页签,←→ 在启用页签间环形移动,Home/End 跳首个/末个启用页签
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-tabs-modal
|
||||
|
||||
@@ -1614,7 +1617,7 @@ API 速览:
|
||||
- pagination(props):独立分页器,page 信号双向绑定,页码过多折叠省略号,首末页禁用翻页,jumpable 时末尾可输入页码直接跳转,键盘可达;pageSizes 可切每页条数
|
||||
- spinner(props):加载指示器,size 控制直径
|
||||
- progress(props):进度条,value 信号驱动 0-100(满格切 success 色);不传 value 退化为不确定态无限往复
|
||||
- tabs(props):标签页,active 信号双向绑定,滑动指示条 + 方向键环形导航,支持禁用页签
|
||||
- tabs(props):标签页,active 信号双向绑定,滑动指示条 + 方向键环形导航,支持禁用页签;页签项 closable 时带关闭按钮(聚焦后 Delete 同样可关),关闭只回调 onClose 由调用方摘数据
|
||||
- stepper(props):步骤条,done/current/upcoming 三态,active 信号双向绑定,clickable 时已完成步骤可回跳
|
||||
- rate(props):评分星标,value 信号双向绑定,hover 预览移出还原,键盘方向键调节,max 可配
|
||||
- empty(props):空状态占位,title/description 可选,action 放操作区
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
- pagination(props):独立分页器,page 信号双向绑定,页码过多折叠省略号,首末页禁用翻页,jumpable 时末尾可输入页码直接跳转,键盘可达;pageSizes 可切每页条数
|
||||
- spinner(props):加载指示器,size 控制直径
|
||||
- progress(props):进度条,value 信号驱动 0-100(满格切 success 色);不传 value 退化为不确定态无限往复
|
||||
- tabs(props):标签页,active 信号双向绑定,滑动指示条 + 方向键环形导航,支持禁用页签
|
||||
- tabs(props):标签页,active 信号双向绑定,滑动指示条 + 方向键环形导航,支持禁用页签;页签项 closable 时带关闭按钮(聚焦后 Delete 同样可关),关闭只回调 onClose 由调用方摘数据
|
||||
- stepper(props):步骤条,done/current/upcoming 三态,active 信号双向绑定,clickable 时已完成步骤可回跳
|
||||
- rate(props):评分星标,value 信号双向绑定,hover 预览移出还原,键盘方向键调节,max 可配
|
||||
- empty(props):空状态占位,title/description 可选,action 放操作区
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { TabsProps } from './types.js';
|
||||
import type { TabItem, TabsProps } from './types.js';
|
||||
|
||||
/*** 标签页:active 信号双向绑定 + 滑动指示条 + 方向键环形导航;内容由调用方按 active 派生 ***/
|
||||
/*** 标签页:active 信号双向绑定 + 滑动指示条 + 方向键环形导航;closable 页签带关闭按钮(聚焦后 Delete 亦可关) ***/
|
||||
export function tabs(props: TabsProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const buttons: HTMLButtonElement[] = []; // 页签按钮,顺序与 tabs 一致
|
||||
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) {
|
||||
@@ -16,13 +16,19 @@ export function tabs(props: TabsProps): HTMLElement {
|
||||
btn.classList.toggle('ua-tab-active', on);
|
||||
btn.setAttribute('aria-selected', String(on));
|
||||
btn.tabIndex = on ? 0 : -1; // 漫游 tabindex:仅激活页签可 Tab 聚焦
|
||||
if (on) {
|
||||
indicator.style.left = `${btn.offsetLeft}px`;
|
||||
indicator.style.width = `${btn.offsetWidth}px`;
|
||||
}
|
||||
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;
|
||||
@@ -33,8 +39,15 @@ export function tabs(props: TabsProps): HTMLElement {
|
||||
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(btn);
|
||||
head.append(wrap);
|
||||
}
|
||||
|
||||
head.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
|
||||
@@ -324,11 +324,17 @@ export interface ProgressProps {
|
||||
variant?: 'default' | 'success' | 'warning' | 'danger'; // 填充语义色;缺省 default(满格自动切 success)
|
||||
}
|
||||
|
||||
/*** 页签项:通用选项 + 可关闭开关 ***/
|
||||
export interface TabItem extends OptionItem {
|
||||
closable?: boolean; // 是否显示关闭按钮(聚焦后 Delete 同样可关);缺省不可关
|
||||
}
|
||||
|
||||
/*** 标签页入参 ***/
|
||||
export interface TabsProps {
|
||||
tabs: OptionItem[]; // 页签列表
|
||||
tabs: TabItem[]; // 页签列表
|
||||
active?: Signal<string>; // 当前激活页签的 value 信号,双向绑定
|
||||
onChange?: (value: string) => void; // 切换回调
|
||||
onClose?: (value: string) => void; // 关闭页签回调:组件不自行移除,凭 value 从数据源里摘掉
|
||||
}
|
||||
|
||||
/*** 弹窗入参 ***/
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"pagination(props):独立分页器,page 信号双向绑定,页码过多折叠省略号,首末页禁用翻页,jumpable 时末尾可输入页码直接跳转,键盘可达;pageSizes 可切每页条数",
|
||||
"spinner(props):加载指示器,size 控制直径",
|
||||
"progress(props):进度条,value 信号驱动 0-100(满格切 success 色);不传 value 退化为不确定态无限往复",
|
||||
"tabs(props):标签页,active 信号双向绑定,滑动指示条 + 方向键环形导航,支持禁用页签",
|
||||
"tabs(props):标签页,active 信号双向绑定,滑动指示条 + 方向键环形导航,支持禁用页签;页签项 closable 时带关闭按钮(聚焦后 Delete 同样可关),关闭只回调 onClose 由调用方摘数据",
|
||||
"stepper(props):步骤条,done/current/upcoming 三态,active 信号双向绑定,clickable 时已完成步骤可回跳",
|
||||
"rate(props):评分星标,value 信号双向绑定,hover 预览移出还原,键盘方向键调节,max 可配",
|
||||
"empty(props):空状态占位,title/description 可选,action 放操作区",
|
||||
@@ -1175,9 +1175,9 @@
|
||||
"props": [
|
||||
{
|
||||
"n": "tabs",
|
||||
"t": "OptionItem[]",
|
||||
"t": "TabItem[]",
|
||||
"r": 1,
|
||||
"d": "页签列表,每项 { value, label }"
|
||||
"d": "页签列表,每项 { value, label, disabled?, closable? }"
|
||||
},
|
||||
{
|
||||
"n": "active",
|
||||
@@ -1189,13 +1189,25 @@
|
||||
"t": "(value: string) => void",
|
||||
"k": 1,
|
||||
"d": "切换回调"
|
||||
},
|
||||
{
|
||||
"n": "onClose",
|
||||
"t": "(value: string) => void",
|
||||
"k": 1,
|
||||
"d": "关闭页签回调:组件不自行移除,凭 value 从数据源里摘掉后重绘"
|
||||
},
|
||||
{
|
||||
"n": "closable",
|
||||
"t": "boolean",
|
||||
"d": "页签项上的开关:true 时渲染关闭按钮(×),关闭按钮不进 Tab 序但可用鼠标点;缺省不可关"
|
||||
}
|
||||
],
|
||||
"example": "const tab = signal('a'); tabs({ tabs: [{ value: 'a', label: '概览' }, { value: 'b', label: '明细', disabled: true }], active: tab })",
|
||||
"example": "const tab = signal('a'); tabs({ tabs: [{ value: 'a', label: '概览', closable: true }, { value: 'b', label: '明细', disabled: true }], active: tab, onClose: (v) => toast.info(`关闭 ${v}`) })",
|
||||
"steps": [
|
||||
"创建信号 tab = signal('a'),初值为默认页签",
|
||||
"tabs 只渲染页签头;页签内容用响应式函数按信号切换,如 () => tab() === 'a' ? panelA : panelB",
|
||||
"点击或方向键(←→ 环形)、Home/End(直达首/末页签)切换,滑动指示条自动跟随;disabled: true 的页签置灰不可点",
|
||||
"需要可关闭页签时给该项加 closable: true,× 或聚焦后按 Delete 触发 onClose(value);组件不改数据源,调用方从数组里摘掉该项再重绘",
|
||||
"键盘可访问:Tab 聚焦激活页签,←→ 在启用页签间环形移动,Home/End 跳首个/末个启用页签"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1461,6 +1461,47 @@ button.ua-step-dot:hover {
|
||||
color: var(--ua-color-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.ua-tab-item {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.ua-tab-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: -4px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
color: var(--ua-color-text-muted);
|
||||
font-size: var(--ua-font-size-md);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background-color 0.15s, color 0.15s;
|
||||
}
|
||||
.ua-tab-item:hover .ua-tab-close,
|
||||
.ua-tab-item:focus-within .ua-tab-close {
|
||||
opacity: 1;
|
||||
}
|
||||
.ua-tab-close:focus-visible {
|
||||
opacity: 1;
|
||||
outline: 2px solid var(--ua-color-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.ua-tab-close:hover {
|
||||
color: var(--ua-color-text);
|
||||
background: color-mix(in srgb, var(--ua-color-accent) 12%, transparent);
|
||||
}
|
||||
@media (hover: none) {
|
||||
.ua-tab-close {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.ua-tabs-indicator {
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
|
||||
Reference in New Issue
Block a user