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 { 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 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); // 跨列数随可见列变化 // —— 表头全选框:checked / indeterminate 三态跟随当页选中情况 —— const headCheck = h('input', { type: 'checkbox', class: 'ua-checkbox' }) 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; }); /*** 排序标记:升 ↑ / 降 ↓ / 未排序 ↕(可点击提示) ***/ const sortMark = (key: string) => h( 'span', { class: 'ua-sort-mark' }, () => { const rule = state().sorting.find((r) => r.key === key); return rule ? (rule.dir === 'asc' ? '↑' : '↓') : '↕'; }, ); const th = (col: (typeof props.columns)[number]) => { const toggle = () => col.sortable && table.dispatch({ type: 'toggleSort', key: col.key }); // 鼠标与键盘共用的排序入口 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: toggle, onKeydown: (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } // Enter / 空格 等价鼠标点击 }, }, 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)]; // 空状态:筛选词与内核筛选一起清 return rows.map((row, i) => { const key = visible().keys[i]; const selected = sel.includes(key); const cells = shown().map((col) => { const raw = table.getCellValue(row, col.key); const text = col.format ? col.format(raw, row) : String(raw); // format 只产出纯文本,天然免注入 return h('td', col.align ? { class: `ua-align-${col.align}` } : null, text); }); if (selectable) { const check = h('input', { type: 'checkbox', class: 'ua-checkbox' }) as HTMLInputElement; // 受控:选中态由内核 selection 决定 check.checked = selected; check.addEventListener('change', () => table.dispatch({ type: 'toggleSelect', key })); cells.unshift(h('td', { class: 'ua-cell-check' }, check)); } 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); }); }); // —— 列宽:数字落成 固定宽度,'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 页 }, }); // —— 表头:随可见列重绘(隐藏列既不占表头也不占单元格) —— const thead = h('thead'); renderList(thead, () => [h('tr', null, ...(selectable ? [headCheck] : []), ...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 }), }), ); }