iter(g315): 表格支持行展开详情

This commit is contained in:
HIsheR
2026-10-27 00:46:37 +08:00
parent 27500636c4
commit aed5c75825
9 changed files with 95 additions and 9 deletions
+9
View File
@@ -3221,6 +3221,15 @@
"motivation": "清单停在十六段(g299 扩过一轮),之后新增的拖拽排序与瀑布流没有抽查位——这两个组件恰好是「靠观感判断做没做对」的类型(拖拽虚影、列边界断卡),照着清单过一遍时会被直接跳过。",
"acceptance": "① ITERATION.md 新增两段共 8 条,维度标签落在五类内;② ua.js 的 REQUIRED 从 16 扩到 18(条目下限由段数自动推到 54)、verify OK 文案同步;③ AGENTS 的清单描述同步为十八段 ≥54 条;④ npm run verify / check 全绿。",
"notes": ""
},
{
"id": "g315",
"domain": "tune",
"status": "open",
"title": "表格补行展开:长内容只能挤在单元格里或跳页",
"motivation": "订单备注、用户标签、二级操作这类内容塞进单元格会把列撑爆,跳详情页又太重;dataTable 只有 onRowClick 一条路,等于逼调用方在「挤」和「跳走」之间二选一。",
"acceptance": "① 新增 renderDetail 入参,传了才多一列展开钮(▸ 旋转 90° 表示展开);② 详情行整行跨列、凹陷底、允许折行,展开态按 rowKey 记,筛选与翻页不丢;③ 展开钮拦冒泡,整行可点时不会顺带触发 onRowClick;④ 空状态与骨架行的 colspan 计入展开列;⑤ manifest 与 demo 同步,npm run check 全绿。",
"notes": ""
}
]
}
+9 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "ui-arch-registry-index/0.1",
"generatedAt": "2026-10-26T16:43:41.739Z",
"generatedAt": "2026-10-26T16:45:42.239Z",
"repoRoot": ".",
"packages": [
{
@@ -2135,6 +2135,12 @@
"n": "hiddenColumns",
"t": "Signal<string[]>",
"d": "受控隐藏列键集:与「列」面板双向同步,外部可据此持久化用户偏好;隐藏列不渲染、不进导出"
},
{
"n": "renderDetail",
"t": "(row: T) => unknown",
"k": 1,
"d": "传了才多一列展开钮:点开在该行下铺一条详情行(内容由这里给,展开态按 rowKey 记,筛选翻页不丢)"
}
],
"example": "dataTable({ data: users, selectable: true, columns: [{ key: 'name', header: '姓名', sortable: true }], pageSize: 5 })",
@@ -2158,7 +2164,8 @@
"整行可点(进详情、选中一行做后续操作)传 onRowClick:行会变成可聚焦项,Enter / 空格与点击等价;不传时行不进 Tab 序列,纯展示表不会被读屏当成按钮",
"需要让用户自己调列时开 columnToggle:工具栏多一个「列」按钮,弹层勾选即刻重绘表头与单元格(隐藏列不占 colspan,空状态与骨架行同步收窄)",
"想记住用户偏好就传 hiddenColumns 信号:面板勾选实时写回,外部赋值即整表跟随;隐藏筛选列后筛选条件仍然生效,属预期而非失效",
"表里本来就空(不是筛掉的)时传 emptyAction 给个「新建」之类的出口:它只在「真没数据」时出现,一旦有筛选词就换成「清除筛选」——先让用户清条件,而不是急着让他新建"
"表里本来就空(不是筛掉的)时传 emptyAction 给个「新建」之类的出口:它只在「真没数据」时出现,一旦有筛选词就换成「清除筛选」——先让用户清条件,而不是急着让他新建",
"表里塞不下的长内容(备注、标签、二级操作)交给 renderDetail:多出一列 ▸,点开在该行下方铺一条凹陷底的详情行,按钮自己拦冒泡所以不会顺带触发 onRowClick"
]
},
{
+1
View File
@@ -1488,6 +1488,7 @@ const content = h(
],
pageSize: 5,
selected: picked,
renderDetail: (row) => h('div', null, `${row.name} · ${row.age} 岁 · ${row.city}:展开行放表里塞不下的长内容(备注、标签、二级操作),点行首的 ▸ 开合,状态按 rowKey 记,筛选与翻页都不丢。`), // 详情行:多在表里加一列展开钮
onRowClick: (row) => toast.info(`打开 ${row.name} 的详情`), // 整行可点:Tab 进入 + Enter 等价
}), h(
'div',
+3 -1
View File
@@ -1,6 +1,6 @@
# @ui-arch 组件库 Agent 文档(无图形版)
> 自动生成于 2026-10-26T16:43:41.739Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
> 自动生成于 2026-10-26T16:45:42.239Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
> 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。
## 快速上手
@@ -1036,6 +1036,7 @@ const name = signal(''); const f = form({ onSubmit: submit }, [formItem({ label:
- onRowClick(回调):(row: T) => void —— 整行点击 / Enter / 空格的回调;传了行才可聚焦可点(未传时行保持纯展示,不抢 Tab 焦点)
- columnToggle(默认 false):boolean —— 工具栏显示「列」按钮:弹层内勾选控制列显隐,至少保留一列(最后一列的勾选框锁死),缺省 false
- hiddenColumns:Signal<string[]> —— 受控隐藏列键集:与「列」面板双向同步,外部可据此持久化用户偏好;隐藏列不渲染、不进导出
- renderDetail(回调):(row: T) => unknown —— 传了才多一列展开钮:点开在该行下铺一条详情行(内容由这里给,展开态按 rowKey 记,筛选翻页不丢)
示例:
@@ -1065,6 +1066,7 @@ dataTable({ data: users, selectable: true, columns: [{ key: 'name', header: '姓
18. 需要让用户自己调列时开 columnToggle:工具栏多一个「列」按钮,弹层勾选即刻重绘表头与单元格(隐藏列不占 colspan,空状态与骨架行同步收窄)
19. 想记住用户偏好就传 hiddenColumns 信号:面板勾选实时写回,外部赋值即整表跟随;隐藏筛选列后筛选条件仍然生效,属预期而非失效
20. 表里本来就空(不是筛掉的)时传 emptyAction 给个「新建」之类的出口:它只在「真没数据」时出现,一旦有筛选词就换成「清除筛选」——先让用户清条件,而不是急着让他新建
21. 表里塞不下的长内容(备注、标签、二级操作)交给 renderDetail:多出一列 ▸,点开在该行下方铺一条凹陷底的详情行,按钮自己拦冒泡所以不会顺带触发 onRowClick
现场演示:apps/docs/demo.html#sec-table
+14 -5
View File
@@ -12,6 +12,7 @@ import { exportButton } from './csv.js';
import { skeleton } from './skeleton.js';
import { textField } from './textField.js';
import { emptyRow } from './tableEmpty.js';
import { detailRow, expandCell } from './tableExpand.js';
import { tablePager } from './tablePager.js';
import { attachDataSource, readRows } from './tableDataSource.js';
import { bridgePage, bridgeSelection } from './tableBridge.js';
@@ -21,6 +22,7 @@ import type { DataTableProps, TableApi } from './types.js';
/*** 数据表格:粘性表头 + 行选择 + 排序 + 筛选 + 分页 + 空状态;状态由 table-core 持有,信号只做桥接渲染 ***/
export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): HTMLElement {
const selectable = props.selectable ?? false; // 状态集中声明:行选择开关
const expanded = signal<RowKey[]>([]); // 已展开行的 rowKey:纯视图状态,不参与内核派生
const keyword = signal(''); // 筛选框关键字:受控信号,空状态里的「清除筛选」靠它一键还原
const table = createTable({
data: readRows(props.data), // 信号数据源先取当前值,后续靠 attachDataSource 换
@@ -56,7 +58,7 @@ export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): H
const filterKey = props.filterKey ?? props.columns[0].key;
const hidden = props.hiddenColumns ?? signal<string[]>([]); // 隐藏列键集:外部没给就自己持有
const shown = computed(() => props.columns.filter((c) => !hidden().includes(c.key))); // 可见列:表头 / 单元格 / 导出 / colspan 全从这一处派生
const span = (): number => shown().length + (selectable ? 1 : 0); // 跨列数随可见列变化
const span = (): number => shown().length + (selectable ? 1 : 0) + (props.renderDetail ? 1 : 0); // 跨列数随可见列与展开列变化
// —— 表头全选框:checked / indeterminate 三态跟随当页选中情况 ——
const headCheck = h('input', { type: 'checkbox', class: 'ua-checkbox' }) as HTMLInputElement;
@@ -125,9 +127,10 @@ export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): H
const sel = state().selection; // 读入依赖:选择变化即整表重绘(行数 = pageSize,代价可控)
const rows = visible().rows;
if (!rows.length) return [emptyRow(span(), keyword, () => table.dispatch({ type: 'setFilter', key: filterKey, value: '' }), props.emptyText, props.emptyAction)]; // 空状态:筛选词与内核筛选一起清;真没数据时给调用方的操作插槽
return rows.map((row, i) => {
return rows.flatMap((row, i) => {
const key = visible().keys[i];
const selected = sel.includes(key);
const open = !!props.renderDetail && expanded().includes(key); // 展开态按 rowKey 记:翻页或筛选后同一行仍保持着
const cells = shown().map((col) => {
const raw = table.getCellValue(row, col.key);
const text = col.format ? col.format(raw, row) : String(raw); // format 只产出纯文本,天然免注入
@@ -139,10 +142,12 @@ export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): H
check.addEventListener('change', () => table.dispatch({ type: 'toggleSelect', key }));
cells.unshift(h('td', { class: 'ua-cell-check' }, check));
}
if (props.renderDetail) cells.unshift(expandCell(open, `第 ${i + 1} 行`, () => expanded(open ? expanded().filter((k) => k !== key) : [...expanded(), key]))); // 展开列在最前:与选择列并列但不互斥
const rowProps = props.onRowClick // 传了才把整行做成可聚焦按钮语义(Tab 进入、Enter / 空格触发)
? { tabindex: '0', class: `ua-row-clickable${selected ? ' ua-row-selected' : ''}`, onclick: () => props.onRowClick?.(row), onkeydown: (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); props.onRowClick?.(row); } } }
: selected ? { class: 'ua-row-selected' } : null;
return h('tr', rowProps, ...cells);
const tr = h('tr', rowProps, ...cells);
return props.renderDetail && open ? [tr, detailRow(span(), props.renderDetail(row))] : [tr];
});
});
// —— 列宽:数字落成 <col> 固定宽度,'flex' 或未设置交给浏览器自适应;按列键建一次,显隐只切换是否挂进 colgroup ——
@@ -161,9 +166,13 @@ export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): H
},
});
// —— 表头:随可见列重绘(隐藏列既不占表头也不占单元格) ——
// —— 表头:随可见列重绘(隐藏列既不占表头也不占单元格);选择与展开各占一个 th,顺序与行内单元格一致 ——
const thead = h('thead');
renderList(thead, () => [h('tr', null, ...(selectable ? [headCheck] : []), ...shown().map(th))]);
const lead = (): unknown[] => [
props.renderDetail ? h('th', { class: 'ua-cell-expand', scope: 'col' }, h('span', { class: 'ua-visually-hidden' }, '展开详情')) : null, // 展开列在最前
selectable ? h('th', { class: 'ua-cell-check', scope: 'col' }, headCheck) : null, // 全选框借这次归位进 th(原先直接塞进 tr,属无效结构)
];
renderList(thead, () => [h('tr', null, ...lead(), ...shown().map(th))]);
return h(
'section',
+21
View File
@@ -0,0 +1,21 @@
import { h } from '@ui-arch/frame';
/*** 展开列单元格:按钮带 aria-expanded 与可读标签,箭头靠 CSS 旋转,开合状态同时给视觉与读屏 ***/
export function expandCell(open: boolean, label: string, onToggle: () => void): HTMLElement {
return h(
'td',
{ class: 'ua-cell-expand' },
h('button', {
type: 'button',
class: `ua-expand-btn${open ? ' is-open' : ''}`,
'aria-expanded': String(open),
'aria-label': `${open ? '收起' : '展开'} ${label} 的详情`,
onClick: (e: MouseEvent) => { e.stopPropagation(); onToggle(); }, // 拦住冒泡:整行可点时别顺带触发「打开详情」
}, '▸'),
);
}
/*** 详情行:整行跨列承载调用方内容,随主行一起重绘(不单独持有状态) ***/
export function detailRow(colspan: number, content: unknown): HTMLElement {
return h('tr', { class: 'ua-detail-row' }, h('td', { colspan }, content));
}
+1
View File
@@ -68,6 +68,7 @@ export interface DataTableProps<T> {
pageSizes?: number[]; // 分页条「每页条数」候选;缺省 [5, 10, 20],当前 pageSize 不在候选里会自动补进去
emptyText?: string; // 无数据时的空状态文案(有筛选词时仍显示「没有符合「x」的行」);缺省「暂无数据」
emptyAction?: unknown; // 真没数据时占位区右侧的操作(如「新建」按钮);有筛选词时换成「清除筛选」,两者互斥
renderDetail?: (row: T) => unknown; // 传了才多一列展开钮:点开在行下铺一条详情行(内容由这里给,展开态按 rowKey 记)
selectable?: boolean; // 是否启用行选择列(含全选三态与已选计数);缺省 false
page?: Signal<number>; // 受控页码信号:与内核分页双向同步,可接独立 pagination 组件联动
loading?: boolean | Signal<boolean>; // 加载态:为真时数据行换成骨架行,筛选与翻页禁用
+8 -1
View File
@@ -2006,6 +2006,12 @@
"n": "hiddenColumns",
"t": "Signal<string[]>",
"d": "受控隐藏列键集:与「列」面板双向同步,外部可据此持久化用户偏好;隐藏列不渲染、不进导出"
},
{
"n": "renderDetail",
"t": "(row: T) => unknown",
"k": 1,
"d": "传了才多一列展开钮:点开在该行下铺一条详情行(内容由这里给,展开态按 rowKey 记,筛选翻页不丢)"
}
],
"example": "dataTable({ data: users, selectable: true, columns: [{ key: 'name', header: '姓名', sortable: true }], pageSize: 5 })",
@@ -2029,7 +2035,8 @@
"整行可点(进详情、选中一行做后续操作)传 onRowClick:行会变成可聚焦项,Enter / 空格与点击等价;不传时行不进 Tab 序列,纯展示表不会被读屏当成按钮",
"需要让用户自己调列时开 columnToggle:工具栏多一个「列」按钮,弹层勾选即刻重绘表头与单元格(隐藏列不占 colspan,空状态与骨架行同步收窄)",
"想记住用户偏好就传 hiddenColumns 信号:面板勾选实时写回,外部赋值即整表跟随;隐藏筛选列后筛选条件仍然生效,属预期而非失效",
"表里本来就空(不是筛掉的)时传 emptyAction 给个「新建」之类的出口:它只在「真没数据」时出现,一旦有筛选词就换成「清除筛选」——先让用户清条件,而不是急着让他新建"
"表里本来就空(不是筛掉的)时传 emptyAction 给个「新建」之类的出口:它只在「真没数据」时出现,一旦有筛选词就换成「清除筛选」——先让用户清条件,而不是急着让他新建",
"表里塞不下的长内容(备注、标签、二级操作)交给 renderDetail:多出一列 ▸,点开在该行下方铺一条凹陷底的详情行,按钮自己拦冒泡所以不会顺带触发 onRowClick"
]
},
{
+29
View File
@@ -222,6 +222,35 @@ body.ua-col-resizing {
width: 36px;
text-align: center !important;
}
/* 展开列:窄一格,按钮本体撑满单元格好点 */
.ua-cell-expand {
width: 36px;
text-align: center !important;
padding-right: 0 !important;
}
.ua-expand-btn {
padding: 0 var(--ua-space-05);
border: none;
background: none;
color: var(--ua-color-text-muted);
font-size: var(--ua-font-size-md);
line-height: var(--ua-leading-none);
cursor: pointer;
transition: color var(--ua-duration-fast) var(--ua-ease), transform var(--ua-duration-fast) var(--ua-ease);
}
.ua-expand-btn:hover {
color: var(--ua-color-accent);
}
.ua-expand-btn.is-open {
color: var(--ua-color-accent);
transform: rotate(90deg); /* 箭头转成朝下:开合状态不必靠文字再说一遍 */
}
.ua-detail-row td {
padding: var(--ua-space-3) var(--ua-space-4);
background: var(--ua-color-surface-inset); /* 凹陷底:一眼看出这条是附属信息,不是新的一行数据 */
font-size: var(--ua-font-size-sm);
white-space: normal; /* 详情多为长文本,允许折行 */
}
/* 列对齐:表头与单元格共用,由列定义的 align 决定 */
.ua-align-center {
text-align: center;