init: @ui-arch 私人组件库 monorepo

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