init: @ui-arch 私人组件库 monorepo
- core/table-core: Headless 表格内核(排序/筛选/分页/选择插件 + 冒烟 9 项) - core/frame: signal/computed/effect + hyperscript 微框架(零依赖) - skin/ui: 16 个 DOM 组件(含自绘 select、滑动指示条 tabs、行选择 dataTable、form 表单校验) - design/tokens: --ua-* 设计令牌(light/dark) - tools/cli: ua 命令行(index/list/info/docs/style/mcp)+ MCP 注册表服务 - apps: demo 演示站(esbuild 单文件)+ docs 双轨文档(人类版 + llms-full.txt)
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@ui-arch/table-core",
|
||||
"version": "0.1.0",
|
||||
"description": "Headless, framework-agnostic table state core with a composable plugin pipeline",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"smoke": "node scripts/smoke.mjs"
|
||||
},
|
||||
"keywords": [
|
||||
"table",
|
||||
"headless",
|
||||
"datagrid",
|
||||
"framework-agnostic"
|
||||
],
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
createTable,
|
||||
filterPlugin,
|
||||
paginationPlugin,
|
||||
selectionPlugin,
|
||||
sortPlugin,
|
||||
} from '../dist/index.js';
|
||||
|
||||
const users = [
|
||||
{ id: 1, name: 'Charlie', age: 35, city: 'Shenzhen' },
|
||||
{ id: 2, name: 'Alice', age: 28, city: 'Beijing' },
|
||||
{ id: 3, name: 'Bob', age: 31, city: 'Shanghai' },
|
||||
{ id: 4, name: 'Dave', age: 22, city: 'Beijing' },
|
||||
{ id: 5, name: 'Eve', age: 29, city: 'Shanghai' },
|
||||
];
|
||||
|
||||
const table = createTable({
|
||||
data: users,
|
||||
columns: [
|
||||
{ key: 'name', header: 'Name', sortable: true },
|
||||
{ key: 'age', header: 'Age', sortable: true },
|
||||
{ key: 'city', header: 'City' },
|
||||
],
|
||||
rowKey: 'id',
|
||||
plugins: [filterPlugin(), sortPlugin(), paginationPlugin(), selectionPlugin()],
|
||||
initialState: { pagination: { page: 1, pageSize: 2 } },
|
||||
});
|
||||
|
||||
/*** 断言工具:失败即退出 ***/
|
||||
const assert = (cond, msg) => {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', msg);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('ok -', msg);
|
||||
};
|
||||
|
||||
table.dispatch({ type: 'toggleSort', key: 'name' }); // 按名称升序
|
||||
assert(
|
||||
JSON.stringify(table.getVisibleRows().rows.map((r) => r.name)) ===
|
||||
JSON.stringify(['Alice', 'Bob']),
|
||||
'sort by name asc + page 1 of size 2',
|
||||
);
|
||||
|
||||
table.dispatch({ type: 'setFilter', key: 'city', value: 'shang' }); // 筛选上海
|
||||
const filtered = table.getVisibleRows();
|
||||
assert(
|
||||
JSON.stringify(filtered.rows.map((r) => r.name)) ===
|
||||
JSON.stringify(['Bob', 'Eve']) &&
|
||||
filtered.total === 2,
|
||||
'filter city~sh keeps Shanghai rows, total=2',
|
||||
);
|
||||
assert(table.getPageInfo().totalPages === 1, 'totalPages computed from filtered total');
|
||||
|
||||
table.dispatch({ type: 'clearFilters' }); // 清筛选回到全量
|
||||
table.dispatch({ type: 'setPage', page: 2 });
|
||||
assert(
|
||||
JSON.stringify(table.getVisibleRows().rows.map((r) => r.name)) ===
|
||||
JSON.stringify(['Charlie', 'Dave']),
|
||||
'page 2 after clearing filter',
|
||||
);
|
||||
assert(table.getPageInfo().page === 2 && table.getPageInfo().totalPages === 3, 'page info total=3 pages');
|
||||
|
||||
table.dispatch({ type: 'toggleSelect', key: 3 }); // 勾选行 3
|
||||
assert(JSON.stringify(table.getState().selection) === JSON.stringify([3]), 'toggleSelect adds rowKey');
|
||||
table.selectAllVisible(); // 全选当前页(替换语义)
|
||||
assert(
|
||||
JSON.stringify(table.getState().selection) === JSON.stringify([1, 4]),
|
||||
'selectAllVisible replaces selection with visible rowKeys',
|
||||
);
|
||||
|
||||
table.dispatch({ type: 'setPage', page: 99 }); // 越界页码
|
||||
const clamped = table.getVisibleRows();
|
||||
assert(
|
||||
table.getPageInfo().page === 3 && clamped.rows.length === 1,
|
||||
'page clamped to totalPages',
|
||||
);
|
||||
|
||||
const before = table.getVisibleRows(); // memo 基线
|
||||
table.dispatch({ type: 'clearSelection' }); // 无关状态变更
|
||||
assert(table.getVisibleRows() === before, 'derive stages memoized across unrelated state changes');
|
||||
|
||||
console.log('SMOKE OK');
|
||||
@@ -0,0 +1,110 @@
|
||||
import type {
|
||||
Action,
|
||||
DeriveContext,
|
||||
RowKey,
|
||||
RowModel,
|
||||
TableInstance,
|
||||
TableOptions,
|
||||
TablePlugin,
|
||||
TableState,
|
||||
} from './types.js';
|
||||
|
||||
/*** 创建表格实例:状态机 + 派生管线 + 插件 API 合并,框架无关、零渲染 ***/
|
||||
export function createTable<T>(
|
||||
options: TableOptions<T>,
|
||||
): TableInstance<T> & Record<string, unknown> {
|
||||
const { data, columns, plugins = [], onStateChange } = options;
|
||||
|
||||
const rowKey = options.rowKey ?? ('id' as keyof T & string); // 默认行标识
|
||||
const getKey: (row: T) => RowKey =
|
||||
typeof rowKey === 'function'
|
||||
? rowKey
|
||||
: (row) => row[rowKey] as unknown as RowKey;
|
||||
|
||||
const defaultState: TableState = {
|
||||
sorting: [],
|
||||
filters: [],
|
||||
pagination: { page: 1, pageSize: 20 },
|
||||
selection: [],
|
||||
};
|
||||
|
||||
const ctx: DeriveContext<T> = { getCellValue, getKey }; // 派生上下文(getCellValue 有函数提升)
|
||||
|
||||
let state: TableState = { ...defaultState, ...options.initialState };
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const corePatch = (s: TableState, a: Action): TableState =>
|
||||
a.type === 'reset' ? { ...defaultState } : s; // 核心只认 reset,其余归插件
|
||||
|
||||
const patchers = [
|
||||
corePatch,
|
||||
...plugins
|
||||
.map((p) => p.patchState)
|
||||
.filter((p): p is NonNullable<typeof p> => typeof p === 'function'),
|
||||
];
|
||||
|
||||
/*** 派发动作:依序过各 patchState,状态有变才触发回调与订阅通知 ***/
|
||||
function dispatch(action: Action): void {
|
||||
let next = state;
|
||||
for (const patch of patchers) next = patch(next, action);
|
||||
if (next === state) return; // 无变化不通知
|
||||
state = next;
|
||||
onStateChange?.(state);
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
|
||||
/*** 订阅状态变更,返回取消订阅函数 ***/
|
||||
function subscribe(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => {
|
||||
listeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
/*** 取单元格值:优先列 accessor,缺省取 row[key] ***/
|
||||
function getCellValue(row: T, key: string): unknown {
|
||||
const col = columns.find((c) => c.key === key);
|
||||
if (col?.accessor) return col.accessor(row);
|
||||
return (row as Record<string, unknown>)[key];
|
||||
}
|
||||
|
||||
// 基础行模型按 data 引用 memo,避免重复建 keys
|
||||
let lastData: T[] | null = null;
|
||||
let cachedBase: RowModel<T> | null = null;
|
||||
|
||||
function baseModel(): RowModel<T> {
|
||||
if (data !== lastData || !cachedBase) {
|
||||
cachedBase = { rows: data, keys: data.map(getKey), total: data.length };
|
||||
lastData = data;
|
||||
}
|
||||
return cachedBase;
|
||||
}
|
||||
|
||||
/*** 可见行模型:依序过插件 derive(filter → sort → paginate) ***/
|
||||
function getVisibleRows(): RowModel<T> {
|
||||
let model = baseModel();
|
||||
for (const p of plugins) {
|
||||
if (p.derive) model = p.derive(model, state, ctx);
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
const instance: TableInstance<T> = {
|
||||
getState: () => state,
|
||||
dispatch,
|
||||
subscribe,
|
||||
getVisibleRows,
|
||||
getColumns: () => columns,
|
||||
getCellValue,
|
||||
isRowSelected: (key) => state.selection.includes(key),
|
||||
};
|
||||
|
||||
// 插件 API 合并到实例(setPage / selectAllVisible 等)
|
||||
const pluginApis: Record<string, unknown> = {};
|
||||
for (const p of plugins) {
|
||||
if (p.api) Object.assign(pluginApis, p.api(instance));
|
||||
}
|
||||
|
||||
return Object.assign(instance, pluginApis) as TableInstance<T> &
|
||||
Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from './types.js';
|
||||
export { createTable } from './core.js';
|
||||
export { sortPlugin } from './plugins/sort.js';
|
||||
export type { SortPluginOptions } from './plugins/sort.js';
|
||||
export { filterPlugin } from './plugins/filter.js';
|
||||
export type { FilterPluginOptions } from './plugins/filter.js';
|
||||
export { paginationPlugin } from './plugins/pagination.js';
|
||||
export type { PageInfo } from './plugins/pagination.js';
|
||||
export { selectionPlugin } from './plugins/selection.js';
|
||||
@@ -0,0 +1,67 @@
|
||||
import type {
|
||||
Action,
|
||||
DeriveContext,
|
||||
FilterRule,
|
||||
RowKey,
|
||||
RowModel,
|
||||
TablePlugin,
|
||||
TableState,
|
||||
} from '../types.js';
|
||||
|
||||
/*** 筛选插件入参 ***/
|
||||
export interface FilterPluginOptions<T> {
|
||||
predicate?: (cellValue: unknown, filterValue: unknown, row: T) => boolean; // 自定义匹配谓词;缺省大小写不敏感子串匹配
|
||||
}
|
||||
|
||||
/*** 列筛选插件:'' / null 表示清除该列筛选;按(输入模型, filters 切片)memo ***/
|
||||
export function filterPlugin<T = unknown>(
|
||||
opts: FilterPluginOptions<T> = {},
|
||||
): TablePlugin<T> {
|
||||
const matches =
|
||||
opts.predicate ??
|
||||
((value: unknown, filterValue: unknown) =>
|
||||
String(value ?? '')
|
||||
.toLowerCase()
|
||||
.includes(String(filterValue ?? '').toLowerCase()));
|
||||
|
||||
let lastInput: RowModel<T> | null = null;
|
||||
let lastFilters: FilterRule[] | null = null;
|
||||
let cached: RowModel<T> | null = null;
|
||||
|
||||
return {
|
||||
name: 'filter',
|
||||
patchState(state: TableState, action: Action): TableState {
|
||||
switch (action.type) {
|
||||
case 'setFilter': {
|
||||
const filters = state.filters.filter((f) => f.key !== action.key);
|
||||
if (action.value !== '' && action.value != null) filters.push({ key: action.key, value: action.value });
|
||||
return { ...state, filters };
|
||||
}
|
||||
case 'clearFilters':
|
||||
return state.filters.length ? { ...state, filters: [] } : state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
},
|
||||
derive(model: RowModel<T>, state: TableState, ctx: DeriveContext<T>): RowModel<T> {
|
||||
if (model === lastInput && state.filters === lastFilters && cached) return cached;
|
||||
const { filters } = state;
|
||||
let out = model;
|
||||
if (filters.length > 0) {
|
||||
const rows: T[] = [];
|
||||
const keys: RowKey[] = [];
|
||||
for (let i = 0; i < model.rows.length; i++) {
|
||||
const row = model.rows[i];
|
||||
if (!filters.every((f) => matches(ctx.getCellValue(row, f.key), f.value, row))) continue;
|
||||
rows.push(row);
|
||||
keys.push(model.keys[i]);
|
||||
}
|
||||
out = { rows, keys, total: rows.length }; // total = 筛选后行数,供分页算页数
|
||||
}
|
||||
cached = out;
|
||||
lastInput = model;
|
||||
lastFilters = state.filters;
|
||||
return cached;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
Action,
|
||||
RowModel,
|
||||
TableInstance,
|
||||
TablePlugin,
|
||||
TableState,
|
||||
} from '../types.js';
|
||||
|
||||
/*** 分页快照:由上游 total 推导的派生量,语义与 RowModel.total 一致 ***/
|
||||
export interface PageInfo {
|
||||
page: number; // 当前页(钳制后实际生效值)
|
||||
pageSize: number; // 每页行数
|
||||
totalPages: number; // 总页数 = ceil(total / pageSize)
|
||||
}
|
||||
|
||||
/*** 客户端分页插件:读入参 total 算页数,越界页在派生时钳制;按(输入模型, pagination 切片)memo ***/
|
||||
export function paginationPlugin<T = unknown>(): TablePlugin<T> {
|
||||
let lastInput: RowModel<T> | null = null;
|
||||
let lastPage: { page: number; pageSize: number } | null = null;
|
||||
let cached: RowModel<T> | null = null;
|
||||
let pageInfo: PageInfo = { page: 1, pageSize: 20, totalPages: 1 };
|
||||
|
||||
return {
|
||||
name: 'pagination',
|
||||
patchState(state: TableState, action: Action): TableState {
|
||||
switch (action.type) {
|
||||
case 'setPage':
|
||||
return { ...state, pagination: { ...state.pagination, page: action.page } };
|
||||
case 'setPageSize':
|
||||
return { ...state, pagination: { page: 1, pageSize: action.pageSize } }; // 改页大小回到第 1 页
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
},
|
||||
derive(model: RowModel<T>, state: TableState): RowModel<T> {
|
||||
if (model === lastInput && state.pagination === lastPage && cached) return cached;
|
||||
const { page, pageSize } = state.pagination;
|
||||
const totalPages = Math.max(1, Math.ceil(model.total / pageSize));
|
||||
const safePage = Math.min(Math.max(1, page), totalPages); // 越界钳制,视图不空白
|
||||
const start = (safePage - 1) * pageSize;
|
||||
cached = {
|
||||
rows: model.rows.slice(start, start + pageSize),
|
||||
keys: model.keys.slice(start, start + pageSize),
|
||||
total: model.total,
|
||||
};
|
||||
pageInfo = { page: safePage, pageSize, totalPages };
|
||||
lastInput = model;
|
||||
lastPage = state.pagination;
|
||||
return cached;
|
||||
},
|
||||
api(table: TableInstance<T>) {
|
||||
return {
|
||||
setPage: (page: number) => table.dispatch({ type: 'setPage', page }),
|
||||
nextPage: () => table.dispatch({ type: 'setPage', page: pageInfo.page + 1 }),
|
||||
prevPage: () => table.dispatch({ type: 'setPage', page: pageInfo.page - 1 }),
|
||||
setPageSize: (pageSize: number) => table.dispatch({ type: 'setPageSize', pageSize }),
|
||||
getPageInfo: (): PageInfo => ({ ...pageInfo }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Action, TableInstance, TablePlugin, TableState } from '../types.js';
|
||||
|
||||
/*** 行选择插件:一律存 rowKey 而非索引,跨页、排序、异步刷新都不丢失 ***/
|
||||
export function selectionPlugin<T = unknown>(): TablePlugin<T> {
|
||||
return {
|
||||
name: 'selection',
|
||||
patchState(state: TableState, action: Action): TableState {
|
||||
switch (action.type) {
|
||||
case 'setSelection':
|
||||
return { ...state, selection: [...action.keys] }; // 整体替换语义
|
||||
case 'toggleSelect': {
|
||||
const has = state.selection.includes(action.key);
|
||||
return {
|
||||
...state,
|
||||
selection: has
|
||||
? state.selection.filter((k) => k !== action.key)
|
||||
: [...state.selection, action.key],
|
||||
};
|
||||
}
|
||||
case 'clearSelection':
|
||||
return state.selection.length ? { ...state, selection: [] } : state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
},
|
||||
api(table: TableInstance<T>) {
|
||||
return {
|
||||
selectAllVisible: () => {
|
||||
table.dispatch({ type: 'setSelection', keys: [...table.getVisibleRows().keys] });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
Action,
|
||||
DeriveContext,
|
||||
RowModel,
|
||||
SortRule,
|
||||
TablePlugin,
|
||||
TableState,
|
||||
} from '../types.js';
|
||||
|
||||
/*** 值比较:数值按大小、其余按本地化字符串序,null 恒排末尾 ***/
|
||||
function compareValues(a: unknown, b: unknown): number {
|
||||
if (a == null && b == null) return 0;
|
||||
if (a == null) return 1;
|
||||
if (b == null) return -1;
|
||||
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
||||
return String(a).localeCompare(String(b), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
}
|
||||
|
||||
/*** 排序插件入参 ***/
|
||||
export interface SortPluginOptions {
|
||||
initialDir?: 'asc' | 'desc'; // 首次点击列头时的排序方向,默认升序
|
||||
}
|
||||
|
||||
/*** 多列排序插件:点击循环 asc → desc → 取消;按(输入模型, sorting 切片)memo,翻页不重排 ***/
|
||||
export function sortPlugin<T = unknown>(
|
||||
opts: SortPluginOptions = {},
|
||||
): TablePlugin<T> {
|
||||
const initialDir = opts.initialDir ?? 'asc';
|
||||
|
||||
let lastInput: RowModel<T> | null = null;
|
||||
let lastRules: SortRule[] | null = null;
|
||||
let cached: RowModel<T> | null = null;
|
||||
|
||||
return {
|
||||
name: 'sort',
|
||||
patchState(state: TableState, action: Action): TableState {
|
||||
switch (action.type) {
|
||||
case 'setSorting':
|
||||
return { ...state, sorting: action.sorting };
|
||||
case 'toggleSort': {
|
||||
const rules = action.multi ? [...state.sorting] : []; // 非 multi 直接单列重置
|
||||
const idx = rules.findIndex((r) => r.key === action.key);
|
||||
if (idx === -1) rules.push({ key: action.key, dir: initialDir });
|
||||
else if (rules[idx].dir === 'asc') rules[idx] = { key: action.key, dir: 'desc' };
|
||||
else rules.splice(idx, 1); // desc 再点即取消
|
||||
return { ...state, sorting: rules };
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
},
|
||||
derive(model: RowModel<T>, state: TableState, ctx: DeriveContext<T>): RowModel<T> {
|
||||
if (model === lastInput && state.sorting === lastRules && cached) return cached;
|
||||
const rules = state.sorting;
|
||||
let rows = model.rows;
|
||||
let keys = model.keys;
|
||||
if (rules.length > 0) {
|
||||
const order = rows.map((_, i) => i); // 只排索引,避免行对象来回搬
|
||||
order.sort((a, b) => {
|
||||
for (const rule of rules) {
|
||||
const cmp = compareValues(
|
||||
ctx.getCellValue(rows[a], rule.key),
|
||||
ctx.getCellValue(rows[b], rule.key),
|
||||
);
|
||||
if (cmp !== 0) return rule.dir === 'asc' ? cmp : -cmp;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
rows = order.map((i) => model.rows[i]);
|
||||
keys = order.map((i) => model.keys[i]);
|
||||
}
|
||||
cached = { rows, keys, total: model.total };
|
||||
lastInput = model;
|
||||
lastRules = state.sorting;
|
||||
return cached;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/*** 行唯一标识:跨页 / 异步刷新保持稳定 ***/
|
||||
export type RowKey = string | number;
|
||||
|
||||
/*** 列定义:数据语义(accessor)与渲染语义(cell)分离 ***/
|
||||
export interface ColumnDef<T = unknown> {
|
||||
key: string; // 列标识,与数据字段对应
|
||||
header?: string; // 表头文案
|
||||
accessor?: (row: T) => unknown; // 取值器;缺省按 row[key] 直取
|
||||
sortable?: boolean; // 是否允许排序
|
||||
filterable?: boolean; // 是否允许筛选
|
||||
width?: number | 'flex'; // 固定像素或弹性填充
|
||||
pinned?: 'left' | 'right'; // 固定列方向
|
||||
}
|
||||
|
||||
/*** 一条排序规则 ***/
|
||||
export interface SortRule {
|
||||
key: string; // 排序字段,对应 ColumnDef.key
|
||||
dir: 'asc' | 'desc'; // 排序方向
|
||||
}
|
||||
|
||||
/*** 一条筛选规则 ***/
|
||||
export interface FilterRule {
|
||||
key: string; // 筛选字段,对应 ColumnDef.key
|
||||
value: unknown; // 筛选值;'' / null 语义上等于清除
|
||||
}
|
||||
|
||||
/*** 分页状态 ***/
|
||||
export interface PaginationState {
|
||||
page: number; // 当前页,从 1 起
|
||||
pageSize: number; // 每页行数
|
||||
}
|
||||
|
||||
/*** 表格状态:不可变,一切变更走 Action ***/
|
||||
export interface TableState {
|
||||
sorting: SortRule[]; // 多列排序,按数组序生效
|
||||
filters: FilterRule[]; // 列筛选,逐条 AND
|
||||
pagination: PaginationState; // 分页
|
||||
selection: RowKey[]; // 已选行,存 rowKey 而非行索引
|
||||
}
|
||||
|
||||
/*** 全部动作:状态变更的唯一入口 ***/
|
||||
export type Action =
|
||||
| { type: 'reset' } // 重置回默认状态
|
||||
| { type: 'setSorting'; sorting: SortRule[] } // 整体替换排序规则
|
||||
| { type: 'toggleSort'; key: string; multi?: boolean } // 点列头循环 asc → desc → 取消;multi 追加多列
|
||||
| { type: 'setFilter'; key: string; value: unknown } // 设单列筛选;'' / null 清除该列
|
||||
| { type: 'clearFilters' } // 清空全部筛选
|
||||
| { type: 'setPage'; page: number } // 跳页,越界由派生层钳制
|
||||
| { type: 'setPageSize'; pageSize: number } // 改页大小并回到第 1 页
|
||||
| { type: 'setSelection'; keys: RowKey[] } // 整体替换选中集
|
||||
| { type: 'toggleSelect'; key: RowKey } // 反选单行
|
||||
| { type: 'clearSelection' } // 清空选择
|
||||
;
|
||||
|
||||
/*** 流经派生管线的数据;total 语义全链路一致 = 筛选后、分页前的行数 ***/
|
||||
export interface RowModel<T = unknown> {
|
||||
rows: T[]; // 行数据,顺序即管线处理后的顺序
|
||||
keys: RowKey[]; // 与 rows 一一对应的行标识
|
||||
total: number; // 分页 UI 算页数的依据
|
||||
}
|
||||
|
||||
/*** 派生上下文:核心注入给插件的取值能力 ***/
|
||||
export interface DeriveContext<T = unknown> {
|
||||
getCellValue(row: T, key: string): unknown; // 单元格取值(走列 accessor)
|
||||
getKey(row: T): RowKey; // 行标识取值
|
||||
}
|
||||
|
||||
/*** 插件三钩子:patchState 改状态、derive 插入数据管线、api 向实例注入方法 ***/
|
||||
export interface TablePlugin<T = unknown> {
|
||||
name: string; // 插件名,诊断用
|
||||
patchState?(state: TableState, action: Action): TableState; // 处理自己关心的动作,无关动作原样返回
|
||||
derive?(model: RowModel<T>, state: TableState, ctx: DeriveContext<T>): RowModel<T>; // 插入派生管线阶段
|
||||
api?(table: TableInstance<T>): Record<string, unknown>; // 向实例注入便捷方法
|
||||
}
|
||||
|
||||
/*** 建表入参 ***/
|
||||
export interface TableOptions<T> {
|
||||
data: T[]; // 全量行数据
|
||||
columns: ColumnDef<T>[]; // 列定义
|
||||
rowKey?: (keyof T & string) | ((row: T) => RowKey); // 行标识字段名或取值函数;缺省 'id'
|
||||
initialState?: Partial<TableState>; // 初始状态,逐项浅合并
|
||||
plugins?: TablePlugin<T>[]; // 插件按数组序组成派生管线
|
||||
onStateChange?(state: TableState): void; // 状态变更回调(受控 / 持久化用)
|
||||
}
|
||||
|
||||
/*** 表格实例:框架适配层只消费这里的面 ***/
|
||||
export interface TableInstance<T = unknown> {
|
||||
getState(): TableState; // 当前状态快照
|
||||
dispatch(action: Action): void; // 派发动作,唯一变更入口
|
||||
subscribe(fn: () => void): () => void; // 订阅变更,返回取消函数
|
||||
getVisibleRows(): RowModel<T>; // 可见行模型(过完整管线)
|
||||
getColumns(): ColumnDef<T>[]; // 列定义
|
||||
getCellValue(row: T, key: string): unknown; // 单元格取值
|
||||
isRowSelected(key: RowKey): boolean; // 行是否已选
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@ui-arch/table-core",
|
||||
"type": "headless-core",
|
||||
"description": "Headless table state machine: immutable state, dispatch/reducer, composable derive pipeline (filter -> sort -> paginate) with per-stage memoization. No DOM, no framework dependency.",
|
||||
"tags": [
|
||||
"table",
|
||||
"headless",
|
||||
"state-machine",
|
||||
"datagrid"
|
||||
],
|
||||
"api": {
|
||||
"entry": "src/index.ts",
|
||||
"exports": [
|
||||
"createTable(options) -> TableInstance: create headless table instance",
|
||||
"sortPlugin(options?) -> multi-column sorting plugin (asc -> desc -> off cycle)",
|
||||
"filterPlugin(options?) -> column filter plugin (default substring match, custom predicate supported)",
|
||||
"paginationPlugin() -> client-side pagination with page clamping, exposes setPage/nextPage/prevPage/getPageInfo",
|
||||
"selectionPlugin() -> rowKey-based selection, exposes selectAllVisible"
|
||||
],
|
||||
"keyTypes": [
|
||||
"ColumnDef",
|
||||
"TableState",
|
||||
"TablePlugin",
|
||||
"RowModel",
|
||||
"Action"
|
||||
]
|
||||
},
|
||||
"usage": "const table = createTable({ data, columns, rowKey: 'id', plugins: [filterPlugin(), sortPlugin(), paginationPlugin()], initialState: { pagination: { page: 1, pageSize: 20 } } }); table.dispatch({ type: 'toggleSort', key: 'name' }); table.getVisibleRows()",
|
||||
"conventions": [
|
||||
"selection/expanded must store rowKey, never row index",
|
||||
"derive pipeline order is fixed: filter -> sort -> paginate",
|
||||
"actions are the only way to mutate state; state is immutable"
|
||||
],
|
||||
"docs": {
|
||||
"intro": "Headless 表格内核:不渲染任何 DOM,只管理状态与派生。任何渲染层通过 getVisibleRows() 拿行模型,配 subscribe() 刷新。",
|
||||
"steps": [
|
||||
"创建实例:const table = createTable({ data, columns, rowKey: 'id', plugins: [filterPlugin(), sortPlugin(), paginationPlugin()] })",
|
||||
"取派生数据:table.getVisibleRows() 返回 { rows, total };total 语义固定 = 筛选后、分页前的行数",
|
||||
"变更状态:只能 dispatch action,如 table.dispatch({ type: 'toggleSort', key: 'name' });状态不可变",
|
||||
"订阅刷新:table.subscribe(fn),fn 里重新 getVisibleRows() 并交给渲染层",
|
||||
"分页信息:api.getPageInfo() 返回 { page, pageSize, totalPages };越界页码自动钳制",
|
||||
"扩展能力:实现 TablePlugin(derive / patchState / api 三钩子)传入 plugins 即插入管线"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user