import { createTable, filterPlugin, paginationPlugin, selectionPlugin, sortPlugin, } from '@ui-arch/table-core'; import type { PageInfo, RowKey, TableInstance } from '@ui-arch/table-core'; import { computed, effect, h, renderList, signal } from '@ui-arch/frame'; import { button } from './button.js'; import { skeleton } from './skeleton.js'; import { textField } from './textField.js'; import type { DataTableProps } from './types.js'; /*** 数据表格:粘性表头 + 行选择 + 排序 + 筛选 + 分页 + 空状态;状态由 table-core 持有,信号只做桥接渲染 ***/ export function dataTable(props: DataTableProps): HTMLElement { // —— 状态集中声明 —— const selectable = props.selectable ?? false; // 行选择开关 const table = createTable({ data: props.data, columns: props.columns, rowKey: 'id', plugins: [filterPlugin(), sortPlugin(), paginationPlugin(), selectionPlugin()], initialState: { pagination: { page: 1, pageSize: props.pageSize ?? 5 } }, }); const api = table as unknown as TableInstance & { getPageInfo(): PageInfo; selectAllVisible(): void; }; const version = signal(0); // 桥接信号:table-core 订阅通知 → 递增触发信号系统 table.subscribe(() => version(version() + 1)); // —— 派生:全部由 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); // —— 受控页码桥接:外部信号 ↔ 内核分页;外部越界先钳制,signal 等值写回不触发,双向同步收敛安全 —— if (props.page) { const pageSig = props.page; effect(() => { const external = Math.min(api.getPageInfo().totalPages, Math.max(1, pageSig())); // 越界钳制,防无效派发 if (external !== info().page) table.dispatch({ type: 'setPage', page: external }); // 外部翻页 → 内核 }); effect(() => { const internal = info().page; if (internal !== pageSig()) pageSig(internal); // 内部翻页(筛选/内置翻页器)→ 写回外部 }); } // —— 受控选中桥接:外部信号 ↔ 内核选择集;比较内容而非引用,避免来回派发 —— if (props.selected) { const selSig = props.selected; const sameKeys = (a: RowKey[], b: RowKey[]): boolean => a.length === b.length && a.every((k, i) => k === b[i]); effect(() => { const external = selSig(); if (!sameKeys(external, state().selection)) table.dispatch({ type: 'setSelection', keys: external }); // 外部改写 → 内核 }); effect(() => { const internal = state().selection; if (!sameKeys(internal, selSig())) selSig([...internal]); // 内部勾选 → 写回外部(拷贝避免外部改到内核数组) }); } const filterKey = props.filterKey ?? props.columns[0].key; const colspan = props.columns.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]) => h( 'th', { class: `${col.sortable ? 'ua-sortable' : ''}${col.align ? ` ua-align-${col.align}` : ''}`.trim(), onClick: () => col.sortable && table.dispatch({ type: 'toggleSort', key: col.key }), }, col.header ?? col.key, col.sortable ? sortMark(col.key) : null, ); /*** 骨架行:列宽与表头对齐,行数取当前每页条数(上限 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: colspan }, cell))); }; const tbody = h('tbody'); renderList(tbody, () => { if (busy()) return skeletonRows(); // 加载态优先:不看数据,避免闪出旧行 const sel = state().selection; // 读入依赖:选择变化即整表重绘(行数 = pageSize,代价可控) const rows = visible().rows; if (!rows.length) { return [h('tr', { class: 'ua-empty-row' }, h('td', { colspan }, '暂无匹配数据'))]; } return rows.map((row, i) => { const key = visible().keys[i]; const selected = sel.includes(key); const cells = props.columns.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; check.checked = selected; check.addEventListener('change', () => table.dispatch({ type: 'toggleSelect', key })); cells.unshift(h('td', { class: 'ua-cell-check' }, check)); } return h('tr', selected ? { class: 'ua-row-selected' } : null, ...cells); }); }); // —— 列宽:数字落成 固定宽度,'flex' 或未设置交给浏览器自适应 —— const colgroup = h( 'colgroup', null, selectable ? h('col', { style: 'width:44px' }) : null, ...props.columns.map((col) => (typeof col.width === 'number' ? h('col', { style: `width:${col.width}px` }) : h('col'))), ); const filterInput = textField({ placeholder: `筛选 ${filterKey}…`, prefix: '🔍', disabled: props.loading, // 加载中禁止筛选,避免与在途结果竞争 onInput: (value) => { table.dispatch({ type: 'setFilter', key: filterKey, value }); table.dispatch({ type: 'setPage', page: 1 }); // 筛选变化回第 1 页 }, }); 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, ), h('div', { class: 'ua-table-scroll' }, h('table', null, colgroup, h('thead', null, h('tr', null, selectable ? headCheck : null, ...props.columns.map(th))), tbody)), h( 'div', { class: 'ua-pager' }, button({ label: '上一页', variant: 'ghost', disabled: props.loading, onClick: () => table.dispatch({ type: 'setPage', page: info().page - 1 }) }), h('span', { class: 'ua-page-info' }, () => `第 ${info().page} / ${info().totalPages} 页 · 共 ${visible().total} 行`), button({ label: '下一页', variant: 'ghost', disabled: props.loading, onClick: () => table.dispatch({ type: 'setPage', page: info().page + 1 }) }), ), ); }