iter(g315): 表格支持行展开详情
This commit is contained in:
@@ -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
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>; // 加载态:为真时数据行换成骨架行,筛选与翻页禁用
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user