diff --git a/.ua/backlog.json b/.ua/backlog.json index 513f8e9..c428db3 100644 --- a/.ua/backlog.json +++ b/.ua/backlog.json @@ -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": "" } ] } diff --git a/.ua/index.json b/.ua/index.json index 747a18e..07cba14 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -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", "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" ] }, { diff --git a/apps/demo/main.ts b/apps/demo/main.ts index 2e6d6a4..37a182d 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -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', diff --git a/llms-full.txt b/llms-full.txt index 8dafa81..4925d98 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -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 —— 受控隐藏列键集:与「列」面板双向同步,外部可据此持久化用户偏好;隐藏列不渲染、不进导出 +- 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 diff --git a/packages/skin/ui/src/dataTable.ts b/packages/skin/ui/src/dataTable.ts index 5ed751a..8f3e4c9 100644 --- a/packages/skin/ui/src/dataTable.ts +++ b/packages/skin/ui/src/dataTable.ts @@ -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(props: DataTableProps): HTMLElement { const selectable = props.selectable ?? false; // 状态集中声明:行选择开关 + const expanded = signal([]); // 已展开行的 rowKey:纯视图状态,不参与内核派生 const keyword = signal(''); // 筛选框关键字:受控信号,空状态里的「清除筛选」靠它一键还原 const table = createTable({ data: readRows(props.data), // 信号数据源先取当前值,后续靠 attachDataSource 换 @@ -56,7 +58,7 @@ export function dataTable(props: DataTableProps): H const filterKey = props.filterKey ?? props.columns[0].key; const hidden = props.hiddenColumns ?? signal([]); // 隐藏列键集:外部没给就自己持有 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(props: DataTableProps): 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(props: DataTableProps): 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]; }); }); // —— 列宽:数字落成 固定宽度,'flex' 或未设置交给浏览器自适应;按列键建一次,显隐只切换是否挂进 colgroup —— @@ -161,9 +166,13 @@ export function dataTable(props: DataTableProps): 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', diff --git a/packages/skin/ui/src/tableExpand.ts b/packages/skin/ui/src/tableExpand.ts new file mode 100644 index 0000000..bd86625 --- /dev/null +++ b/packages/skin/ui/src/tableExpand.ts @@ -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)); +} diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts index 852a574..3015e9f 100644 --- a/packages/skin/ui/src/types.ts +++ b/packages/skin/ui/src/types.ts @@ -68,6 +68,7 @@ export interface DataTableProps { pageSizes?: number[]; // 分页条「每页条数」候选;缺省 [5, 10, 20],当前 pageSize 不在候选里会自动补进去 emptyText?: string; // 无数据时的空状态文案(有筛选词时仍显示「没有符合「x」的行」);缺省「暂无数据」 emptyAction?: unknown; // 真没数据时占位区右侧的操作(如「新建」按钮);有筛选词时换成「清除筛选」,两者互斥 + renderDetail?: (row: T) => unknown; // 传了才多一列展开钮:点开在行下铺一条详情行(内容由这里给,展开态按 rowKey 记) selectable?: boolean; // 是否启用行选择列(含全选三态与已选计数);缺省 false page?: Signal; // 受控页码信号:与内核分页双向同步,可接独立 pagination 组件联动 loading?: boolean | Signal; // 加载态:为真时数据行换成骨架行,筛选与翻页禁用 diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index 81417a9..d26672a 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -2006,6 +2006,12 @@ "n": "hiddenColumns", "t": "Signal", "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" ] }, { diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css index 1a7a24c..871a257 100644 --- a/packages/skin/ui/ui.css +++ b/packages/skin/ui/ui.css @@ -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;