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
+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;
}
}