199 lines
12 KiB
TypeScript
199 lines
12 KiB
TypeScript
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<T extends { id: RowKey }>(props: DataTableProps<T>): HTMLElement {
|
|
const selectable = props.selectable ?? false; // 状态集中声明:行选择开关
|
|
const expanded = signal<RowKey[]>([]); // 已展开行的 rowKey:纯视图状态,不参与内核派生
|
|
const keyword = signal(''); // 筛选框关键字:受控信号,空状态里的「清除筛选」靠它一键还原
|
|
const table = createTable({
|
|
data: readRows(props.data), // 信号数据源先取当前值,后续靠 attachDataSource 换
|
|
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 TableApi<T>; // 用到的两个取数方法在类型别名里声明
|
|
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<string[]>([]); // 隐藏列键集:外部没给就自己持有
|
|
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' }) 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' }) 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' : ''}`, onclick: () => props.onRowClick?.(row), onkeydown: (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); props.onRowClick?.(row); } } }
|
|
: selected ? { class: 'ua-row-selected' } : null;
|
|
const tr = h('tr', rowProps, ...cells);
|
|
return props.renderDetail && open ? [tr, detailRow(span(), props.renderDetail(row))] : [tr];
|
|
});
|
|
});
|
|
// —— 列宽:数字落成 <col> 固定宽度,'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')])); // 复用同一批 <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 }),
|
|
}),
|
|
);
|
|
}
|