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,28 @@
|
||||
{
|
||||
"name": "@ui-arch/frame",
|
||||
"version": "0.1.0",
|
||||
"description": "Minimal reactive DOM framework: signal / computed / effect + hyperscript, zero dependency",
|
||||
"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"
|
||||
},
|
||||
"keywords": [
|
||||
"signal",
|
||||
"reactive",
|
||||
"dom",
|
||||
"micro-framework"
|
||||
],
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { effect } from './signal.js';
|
||||
|
||||
type Child = Node | string | number | boolean | null | undefined | (() => unknown);
|
||||
type Props = Record<string, unknown>;
|
||||
|
||||
/*** 响应式属性绑定:函数值属性在依赖变化时自动写回 DOM ***/
|
||||
function bindProp(el: HTMLElement, key: string, fn: () => unknown): void {
|
||||
effect(() => {
|
||||
const v = fn();
|
||||
if (key === 'value') (el as unknown as HTMLInputElement).value = String(v);
|
||||
else if (key === 'checked') (el as unknown as HTMLInputElement).checked = Boolean(v);
|
||||
else if (v === false || v == null) el.removeAttribute(key);
|
||||
else el.setAttribute(key, v === true ? '' : String(v));
|
||||
});
|
||||
}
|
||||
|
||||
/*** 静态属性写入:false / null 移除属性,true 置空标记属性 ***/
|
||||
function bindStatic(el: HTMLElement, key: string, value: unknown): void {
|
||||
if (value === false || value == null) el.removeAttribute(key);
|
||||
else if (value === true) el.setAttribute(key, '');
|
||||
else el.setAttribute(key, String(value));
|
||||
}
|
||||
|
||||
/*** 挂载子节点:函数型子节点转为响应式文本节点 ***/
|
||||
function appendChild(el: HTMLElement, child: unknown): void {
|
||||
if (child == null || child === false) return;
|
||||
if (typeof child === 'function') {
|
||||
const node = document.createTextNode('');
|
||||
effect(() => {
|
||||
node.textContent = String(child());
|
||||
});
|
||||
el.append(node);
|
||||
} else if (typeof child === 'object') {
|
||||
el.append(child as Node);
|
||||
} else {
|
||||
el.append(String(child));
|
||||
}
|
||||
}
|
||||
|
||||
/*** hyperscript 入口:h(tag, props, ...children);on* 为事件,函数值为响应式属性 ***/
|
||||
export function h(tag: string, props?: Props | null, ...children: unknown[]): HTMLElement {
|
||||
const el = document.createElement(tag);
|
||||
for (const [key, value] of Object.entries(props ?? {})) {
|
||||
if (key.startsWith('on') && typeof value === 'function') {
|
||||
el.addEventListener(key.slice(2).toLowerCase(), value as EventListener);
|
||||
} else if (typeof value === 'function') {
|
||||
bindProp(el, key, value as () => unknown);
|
||||
} else if (key === 'class') {
|
||||
el.className = String(value);
|
||||
} else {
|
||||
bindStatic(el, key, value);
|
||||
}
|
||||
}
|
||||
for (const child of children.flat(Infinity)) appendChild(el, child);
|
||||
return el;
|
||||
}
|
||||
|
||||
/*** 列表渲染:信号变化时整体替换容器子节点(tbody / 列表等场景) ***/
|
||||
export function renderList(el: HTMLElement, fn: () => Node[]): void {
|
||||
effect(() => {
|
||||
el.replaceChildren(...fn());
|
||||
});
|
||||
}
|
||||
|
||||
/*** 挂载视图到容器;容器不存在时静默跳过 ***/
|
||||
export function mount(target: Element | string, view: () => Node): void {
|
||||
const root = typeof target === 'string' ? document.querySelector(target) : target;
|
||||
if (!root) return; // 卫语句:容器不存在
|
||||
root.replaceChildren(view());
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './signal.js';
|
||||
export * from './dom.js';
|
||||
@@ -0,0 +1,49 @@
|
||||
// 当前正在收集依赖的 effect;null 表示非追踪态
|
||||
let active: (() => void) | null = null;
|
||||
|
||||
/*** 响应式信号:无参调用读值并收集依赖,传参写值并触发订阅 ***/
|
||||
export interface Signal<T> {
|
||||
(): T;
|
||||
(value: T): void;
|
||||
}
|
||||
|
||||
/*** 创建信号:框架状态的最小单元 ***/
|
||||
export function signal<T>(initial: T): Signal<T> {
|
||||
let value = initial;
|
||||
const subs = new Set<() => void>();
|
||||
const read = (...args: unknown[]) => {
|
||||
if (args.length === 0) {
|
||||
if (active) subs.add(active); // 依赖收集
|
||||
return value;
|
||||
}
|
||||
const v = args[0] as T;
|
||||
if (Object.is(v, value)) return; // 值未变不触发
|
||||
value = v;
|
||||
for (const fn of [...subs]) fn(); // 拷贝后触发,防遍历中修改
|
||||
};
|
||||
return read as Signal<T>;
|
||||
}
|
||||
|
||||
/*** 副作用:立即执行一次并追踪内部读取的信号,依赖变化自动重跑 ***/
|
||||
export function effect(fn: () => void): () => void {
|
||||
const run = () => {
|
||||
const prev = active;
|
||||
active = run;
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
active = prev;
|
||||
}
|
||||
};
|
||||
run();
|
||||
return () => {
|
||||
/* 演示级实现:不做依赖清理 */
|
||||
};
|
||||
}
|
||||
|
||||
/*** 计算值:由信号派生的只读视图,同样参与上层依赖收集 ***/
|
||||
export function computed<T>(fn: () => T): () => T {
|
||||
const out = signal<T>(undefined as T);
|
||||
effect(() => out(fn()));
|
||||
return () => out();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@ui-arch/frame",
|
||||
"type": "headless-core",
|
||||
"description": "自研微框架:signal / computed / effect 信号系统 + hyperscript(h)DOM 层。函数值属性与函数型子节点自动响应式;renderList 做列表整体替换;零依赖、无虚拟 DOM。",
|
||||
"tags": [
|
||||
"framework",
|
||||
"signal",
|
||||
"reactive",
|
||||
"hyperscript"
|
||||
],
|
||||
"api": {
|
||||
"entry": "src/index.ts",
|
||||
"exports": [
|
||||
"signal(initial) -> Signal<T>:无参读值并收集依赖,传参写值并触发订阅",
|
||||
"effect(fn):立即执行并追踪依赖,信号变化自动重跑",
|
||||
"computed(fn):由信号派生的只读计算值",
|
||||
"h(tag, props, ...children):hyperscript;on* 绑事件,函数值属性/子节点自动响应式",
|
||||
"renderList(el, fn):信号变化时整体替换容器子节点",
|
||||
"mount(target, view):挂载视图"
|
||||
],
|
||||
"keyTypes": [
|
||||
"Signal"
|
||||
]
|
||||
},
|
||||
"usage": "const count = signal(0); mount('#app', () => h('button', { onClick: () => count(count() + 1) }, () => `count = ${count()}`))",
|
||||
"conventions": [
|
||||
"组件 = 返回 DOM 节点的普通函数,不做类组件与生命周期抽象",
|
||||
"跨系统状态(如 table-core)用桥接信号:subscribe 里递增 version 信号"
|
||||
],
|
||||
"docs": {
|
||||
"intro": "零依赖微框架:组件 = 返回 DOM 的普通函数。signal/effect/computed 管状态,h() 管 DOM,无虚拟 DOM 无生命周期抽象。",
|
||||
"steps": [
|
||||
"引入:import { signal, computed, effect, h, mount } from '@ui-arch/frame'",
|
||||
"声明状态:const count = signal(0);无参调用读值 count(),传参写值 count(1)",
|
||||
"渲染视图:mount('#app', () => h('div', null, () => `count = ${count()}`))——函数型子节点即响应式文本",
|
||||
"绑定事件:props 里 on 开头且值为函数的键(onClick 等)自动 addEventListener",
|
||||
"派生数据:const double = computed(() => count() * 2),视图里直接调用 double(),依赖自动追踪",
|
||||
"列表渲染:renderList(tbody, () => rows.map(r => h('tr', null, r.name))),信号变化整体替换子节点"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 即插入管线"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@ui-arch/tokens",
|
||||
"version": "0.1.0",
|
||||
"description": "Design tokens for the @ui-arch component library (CSS variables + JSON)",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./tokens.css": "./tokens.css",
|
||||
"./tokens.json": "./tokens.json"
|
||||
},
|
||||
"files": [
|
||||
"tokens.css",
|
||||
"tokens.json"
|
||||
],
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/* @ui-arch design tokens - light theme defaults + dark theme overrides */
|
||||
:root {
|
||||
--ua-color-bg: #ffffff;
|
||||
--ua-color-surface: #f6f7f9;
|
||||
--ua-color-text: #1c1e21;
|
||||
--ua-color-text-muted: #6b7280;
|
||||
--ua-color-border: rgba(0, 0, 0, 0.15);
|
||||
--ua-color-accent: #185fa5;
|
||||
--ua-color-accent-contrast: #ffffff;
|
||||
--ua-color-danger: #a32d2d;
|
||||
--ua-color-success: #0f6e56;
|
||||
--ua-color-warning: #9a6b1f;
|
||||
|
||||
--ua-radius-sm: 4px;
|
||||
--ua-radius-md: 8px;
|
||||
--ua-radius-lg: 12px;
|
||||
|
||||
--ua-space-1: 4px;
|
||||
--ua-space-2: 8px;
|
||||
--ua-space-3: 12px;
|
||||
--ua-space-4: 16px;
|
||||
--ua-space-table-cell: var(--ua-space-2) var(--ua-space-3);
|
||||
|
||||
--ua-font-size-sm: 12px;
|
||||
--ua-font-size-md: 13px;
|
||||
--ua-font-size-lg: 14px;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--ua-color-bg: #101216;
|
||||
--ua-color-surface: #1a1c22;
|
||||
--ua-color-text: #e7e9ec;
|
||||
--ua-color-text-muted: #888780;
|
||||
--ua-color-border: rgba(255, 255, 255, 0.15);
|
||||
--ua-color-accent: #378add;
|
||||
--ua-color-accent-contrast: #042c53;
|
||||
--ua-color-danger: #d96a6a;
|
||||
--ua-color-success: #4fc39e;
|
||||
--ua-color-warning: #d9a94f;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://design-tokens.github.io/community-group/format/",
|
||||
"color": {
|
||||
"bg": { "light": "#ffffff", "dark": "#101216" },
|
||||
"surface": { "light": "#f6f7f9", "dark": "#1a1c22" },
|
||||
"text": { "light": "#1c1e21", "dark": "#e7e9ec" },
|
||||
"textMuted": { "light": "#6b7280", "dark": "#888780" },
|
||||
"accent": { "light": "#185fa5", "dark": "#378add" },
|
||||
"danger": "#a32d2d",
|
||||
"success": "#0f6e56"
|
||||
},
|
||||
"radius": { "sm": "4px", "md": "8px", "lg": "12px" },
|
||||
"space": { "1": "4px", "2": "8px", "3": "12px", "4": "16px" }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@ui-arch/tokens",
|
||||
"type": "design-tokens",
|
||||
"description": "Single source of design tokens. Skin packages consume CSS variables only - hardcoded colors inside skins are forbidden. Dark theme overrides values under [data-theme='dark'], never names.",
|
||||
"tags": [
|
||||
"tokens",
|
||||
"theme",
|
||||
"css-variables"
|
||||
],
|
||||
"api": {
|
||||
"entry": "tokens.css",
|
||||
"exports": [
|
||||
"tokens.css: import '@ui-arch/tokens/tokens.css' once at app root",
|
||||
"tokens.json: programmatic access for tools and agents"
|
||||
],
|
||||
"keyTypes": [
|
||||
"--ua-color-*",
|
||||
"--ua-space-*",
|
||||
"--ua-radius-*"
|
||||
]
|
||||
},
|
||||
"usage": "import '@ui-arch/tokens/tokens.css'; then use var(--ua-color-accent) etc. in any skin package",
|
||||
"docs": {
|
||||
"intro": "设计令牌唯一来源:CSS 变量 --ua-* + 同名 JSON。皮肤只允许消费变量,禁止硬编码颜色;暗色只换值不换名。",
|
||||
"steps": [
|
||||
"引入:在应用入口 import '@ui-arch/tokens/tokens.css'",
|
||||
"消费:组件样式一律 var(--ua-color-accent) 这类变量,不写死色值",
|
||||
"暗色:根元素设 data-theme=dark,变量值自动切换",
|
||||
"扩展:新令牌先同时加进 tokens.css 与 tokens.json,再重新生成文档站"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@ui-arch/ui",
|
||||
"version": "0.1.0",
|
||||
"description": "DOM component skin for @ui-arch: 15 components built on frame + table-core, token-driven styles",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./ui.css": "./ui.css"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"ui.css"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ui-arch/frame": "workspace:*",
|
||||
"@ui-arch/table-core": "workspace:*",
|
||||
"@ui-arch/tokens": "workspace:*"
|
||||
},
|
||||
"keywords": [
|
||||
"components",
|
||||
"dom",
|
||||
"skin",
|
||||
"design-system"
|
||||
],
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { AlertProps } from './types.js';
|
||||
|
||||
/*** 提示条:variant 决定语义色,纯展示不持有状态 ***/
|
||||
export function alert(props: AlertProps): HTMLElement {
|
||||
return h('div', { class: `ua-alert ua-alert-${props.variant ?? 'info'}` }, props.message);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { BadgeProps } from './types.js';
|
||||
|
||||
/*** 徽标:variant 决定语义色,纯展示不持有状态 ***/
|
||||
export function badge(props: BadgeProps): HTMLElement {
|
||||
return h('span', { class: `ua-badge ua-badge-${props.variant ?? 'default'}` }, props.label);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { ButtonProps } from './types.js';
|
||||
|
||||
/*** 按钮:variant 控制视觉强度,onClick 为点击回调 ***/
|
||||
export function button(props: ButtonProps): HTMLElement {
|
||||
return h(
|
||||
'button',
|
||||
{ class: `ua-btn ua-btn-${props.variant ?? 'primary'}`, onClick: props.onClick },
|
||||
props.label,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { CardProps } from './types.js';
|
||||
|
||||
/*** 卡片容器:可选标题 + 任意子内容 ***/
|
||||
export function card(props: CardProps, ...children: unknown[]): HTMLElement {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'ua-card' },
|
||||
props.title ? h('h3', { class: 'ua-card-title' }, props.title) : null,
|
||||
...children,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { CheckboxProps } from './types.js';
|
||||
|
||||
/*** 复选框:checked 信号双向绑定,onChange 为变更回调 ***/
|
||||
export function checkbox(props: CheckboxProps): HTMLElement {
|
||||
const el = h('input', { type: 'checkbox', class: 'ua-checkbox' }) as HTMLInputElement;
|
||||
if (props.checked) {
|
||||
const checked = props.checked;
|
||||
el.addEventListener('change', () => checked(el.checked)); // 用户勾选写回信号
|
||||
effect(() => {
|
||||
if (el.checked !== checked()) el.checked = checked(); // 外部变化写回勾选框
|
||||
});
|
||||
}
|
||||
if (props.onChange) el.addEventListener('change', () => props.onChange!(el.checked));
|
||||
if (!props.label) return el;
|
||||
const wrap = h('label', { class: 'ua-check-row' }, el, props.label);
|
||||
return wrap;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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 { 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);
|
||||
|
||||
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' : ''),
|
||||
onClick: () => col.sortable && table.dispatch({ type: 'toggleSort', key: col.key }),
|
||||
},
|
||||
col.header ?? col.key,
|
||||
col.sortable ? sortMark(col.key) : null,
|
||||
);
|
||||
|
||||
const tbody = h('tbody');
|
||||
renderList(tbody, () => {
|
||||
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) => h('td', null, String(table.getCellValue(row, col.key))));
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
const filterInput = textField({
|
||||
placeholder: `筛选 ${filterKey}…`,
|
||||
onInput: (value) => {
|
||||
table.dispatch({ type: 'setFilter', key: filterKey, value });
|
||||
table.dispatch({ type: 'setPage', page: 1 }); // 筛选变化回第 1 页
|
||||
},
|
||||
});
|
||||
|
||||
return h(
|
||||
'section',
|
||||
{ class: 'ua-table' },
|
||||
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, h('thead', null, h('tr', null, selectable ? headCheck : null, ...props.columns.map(th))), tbody)),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'ua-pager' },
|
||||
button({ label: '上一页', variant: 'ghost', 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', onClick: () => table.dispatch({ type: 'setPage', page: info().page + 1 }) }),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import { button } from './button.js';
|
||||
import { textField } from './textField.js';
|
||||
import type { FormItemHandle, FormItemProps, FormProps, FormRule } from './types.js';
|
||||
|
||||
/*** 执行单条规则:null 通过,字符串为错误消息 ***/
|
||||
function checkRule(value: string, rule: FormRule): string | null {
|
||||
if (rule.required && !value.trim()) return rule.message ?? '该项为必填项';
|
||||
if (!value) return null; // 非必填且为空:其余规则跳过
|
||||
if (rule.min !== undefined && value.length < rule.min) return rule.message ?? `至少 ${rule.min} 个字符`;
|
||||
if (rule.max !== undefined && value.length > rule.max) return rule.message ?? `最多 ${rule.max} 个字符`;
|
||||
if (rule.pattern && !rule.pattern.test(value)) return rule.message ?? '格式不正确';
|
||||
if (rule.validator) {
|
||||
const r = rule.validator(value);
|
||||
if (r !== true) return typeof r === 'string' ? r : rule.message ?? '校验未通过';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/*** 表单字段:label + 控件插槽 + 错误区;返回句柄供 form 聚合校验 ***/
|
||||
export function formItem(props: FormItemProps, control: HTMLElement): FormItemHandle {
|
||||
// —— 状态集中声明 ——
|
||||
const mode = props.validateOn ?? 'blur'; // 校验时机;缺省失焦校验
|
||||
const rules = [...(props.required ? [{ required: true }] : []), ...(props.rules ?? [])];
|
||||
const error = h('div', { class: 'ua-form-error' });
|
||||
const label = h('label', { class: 'ua-form-label' }, props.label, props.required ? h('i', { class: 'ua-form-req' }, '*') : null);
|
||||
const el = h('div', { class: 'ua-form-item' }, label, h('div', { class: 'ua-form-control' }, control, error));
|
||||
|
||||
/*** 执行全部规则:返回首个错误消息,同时同步错误区与控件态 ***/
|
||||
const validate = () => {
|
||||
const value = props.value();
|
||||
let msg: string | null = null;
|
||||
for (const rule of rules) {
|
||||
msg = checkRule(value, rule);
|
||||
if (msg) break; // 首个失败即停止
|
||||
}
|
||||
error.textContent = msg ?? '';
|
||||
control.classList.toggle('ua-invalid', !!msg);
|
||||
return msg;
|
||||
};
|
||||
|
||||
control.addEventListener('input', () => {
|
||||
if (mode === 'input' || error.textContent) validate(); // input 模式即时校验;其余模式仅清已报错误
|
||||
});
|
||||
control.addEventListener('blur', () => {
|
||||
if (mode !== 'submit') validate();
|
||||
});
|
||||
return { el, validate };
|
||||
}
|
||||
|
||||
/*** 表单容器:布局 + 原生 submit 拦截 + 聚合校验(全部通过才触发 onSubmit) ***/
|
||||
export function form(props: FormProps, items: FormItemHandle[]): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
/*** 整体提交:逐项校验,聚焦首个出错项并中止 ***/
|
||||
const submit = () => {
|
||||
for (const item of items) {
|
||||
if (item.validate()) {
|
||||
(item.el.querySelector('input, textarea, select') as HTMLElement | null)?.focus();
|
||||
return; // 卫语句:有错误不提交
|
||||
}
|
||||
}
|
||||
props.onSubmit?.();
|
||||
};
|
||||
const submitBtn = button({ label: props.submitText ?? '提交', onClick: submit });
|
||||
const resetBtn = props.resetText ? button({ label: props.resetText, variant: 'ghost', onClick: props.onReset }) : null;
|
||||
const actions = h('div', { class: 'ua-form-actions' }, submitBtn, resetBtn);
|
||||
const el = h(
|
||||
'form',
|
||||
{ class: props.layout === 'horizontal' ? 'ua-form ua-form-h' : 'ua-form' },
|
||||
...items.map((i) => i.el),
|
||||
actions,
|
||||
);
|
||||
el.addEventListener('submit', (e) => {
|
||||
e.preventDefault(); // 拦截原生提交,走校验流程
|
||||
submit();
|
||||
});
|
||||
return el;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export * from './types.js';
|
||||
export { button } from './button.js';
|
||||
export { textField } from './textField.js';
|
||||
export { textarea } from './textarea.js';
|
||||
export { checkbox } from './checkbox.js';
|
||||
export { switchButton } from './switch.js';
|
||||
export { select } from './select.js';
|
||||
export { radioGroup } from './radioGroup.js';
|
||||
export { card } from './card.js';
|
||||
export { badge } from './badge.js';
|
||||
export { alert } from './alert.js';
|
||||
export { spinner } from './spinner.js';
|
||||
export { progress } from './progress.js';
|
||||
export { tabs } from './tabs.js';
|
||||
export { modal } from './modal.js';
|
||||
export { form, formItem } from './form.js';
|
||||
export { dataTable } from './dataTable.js';
|
||||
@@ -0,0 +1,33 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { ModalProps } from './types.js';
|
||||
|
||||
/*** 弹窗:open 信号驱动显隐,点遮罩、× 或 Esc 触发 onClose ***/
|
||||
export function modal(props: ModalProps, ...children: unknown[]): HTMLElement {
|
||||
const close = () => {
|
||||
if (!props.open()) return; // 卫语句:已关闭不重复触发回调
|
||||
props.open(false); // 写回信号触发隐藏
|
||||
props.onClose?.();
|
||||
};
|
||||
const overlay = h('div', {
|
||||
class: 'ua-modal-overlay',
|
||||
onClick: (e: Event) => {
|
||||
if (e.target === overlay) close(); // 卫语句:仅点遮罩本身关闭
|
||||
},
|
||||
});
|
||||
const box = h(
|
||||
'div',
|
||||
{ class: 'ua-modal' },
|
||||
h('div', { class: 'ua-modal-head' }, h('h3', { class: 'ua-modal-title' }, props.title), h('button', { class: 'ua-modal-close', onClick: close }, '×')),
|
||||
h('div', { class: 'ua-modal-body' }, ...children),
|
||||
);
|
||||
overlay.append(box);
|
||||
effect(() => {
|
||||
const open = props.open();
|
||||
overlay.classList.toggle('ua-open', open); // 显隐跟随信号
|
||||
if (open) (box.querySelector('button, input, textarea') as HTMLElement | null)?.focus(); // 打开时聚焦首个可交互元素
|
||||
});
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && props.open()) close(); // Esc 关闭当前打开的弹窗
|
||||
});
|
||||
return overlay;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { ProgressProps } from './types.js';
|
||||
|
||||
/*** 进度条:轨道 + 渐变流动填充,value 信号驱动 0-100;满格切换 success 色并停止条纹 ***/
|
||||
export function progress(props: ProgressProps): HTMLElement {
|
||||
const fill = h('div', { class: 'ua-progress-fill' });
|
||||
const track = h('div', { class: 'ua-progress-track' }, fill);
|
||||
const text = h('span', { class: 'ua-progress-text' });
|
||||
effect(() => {
|
||||
const v = clamp(props.value()); // 依赖收集:信号变化自动写回
|
||||
fill.style.width = `${v}%`;
|
||||
fill.classList.toggle('ua-done', v >= 100); // 满格切语义色
|
||||
text.textContent = `${Math.round(v)}%`;
|
||||
});
|
||||
return h('div', { class: 'ua-progress' }, track, props.showText === false ? null : text);
|
||||
}
|
||||
|
||||
/*** 进度钳制到 0-100 ***/
|
||||
function clamp(v: number): number {
|
||||
if (Number.isNaN(v)) return 0; // 卫语句:非法值归零
|
||||
return Math.min(100, Math.max(0, v));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { RadioGroupProps } from './types.js';
|
||||
|
||||
/*** 单选组:value 信号双向绑定,options 同组互斥,name 固定保证原生互斥行为 ***/
|
||||
export function radioGroup(props: RadioGroupProps): HTMLElement {
|
||||
const groupName = `ua-radio-${Math.random().toString(36).slice(2, 8)}`; // 组内唯一 name
|
||||
const radios: HTMLInputElement[] = [];
|
||||
for (const opt of props.options) {
|
||||
const input = h('input', { type: 'radio', name: groupName, class: 'ua-radio' }) as HTMLInputElement;
|
||||
input.value = opt.value;
|
||||
radios.push(input);
|
||||
input.addEventListener('change', () => {
|
||||
if (props.value) props.value(input.value); // 用户选择写回信号
|
||||
props.onChange?.(input.value);
|
||||
});
|
||||
}
|
||||
if (props.value) {
|
||||
const value = props.value;
|
||||
effect(() => {
|
||||
for (const input of radios) input.checked = input.value === value(); // 外部变化同步勾选态
|
||||
});
|
||||
}
|
||||
const rows = props.options.map((opt, i) => h('label', { class: 'ua-check-row' }, radios[i], opt.label));
|
||||
return h('div', { class: 'ua-radio-group' }, ...rows);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { SelectProps } from './types.js';
|
||||
|
||||
/*** 下拉选择:自绘弹层替代原生 select,value 信号双向绑定;点选项 / 点外部 / Esc 关闭 ***/
|
||||
export function select(props: SelectProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const open = signal(false); // 弹层开合
|
||||
const value = props.value ?? signal(''); // 选中值;未传信号则内部持有
|
||||
|
||||
const labelOf = (v: string) => props.options.find((o) => o.value === v)?.label ?? '';
|
||||
const current = () => labelOf(value()); // 触发器显示文案(响应式)
|
||||
|
||||
const pick = (v: string) => {
|
||||
value(v);
|
||||
open(false);
|
||||
props.onChange?.(v);
|
||||
};
|
||||
|
||||
// —— 触发器 ——
|
||||
const trigger = h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'ua-select-trigger',
|
||||
'aria-haspopup': 'listbox',
|
||||
'aria-expanded': () => String(open()),
|
||||
onClick: () => open(!open()),
|
||||
},
|
||||
h('span', { class: () => `ua-select-value${value() ? '' : ' is-empty'}` }, () => current() || props.placeholder || ''),
|
||||
h('span', { class: 'ua-select-arrow' }, '▾'),
|
||||
);
|
||||
|
||||
// —— 选项弹层 ——
|
||||
const options = props.options.map((opt) =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: () => `ua-option${opt.value === value() ? ' ua-option-selected' : ''}`,
|
||||
role: 'option',
|
||||
'aria-selected': () => String(opt.value === value()),
|
||||
onClick: () => pick(opt.value),
|
||||
},
|
||||
opt.label,
|
||||
),
|
||||
);
|
||||
const pop = h('div', { class: () => `ua-select-pop${open() ? ' ua-open' : ''}`, role: 'listbox' }, ...options);
|
||||
|
||||
const root = h('div', { class: 'ua-select' }, trigger, pop);
|
||||
|
||||
root.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') open(false); // Esc 关闭弹层
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
|
||||
open(false); // 点外部关闭
|
||||
});
|
||||
effect(() => open()); // 建立依赖,保证开合信号生效
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { SpinnerProps } from './types.js';
|
||||
|
||||
/*** 加载指示器:纯 CSS 旋转动画,size 控制直径 ***/
|
||||
export function spinner(props: SpinnerProps = {}): HTMLElement {
|
||||
const size = `${props.size ?? 18}px`;
|
||||
const el = h('span', { class: 'ua-spinner' });
|
||||
el.style.width = size;
|
||||
el.style.height = size;
|
||||
return el;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { SwitchProps } from './types.js';
|
||||
|
||||
/*** 开关:checked 信号双向绑定,轨道 + 滑块的纯 CSS 实现 ***/
|
||||
export function switchButton(props: SwitchProps): HTMLElement {
|
||||
const input = h('input', { type: 'checkbox', class: 'ua-switch-input' }) as HTMLInputElement;
|
||||
if (props.checked) {
|
||||
const checked = props.checked;
|
||||
input.addEventListener('change', () => checked(input.checked)); // 用户切换写回信号
|
||||
effect(() => {
|
||||
if (input.checked !== checked()) input.checked = checked(); // 外部变化写回开关
|
||||
});
|
||||
}
|
||||
if (props.onChange) input.addEventListener('change', () => props.onChange!(input.checked));
|
||||
const track = h('span', { class: 'ua-switch-track' }, h('span', { class: 'ua-switch-thumb' }));
|
||||
const el = h('label', { class: 'ua-switch' }, input, track);
|
||||
if (props.label) el.append(h('span', { class: 'ua-switch-label' }, props.label));
|
||||
return el;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { TabsProps } from './types.js';
|
||||
|
||||
/*** 标签页:active 信号双向绑定 + 滑动指示条 + 方向键环形导航;内容由调用方按 active 派生 ***/
|
||||
export function tabs(props: TabsProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const buttons: HTMLButtonElement[] = []; // 页签按钮,顺序与 tabs 一致
|
||||
const indicator = h('span', { class: 'ua-tabs-indicator' });
|
||||
const head = h('div', { class: 'ua-tabs-head', role: 'tablist' }, indicator);
|
||||
|
||||
/*** 同步激活态与指示条几何位置 ***/
|
||||
const sync = () => {
|
||||
const value = props.active?.();
|
||||
for (const btn of buttons) {
|
||||
const on = btn.dataset.value === value;
|
||||
btn.classList.toggle('ua-tab-active', on);
|
||||
btn.setAttribute('aria-selected', String(on));
|
||||
btn.tabIndex = on ? 0 : -1; // 漫游 tabindex:仅激活页签可 Tab 聚焦
|
||||
if (on) {
|
||||
indicator.style.left = `${btn.offsetLeft}px`;
|
||||
indicator.style.width = `${btn.offsetWidth}px`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const tab of props.tabs) {
|
||||
const btn = h('button', { class: 'ua-tab', role: 'tab', type: 'button' }, tab.label) as HTMLButtonElement;
|
||||
btn.dataset.value = tab.value;
|
||||
btn.disabled = tab.disabled ?? false;
|
||||
btn.addEventListener('click', () => {
|
||||
if (btn.disabled) return; // 卫语句:禁用页签不响应
|
||||
if (props.active) props.active(tab.value); // 用户切换写回信号
|
||||
props.onChange?.(tab.value);
|
||||
});
|
||||
buttons.push(btn);
|
||||
head.append(btn);
|
||||
}
|
||||
|
||||
head.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
const delta = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
|
||||
if (!delta) return; // 卫语句:非方向键放行
|
||||
const list = buttons.filter((b) => !b.disabled);
|
||||
const i = list.indexOf(document.activeElement as HTMLButtonElement);
|
||||
if (i < 0) return; // 卫语句:焦点不在页签上
|
||||
e.preventDefault();
|
||||
const next = list[(i + delta + list.length) % list.length]; // 环形移动
|
||||
next.focus();
|
||||
next.click();
|
||||
});
|
||||
|
||||
effect(sync);
|
||||
requestAnimationFrame(sync); // 挂载后再量一次布局,否则指示条宽度为 0
|
||||
return h('div', { class: 'ua-tabs' }, head);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { TextFieldProps } from './types.js';
|
||||
|
||||
/*** 文本输入:value 信号双向绑定,onInput 为输入回调 ***/
|
||||
export function textField(props: TextFieldProps): HTMLInputElement {
|
||||
const el = h('input', { class: 'ua-input', placeholder: props.placeholder ?? '' }) as HTMLInputElement;
|
||||
if (props.value) {
|
||||
const signal = props.value;
|
||||
el.addEventListener('input', () => signal(el.value)); // 用户输入写回信号
|
||||
effect(() => {
|
||||
if (el.value !== signal()) el.value = signal(); // 外部变化写回输入框
|
||||
});
|
||||
}
|
||||
if (props.onInput) el.addEventListener('input', () => props.onInput!(el.value));
|
||||
return el;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { TextareaProps } from './types.js';
|
||||
|
||||
/*** 多行输入:value 信号双向绑定,onInput 为输入回调 ***/
|
||||
export function textarea(props: TextareaProps): HTMLTextAreaElement {
|
||||
const el = h('textarea', { class: 'ua-textarea', rows: props.rows ?? 3, placeholder: props.placeholder ?? '' }) as HTMLTextAreaElement;
|
||||
if (props.value) {
|
||||
const signal = props.value;
|
||||
el.addEventListener('input', () => signal(el.value)); // 用户输入写回信号
|
||||
effect(() => {
|
||||
if (el.value !== signal()) el.value = signal(); // 外部变化写回输入框
|
||||
});
|
||||
}
|
||||
if (props.onInput) el.addEventListener('input', () => props.onInput!(el.value));
|
||||
return el;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { ColumnDef, RowKey } from '@ui-arch/table-core';
|
||||
import type { Signal } from '@ui-arch/frame';
|
||||
|
||||
/*** 按钮入参 ***/
|
||||
export interface ButtonProps {
|
||||
label: string; // 按钮文案
|
||||
variant?: 'primary' | 'ghost' | 'danger'; // 视觉强度;缺省 primary
|
||||
onClick?: () => void; // 点击回调
|
||||
}
|
||||
|
||||
/*** 文本输入入参 ***/
|
||||
export interface TextFieldProps {
|
||||
placeholder?: string; // 占位文案
|
||||
value?: Signal<string>; // 双向绑定的信号
|
||||
onInput?: (value: string) => void; // 输入回调
|
||||
}
|
||||
|
||||
/*** 卡片入参 ***/
|
||||
export interface CardProps {
|
||||
title?: string; // 标题;缺省不渲染标题行
|
||||
}
|
||||
|
||||
/*** 数据表格入参 ***/
|
||||
export interface DataTableProps<T> {
|
||||
data: T[]; // 全量行数据,行须含 id 字段作 rowKey
|
||||
columns: ColumnDef<T>[]; // 列定义
|
||||
filterKey?: string; // 工具栏筛选的列;缺省取第一列
|
||||
pageSize?: number; // 每页行数;缺省 5
|
||||
selectable?: boolean; // 是否启用行选择列(含全选三态与已选计数);缺省 false
|
||||
}
|
||||
|
||||
/*** 选项条目:select / radioGroup / tabs 共用的选项形状 ***/
|
||||
export interface OptionItem {
|
||||
value: string; // 选项值(提交/匹配用)
|
||||
label: string; // 选项文案(展示用)
|
||||
disabled?: boolean; // 仅 tabs 消费:禁用该页签
|
||||
}
|
||||
|
||||
/*** 复选框入参 ***/
|
||||
export interface CheckboxProps {
|
||||
label?: string; // 右侧文案;缺省不渲染
|
||||
checked?: Signal<boolean>; // 勾选状态信号,双向绑定
|
||||
onChange?: (checked: boolean) => void; // 变更回调
|
||||
}
|
||||
|
||||
/*** 开关入参 ***/
|
||||
export interface SwitchProps {
|
||||
label?: string; // 左侧文案;缺省不渲染
|
||||
checked?: Signal<boolean>; // 开关状态信号,双向绑定
|
||||
onChange?: (checked: boolean) => void; // 变更回调
|
||||
}
|
||||
|
||||
/*** 下拉选择入参 ***/
|
||||
export interface SelectProps {
|
||||
options: OptionItem[]; // 选项列表
|
||||
value?: Signal<string>; // 选中值信号,双向绑定
|
||||
placeholder?: string; // 未选中时的占位项文案
|
||||
onChange?: (value: string) => void; // 变更回调
|
||||
}
|
||||
|
||||
/*** 单选组入参 ***/
|
||||
export interface RadioGroupProps {
|
||||
options: OptionItem[]; // 选项列表
|
||||
value?: Signal<string>; // 选中值信号,双向绑定
|
||||
onChange?: (value: string) => void; // 变更回调
|
||||
}
|
||||
|
||||
/*** 多行输入入参 ***/
|
||||
export interface TextareaProps {
|
||||
placeholder?: string; // 占位文案
|
||||
rows?: number; // 行数;缺省 3
|
||||
value?: Signal<string>; // 双向绑定的信号
|
||||
onInput?: (value: string) => void; // 输入回调
|
||||
}
|
||||
|
||||
/*** 徽标入参 ***/
|
||||
export interface BadgeProps {
|
||||
label: string; // 徽标文案
|
||||
variant?: 'default' | 'success' | 'warning' | 'danger'; // 语义色;缺省 default
|
||||
}
|
||||
|
||||
/*** 提示条入参 ***/
|
||||
export interface AlertProps {
|
||||
message: string; // 提示内容
|
||||
variant?: 'info' | 'success' | 'warning' | 'danger'; // 语义色;缺省 info
|
||||
}
|
||||
|
||||
/*** 加载指示器入参 ***/
|
||||
export interface SpinnerProps {
|
||||
size?: number; // 直径像素;缺省 18
|
||||
}
|
||||
|
||||
/*** 进度条入参 ***/
|
||||
export interface ProgressProps {
|
||||
value: Signal<number>; // 当前进度 0-100 的信号
|
||||
showText?: boolean; // 是否右侧显示百分比;缺省 true
|
||||
}
|
||||
|
||||
/*** 标签页入参 ***/
|
||||
export interface TabsProps {
|
||||
tabs: OptionItem[]; // 页签列表
|
||||
active?: Signal<string>; // 当前激活页签的 value 信号,双向绑定
|
||||
onChange?: (value: string) => void; // 切换回调
|
||||
}
|
||||
|
||||
/*** 弹窗入参 ***/
|
||||
export interface ModalProps {
|
||||
title: string; // 标题
|
||||
open: Signal<boolean>; // 开关信号:true 显示 / false 隐藏
|
||||
onClose?: () => void; // 关闭回调(点遮罩、× 或 Esc 触发)
|
||||
}
|
||||
|
||||
/*** 表单校验规则:按序执行,首个失败即停止 ***/
|
||||
export interface FormRule {
|
||||
required?: boolean; // 非空校验(trim 后判定)
|
||||
min?: number; // 最小长度
|
||||
max?: number; // 最大长度
|
||||
pattern?: RegExp; // 正则格式校验
|
||||
validator?: (value: string) => boolean | string; // 自定义校验:true 通过 / 字符串为错误消息
|
||||
message?: string; // 该规则的错误消息;缺省按规则类型给默认文案
|
||||
}
|
||||
|
||||
/*** 表单字段入参 ***/
|
||||
export interface FormItemProps {
|
||||
label: string; // 字段标签
|
||||
value: Signal<string>; // 字段值信号(校验数据源)
|
||||
required?: boolean; // 必填:显示红星并自动追加非空规则
|
||||
rules?: FormRule[]; // 附加校验规则,按序执行
|
||||
validateOn?: 'input' | 'blur' | 'submit'; // 校验时机;缺省 blur
|
||||
}
|
||||
|
||||
/*** 表单字段句柄:form 聚合校验用 ***/
|
||||
export interface FormItemHandle {
|
||||
el: HTMLElement; // 字段根元素(label + 控件 + 错误区)
|
||||
validate(): string | null; // 执行校验:null 通过 / 字符串为错误消息
|
||||
}
|
||||
|
||||
/*** 表单容器入参 ***/
|
||||
export interface FormProps {
|
||||
layout?: 'vertical' | 'horizontal'; // 布局方向;缺省 vertical
|
||||
submitText?: string; // 提交按钮文案;缺省「提交」
|
||||
resetText?: string; // 重置按钮文案;缺省不渲染重置按钮
|
||||
onSubmit?: () => void; // 全部字段校验通过后的提交回调
|
||||
onReset?: () => void; // 重置回调
|
||||
}
|
||||
|
||||
export type { ColumnDef, RowKey }; // 再导出常用内核类型,方便调用方
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "dist",
|
||||
"paths": {
|
||||
"@ui-arch/frame": ["../../core/frame/dist/index.d.ts"],
|
||||
"@ui-arch/table-core": ["../../core/table-core/dist/index.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
{
|
||||
"name": "@ui-arch/ui",
|
||||
"type": "skin-dom",
|
||||
"description": "DOM 皮肤组件库:button / textField / textarea / checkbox / switch / select / radioGroup / card / badge / alert / spinner / progress / tabs / modal / form / formItem / dataTable,构建在 @ui-arch/frame 与 @ui-arch/table-core 之上,样式只消费 --ua-* 令牌。",
|
||||
"tags": [
|
||||
"components",
|
||||
"dom",
|
||||
"skin",
|
||||
"table",
|
||||
"form",
|
||||
"feedback"
|
||||
],
|
||||
"api": {
|
||||
"entry": "src/index.ts",
|
||||
"exports": [
|
||||
"button(props):按钮,variant = primary | ghost | danger",
|
||||
"textField(props):文本输入,value 信号双向绑定",
|
||||
"textarea(props):多行输入,value 信号双向绑定",
|
||||
"checkbox(props):复选框,checked 信号双向绑定",
|
||||
"switchButton(props):开关,checked 信号双向绑定",
|
||||
"select(props):自绘下拉选择(非原生 select),value 信号双向绑定,点外部 / Esc 关闭,选项弹层带微动画",
|
||||
"radioGroup(props):单选组,options + value 信号双向绑定",
|
||||
"card(props, ...children):卡片容器",
|
||||
"badge(props):徽标,variant = default | success | warning | danger",
|
||||
"alert(props):提示条,variant = info | success | warning | danger",
|
||||
"spinner(props):加载指示器,size 控制直径",
|
||||
"progress(props):进度条,轨道 + 渐变流动填充,value 信号驱动 0-100,满格切 success 色",
|
||||
"tabs(props):标签页,active 信号双向绑定,滑动指示条 + 方向键环形导航,支持禁用页签",
|
||||
"modal(props, ...children):弹窗,open 信号驱动显隐,Esc / 遮罩 / × 关闭",
|
||||
"form(props, items):表单容器,布局 + 聚合校验,全部通过才触发 onSubmit",
|
||||
"formItem(props, control):表单字段,label + 必填红星 + 规则校验 + 错误区,返回 { el, validate } 句柄",
|
||||
"dataTable(props):数据表格,粘性表头 / 行选择(全选三态)/ 排序 / 筛选 / 分页 / 空状态,内核为 table-core"
|
||||
],
|
||||
"keyTypes": [
|
||||
"ButtonProps",
|
||||
"TextFieldProps",
|
||||
"TextareaProps",
|
||||
"CheckboxProps",
|
||||
"SwitchProps",
|
||||
"SelectProps",
|
||||
"RadioGroupProps",
|
||||
"OptionItem",
|
||||
"CardProps",
|
||||
"BadgeProps",
|
||||
"AlertProps",
|
||||
"SpinnerProps",
|
||||
"ProgressProps",
|
||||
"TabsProps",
|
||||
"ModalProps",
|
||||
"FormRule",
|
||||
"FormItemProps",
|
||||
"FormItemHandle",
|
||||
"FormProps",
|
||||
"DataTableProps"
|
||||
]
|
||||
},
|
||||
"usage": "const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')",
|
||||
"conventions": [
|
||||
"皮肤不持有业务状态,一律委托 headless 内核或调用方信号",
|
||||
"表单类组件统一约定:*Props.value/checked/active 为 Signal 双向绑定,onChange/onInput 为回调",
|
||||
"语义色 variant 四档:default|info / success / warning / danger,一律来自令牌,禁止硬编码颜色"
|
||||
],
|
||||
"docs": {
|
||||
"intro": "16 个 DOM 组件,统一用法:组件是普通函数,传入 props 返回 DOM 节点;需要状态时先创建 signal 再双向绑定。引入 CSS:import '@ui-arch/ui/ui.css'(内部已消费 tokens)。",
|
||||
"steps": [
|
||||
"安装依赖:pnpm add @ui-arch/ui @ui-arch/frame(workspace 内自动链接)",
|
||||
"引入样式:在应用入口 import '@ui-arch/ui/ui.css',暗色主题在根元素设 data-theme='dark'",
|
||||
"创建状态:import { signal } from '@ui-arch/frame',如 const name = signal('')",
|
||||
"调用组件:const el = textField({ value: name }),把 el 挂到你的容器或配合 h() 组合",
|
||||
"读写状态:name() 读、name('张三') 写,界面自动同步,无需手动操作 DOM"
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"id": "button",
|
||||
"title": "按钮 Button",
|
||||
"demo": "sec-counter",
|
||||
"props": [
|
||||
{
|
||||
"n": "label",
|
||||
"t": "string",
|
||||
"r": 1,
|
||||
"d": "按钮文案"
|
||||
},
|
||||
{
|
||||
"n": "variant",
|
||||
"t": "'primary' | 'ghost' | 'danger'",
|
||||
"d": "视觉强度;缺省 primary"
|
||||
},
|
||||
{
|
||||
"n": "onClick",
|
||||
"t": "() => void",
|
||||
"k": 1,
|
||||
"d": "点击回调"
|
||||
}
|
||||
],
|
||||
"example": "button({ label: '提交', variant: 'primary', onClick: () => console.log('clicked') })",
|
||||
"steps": [
|
||||
"确定语义强度:主操作用 primary(默认),次要操作用 ghost,危险操作用 danger",
|
||||
"绑定 onClick 回调处理点击逻辑",
|
||||
"需要禁用态时自行在回调首行卫语句判断,或外层包条件渲染"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "textField",
|
||||
"title": "文本输入 TextField",
|
||||
"demo": "sec-bind",
|
||||
"props": [
|
||||
{
|
||||
"n": "value",
|
||||
"t": "Signal<string>",
|
||||
"d": "双向绑定信号:输入写回信号,信号变化写回输入框"
|
||||
},
|
||||
{
|
||||
"n": "placeholder",
|
||||
"t": "string",
|
||||
"d": "占位文案"
|
||||
},
|
||||
{
|
||||
"n": "onInput",
|
||||
"t": "(value: string) => void",
|
||||
"k": 1,
|
||||
"d": "输入回调,每次按键触发"
|
||||
}
|
||||
],
|
||||
"example": "const name = signal(''); const el = textField({ value: name, placeholder: '输入名字…', onInput: (v) => console.log(v) })",
|
||||
"steps": [
|
||||
"先用 signal('') 创建字符串信号",
|
||||
"传入 value 完成双向绑定:用户输入自动写回,外部赋值自动回显",
|
||||
"在任意响应式位置用 name() 读取最新值,如 () => `你好,${name()}`"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "textarea",
|
||||
"title": "多行输入 Textarea",
|
||||
"demo": "sec-form",
|
||||
"props": [
|
||||
{
|
||||
"n": "value",
|
||||
"t": "Signal<string>",
|
||||
"d": "双向绑定信号"
|
||||
},
|
||||
{
|
||||
"n": "rows",
|
||||
"t": "number",
|
||||
"d": "初始行数;缺省 3"
|
||||
},
|
||||
{
|
||||
"n": "placeholder",
|
||||
"t": "string",
|
||||
"d": "占位文案"
|
||||
},
|
||||
{
|
||||
"n": "onInput",
|
||||
"t": "(value: string) => void",
|
||||
"k": 1,
|
||||
"d": "输入回调"
|
||||
}
|
||||
],
|
||||
"example": "const bio = signal(''); textarea({ value: bio, rows: 4, placeholder: '一句话介绍…' })",
|
||||
"steps": [
|
||||
"创建信号并传入 value",
|
||||
"用 rows 控制初始高度,用户可纵向拉伸",
|
||||
"字数统计等派生逻辑直接在响应式文本里读 bio().length"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "checkbox",
|
||||
"title": "复选框 Checkbox",
|
||||
"demo": "sec-form",
|
||||
"props": [
|
||||
{
|
||||
"n": "checked",
|
||||
"t": "Signal<boolean>",
|
||||
"d": "勾选状态信号,双向绑定"
|
||||
},
|
||||
{
|
||||
"n": "label",
|
||||
"t": "string",
|
||||
"d": "右侧文案;缺省只渲染勾选框"
|
||||
},
|
||||
{
|
||||
"n": "onChange",
|
||||
"t": "(checked: boolean) => void",
|
||||
"k": 1,
|
||||
"d": "变更回调"
|
||||
}
|
||||
],
|
||||
"example": "const agreed = signal(false); checkbox({ label: '同意条款', checked: agreed, onChange: (v) => console.log(v) })",
|
||||
"steps": [
|
||||
"创建布尔信号 agreed = signal(false)",
|
||||
"传入 checked 与 label,勾选即写回信号",
|
||||
"表单提交前用 agreed() 判断是否勾选"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "switchButton",
|
||||
"title": "开关 Switch",
|
||||
"demo": "sec-form",
|
||||
"props": [
|
||||
{
|
||||
"n": "checked",
|
||||
"t": "Signal<boolean>",
|
||||
"d": "开关状态信号,双向绑定"
|
||||
},
|
||||
{
|
||||
"n": "label",
|
||||
"t": "string",
|
||||
"d": "左侧文案"
|
||||
},
|
||||
{
|
||||
"n": "onChange",
|
||||
"t": "(checked: boolean) => void",
|
||||
"k": 1,
|
||||
"d": "变更回调"
|
||||
}
|
||||
],
|
||||
"example": "const night = signal(true); switchButton({ label: '夜间模式', checked: night })",
|
||||
"steps": [
|
||||
"创建布尔信号并传入 checked",
|
||||
"轨道与滑块的动画由 CSS 完成,无需干预",
|
||||
"状态变化在 onChange 里做副作用(如切换主题)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "select",
|
||||
"title": "下拉选择 Select(自绘弹层)",
|
||||
"demo": "sec-form",
|
||||
"props": [
|
||||
{
|
||||
"n": "options",
|
||||
"t": "OptionItem[]",
|
||||
"r": 1,
|
||||
"d": "选项列表,每项 { value, label }"
|
||||
},
|
||||
{
|
||||
"n": "value",
|
||||
"t": "Signal<string>",
|
||||
"d": "选中值信号,双向绑定"
|
||||
},
|
||||
{
|
||||
"n": "placeholder",
|
||||
"t": "string",
|
||||
"d": "未选中时触发器显示的占位文案"
|
||||
},
|
||||
{
|
||||
"n": "onChange",
|
||||
"t": "(value: string) => void",
|
||||
"k": 1,
|
||||
"d": "选中回调"
|
||||
}
|
||||
],
|
||||
"example": "const city = signal(''); select({ options: [{ value: 'bj', label: '北京' }], value: city, placeholder: '选择城市…' })",
|
||||
"steps": [
|
||||
"准备 options 数组,value 是提交用值、label 是展示文案",
|
||||
"创建信号并传入 value;未传时组件内部自持状态",
|
||||
"点击触发器展开弹层,点选项写回信号并收起;点外部或按 Esc 也会收起",
|
||||
"选中项在弹层中用淡色弱化标记,不抢视觉焦点"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "radioGroup",
|
||||
"title": "单选组 RadioGroup",
|
||||
"demo": "sec-form",
|
||||
"props": [
|
||||
{
|
||||
"n": "options",
|
||||
"t": "OptionItem[]",
|
||||
"r": 1,
|
||||
"d": "选项列表"
|
||||
},
|
||||
{
|
||||
"n": "value",
|
||||
"t": "Signal<string>",
|
||||
"d": "选中值信号,双向绑定"
|
||||
},
|
||||
{
|
||||
"n": "onChange",
|
||||
"t": "(value: string) => void",
|
||||
"k": 1,
|
||||
"d": "变更回调"
|
||||
}
|
||||
],
|
||||
"example": "const size = signal('m'); radioGroup({ options: [{ value: 's', label: '小' }, { value: 'm', label: '中' }], value: size })",
|
||||
"steps": [
|
||||
"创建字符串信号,初值设为默认选项的 value",
|
||||
"传入 options 与 value,组内自动互斥",
|
||||
"读取 size() 获取当前选中值"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card",
|
||||
"title": "卡片 Card",
|
||||
"demo": "sec-card",
|
||||
"props": [
|
||||
{
|
||||
"n": "title",
|
||||
"t": "string",
|
||||
"d": "标题;缺省不渲染标题行"
|
||||
},
|
||||
{
|
||||
"n": "...children",
|
||||
"t": "unknown[]",
|
||||
"d": "任意子节点:字符串 / DOM / 响应式函数"
|
||||
}
|
||||
],
|
||||
"example": "card({ title: '用户表' }, dataTable({ data, columns }))",
|
||||
"steps": [
|
||||
"card 是容器组件:第一个参数是 props,其余参数是任意子节点",
|
||||
"传 title 显示标题行,否则只有内容区",
|
||||
"子节点可以混合字符串、DOM 节点与响应式函数"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "badge",
|
||||
"title": "徽标 Badge",
|
||||
"demo": "sec-feedback",
|
||||
"props": [
|
||||
{
|
||||
"n": "label",
|
||||
"t": "string",
|
||||
"r": 1,
|
||||
"d": "徽标文案"
|
||||
},
|
||||
{
|
||||
"n": "variant",
|
||||
"t": "'default' | 'success' | 'warning' | 'danger'",
|
||||
"d": "语义色;缺省 default"
|
||||
}
|
||||
],
|
||||
"example": "badge({ label: '成功', variant: 'success' })",
|
||||
"steps": [
|
||||
"选择语义色:default 中性、success 成功、warning 警告、danger 危险",
|
||||
"传入 label 即可,纯展示组件无状态"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "alert",
|
||||
"title": "提示条 Alert",
|
||||
"demo": "sec-feedback",
|
||||
"props": [
|
||||
{
|
||||
"n": "message",
|
||||
"t": "string",
|
||||
"r": 1,
|
||||
"d": "提示内容"
|
||||
},
|
||||
{
|
||||
"n": "variant",
|
||||
"t": "'info' | 'success' | 'warning' | 'danger'",
|
||||
"d": "语义色;缺省 info"
|
||||
}
|
||||
],
|
||||
"example": "alert({ message: '操作可能存在风险', variant: 'warning' })",
|
||||
"steps": [
|
||||
"按信息级别选 variant:info 提示、success 成功、warning 警告、danger 错误",
|
||||
"传入 message 即可,入场自带下滑动画"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "spinner",
|
||||
"title": "加载指示器 Spinner",
|
||||
"demo": "sec-feedback",
|
||||
"props": [
|
||||
{
|
||||
"n": "size",
|
||||
"t": "number",
|
||||
"d": "直径像素;缺省 18"
|
||||
}
|
||||
],
|
||||
"example": "spinner(); spinner({ size: 26 })",
|
||||
"steps": [
|
||||
"数据加载时插入 spinner(),加载完成移除",
|
||||
"用 size 匹配上下文尺寸(按钮内用小号,整页 loading 用大号)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "progress",
|
||||
"title": "进度条 Progress",
|
||||
"demo": "sec-feedback",
|
||||
"props": [
|
||||
{
|
||||
"n": "value",
|
||||
"t": "Signal<number>",
|
||||
"r": 1,
|
||||
"d": "进度 0-100 信号,变化自动平滑过渡"
|
||||
},
|
||||
{
|
||||
"n": "showText",
|
||||
"t": "boolean",
|
||||
"d": "是否显示百分比文字;缺省 true"
|
||||
}
|
||||
],
|
||||
"example": "const pct = signal(0); progress({ value: pct }); setInterval(() => pct((pct() + 5) % 105), 400)",
|
||||
"steps": [
|
||||
"创建数值信号 pct = signal(0),范围 0-100(越界自动钳制)",
|
||||
"传入 value,填充宽度与百分比文字随信号平滑过渡",
|
||||
"进行中填充带流动条纹,到 100% 自动变 success 色并停止条纹",
|
||||
"不需要百分比文字时传 showText: false"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tabs",
|
||||
"title": "标签页 Tabs",
|
||||
"demo": "sec-tabs-modal",
|
||||
"props": [
|
||||
{
|
||||
"n": "tabs",
|
||||
"t": "OptionItem[]",
|
||||
"r": 1,
|
||||
"d": "页签列表,每项 { value, label }"
|
||||
},
|
||||
{
|
||||
"n": "active",
|
||||
"t": "Signal<string>",
|
||||
"d": "当前激活页签 value 信号,双向绑定"
|
||||
},
|
||||
{
|
||||
"n": "onChange",
|
||||
"t": "(value: string) => void",
|
||||
"k": 1,
|
||||
"d": "切换回调"
|
||||
}
|
||||
],
|
||||
"example": "const tab = signal('a'); tabs({ tabs: [{ value: 'a', label: '概览' }, { value: 'b', label: '明细', disabled: true }], active: tab })",
|
||||
"steps": [
|
||||
"创建信号 tab = signal('a'),初值为默认页签",
|
||||
"tabs 只渲染页签头;页签内容用响应式函数按信号切换,如 () => tab() === 'a' ? panelA : panelB",
|
||||
"点击或方向键(←→ 环形)切换,滑动指示条自动跟随;disabled: true 的页签置灰不可点",
|
||||
"键盘可访问:Tab 聚焦激活页签,←→ 在启用页签间环形移动"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "modal",
|
||||
"title": "弹窗 Modal",
|
||||
"demo": "sec-tabs-modal",
|
||||
"props": [
|
||||
{
|
||||
"n": "open",
|
||||
"t": "Signal<boolean>",
|
||||
"r": 1,
|
||||
"d": "开关信号:true 显示 / false 隐藏"
|
||||
},
|
||||
{
|
||||
"n": "title",
|
||||
"t": "string",
|
||||
"r": 1,
|
||||
"d": "标题"
|
||||
},
|
||||
{
|
||||
"n": "onClose",
|
||||
"t": "() => void",
|
||||
"k": 1,
|
||||
"d": "关闭回调(点遮罩、× 或 Esc 触发)"
|
||||
},
|
||||
{
|
||||
"n": "...children",
|
||||
"t": "unknown[]",
|
||||
"d": "弹窗内容子节点"
|
||||
}
|
||||
],
|
||||
"example": "const open = signal(false); modal({ title: '确认操作', open, onClose: () => open(false) }, h('p', null, '内容'))",
|
||||
"steps": [
|
||||
"创建布尔信号 open = signal(false),弹窗初始隐藏",
|
||||
"把 open 传给 modal,其余参数为弹窗内容子节点",
|
||||
"打开:open(true);关闭:open(false),点遮罩或 × 会自动写回 false",
|
||||
"入场自带遮罩淡入 + 弹窗上浮动画"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "form",
|
||||
"title": "表单 Form / 表单字段 FormItem",
|
||||
"demo": "sec-formlab",
|
||||
"props": [
|
||||
{
|
||||
"n": "layout",
|
||||
"t": "'vertical' | 'horizontal'",
|
||||
"d": "布局方向;缺省 vertical(horizontal 为标签左置)"
|
||||
},
|
||||
{
|
||||
"n": "submitText",
|
||||
"t": "string",
|
||||
"d": "提交按钮文案;缺省「提交」"
|
||||
},
|
||||
{
|
||||
"n": "resetText",
|
||||
"t": "string",
|
||||
"d": "重置按钮文案;缺省不渲染"
|
||||
},
|
||||
{
|
||||
"n": "onSubmit",
|
||||
"t": "() => void",
|
||||
"k": 1,
|
||||
"d": "全部字段校验通过后的提交回调"
|
||||
},
|
||||
{
|
||||
"n": "(formItem)label",
|
||||
"t": "string",
|
||||
"r": 1,
|
||||
"d": "字段标签"
|
||||
},
|
||||
{
|
||||
"n": "(formItem)value",
|
||||
"t": "Signal<string>",
|
||||
"r": 1,
|
||||
"d": "字段值信号,校验数据源"
|
||||
},
|
||||
{
|
||||
"n": "(formItem)required",
|
||||
"t": "boolean",
|
||||
"d": "必填:显示红星并自动追加非空规则"
|
||||
},
|
||||
{
|
||||
"n": "(formItem)rules",
|
||||
"t": "FormRule[]",
|
||||
"d": "校验规则:required / min / max / pattern / validator,按序执行取首个失败"
|
||||
},
|
||||
{
|
||||
"n": "(formItem)validateOn",
|
||||
"t": "'input' | 'blur' | 'submit'",
|
||||
"d": "校验时机;缺省 blur(输入时自动清已报错误)"
|
||||
}
|
||||
],
|
||||
"example": "const name = signal(''); const f = form({ onSubmit: submit }, [formItem({ label: '用户名', required: true, value: name, rules: [{ min: 2, max: 12 }] }, textField({ value: name }))])",
|
||||
"steps": [
|
||||
"为每个字段创建 signal 与控件,如 const name = signal(''); textField({ value: name })",
|
||||
"用 formItem 包住控件:formItem({ label, required, value, rules }, control),返回 { el, validate } 句柄",
|
||||
"把句柄数组交给 form:form({ onSubmit }, items),form 渲染全部字段 + 提交/重置按钮",
|
||||
"提交时逐项校验:全过触发 onSubmit;有错则标红控件、显示错误文案并聚焦首个出错项",
|
||||
"规则语义:required 非空(trim)、min/max 长度、pattern 正则、validator 自定义(true 通过 / 字符串为错误)",
|
||||
"非必填字段为空时跳过其余规则;Enter 键触发表单原生 submit(已拦截走校验流程)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dataTable",
|
||||
"title": "数据表格 DataTable",
|
||||
"demo": "sec-table",
|
||||
"props": [
|
||||
{
|
||||
"n": "data",
|
||||
"t": "T[]",
|
||||
"r": 1,
|
||||
"d": "全量行数据,每行须含 id 字段作 rowKey"
|
||||
},
|
||||
{
|
||||
"n": "columns",
|
||||
"t": "ColumnDef<T>[]",
|
||||
"r": 1,
|
||||
"d": "列定义:{ key, header?, sortable? }"
|
||||
},
|
||||
{
|
||||
"n": "filterKey",
|
||||
"t": "string",
|
||||
"d": "工具栏筛选的列;缺省取第一列"
|
||||
},
|
||||
{
|
||||
"n": "pageSize",
|
||||
"t": "number",
|
||||
"d": "每页行数;缺省 5"
|
||||
},
|
||||
{
|
||||
"n": "selectable",
|
||||
"t": "boolean",
|
||||
"d": "启用行选择列:全选三态(含半选)、行 checkbox、已选计数;缺省 false"
|
||||
}
|
||||
],
|
||||
"example": "dataTable({ data: users, selectable: true, columns: [{ key: 'name', header: '姓名', sortable: true }], pageSize: 5 })",
|
||||
"steps": [
|
||||
"准备行数据,确保每行有唯一 id(跨页选择与刷新不丢行靠它)",
|
||||
"定义 columns:sortable: true 的列表头可点击排序(升 → 降 → 取消循环),标记箭头 ↑↓↕",
|
||||
"selectable: true 开启行选择:表头全选框带 indeterminate 半选态,工具栏显示已选计数",
|
||||
"选中行用低视觉比重样式(淡色底 + 弱化文字),选择状态存 rowKey,跨页不丢失",
|
||||
"工具栏输入框即筛选,输入即时过滤并回第 1 页;无匹配时显示空状态行",
|
||||
"表头粘性固定(滚动容器 max-height 420px),斑马纹 + 悬停高亮由 CSS 提供"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
/* @ui-arch 组件皮肤:只消费 @ui-arch/tokens 的 --ua-* 变量 */
|
||||
.ua-btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--ua-color-accent);
|
||||
border-radius: var(--ua-radius-md);
|
||||
background: var(--ua-color-accent);
|
||||
color: var(--ua-color-accent-contrast);
|
||||
font-size: var(--ua-font-size-md);
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s, transform 0.12s, box-shadow 0.15s;
|
||||
}
|
||||
.ua-btn:hover {
|
||||
filter: brightness(1.08);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 10px color-mix(in srgb, var(--ua-color-accent) 25%, transparent);
|
||||
}
|
||||
.ua-btn:active {
|
||||
transform: translateY(0) scale(0.97);
|
||||
box-shadow: none;
|
||||
}
|
||||
.ua-btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--ua-color-accent);
|
||||
}
|
||||
.ua-btn-ghost:hover {
|
||||
background: color-mix(in srgb, var(--ua-color-accent) 8%, transparent);
|
||||
}
|
||||
.ua-btn-danger {
|
||||
background: var(--ua-color-danger);
|
||||
border-color: var(--ua-color-danger);
|
||||
color: #fff;
|
||||
}
|
||||
.ua-input {
|
||||
min-width: 200px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--ua-color-border);
|
||||
border-radius: var(--ua-radius-md);
|
||||
background: var(--ua-color-bg);
|
||||
color: var(--ua-color-text);
|
||||
font-size: var(--ua-font-size-md);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.ua-input:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--ua-color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent);
|
||||
}
|
||||
.ua-card {
|
||||
padding: var(--ua-space-4);
|
||||
border: 1px solid var(--ua-color-border);
|
||||
border-radius: var(--ua-radius-lg);
|
||||
background: var(--ua-color-surface);
|
||||
}
|
||||
.ua-card-title {
|
||||
margin: 0 0 var(--ua-space-3);
|
||||
font-size: var(--ua-font-size-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
.ua-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.ua-table th,
|
||||
.ua-table td {
|
||||
padding: var(--ua-space-table-cell);
|
||||
border-bottom: 1px solid var(--ua-color-border);
|
||||
text-align: left;
|
||||
font-size: var(--ua-font-size-md);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 粘性表头:滚动容器内固定,背景必须实色否则透视 */
|
||||
.ua-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--ua-color-surface);
|
||||
font-weight: 500;
|
||||
color: var(--ua-color-text-muted);
|
||||
box-shadow: inset 0 -1px 0 var(--ua-color-border);
|
||||
}
|
||||
.ua-table th.ua-sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.ua-table th.ua-sortable:hover {
|
||||
color: var(--ua-color-text);
|
||||
}
|
||||
.ua-sort-mark {
|
||||
margin-left: 4px;
|
||||
font-size: var(--ua-font-size-sm);
|
||||
color: var(--ua-color-accent);
|
||||
}
|
||||
/* 斑马纹 + 悬停高亮 + 选中行(低视觉比重:淡色底 + 弱化文字) */
|
||||
.ua-table tbody tr {
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.ua-table tbody tr:nth-child(even) {
|
||||
background: color-mix(in srgb, var(--ua-color-border) 18%, transparent);
|
||||
}
|
||||
.ua-table tbody tr:hover {
|
||||
background: color-mix(in srgb, var(--ua-color-accent) 6%, transparent);
|
||||
}
|
||||
.ua-table tr.ua-row-selected,
|
||||
.ua-table tr.ua-row-selected:hover {
|
||||
background: color-mix(in srgb, var(--ua-color-accent) 10%, transparent);
|
||||
}
|
||||
.ua-table tr.ua-row-selected td {
|
||||
color: var(--ua-color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.ua-cell-check {
|
||||
width: 36px;
|
||||
text-align: center !important;
|
||||
}
|
||||
.ua-empty-row td {
|
||||
text-align: center;
|
||||
color: var(--ua-color-text-muted);
|
||||
padding: 28px 12px;
|
||||
}
|
||||
.ua-table-scroll {
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--ua-color-border);
|
||||
border-radius: var(--ua-radius-md);
|
||||
background: var(--ua-color-bg);
|
||||
}
|
||||
.ua-table-toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: var(--ua-space-3);
|
||||
}
|
||||
.ua-sel-count {
|
||||
font-size: var(--ua-font-size-sm);
|
||||
color: var(--ua-color-accent);
|
||||
}
|
||||
.ua-pager {
|
||||
display: flex;
|
||||
gap: var(--ua-space-3);
|
||||
align-items: center;
|
||||
margin-top: var(--ua-space-3);
|
||||
}
|
||||
.ua-page-info {
|
||||
color: var(--ua-color-text-muted);
|
||||
font-size: var(--ua-font-size-sm);
|
||||
}
|
||||
|
||||
/* —— 复选框 / 单选 —— */
|
||||
.ua-check-row {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ua-checkbox,
|
||||
.ua-radio {
|
||||
accent-color: var(--ua-color-accent);
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s;
|
||||
}
|
||||
.ua-checkbox:active,
|
||||
.ua-radio:active {
|
||||
transform: scale(0.88);
|
||||
}
|
||||
.ua-radio-group {
|
||||
display: inline-flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* —— 开关 —— */
|
||||
.ua-switch {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ua-switch-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.ua-switch-track {
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: var(--ua-color-border);
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.ua-switch-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: left 0.2s;
|
||||
}
|
||||
.ua-switch-input:checked + .ua-switch-track {
|
||||
background: var(--ua-color-accent);
|
||||
}
|
||||
.ua-switch-input:checked + .ua-switch-track .ua-switch-thumb {
|
||||
left: 18px;
|
||||
}
|
||||
.ua-switch-label {
|
||||
font-size: var(--ua-font-size-md);
|
||||
}
|
||||
|
||||
/* —— 下拉选择(自绘弹层) —— */
|
||||
.ua-select {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
min-width: 160px;
|
||||
}
|
||||
.ua-select-trigger {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--ua-color-border);
|
||||
border-radius: var(--ua-radius-md);
|
||||
background: var(--ua-color-bg);
|
||||
color: var(--ua-color-text);
|
||||
font-size: var(--ua-font-size-md);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.ua-select-trigger:hover {
|
||||
border-color: color-mix(in srgb, var(--ua-color-accent) 55%, var(--ua-color-border));
|
||||
}
|
||||
.ua-select-trigger:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--ua-color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent);
|
||||
}
|
||||
.ua-select-value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ua-select-value.is-empty {
|
||||
color: var(--ua-color-text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.ua-select-arrow {
|
||||
font-size: 10px;
|
||||
color: var(--ua-color-text-muted);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.ua-select.ua-open .ua-select-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.ua-select-pop {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--ua-color-border);
|
||||
border-radius: var(--ua-radius-md);
|
||||
background: var(--ua-color-surface);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14);
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
visibility: hidden;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
|
||||
}
|
||||
.ua-select-pop.ua-open {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
visibility: visible;
|
||||
}
|
||||
.ua-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: var(--ua-radius-sm);
|
||||
background: none;
|
||||
color: var(--ua-color-text);
|
||||
font-size: var(--ua-font-size-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.ua-option:hover {
|
||||
background: color-mix(in srgb, var(--ua-color-accent) 8%, transparent);
|
||||
}
|
||||
/* 选中态:低视觉比重——淡色底 + 弱化文字,不抢焦点 */
|
||||
.ua-option-selected {
|
||||
background: color-mix(in srgb, var(--ua-color-accent) 10%, transparent);
|
||||
color: var(--ua-color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.ua-option-selected:hover {
|
||||
background: color-mix(in srgb, var(--ua-color-accent) 14%, transparent);
|
||||
}
|
||||
|
||||
/* —— 多行输入 —— */
|
||||
.ua-textarea {
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--ua-color-border);
|
||||
border-radius: var(--ua-radius-md);
|
||||
background: var(--ua-color-bg);
|
||||
color: var(--ua-color-text);
|
||||
font: inherit;
|
||||
font-size: var(--ua-font-size-md);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* —— 徽标 —— */
|
||||
.ua-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: var(--ua-font-size-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.ua-badge-default {
|
||||
background: var(--ua-color-border);
|
||||
color: var(--ua-color-text);
|
||||
}
|
||||
.ua-badge-success {
|
||||
background: var(--ua-color-success);
|
||||
color: #fff;
|
||||
}
|
||||
.ua-badge-warning {
|
||||
background: var(--ua-color-warning);
|
||||
color: #fff;
|
||||
}
|
||||
.ua-badge-danger {
|
||||
background: var(--ua-color-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* —— 提示条 —— */
|
||||
.ua-alert {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--ua-radius-md);
|
||||
border: 1px solid;
|
||||
font-size: var(--ua-font-size-md);
|
||||
animation: ua-slide-in 0.25s ease-out;
|
||||
}
|
||||
@keyframes ua-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
.ua-alert-info {
|
||||
border-color: var(--ua-color-accent);
|
||||
background: color-mix(in srgb, var(--ua-color-accent) 10%, transparent);
|
||||
}
|
||||
.ua-alert-success {
|
||||
border-color: var(--ua-color-success);
|
||||
background: color-mix(in srgb, var(--ua-color-success) 12%, transparent);
|
||||
}
|
||||
.ua-alert-warning {
|
||||
border-color: var(--ua-color-warning);
|
||||
background: color-mix(in srgb, var(--ua-color-warning) 12%, transparent);
|
||||
}
|
||||
.ua-alert-danger {
|
||||
border-color: var(--ua-color-danger);
|
||||
background: color-mix(in srgb, var(--ua-color-danger) 12%, transparent);
|
||||
}
|
||||
|
||||
/* —— 加载指示器 —— */
|
||||
.ua-spinner {
|
||||
display: inline-block;
|
||||
border: 2px solid var(--ua-color-border);
|
||||
border-top-color: var(--ua-color-accent);
|
||||
border-radius: 50%;
|
||||
animation: ua-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes ua-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* —— 进度条:轨道 + 渐变流动填充 —— */
|
||||
.ua-progress {
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
.ua-progress-track {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--ua-color-border) 40%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ua-progress-fill {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, color-mix(in srgb, var(--ua-color-accent) 65%, transparent), var(--ua-color-accent));
|
||||
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.ua-progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: repeating-linear-gradient(45deg, rgba(255, 255, 255, 0.18) 0 6px, transparent 6px 12px);
|
||||
background-size: 17px 100%;
|
||||
animation: ua-stripe 0.6s linear infinite;
|
||||
}
|
||||
.ua-progress-fill.ua-done {
|
||||
background: var(--ua-color-success);
|
||||
}
|
||||
.ua-progress-fill.ua-done::after {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
@keyframes ua-stripe {
|
||||
to {
|
||||
background-position: 17px 0;
|
||||
}
|
||||
}
|
||||
.ua-progress-text {
|
||||
font-size: var(--ua-font-size-sm);
|
||||
color: var(--ua-color-text-muted);
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* —— 标签页:滑动指示条 + 可滚动页签头 —— */
|
||||
.ua-tabs {
|
||||
width: 100%;
|
||||
}
|
||||
.ua-tabs-head {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid var(--ua-color-border);
|
||||
}
|
||||
.ua-tab {
|
||||
padding: 7px 14px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--ua-color-text-muted);
|
||||
font-size: var(--ua-font-size-md);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.ua-tab:hover {
|
||||
color: var(--ua-color-text);
|
||||
background: color-mix(in srgb, var(--ua-color-accent) 5%, transparent);
|
||||
}
|
||||
.ua-tab:focus-visible {
|
||||
outline: none;
|
||||
color: var(--ua-color-accent);
|
||||
}
|
||||
.ua-tab:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ua-tab-active {
|
||||
color: var(--ua-color-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.ua-tabs-indicator {
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--ua-color-accent);
|
||||
transition: left 0.25s cubic-bezier(0.4, 0, 0.2, 1), width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* —— 表单 —— */
|
||||
.ua-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
.ua-form-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
/* 水平布局:标签左置右对齐 */
|
||||
.ua-form-h .ua-form-item {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.ua-form-h .ua-form-label {
|
||||
min-width: 80px;
|
||||
padding-top: 6px;
|
||||
text-align: right;
|
||||
}
|
||||
.ua-form-h .ua-form-control {
|
||||
flex: 1;
|
||||
}
|
||||
.ua-form-label {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
font-size: var(--ua-font-size-sm);
|
||||
color: var(--ua-color-text-muted);
|
||||
}
|
||||
.ua-form-req {
|
||||
color: var(--ua-color-danger);
|
||||
font-style: normal;
|
||||
}
|
||||
.ua-form-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.ua-form-error {
|
||||
color: var(--ua-color-danger);
|
||||
font-size: var(--ua-font-size-sm);
|
||||
animation: ua-slide-in 0.2s ease-out;
|
||||
}
|
||||
.ua-form-error:empty {
|
||||
display: none;
|
||||
}
|
||||
/* 校验失败:控件描红 + 危险色光环 */
|
||||
.ua-invalid {
|
||||
border-color: var(--ua-color-danger) !important;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-danger) 15%, transparent) !important;
|
||||
}
|
||||
.ua-form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* —— 弹窗 —— */
|
||||
.ua-modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ua-modal-overlay.ua-open {
|
||||
display: flex;
|
||||
animation: ua-fade 0.2s ease-out;
|
||||
}
|
||||
.ua-modal {
|
||||
width: min(420px, 90vw);
|
||||
border-radius: var(--ua-radius-lg);
|
||||
background: var(--ua-color-surface);
|
||||
border: 1px solid var(--ua-color-border);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
animation: ua-pop-in 0.22s cubic-bezier(0.2, 0.9, 0.3, 1.2);
|
||||
}
|
||||
@keyframes ua-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes ua-pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.96);
|
||||
}
|
||||
}
|
||||
.ua-modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--ua-color-border);
|
||||
}
|
||||
.ua-modal-title {
|
||||
margin: 0;
|
||||
font-size: var(--ua-font-size-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
.ua-modal-close {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--ua-color-text-muted);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.ua-modal-body {
|
||||
padding: 16px;
|
||||
}
|
||||
.ua-textarea:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--ua-color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent);
|
||||
}
|
||||
|
||||
/* —— 无障碍:用户偏好减少动效时关闭全部动画 —— */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ua-btn,
|
||||
.ua-checkbox,
|
||||
.ua-radio,
|
||||
.ua-select-trigger,
|
||||
.ua-select-arrow,
|
||||
.ua-select-pop,
|
||||
.ua-option,
|
||||
.ua-switch-track,
|
||||
.ua-switch-thumb,
|
||||
.ua-tab,
|
||||
.ua-tabs-indicator,
|
||||
.ua-table tbody tr,
|
||||
.ua-progress-fill,
|
||||
.ua-progress-fill::after,
|
||||
.ua-alert,
|
||||
.ua-form-error,
|
||||
.ua-modal,
|
||||
.ua-modal-overlay {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
#!/usr/bin/env node
|
||||
/*** @ui-arch CLI:组件库注册表命令行 + 内置 MCP 服务(零依赖,Node >= 18) ***/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/*** 自底向上查找仓库根(以 pnpm-workspace.yaml 为界) ***/
|
||||
function findRepoRoot(start) {
|
||||
let dir = start;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return process.cwd();
|
||||
dir = parent;
|
||||
}
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
const ROOT = findRepoRoot(__dirname);
|
||||
const PKG_DIR = path.join(ROOT, 'packages');
|
||||
const INDEX_DIR = path.join(ROOT, '.ua');
|
||||
const INDEX_FILE = path.join(INDEX_DIR, 'index.json');
|
||||
const LLMS_FILE = path.join(ROOT, 'llms.txt');
|
||||
const LLMS_FULL = path.join(ROOT, 'llms-full.txt');
|
||||
|
||||
/*** 汇总各包 ua.manifest.json 与 package.json 版本(扫描 packages/ 两层目录) ***/
|
||||
function collectManifests() {
|
||||
const out = [];
|
||||
if (!fs.existsSync(PKG_DIR)) return out;
|
||||
const readManifest = (dir) => {
|
||||
const manifestPath = path.join(dir, 'ua.manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) return; // 卫语句:无清单不入索引
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
const pkgPath = path.join(dir, 'package.json');
|
||||
let version = '0.0.0';
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
version = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version ?? version;
|
||||
}
|
||||
out.push({
|
||||
...manifest,
|
||||
version,
|
||||
dir: path.relative(ROOT, dir).split(path.sep).join('/'),
|
||||
});
|
||||
};
|
||||
for (const layer of fs.readdirSync(PKG_DIR)) {
|
||||
const layerDir = path.join(PKG_DIR, layer);
|
||||
if (!fs.statSync(layerDir).isDirectory()) continue;
|
||||
readManifest(layerDir); // 兼容单层直放
|
||||
for (const name of fs.readdirSync(layerDir)) {
|
||||
const dir = path.join(layerDir, name);
|
||||
if (fs.statSync(dir).isDirectory()) readManifest(dir);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/*** 聚合清单,生成 .ua/index.json 与 llms.txt ***/
|
||||
function cmdIndex() {
|
||||
const packages = collectManifests();
|
||||
fs.mkdirSync(INDEX_DIR, { recursive: true });
|
||||
const index = {
|
||||
$schema: 'ui-arch-registry-index/0.1',
|
||||
generatedAt: new Date().toISOString(),
|
||||
repoRoot: '.',
|
||||
packages,
|
||||
};
|
||||
fs.writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2) + '\n');
|
||||
|
||||
const lines = [
|
||||
'# @ui-arch 私人组件库索引',
|
||||
'',
|
||||
'> 本文件由 `ua index` 自动生成,请勿手改。机器可读版:`.ua/index.json`。',
|
||||
'> Agent 可通过 `ua mcp`(stdio MCP server)直接查询注册表。',
|
||||
'',
|
||||
];
|
||||
for (const p of packages) {
|
||||
lines.push(`## ${p.name} (v${p.version})`, '');
|
||||
lines.push(`- 类型: ${p.type} · 目录: ${p.dir}`);
|
||||
lines.push(`- 描述: ${p.description}`);
|
||||
if (p.api?.exports?.length) {
|
||||
lines.push('- API:');
|
||||
for (const e of p.api.exports) lines.push(` - ${e}`);
|
||||
}
|
||||
if (p.usage) lines.push(`- 用法: \`${p.usage}\``);
|
||||
for (const c of p.conventions ?? []) lines.push(`- 约定: ${c}`);
|
||||
lines.push('');
|
||||
}
|
||||
fs.writeFileSync(LLMS_FILE, lines.join('\n'));
|
||||
console.log(`indexed ${packages.length} package(s) -> .ua/index.json, llms.txt`);
|
||||
}
|
||||
|
||||
/*** 读取注册表索引;缺失则报错退出 ***/
|
||||
function loadIndex() {
|
||||
if (!fs.existsSync(INDEX_FILE)) {
|
||||
console.error('index not found, run: ua index');
|
||||
process.exit(1);
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(INDEX_FILE, 'utf8'));
|
||||
}
|
||||
|
||||
/*** 列出全部已注册包 ***/
|
||||
function cmdList() {
|
||||
const { packages } = loadIndex();
|
||||
if (!packages.length) return console.log('(no packages indexed)');
|
||||
for (const p of packages) {
|
||||
console.log(`${p.name} v${p.version} [${p.type}] ${p.description}`);
|
||||
}
|
||||
}
|
||||
|
||||
/*** 输出单包完整元数据 ***/
|
||||
function cmdInfo(name) {
|
||||
const { packages } = loadIndex();
|
||||
const p = packages.find((x) => x.name === name || x.name === `@ui-arch/${name}`);
|
||||
if (!p) {
|
||||
console.error(`not found: ${name}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(JSON.stringify(p, null, 2));
|
||||
}
|
||||
|
||||
// ---- 静态文档站生成 ----
|
||||
|
||||
/*** HTML 转义:& < > ***/
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/*** 渲染人类文档站:注册表 + 逐组件 props/步骤 + 现场演示 iframe + 令牌,数据内嵌支持搜索 ***/
|
||||
function renderDocs(index, tokens) {
|
||||
const data = JSON.stringify(index.packages).replace(/</g, '\\u003c');
|
||||
const tokData = JSON.stringify(tokens).replace(/</g, '\\u003c');
|
||||
const generatedAt = esc(index.generatedAt);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>@ui-arch 组件库文档</title>
|
||||
<style>
|
||||
:root{--bg:#ffffff;--surface:#f6f7f9;--text:#1c1e21;--muted:#6b7280;--border:rgba(0,0,0,.14);--accent:#185fa5;--code-bg:#101216;--code-text:#e7e9ec}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#101216;--surface:#1a1c22;--text:#e7e9ec;--muted:#888780;--border:rgba(255,255,255,.15);--accent:#378add}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.65 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif}
|
||||
header{position:sticky;top:0;z-index:2;display:flex;gap:16px;align-items:center;flex-wrap:wrap;padding:14px 24px;background:var(--bg);border-bottom:1px solid var(--border)}
|
||||
header h1{margin:0;font-size:16px;font-weight:500}
|
||||
header h1 span{color:var(--muted);font-weight:400;font-size:14px;margin-left:8px}
|
||||
#q{flex:1;min-width:220px;max-width:420px;margin-left:auto;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--text);font-size:13px;outline:none}
|
||||
#q:focus{border-color:var(--accent)}
|
||||
nav.pkgnav{max-width:960px;margin:18px auto 0;padding:0 20px;display:flex;gap:8px;flex-wrap:wrap}
|
||||
nav.pkgnav a{font-size:12px;color:var(--accent);text-decoration:none;border:1px solid var(--border);border-radius:999px;padding:3px 12px}
|
||||
main{max-width:960px;margin:0 auto;padding:20px 20px 60px}
|
||||
.card{border:1px solid var(--border);border-radius:12px;background:var(--surface);padding:22px 24px;margin-bottom:22px}
|
||||
.card h2{margin:0;font-size:16px;font-weight:500;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.ver{font-size:12px;color:var(--muted)}
|
||||
.badge{font-size:11px;padding:2px 8px;border-radius:999px;background:var(--accent);color:#fff}
|
||||
.desc{color:var(--muted);margin:10px 0 0}
|
||||
.tag{display:inline-block;font-size:11px;padding:2px 9px;margin:10px 6px 0 0;border:1px solid var(--border);border-radius:999px;color:var(--muted)}
|
||||
.card h3{font-size:13px;font-weight:500;margin:18px 0 8px}
|
||||
.card ul,.card ol{margin:0;padding-left:18px}
|
||||
.card li{margin:4px 0}
|
||||
.comp{border:1px dashed var(--border);border-radius:10px;padding:14px 16px;margin:14px 0 0;background:var(--bg)}
|
||||
.comp h4{margin:0 0 8px;font-size:14px;font-weight:500;font-family:Consolas,Menlo,monospace}
|
||||
.comp h5{margin:12px 0 6px;font-size:12px;color:var(--muted);font-weight:500}
|
||||
table.props{width:100%;border-collapse:collapse;font-size:12.5px;margin:8px 0}
|
||||
table.props th,table.props td{border:1px solid var(--border);padding:5px 9px;text-align:left;vertical-align:top}
|
||||
table.props th{background:var(--surface);font-weight:500;color:var(--muted)}
|
||||
pre{background:var(--code-bg);color:var(--code-text);border-radius:8px;padding:12px 14px;overflow-x:auto;font:12.5px/1.6 Consolas,Menlo,monospace;margin:8px 0}
|
||||
code{font-family:Consolas,Menlo,monospace;font-size:12.5px}
|
||||
li code,td code{background:var(--code-bg);color:var(--code-text);padding:1px 6px;border-radius:4px}
|
||||
iframe.demo{width:100%;height:430px;border:1px dashed var(--border);border-radius:10px;margin-top:10px;background:var(--surface)}
|
||||
.sw{display:flex;align-items:center;gap:10px;margin:6px 0}
|
||||
.sw i{width:26px;height:26px;border-radius:6px;border:1px solid var(--border);flex:none}
|
||||
.sw code{color:var(--muted)}
|
||||
.sw span{font-size:12px;color:var(--muted)}
|
||||
.swatches{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:4px 20px}
|
||||
.empty{color:var(--muted);text-align:center;padding:40px 0}
|
||||
footer{max-width:960px;margin:0 auto;padding:0 20px 40px;color:var(--muted);font-size:12px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>@ui-arch<span>私人组件库</span></h1><input id="q" placeholder="搜索组件、参数、API…" autocomplete="off"></header>
|
||||
<nav class="pkgnav" id="nav"></nav>
|
||||
<main id="app"></main>
|
||||
<footer>由 <code>ua docs</code> 生成于 ${generatedAt} · AI 版全文:<code>llms-full.txt</code> · 重新生成:npm run docs</footer>
|
||||
<script>
|
||||
var DATA=${data};
|
||||
var TOKENS=${tokData};
|
||||
var ORDER={skin-dom:0,'headless-core':1,'design-tokens':2};
|
||||
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||||
function propsTable(c){
|
||||
var rows=(c.props||[]).map(function(p){
|
||||
return '<tr><td><code>'+esc(p.n)+'</code></td><td><code>'+esc(p.t)+'</code></td><td>'+(p.r?'必填':'否')+'</td><td>'+esc(p.d||'')+(p.k?'(回调)':'')+'</td></tr>';
|
||||
}).join('');
|
||||
return '<table class="props"><tr><th>参数</th><th>类型</th><th>必填</th><th>说明</th></tr>'+rows+'</table>';
|
||||
}
|
||||
function compBlock(c){
|
||||
var steps=(c.steps||[]).map(function(s){return '<li>'+esc(s)+'</li>'}).join('');
|
||||
return '<div class="comp" id="c-'+esc(c.id)+'"><h4>'+esc(c.id)+' · '+esc(c.title||'')+'</h4>'
|
||||
+(c.props?propsTable(c):'')
|
||||
+(c.example?'<pre><code>'+esc(c.example)+'</code></pre>':'')
|
||||
+(steps?'<h5>手把手</h5><ol>'+steps+'</ol>':'')
|
||||
+(c.demo?'<iframe class="demo" src="demo.html#'+esc(c.demo)+'" loading="lazy" title="'+esc(c.id)+' 现场演示"></iframe>':'')
|
||||
+'</div>';
|
||||
}
|
||||
function pkgCard(p){
|
||||
var steps=(p.docs&&p.docs.steps||[]).map(function(s){return '<li>'+esc(s)+'</li>'}).join('');
|
||||
var comps=(p.docs&&p.docs.components||[]).map(compBlock).join('');
|
||||
var ex=(p.api&&p.api.exports||[]).map(function(e){return '<li><code>'+esc(e)+'</code></li>'}).join('');
|
||||
var conv=(p.conventions||[]).map(function(c){return '<li>'+esc(c)+'</li>'}).join('');
|
||||
var tags=(p.tags||[]).map(function(t){return '<span class="tag">'+esc(t)+'</span>'}).join('');
|
||||
return '<section class="card" id="p-'+esc(p.name.replace(/[^\\w-]/g,''))+'">'
|
||||
+'<h2>'+esc(p.name)+' <span class="ver">v'+esc(p.version)+'</span> <span class="badge">'+esc(p.type)+'</span></h2>'
|
||||
+'<p class="desc">'+esc((p.docs&&p.docs.intro)||p.description)+'</p><div>'+tags+'</div>'
|
||||
+(steps?'<h3>手把手接入</h3><ol>'+steps+'</ol>':'')
|
||||
+(comps?'<h3>组件明细</h3>'+comps:'')
|
||||
+(ex?'<h3>API 速览</h3><ul>'+ex+'</ul>':'')
|
||||
+(p.usage?'<h3>一行用法</h3><pre><code>'+esc(p.usage)+'</code></pre>':'')
|
||||
+(conv?'<h3>约定</h3><ul>'+conv+'</ul>':'')
|
||||
+'</section>';
|
||||
}
|
||||
function quickstart(){
|
||||
var ui=DATA.filter(function(p){return p.docs&&p.docs.steps}).sort(function(a,b){return (ORDER[a.type]??9)-(ORDER[b.type]??9)});
|
||||
if(!ui.length)return '';
|
||||
var blocks=ui.map(function(p){
|
||||
var steps=p.docs.steps.map(function(s){return '<li>'+esc(s)+'</li>'}).join('');
|
||||
return '<p class="desc" style="margin:10px 0 4px"><b>'+esc(p.name)+'</b></p><ol>'+steps+'</ol>';
|
||||
}).join('');
|
||||
return '<section class="card"><h2>快速上手 <span class="badge">guide</span></h2>'+blocks+'</section>';
|
||||
}
|
||||
function tokensCard(){
|
||||
if(!TOKENS||!TOKENS.color)return '';
|
||||
var sw=Object.keys(TOKENS.color).map(function(k){
|
||||
var v=TOKENS.color[k];
|
||||
var light=typeof v==='string'?v:v.light;
|
||||
var dark=typeof v==='string'?v:v.dark;
|
||||
var extra=dark&&dark!==light?' / '+dark:'';
|
||||
return '<div class="sw"><i style="background:'+light+'"></i><code>--ua-'+esc(k)+'</code><span>'+light+extra+'</span></div>';
|
||||
}).join('');
|
||||
return '<section class="card"><h2>@ui-arch/tokens <span class="badge">design-tokens</span></h2><p class="desc">皮肤包只允许消费 --ua-* CSS 变量;暗色主题覆盖值不换名(light / dark)。</p><h3>颜色令牌</h3><div class="swatches">'+sw+'</div></section>';
|
||||
}
|
||||
function render(list){
|
||||
var app=document.getElementById('app');
|
||||
if(!list.length){app.innerHTML='<p class="empty">无匹配结果</p>';return}
|
||||
app.innerHTML=quickstart()+list.map(pkgCard).join('')+(list.some(function(p){return p.name.indexOf('tokens')>=0})?'':tokensCard());
|
||||
}
|
||||
function norm(p){
|
||||
var parts=[p.name,p.description,p.type,(p.tags||[]).join(' '),(p.api&&p.api.exports||[]).join(' ')];
|
||||
(p.docs&&p.docs.components||[]).forEach(function(c){parts.push(c.id,c.title||'',(c.props||[]).map(function(x){return x.n}).join(' '))});
|
||||
return parts.join(' ').toLowerCase();
|
||||
}
|
||||
var ordered=DATA.slice().sort(function(a,b){return (ORDER[a.type]??9)-(ORDER[b.type]??9)});
|
||||
document.getElementById('nav').innerHTML=ordered.map(function(p){return '<a href="#p-'+esc(p.name.replace(/[^\\w-]/g,''))+'">'+esc(p.name)+'</a>'}).join('')+'<a href="#top-tokens">令牌</a>';
|
||||
render(ordered);
|
||||
document.getElementById('q').addEventListener('input',function(e){
|
||||
var q=e.target.value.trim().toLowerCase();
|
||||
render(q?ordered.filter(function(p){return norm(p).indexOf(q)>=0}):ordered);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
/*** 渲染 Agent 文档(llms-full.txt):无图形纯文本,逐组件参数 / 回调 / 步骤 / 约定全量收录 ***/
|
||||
function renderAgentDocs(index, tokens) {
|
||||
const lines = [
|
||||
'# @ui-arch 组件库 Agent 文档(无图形版)',
|
||||
'',
|
||||
`> 自动生成于 ${index.generatedAt},数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。`,
|
||||
'> 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。',
|
||||
'',
|
||||
'## 快速上手',
|
||||
'',
|
||||
];
|
||||
for (const p of index.packages) {
|
||||
if (!p.docs?.steps?.length) continue;
|
||||
lines.push(`### ${p.name}`, '');
|
||||
p.docs.steps.forEach((s, i) => lines.push(`${i + 1}. ${s}`));
|
||||
lines.push('');
|
||||
}
|
||||
const order = { 'skin-dom': 0, 'headless-core': 1, 'design-tokens': 2 };
|
||||
for (const p of [...index.packages].sort((a, b) => (order[a.type] ?? 9) - (order[b.type] ?? 9))) {
|
||||
lines.push(`## ${p.name}(${p.type})v${p.version}`, '');
|
||||
lines.push((p.docs?.intro ?? p.description) + '', '');
|
||||
for (const c of p.docs?.components ?? []) {
|
||||
lines.push(`### ${c.id} — ${c.title ?? ''}`, '');
|
||||
if (c.props?.length) {
|
||||
lines.push('参数:', '');
|
||||
for (const prop of c.props) {
|
||||
const marks = [prop.r ? '必填' : '', prop.k ? '回调' : ''].filter(Boolean).join(',');
|
||||
lines.push(`- ${prop.n}${marks ? `(${marks})` : ''}:${prop.t}${prop.d ? ` —— ${prop.d}` : ''}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
if (c.example) lines.push('示例:', '', '```', c.example, '```', '');
|
||||
if (c.steps?.length) {
|
||||
lines.push('手把手:', '');
|
||||
c.steps.forEach((s, i) => lines.push(`${i + 1}. ${s}`));
|
||||
lines.push('');
|
||||
}
|
||||
if (c.demo) lines.push(`现场演示:apps/docs/demo.html#${c.demo}`, '');
|
||||
}
|
||||
if (p.api?.exports?.length) {
|
||||
lines.push('API 速览:', '');
|
||||
for (const e of p.api.exports) lines.push(`- ${e}`);
|
||||
lines.push('');
|
||||
}
|
||||
if (p.usage) lines.push('一行用法:', '', '```', p.usage, '```', '');
|
||||
if (p.conventions?.length) {
|
||||
lines.push('约定:', '');
|
||||
for (const cv of p.conventions) lines.push(`- ${cv}`);
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('---', '');
|
||||
}
|
||||
if (tokens?.color) {
|
||||
lines.push('## 设计令牌(--ua-*)', '');
|
||||
for (const [k, v] of Object.entries(tokens.color)) {
|
||||
const light = typeof v === 'string' ? v : v.light;
|
||||
const dark = typeof v === 'string' ? v : v.dark;
|
||||
lines.push(`- --ua-color-${k}: ${light}${dark !== light ? `(暗色 ${dark})` : ''}`);
|
||||
}
|
||||
for (const [g, vals] of Object.entries(tokens)) {
|
||||
if (g === 'color' || g === '$schema') continue;
|
||||
lines.push('', `### ${g}`, '');
|
||||
for (const [k, v] of Object.entries(vals)) lines.push(`- --ua-${g}-${k}: ${v}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/*** 生成双轨文档:人类版 apps/docs/index.html(含演示 iframe)+ Agent 版 llms-full.txt ***/
|
||||
function cmdDocs() {
|
||||
if (!fs.existsSync(INDEX_FILE)) cmdIndex();
|
||||
const index = loadIndex();
|
||||
const tokensPath = path.join(ROOT, 'packages', 'design', 'tokens', 'tokens.json');
|
||||
const tokens = fs.existsSync(tokensPath)
|
||||
? JSON.parse(fs.readFileSync(tokensPath, 'utf8'))
|
||||
: null;
|
||||
const outDir = path.join(ROOT, 'apps', 'docs');
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outDir, 'index.html'), renderDocs(index, tokens));
|
||||
const demoSrc = path.join(ROOT, 'apps', 'demo', 'dist', 'demo.html'); // 演示页拷入文档站供 iframe
|
||||
if (fs.existsSync(demoSrc)) fs.copyFileSync(demoSrc, path.join(outDir, 'demo.html'));
|
||||
fs.writeFileSync(LLMS_FULL, renderAgentDocs(index, tokens));
|
||||
console.log(
|
||||
`docs generated -> apps/docs/index.html + demo.html, llms-full.txt (${index.packages.length} packages)`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 内置 stdio MCP server(JSON-RPC 2.0,按行分帧) ----
|
||||
|
||||
/*** 启动 MCP server:向 Agent 暴露 list / get / search 三个查询工具 ***/
|
||||
function cmdMcp() {
|
||||
if (!fs.existsSync(INDEX_FILE)) cmdIndex();
|
||||
const { packages } = loadIndex();
|
||||
|
||||
const tools = [
|
||||
{
|
||||
name: 'list_components',
|
||||
description: 'List all packages/components in the @ui-arch private library',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
{
|
||||
name: 'get_component',
|
||||
description: 'Get full metadata (API, usage, conventions) of one component',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string', description: 'package name, e.g. @ui-arch/table-core or table-core' } },
|
||||
required: ['name'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'search_components',
|
||||
description: 'Search packages by keyword across name/description/tags/api',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { query: { type: 'string' } },
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const send = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
|
||||
|
||||
/*** 执行 MCP 工具调用 ***/
|
||||
function callTool(name, args) {
|
||||
if (name === 'list_components') {
|
||||
return packages.map((p) => ({ name: p.name, version: p.version, type: p.type, description: p.description }));
|
||||
}
|
||||
if (name === 'get_component') {
|
||||
const p = packages.find((x) => x.name === args.name || x.name === `@ui-arch/${args.name}`);
|
||||
if (!p) throw new Error(`not found: ${args.name}`);
|
||||
return p;
|
||||
}
|
||||
if (name === 'search_components') {
|
||||
const q = String(args.query ?? '').toLowerCase();
|
||||
return packages
|
||||
.filter((p) =>
|
||||
[p.name, p.description, p.type, ...(p.tags ?? []), ...(p.api?.exports ?? [])]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(q),
|
||||
)
|
||||
.map((p) => ({ name: p.name, version: p.version, type: p.type, description: p.description }));
|
||||
}
|
||||
throw new Error(`unknown tool: ${name}`);
|
||||
}
|
||||
|
||||
/*** 处理单条 JSON-RPC 消息;通知类消息不回包 ***/
|
||||
function handle(msg) {
|
||||
const { id, method, params } = msg;
|
||||
if (method === 'initialize') {
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
protocolVersion: params?.protocolVersion ?? '2024-11-05',
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: 'ui-arch-registry', version: '0.1.0' },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === 'tools/list') {
|
||||
send({ jsonrpc: '2.0', id, result: { tools } });
|
||||
return;
|
||||
}
|
||||
if (method === 'tools/call') {
|
||||
try {
|
||||
const result = callTool(params?.name, params?.arguments ?? {});
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] },
|
||||
});
|
||||
} catch (err) {
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: { isError: true, content: [{ type: 'text', text: String(err.message) }] },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id != null) {
|
||||
send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } });
|
||||
}
|
||||
}
|
||||
|
||||
let buf = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => {
|
||||
buf += chunk;
|
||||
let idx;
|
||||
while ((idx = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, idx).trim();
|
||||
buf = buf.slice(idx + 1);
|
||||
if (!line) continue;
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
handle(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 风格检查(CODING.md 的机器执行版) ----
|
||||
|
||||
/*** 递归收集仓库源码文件(packages + apps),跳过产物与工具目录 ***/
|
||||
function collectSourceFiles() {
|
||||
const exts = new Set(['.ts', '.mts', '.cts', '.mjs', '.js']);
|
||||
const skip = new Set(['node_modules', 'dist', '.ua', '.git', '.workbuddy']);
|
||||
const out = [];
|
||||
const walk = (dir) => {
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
if (skip.has(name)) continue;
|
||||
const full = path.join(dir, name);
|
||||
const st = fs.statSync(full);
|
||||
if (st.isDirectory()) walk(full);
|
||||
else if (exts.has(path.extname(name))) out.push(full);
|
||||
}
|
||||
};
|
||||
walk(ROOT);
|
||||
return out;
|
||||
}
|
||||
|
||||
/*** 检查单文件:导出缺注释块、接口字段缺行尾注释、行注释非中文 ***/
|
||||
function styleCheckFile(file, src) {
|
||||
const issues = [];
|
||||
const lines = src.split(/\r?\n/);
|
||||
let iface = null; // 当前追踪中的接口名
|
||||
let depth = 0; // 接口体花括号深度
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const raw = lines[i];
|
||||
const line = raw.trim();
|
||||
|
||||
if (iface) {
|
||||
for (const ch of raw) { // 花括号计数进出接口体
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
}
|
||||
if (/^[A-Za-z_$][\w$]*\??:/.test(line) && !line.includes('//')) {
|
||||
issues.push(`${file}:${i + 1} 接口 ${iface} 字段缺少行尾注释`);
|
||||
}
|
||||
if (depth <= 0) iface = null;
|
||||
} else {
|
||||
const im = raw.match(/\bexport\s+interface\s+(\w+)/);
|
||||
if (im) {
|
||||
depth = (raw.match(/\{/g) || []).length - (raw.match(/\}/g) || []).length;
|
||||
iface = im[1];
|
||||
}
|
||||
}
|
||||
|
||||
const decl = line.match(/^export\s+(?:async\s+)?(?:function|interface|type|class)\s+([A-Za-z_$][\w$]*)/);
|
||||
if (decl) {
|
||||
let j = i - 1;
|
||||
while (j >= 0 && lines[j].trim() === '') j--; // 找上一个非空行
|
||||
if (j < 0 || !lines[j].includes('/***')) {
|
||||
issues.push(`${file}:${i + 1} 导出 ${decl[1]} 缺少 /*** 作用 ***/ 注释`);
|
||||
}
|
||||
}
|
||||
|
||||
const hash = raw.indexOf('//');
|
||||
if (hash >= 0) {
|
||||
const text = raw.slice(hash + 2);
|
||||
const isPragma = /^[\s*/]*(eslint|@ts-|prettier-ignore|istanbul|c8|node:)/i.test(text);
|
||||
if (!isPragma && /[A-Za-z]{2,}/.test(text) && !/[\u4e00-\u9fff]/.test(text)) {
|
||||
issues.push(`${file}:${i + 1} 行注释需使用中文`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/*** 全仓风格检查:有违规退出码 1,全绿输出 OK ***/
|
||||
function cmdStyle() {
|
||||
const files = collectSourceFiles();
|
||||
const issues = [];
|
||||
for (const f of files) {
|
||||
issues.push(...styleCheckFile(f, fs.readFileSync(f, 'utf8')));
|
||||
}
|
||||
if (issues.length) {
|
||||
for (const it of issues) console.error('style: ' + it);
|
||||
console.error(`${issues.length} violation(s)`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`style OK (${files.length} files)`);
|
||||
}
|
||||
|
||||
// ---- 入口 ----
|
||||
|
||||
const [cmd, ...args] = process.argv.slice(2);
|
||||
switch (cmd) {
|
||||
case 'index':
|
||||
cmdIndex();
|
||||
break;
|
||||
case 'list':
|
||||
cmdList();
|
||||
break;
|
||||
case 'info':
|
||||
if (!args[0]) {
|
||||
console.error('usage: ua info <package-name>');
|
||||
process.exit(1);
|
||||
}
|
||||
cmdInfo(args[0]);
|
||||
break;
|
||||
case 'docs':
|
||||
cmdDocs();
|
||||
break;
|
||||
case 'style':
|
||||
cmdStyle();
|
||||
break;
|
||||
case 'mcp':
|
||||
cmdMcp();
|
||||
break;
|
||||
default:
|
||||
console.log('usage: ua <index|list|info <name>|docs|style|mcp>');
|
||||
process.exit(cmd ? 1 : 0);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@ui-arch/cli",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Agent-friendly CLI + MCP registry server for the @ui-arch component library",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"ua": "./bin/ua.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
Reference in New Issue
Block a user