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:
shawn
2026-10-23 14:58:10 +08:00
committed by HIsheR
parent a186d71412
commit 0de9b6e66e
59 changed files with 5293 additions and 0 deletions
+29
View File
@@ -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');
+110
View File
@@ -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>;
}
+9
View File
@@ -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;
},
};
}
+95
View File
@@ -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; // 行是否已选
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
+45
View File
@@ -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 即插入管线"
]
}
}