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,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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user