import { createTable, filterPlugin, paginationPlugin, selectionPlugin, sortPlugin, type RowKey, } from '@ui-arch/table-core'; import { iconSearch } from './icons.js'; import { computed, effect, h, renderList, signal } from '@ui-arch/frame'; import { attachResize } from './columnResize.js'; 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'; import { columnPicker } from './tableColumns.js'; 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 换 columns: props.columns, rowKey: 'id', plugins: [filterPlugin(), sortPlugin(), paginationPlugin(), selectionPlugin()], initialState: { pagination: { page: 1, pageSize: props.pageSize ?? 5 } }, }); const api = table as unknown as TableApi; // 用到的两个取数方法在类型别名里声明 const version = signal(0); // 桥接信号:table-core 订阅通知 → 递增 → 派生统一重算 table.subscribe(() => version(version() + 1)); attachDataSource(table, props.data); // 信号数据源变化即换源(静态数组直接返回) // —— 派生:全部由 version 单一来源推导,语义与内核一致 —— const visible = computed(() => { version(); return table.getVisibleRows(); }); const state = computed(() => { version(); return table.getState(); }); const info = computed(() => { visible(); return api.getPageInfo(); }); const selectedCount = computed(() => state().selection.length); // 加载态读取器:布尔直接读,信号则保持响应性(供 renderList 依赖收集) const busy = (): boolean => (typeof props.loading === 'function' ? props.loading() : props.loading === true); // —— 受控桥接:页码与选中都走 tableBridge,外部信号与内核状态双向收敛 —— if (props.page) bridgePage({ page: props.page, info, onPage: (page) => table.dispatch({ type: 'setPage', page }) }); if (props.selected) bridgeSelection({ selected: props.selected, selection: () => state().selection, onSelection: (keys) => table.dispatch({ type: 'setSelection', keys }) }); 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) + (props.renderDetail ? 1 : 0); // 跨列数随可见列与展开列变化 // —— 表头全选框:checked / indeterminate 三态跟随当页选中情况 —— const headCheck = h('input', { type: 'checkbox', class: 'ua-checkbox', 'aria-label': '全选本页' }) as HTMLInputElement; // 无名字的复选框读屏只念「复选框」 headCheck.addEventListener('change', () => { if (headCheck.checked) api.selectAllVisible(); else table.dispatch({ type: 'clearSelection' }); }); effect(() => { const keys = visible().keys; const sel = state().selection; const onPage = keys.filter((k) => sel.includes(k)).length; headCheck.checked = keys.length > 0 && onPage === keys.length; headCheck.indeterminate = onPage > 0 && onPage < keys.length; }); /*** 排序标记:升 ↑ / 降 ↓ / 未排序 ↕;多列排序时追加优先级序号(↑1 / ↓2) ***/ const sortMark = (key: string) => h( 'span', { class: 'ua-sort-mark' }, () => { const rules = state().sorting; const at = rules.findIndex((r) => r.key === key); if (at === -1) return '↕'; const arrow = rules[at].dir === 'asc' ? '↑' : '↓'; return rules.length > 1 ? `${arrow}${at + 1}` : arrow; // 只有多列参与排序时才显示序号,单列保持原样 }, ); const th = (col: (typeof props.columns)[number]) => { const toggle = (multi: boolean) => col.sortable && table.dispatch({ type: 'toggleSort', key: col.key, multi }); // multi 由 Shift 决定:叠加 / 单列 const el = h( 'th', { class: `${col.sortable ? 'ua-sortable' : ''}${col.align ? ` ua-align-${col.align}` : ''}`.trim(), scope: 'col', // 表头单元格语义:读屏据此把列标题与数据格关联 tabindex: col.sortable ? '0' : null, // 可排序表头进 Tab 序,不可排序的不抢焦点 onClick: (e: MouseEvent) => toggle(e.shiftKey), // Shift+点击 = 多列叠加排序(内核按 rules 顺序逐列比较) onKeydown: (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(e.shiftKey); } // Enter / 空格 等价鼠标点击,Shift 同样生效 }, }, col.header ?? col.key, col.sortable ? sortMark(col.key) : null, ); if (typeof col.width === 'number') attachResize(el, colEls.get(col.key)!, col.width, String(col.header ?? col.key)); // 定宽列可拖拽 if (col.sortable) { effect(() => { const rule = state().sorting.find((r) => r.key === col.key); // 与视觉箭头同源 el.setAttribute('aria-sort', rule ? (rule.dir === 'asc' ? 'ascending' : 'descending') : 'none'); }); } return el; }; /*** 骨架行:列宽与表头对齐,行数取当前每页条数(上限 6 行防过长) ***/ const skeletonRows = (): Node[] => { const count = Math.min(props.pageSize ?? 5, 6); const cell = () => h('td', null, skeleton({ variant: 'text', rows: 1 })); return Array.from({ length: count }, () => h('tr', { class: 'ua-loading-row' }, ...Array.from({ length: span() }, cell))); }; const tbody = h('tbody'); renderList(tbody, () => { if (busy()) return skeletonRows(); // 加载态优先:不看数据,避免闪出旧行 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.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 custom = col.render?.(row); // 自定义单元格:调用方直接给节点(editable / tag / avatar),排序与筛选仍走原值 return h('td', { class: col.align ? `ua-align-${col.align}` : null, onclick: custom ? (e: MouseEvent) => e.stopPropagation() : null }, custom ?? (col.format ? col.format(raw, row) : String(raw))); // 无 render 退回纯文本(免注入);格内点击不触发整行回调 }); if (selectable) { const check = h('input', { type: 'checkbox', class: 'ua-checkbox', 'aria-label': `选择第 ${i + 1} 行` }) as HTMLInputElement; // 受控:选中态由内核 selection 决定;名字带行号,读屏才分得清选哪行 check.checked = selected; 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' : ''}`, 'aria-selected': selectable ? String(selected) : null, onclick: () => props.onRowClick?.(row), onkeydown: (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); props.onRowClick?.(row); } } } : selected ? { class: 'ua-row-selected', 'aria-selected': 'true' } : null; // 淡底是视觉,aria-selected 才是读屏能听到的「这行被选中」 const tr = h('tr', rowProps, ...cells); return props.renderDetail && open ? [tr, detailRow(span(), props.renderDetail(row))] : [tr]; }); }); // —— 列宽:数字落成 固定宽度,'flex' 或未设置交给浏览器自适应;按列键建一次,显隐只切换是否挂进 colgroup —— const colEls = new Map(props.columns.map((col) => [col.key, typeof col.width === 'number' ? h('col', { style: `width:${col.width}px` }) : h('col')])); // 复用同一批 ,拖拽改的宽度不因显隐丢失 const colgroup = h('colgroup'); renderList(colgroup, () => [...(selectable ? [h('col', { style: 'width:44px' })] : []), ...shown().map((c) => colEls.get(c.key)!)]); const filterInput = textField({ placeholder: `筛选 ${filterKey}…`, prefix: iconSearch(), value: keyword, // 受控信号:空状态里的「清除筛选」能同时清掉输入框内容 disabled: props.loading, // 加载中禁止筛选,避免与在途结果竞争 onInput: (value) => { table.dispatch({ type: 'setFilter', key: filterKey, value }); table.dispatch({ type: 'setPage', page: 1 }); // 筛选变化回第 1 页 }, }); // —— 表头:随可见列重绘(隐藏列既不占表头也不占单元格);选择与展开各占一个 th,顺序与行内单元格一致 —— const thead = h('thead'); 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', { class: 'ua-table', 'aria-busy': () => String(busy()) }, h( 'div', { class: 'ua-table-toolbar' }, filterInput, selectable ? h('span', { class: 'ua-sel-count' }, () => (selectedCount() ? `已选 ${selectedCount()} 行` : '')) : null, props.columnToggle ? columnPicker({ columns: props.columns, hidden, disabled: busy }) : null, // 列显隐面板:勾选即重绘表头与单元格 props.exportable ? exportButton(table, () => shown(), props.exportName ?? 'table.csv') : null, // 导出筛选+排序后的全量行,列跟随当前可见列 ), h('div', { class: 'ua-table-scroll' }, h('table', null, colgroup, thead, tbody)), tablePager({ info, total: () => visible().total, loading: busy, pageSizes: props.pageSizes, onPage: (page) => table.dispatch({ type: 'setPage', page }), onPageSize: (pageSize) => table.dispatch({ type: 'setPageSize', pageSize }), }), ); }