186 lines
8.0 KiB
TypeScript
186 lines
8.0 KiB
TypeScript
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<T extends { id: RowKey }>(props: DataTableProps<T>): HTMLElement {
|
|
// —— 状态集中声明 ——
|
|
const selectable = props.selectable ?? false; // 行选择开关
|
|
const table = createTable({
|
|
data: props.data,
|
|
columns: props.columns,
|
|
rowKey: 'id',
|
|
plugins: [filterPlugin<T>(), sortPlugin<T>(), paginationPlugin<T>(), selectionPlugin<T>()],
|
|
initialState: { pagination: { page: 1, pageSize: props.pageSize ?? 5 } },
|
|
});
|
|
const api = table as unknown as TableInstance<T> & {
|
|
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);
|
|
});
|
|
});
|
|
|
|
// —— 列宽:数字落成 <col> 固定宽度,'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 }) }),
|
|
),
|
|
);
|
|
}
|