init: @ui-arch 私人组件库 monorepo
- core/table-core: Headless 表格内核(排序/筛选/分页/选择插件 + 冒烟 9 项) - core/frame: signal/computed/effect + hyperscript 微框架(零依赖) - skin/ui: 16 个 DOM 组件(含自绘 select、滑动指示条 tabs、行选择 dataTable、form 表单校验) - design/tokens: --ua-* 设计令牌(light/dark) - tools/cli: ua 命令行(index/list/info/docs/style/mcp)+ MCP 注册表服务 - apps: demo 演示站(esbuild 单文件)+ docs 双轨文档(人类版 + llms-full.txt)
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@ui-arch/ui",
|
||||
"version": "0.1.0",
|
||||
"description": "DOM component skin for @ui-arch: 15 components built on frame + table-core, token-driven styles",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./ui.css": "./ui.css"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"ui.css"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ui-arch/frame": "workspace:*",
|
||||
"@ui-arch/table-core": "workspace:*",
|
||||
"@ui-arch/tokens": "workspace:*"
|
||||
},
|
||||
"keywords": [
|
||||
"components",
|
||||
"dom",
|
||||
"skin",
|
||||
"design-system"
|
||||
],
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { AlertProps } from './types.js';
|
||||
|
||||
/*** 提示条:variant 决定语义色,纯展示不持有状态 ***/
|
||||
export function alert(props: AlertProps): HTMLElement {
|
||||
return h('div', { class: `ua-alert ua-alert-${props.variant ?? 'info'}` }, props.message);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { BadgeProps } from './types.js';
|
||||
|
||||
/*** 徽标:variant 决定语义色,纯展示不持有状态 ***/
|
||||
export function badge(props: BadgeProps): HTMLElement {
|
||||
return h('span', { class: `ua-badge ua-badge-${props.variant ?? 'default'}` }, props.label);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { ButtonProps } from './types.js';
|
||||
|
||||
/*** 按钮:variant 控制视觉强度,onClick 为点击回调 ***/
|
||||
export function button(props: ButtonProps): HTMLElement {
|
||||
return h(
|
||||
'button',
|
||||
{ class: `ua-btn ua-btn-${props.variant ?? 'primary'}`, onClick: props.onClick },
|
||||
props.label,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { CardProps } from './types.js';
|
||||
|
||||
/*** 卡片容器:可选标题 + 任意子内容 ***/
|
||||
export function card(props: CardProps, ...children: unknown[]): HTMLElement {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'ua-card' },
|
||||
props.title ? h('h3', { class: 'ua-card-title' }, props.title) : null,
|
||||
...children,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { CheckboxProps } from './types.js';
|
||||
|
||||
/*** 复选框:checked 信号双向绑定,onChange 为变更回调 ***/
|
||||
export function checkbox(props: CheckboxProps): HTMLElement {
|
||||
const el = h('input', { type: 'checkbox', class: 'ua-checkbox' }) as HTMLInputElement;
|
||||
if (props.checked) {
|
||||
const checked = props.checked;
|
||||
el.addEventListener('change', () => checked(el.checked)); // 用户勾选写回信号
|
||||
effect(() => {
|
||||
if (el.checked !== checked()) el.checked = checked(); // 外部变化写回勾选框
|
||||
});
|
||||
}
|
||||
if (props.onChange) el.addEventListener('change', () => props.onChange!(el.checked));
|
||||
if (!props.label) return el;
|
||||
const wrap = h('label', { class: 'ua-check-row' }, el, props.label);
|
||||
return wrap;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
createTable,
|
||||
filterPlugin,
|
||||
paginationPlugin,
|
||||
selectionPlugin,
|
||||
sortPlugin,
|
||||
} from '@ui-arch/table-core';
|
||||
import type { PageInfo, RowKey, TableInstance } from '@ui-arch/table-core';
|
||||
import { computed, effect, h, renderList, signal } from '@ui-arch/frame';
|
||||
import { button } from './button.js';
|
||||
import { textField } from './textField.js';
|
||||
import type { DataTableProps } from './types.js';
|
||||
|
||||
/*** 数据表格:粘性表头 + 行选择 + 排序 + 筛选 + 分页 + 空状态;状态由 table-core 持有,信号只做桥接渲染 ***/
|
||||
export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const selectable = props.selectable ?? false; // 行选择开关
|
||||
const table = createTable({
|
||||
data: props.data,
|
||||
columns: props.columns,
|
||||
rowKey: 'id',
|
||||
plugins: [filterPlugin<T>(), sortPlugin<T>(), paginationPlugin<T>(), selectionPlugin<T>()],
|
||||
initialState: { pagination: { page: 1, pageSize: props.pageSize ?? 5 } },
|
||||
});
|
||||
const api = table as unknown as TableInstance<T> & {
|
||||
getPageInfo(): PageInfo;
|
||||
selectAllVisible(): void;
|
||||
};
|
||||
const version = signal(0); // 桥接信号:table-core 订阅通知 → 递增触发信号系统
|
||||
table.subscribe(() => version(version() + 1));
|
||||
|
||||
// —— 派生:全部由 version 单一来源推导,语义与内核一致 ——
|
||||
const visible = computed(() => {
|
||||
version();
|
||||
return table.getVisibleRows();
|
||||
});
|
||||
const state = computed(() => {
|
||||
version();
|
||||
return table.getState();
|
||||
});
|
||||
const info = computed(() => {
|
||||
visible();
|
||||
return api.getPageInfo();
|
||||
});
|
||||
const selectedCount = computed(() => state().selection.length);
|
||||
|
||||
const filterKey = props.filterKey ?? props.columns[0].key;
|
||||
const colspan = props.columns.length + (selectable ? 1 : 0);
|
||||
|
||||
// —— 表头全选框:checked / indeterminate 三态跟随当页选中情况 ——
|
||||
const headCheck = h('input', { type: 'checkbox', class: 'ua-checkbox' }) as HTMLInputElement;
|
||||
headCheck.addEventListener('change', () => {
|
||||
if (headCheck.checked) api.selectAllVisible();
|
||||
else table.dispatch({ type: 'clearSelection' });
|
||||
});
|
||||
effect(() => {
|
||||
const keys = visible().keys;
|
||||
const sel = state().selection;
|
||||
const onPage = keys.filter((k) => sel.includes(k)).length;
|
||||
headCheck.checked = keys.length > 0 && onPage === keys.length;
|
||||
headCheck.indeterminate = onPage > 0 && onPage < keys.length;
|
||||
});
|
||||
|
||||
/*** 排序标记:升 ↑ / 降 ↓ / 未排序 ↕(可点击提示) ***/
|
||||
const sortMark = (key: string) =>
|
||||
h(
|
||||
'span',
|
||||
{ class: 'ua-sort-mark' },
|
||||
() => {
|
||||
const rule = state().sorting.find((r) => r.key === key);
|
||||
return rule ? (rule.dir === 'asc' ? '↑' : '↓') : '↕';
|
||||
},
|
||||
);
|
||||
|
||||
const th = (col: (typeof props.columns)[number]) =>
|
||||
h(
|
||||
'th',
|
||||
{
|
||||
class: () => (col.sortable ? 'ua-sortable' : ''),
|
||||
onClick: () => col.sortable && table.dispatch({ type: 'toggleSort', key: col.key }),
|
||||
},
|
||||
col.header ?? col.key,
|
||||
col.sortable ? sortMark(col.key) : null,
|
||||
);
|
||||
|
||||
const tbody = h('tbody');
|
||||
renderList(tbody, () => {
|
||||
const sel = state().selection; // 读入依赖:选择变化即整表重绘(行数 = pageSize,代价可控)
|
||||
const rows = visible().rows;
|
||||
if (!rows.length) {
|
||||
return [h('tr', { class: 'ua-empty-row' }, h('td', { colspan }, '暂无匹配数据'))];
|
||||
}
|
||||
return rows.map((row, i) => {
|
||||
const key = visible().keys[i];
|
||||
const selected = sel.includes(key);
|
||||
const cells = props.columns.map((col) => h('td', null, String(table.getCellValue(row, col.key))));
|
||||
if (selectable) {
|
||||
const check = h('input', { type: 'checkbox', class: 'ua-checkbox' }) as HTMLInputElement;
|
||||
check.checked = selected;
|
||||
check.addEventListener('change', () => table.dispatch({ type: 'toggleSelect', key }));
|
||||
cells.unshift(h('td', { class: 'ua-cell-check' }, check));
|
||||
}
|
||||
return h('tr', selected ? { class: 'ua-row-selected' } : null, ...cells);
|
||||
});
|
||||
});
|
||||
|
||||
const filterInput = textField({
|
||||
placeholder: `筛选 ${filterKey}…`,
|
||||
onInput: (value) => {
|
||||
table.dispatch({ type: 'setFilter', key: filterKey, value });
|
||||
table.dispatch({ type: 'setPage', page: 1 }); // 筛选变化回第 1 页
|
||||
},
|
||||
});
|
||||
|
||||
return h(
|
||||
'section',
|
||||
{ class: 'ua-table' },
|
||||
h(
|
||||
'div',
|
||||
{ class: 'ua-table-toolbar' },
|
||||
filterInput,
|
||||
selectable ? h('span', { class: 'ua-sel-count' }, () => (selectedCount() ? `已选 ${selectedCount()} 行` : '')) : null,
|
||||
),
|
||||
h('div', { class: 'ua-table-scroll' }, h('table', null, h('thead', null, h('tr', null, selectable ? headCheck : null, ...props.columns.map(th))), tbody)),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'ua-pager' },
|
||||
button({ label: '上一页', variant: 'ghost', onClick: () => table.dispatch({ type: 'setPage', page: info().page - 1 }) }),
|
||||
h('span', { class: 'ua-page-info' }, () => `第 ${info().page} / ${info().totalPages} 页 · 共 ${visible().total} 行`),
|
||||
button({ label: '下一页', variant: 'ghost', onClick: () => table.dispatch({ type: 'setPage', page: info().page + 1 }) }),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import { button } from './button.js';
|
||||
import { textField } from './textField.js';
|
||||
import type { FormItemHandle, FormItemProps, FormProps, FormRule } from './types.js';
|
||||
|
||||
/*** 执行单条规则:null 通过,字符串为错误消息 ***/
|
||||
function checkRule(value: string, rule: FormRule): string | null {
|
||||
if (rule.required && !value.trim()) return rule.message ?? '该项为必填项';
|
||||
if (!value) return null; // 非必填且为空:其余规则跳过
|
||||
if (rule.min !== undefined && value.length < rule.min) return rule.message ?? `至少 ${rule.min} 个字符`;
|
||||
if (rule.max !== undefined && value.length > rule.max) return rule.message ?? `最多 ${rule.max} 个字符`;
|
||||
if (rule.pattern && !rule.pattern.test(value)) return rule.message ?? '格式不正确';
|
||||
if (rule.validator) {
|
||||
const r = rule.validator(value);
|
||||
if (r !== true) return typeof r === 'string' ? r : rule.message ?? '校验未通过';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/*** 表单字段:label + 控件插槽 + 错误区;返回句柄供 form 聚合校验 ***/
|
||||
export function formItem(props: FormItemProps, control: HTMLElement): FormItemHandle {
|
||||
// —— 状态集中声明 ——
|
||||
const mode = props.validateOn ?? 'blur'; // 校验时机;缺省失焦校验
|
||||
const rules = [...(props.required ? [{ required: true }] : []), ...(props.rules ?? [])];
|
||||
const error = h('div', { class: 'ua-form-error' });
|
||||
const label = h('label', { class: 'ua-form-label' }, props.label, props.required ? h('i', { class: 'ua-form-req' }, '*') : null);
|
||||
const el = h('div', { class: 'ua-form-item' }, label, h('div', { class: 'ua-form-control' }, control, error));
|
||||
|
||||
/*** 执行全部规则:返回首个错误消息,同时同步错误区与控件态 ***/
|
||||
const validate = () => {
|
||||
const value = props.value();
|
||||
let msg: string | null = null;
|
||||
for (const rule of rules) {
|
||||
msg = checkRule(value, rule);
|
||||
if (msg) break; // 首个失败即停止
|
||||
}
|
||||
error.textContent = msg ?? '';
|
||||
control.classList.toggle('ua-invalid', !!msg);
|
||||
return msg;
|
||||
};
|
||||
|
||||
control.addEventListener('input', () => {
|
||||
if (mode === 'input' || error.textContent) validate(); // input 模式即时校验;其余模式仅清已报错误
|
||||
});
|
||||
control.addEventListener('blur', () => {
|
||||
if (mode !== 'submit') validate();
|
||||
});
|
||||
return { el, validate };
|
||||
}
|
||||
|
||||
/*** 表单容器:布局 + 原生 submit 拦截 + 聚合校验(全部通过才触发 onSubmit) ***/
|
||||
export function form(props: FormProps, items: FormItemHandle[]): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
/*** 整体提交:逐项校验,聚焦首个出错项并中止 ***/
|
||||
const submit = () => {
|
||||
for (const item of items) {
|
||||
if (item.validate()) {
|
||||
(item.el.querySelector('input, textarea, select') as HTMLElement | null)?.focus();
|
||||
return; // 卫语句:有错误不提交
|
||||
}
|
||||
}
|
||||
props.onSubmit?.();
|
||||
};
|
||||
const submitBtn = button({ label: props.submitText ?? '提交', onClick: submit });
|
||||
const resetBtn = props.resetText ? button({ label: props.resetText, variant: 'ghost', onClick: props.onReset }) : null;
|
||||
const actions = h('div', { class: 'ua-form-actions' }, submitBtn, resetBtn);
|
||||
const el = h(
|
||||
'form',
|
||||
{ class: props.layout === 'horizontal' ? 'ua-form ua-form-h' : 'ua-form' },
|
||||
...items.map((i) => i.el),
|
||||
actions,
|
||||
);
|
||||
el.addEventListener('submit', (e) => {
|
||||
e.preventDefault(); // 拦截原生提交,走校验流程
|
||||
submit();
|
||||
});
|
||||
return el;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export * from './types.js';
|
||||
export { button } from './button.js';
|
||||
export { textField } from './textField.js';
|
||||
export { textarea } from './textarea.js';
|
||||
export { checkbox } from './checkbox.js';
|
||||
export { switchButton } from './switch.js';
|
||||
export { select } from './select.js';
|
||||
export { radioGroup } from './radioGroup.js';
|
||||
export { card } from './card.js';
|
||||
export { badge } from './badge.js';
|
||||
export { alert } from './alert.js';
|
||||
export { spinner } from './spinner.js';
|
||||
export { progress } from './progress.js';
|
||||
export { tabs } from './tabs.js';
|
||||
export { modal } from './modal.js';
|
||||
export { form, formItem } from './form.js';
|
||||
export { dataTable } from './dataTable.js';
|
||||
@@ -0,0 +1,33 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { ModalProps } from './types.js';
|
||||
|
||||
/*** 弹窗:open 信号驱动显隐,点遮罩、× 或 Esc 触发 onClose ***/
|
||||
export function modal(props: ModalProps, ...children: unknown[]): HTMLElement {
|
||||
const close = () => {
|
||||
if (!props.open()) return; // 卫语句:已关闭不重复触发回调
|
||||
props.open(false); // 写回信号触发隐藏
|
||||
props.onClose?.();
|
||||
};
|
||||
const overlay = h('div', {
|
||||
class: 'ua-modal-overlay',
|
||||
onClick: (e: Event) => {
|
||||
if (e.target === overlay) close(); // 卫语句:仅点遮罩本身关闭
|
||||
},
|
||||
});
|
||||
const box = h(
|
||||
'div',
|
||||
{ class: 'ua-modal' },
|
||||
h('div', { class: 'ua-modal-head' }, h('h3', { class: 'ua-modal-title' }, props.title), h('button', { class: 'ua-modal-close', onClick: close }, '×')),
|
||||
h('div', { class: 'ua-modal-body' }, ...children),
|
||||
);
|
||||
overlay.append(box);
|
||||
effect(() => {
|
||||
const open = props.open();
|
||||
overlay.classList.toggle('ua-open', open); // 显隐跟随信号
|
||||
if (open) (box.querySelector('button, input, textarea') as HTMLElement | null)?.focus(); // 打开时聚焦首个可交互元素
|
||||
});
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && props.open()) close(); // Esc 关闭当前打开的弹窗
|
||||
});
|
||||
return overlay;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { ProgressProps } from './types.js';
|
||||
|
||||
/*** 进度条:轨道 + 渐变流动填充,value 信号驱动 0-100;满格切换 success 色并停止条纹 ***/
|
||||
export function progress(props: ProgressProps): HTMLElement {
|
||||
const fill = h('div', { class: 'ua-progress-fill' });
|
||||
const track = h('div', { class: 'ua-progress-track' }, fill);
|
||||
const text = h('span', { class: 'ua-progress-text' });
|
||||
effect(() => {
|
||||
const v = clamp(props.value()); // 依赖收集:信号变化自动写回
|
||||
fill.style.width = `${v}%`;
|
||||
fill.classList.toggle('ua-done', v >= 100); // 满格切语义色
|
||||
text.textContent = `${Math.round(v)}%`;
|
||||
});
|
||||
return h('div', { class: 'ua-progress' }, track, props.showText === false ? null : text);
|
||||
}
|
||||
|
||||
/*** 进度钳制到 0-100 ***/
|
||||
function clamp(v: number): number {
|
||||
if (Number.isNaN(v)) return 0; // 卫语句:非法值归零
|
||||
return Math.min(100, Math.max(0, v));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { RadioGroupProps } from './types.js';
|
||||
|
||||
/*** 单选组:value 信号双向绑定,options 同组互斥,name 固定保证原生互斥行为 ***/
|
||||
export function radioGroup(props: RadioGroupProps): HTMLElement {
|
||||
const groupName = `ua-radio-${Math.random().toString(36).slice(2, 8)}`; // 组内唯一 name
|
||||
const radios: HTMLInputElement[] = [];
|
||||
for (const opt of props.options) {
|
||||
const input = h('input', { type: 'radio', name: groupName, class: 'ua-radio' }) as HTMLInputElement;
|
||||
input.value = opt.value;
|
||||
radios.push(input);
|
||||
input.addEventListener('change', () => {
|
||||
if (props.value) props.value(input.value); // 用户选择写回信号
|
||||
props.onChange?.(input.value);
|
||||
});
|
||||
}
|
||||
if (props.value) {
|
||||
const value = props.value;
|
||||
effect(() => {
|
||||
for (const input of radios) input.checked = input.value === value(); // 外部变化同步勾选态
|
||||
});
|
||||
}
|
||||
const rows = props.options.map((opt, i) => h('label', { class: 'ua-check-row' }, radios[i], opt.label));
|
||||
return h('div', { class: 'ua-radio-group' }, ...rows);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { SelectProps } from './types.js';
|
||||
|
||||
/*** 下拉选择:自绘弹层替代原生 select,value 信号双向绑定;点选项 / 点外部 / Esc 关闭 ***/
|
||||
export function select(props: SelectProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const open = signal(false); // 弹层开合
|
||||
const value = props.value ?? signal(''); // 选中值;未传信号则内部持有
|
||||
|
||||
const labelOf = (v: string) => props.options.find((o) => o.value === v)?.label ?? '';
|
||||
const current = () => labelOf(value()); // 触发器显示文案(响应式)
|
||||
|
||||
const pick = (v: string) => {
|
||||
value(v);
|
||||
open(false);
|
||||
props.onChange?.(v);
|
||||
};
|
||||
|
||||
// —— 触发器 ——
|
||||
const trigger = h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'ua-select-trigger',
|
||||
'aria-haspopup': 'listbox',
|
||||
'aria-expanded': () => String(open()),
|
||||
onClick: () => open(!open()),
|
||||
},
|
||||
h('span', { class: () => `ua-select-value${value() ? '' : ' is-empty'}` }, () => current() || props.placeholder || ''),
|
||||
h('span', { class: 'ua-select-arrow' }, '▾'),
|
||||
);
|
||||
|
||||
// —— 选项弹层 ——
|
||||
const options = props.options.map((opt) =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: () => `ua-option${opt.value === value() ? ' ua-option-selected' : ''}`,
|
||||
role: 'option',
|
||||
'aria-selected': () => String(opt.value === value()),
|
||||
onClick: () => pick(opt.value),
|
||||
},
|
||||
opt.label,
|
||||
),
|
||||
);
|
||||
const pop = h('div', { class: () => `ua-select-pop${open() ? ' ua-open' : ''}`, role: 'listbox' }, ...options);
|
||||
|
||||
const root = h('div', { class: 'ua-select' }, trigger, pop);
|
||||
|
||||
root.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') open(false); // Esc 关闭弹层
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
|
||||
open(false); // 点外部关闭
|
||||
});
|
||||
effect(() => open()); // 建立依赖,保证开合信号生效
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { SpinnerProps } from './types.js';
|
||||
|
||||
/*** 加载指示器:纯 CSS 旋转动画,size 控制直径 ***/
|
||||
export function spinner(props: SpinnerProps = {}): HTMLElement {
|
||||
const size = `${props.size ?? 18}px`;
|
||||
const el = h('span', { class: 'ua-spinner' });
|
||||
el.style.width = size;
|
||||
el.style.height = size;
|
||||
return el;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { SwitchProps } from './types.js';
|
||||
|
||||
/*** 开关:checked 信号双向绑定,轨道 + 滑块的纯 CSS 实现 ***/
|
||||
export function switchButton(props: SwitchProps): HTMLElement {
|
||||
const input = h('input', { type: 'checkbox', class: 'ua-switch-input' }) as HTMLInputElement;
|
||||
if (props.checked) {
|
||||
const checked = props.checked;
|
||||
input.addEventListener('change', () => checked(input.checked)); // 用户切换写回信号
|
||||
effect(() => {
|
||||
if (input.checked !== checked()) input.checked = checked(); // 外部变化写回开关
|
||||
});
|
||||
}
|
||||
if (props.onChange) input.addEventListener('change', () => props.onChange!(input.checked));
|
||||
const track = h('span', { class: 'ua-switch-track' }, h('span', { class: 'ua-switch-thumb' }));
|
||||
const el = h('label', { class: 'ua-switch' }, input, track);
|
||||
if (props.label) el.append(h('span', { class: 'ua-switch-label' }, props.label));
|
||||
return el;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { TabsProps } from './types.js';
|
||||
|
||||
/*** 标签页:active 信号双向绑定 + 滑动指示条 + 方向键环形导航;内容由调用方按 active 派生 ***/
|
||||
export function tabs(props: TabsProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const buttons: HTMLButtonElement[] = []; // 页签按钮,顺序与 tabs 一致
|
||||
const indicator = h('span', { class: 'ua-tabs-indicator' });
|
||||
const head = h('div', { class: 'ua-tabs-head', role: 'tablist' }, indicator);
|
||||
|
||||
/*** 同步激活态与指示条几何位置 ***/
|
||||
const sync = () => {
|
||||
const value = props.active?.();
|
||||
for (const btn of buttons) {
|
||||
const on = btn.dataset.value === value;
|
||||
btn.classList.toggle('ua-tab-active', on);
|
||||
btn.setAttribute('aria-selected', String(on));
|
||||
btn.tabIndex = on ? 0 : -1; // 漫游 tabindex:仅激活页签可 Tab 聚焦
|
||||
if (on) {
|
||||
indicator.style.left = `${btn.offsetLeft}px`;
|
||||
indicator.style.width = `${btn.offsetWidth}px`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const tab of props.tabs) {
|
||||
const btn = h('button', { class: 'ua-tab', role: 'tab', type: 'button' }, tab.label) as HTMLButtonElement;
|
||||
btn.dataset.value = tab.value;
|
||||
btn.disabled = tab.disabled ?? false;
|
||||
btn.addEventListener('click', () => {
|
||||
if (btn.disabled) return; // 卫语句:禁用页签不响应
|
||||
if (props.active) props.active(tab.value); // 用户切换写回信号
|
||||
props.onChange?.(tab.value);
|
||||
});
|
||||
buttons.push(btn);
|
||||
head.append(btn);
|
||||
}
|
||||
|
||||
head.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
const delta = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
|
||||
if (!delta) return; // 卫语句:非方向键放行
|
||||
const list = buttons.filter((b) => !b.disabled);
|
||||
const i = list.indexOf(document.activeElement as HTMLButtonElement);
|
||||
if (i < 0) return; // 卫语句:焦点不在页签上
|
||||
e.preventDefault();
|
||||
const next = list[(i + delta + list.length) % list.length]; // 环形移动
|
||||
next.focus();
|
||||
next.click();
|
||||
});
|
||||
|
||||
effect(sync);
|
||||
requestAnimationFrame(sync); // 挂载后再量一次布局,否则指示条宽度为 0
|
||||
return h('div', { class: 'ua-tabs' }, head);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { TextFieldProps } from './types.js';
|
||||
|
||||
/*** 文本输入:value 信号双向绑定,onInput 为输入回调 ***/
|
||||
export function textField(props: TextFieldProps): HTMLInputElement {
|
||||
const el = h('input', { class: 'ua-input', placeholder: props.placeholder ?? '' }) as HTMLInputElement;
|
||||
if (props.value) {
|
||||
const signal = props.value;
|
||||
el.addEventListener('input', () => signal(el.value)); // 用户输入写回信号
|
||||
effect(() => {
|
||||
if (el.value !== signal()) el.value = signal(); // 外部变化写回输入框
|
||||
});
|
||||
}
|
||||
if (props.onInput) el.addEventListener('input', () => props.onInput!(el.value));
|
||||
return el;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { TextareaProps } from './types.js';
|
||||
|
||||
/*** 多行输入:value 信号双向绑定,onInput 为输入回调 ***/
|
||||
export function textarea(props: TextareaProps): HTMLTextAreaElement {
|
||||
const el = h('textarea', { class: 'ua-textarea', rows: props.rows ?? 3, placeholder: props.placeholder ?? '' }) as HTMLTextAreaElement;
|
||||
if (props.value) {
|
||||
const signal = props.value;
|
||||
el.addEventListener('input', () => signal(el.value)); // 用户输入写回信号
|
||||
effect(() => {
|
||||
if (el.value !== signal()) el.value = signal(); // 外部变化写回输入框
|
||||
});
|
||||
}
|
||||
if (props.onInput) el.addEventListener('input', () => props.onInput!(el.value));
|
||||
return el;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { ColumnDef, RowKey } from '@ui-arch/table-core';
|
||||
import type { Signal } from '@ui-arch/frame';
|
||||
|
||||
/*** 按钮入参 ***/
|
||||
export interface ButtonProps {
|
||||
label: string; // 按钮文案
|
||||
variant?: 'primary' | 'ghost' | 'danger'; // 视觉强度;缺省 primary
|
||||
onClick?: () => void; // 点击回调
|
||||
}
|
||||
|
||||
/*** 文本输入入参 ***/
|
||||
export interface TextFieldProps {
|
||||
placeholder?: string; // 占位文案
|
||||
value?: Signal<string>; // 双向绑定的信号
|
||||
onInput?: (value: string) => void; // 输入回调
|
||||
}
|
||||
|
||||
/*** 卡片入参 ***/
|
||||
export interface CardProps {
|
||||
title?: string; // 标题;缺省不渲染标题行
|
||||
}
|
||||
|
||||
/*** 数据表格入参 ***/
|
||||
export interface DataTableProps<T> {
|
||||
data: T[]; // 全量行数据,行须含 id 字段作 rowKey
|
||||
columns: ColumnDef<T>[]; // 列定义
|
||||
filterKey?: string; // 工具栏筛选的列;缺省取第一列
|
||||
pageSize?: number; // 每页行数;缺省 5
|
||||
selectable?: boolean; // 是否启用行选择列(含全选三态与已选计数);缺省 false
|
||||
}
|
||||
|
||||
/*** 选项条目:select / radioGroup / tabs 共用的选项形状 ***/
|
||||
export interface OptionItem {
|
||||
value: string; // 选项值(提交/匹配用)
|
||||
label: string; // 选项文案(展示用)
|
||||
disabled?: boolean; // 仅 tabs 消费:禁用该页签
|
||||
}
|
||||
|
||||
/*** 复选框入参 ***/
|
||||
export interface CheckboxProps {
|
||||
label?: string; // 右侧文案;缺省不渲染
|
||||
checked?: Signal<boolean>; // 勾选状态信号,双向绑定
|
||||
onChange?: (checked: boolean) => void; // 变更回调
|
||||
}
|
||||
|
||||
/*** 开关入参 ***/
|
||||
export interface SwitchProps {
|
||||
label?: string; // 左侧文案;缺省不渲染
|
||||
checked?: Signal<boolean>; // 开关状态信号,双向绑定
|
||||
onChange?: (checked: boolean) => void; // 变更回调
|
||||
}
|
||||
|
||||
/*** 下拉选择入参 ***/
|
||||
export interface SelectProps {
|
||||
options: OptionItem[]; // 选项列表
|
||||
value?: Signal<string>; // 选中值信号,双向绑定
|
||||
placeholder?: string; // 未选中时的占位项文案
|
||||
onChange?: (value: string) => void; // 变更回调
|
||||
}
|
||||
|
||||
/*** 单选组入参 ***/
|
||||
export interface RadioGroupProps {
|
||||
options: OptionItem[]; // 选项列表
|
||||
value?: Signal<string>; // 选中值信号,双向绑定
|
||||
onChange?: (value: string) => void; // 变更回调
|
||||
}
|
||||
|
||||
/*** 多行输入入参 ***/
|
||||
export interface TextareaProps {
|
||||
placeholder?: string; // 占位文案
|
||||
rows?: number; // 行数;缺省 3
|
||||
value?: Signal<string>; // 双向绑定的信号
|
||||
onInput?: (value: string) => void; // 输入回调
|
||||
}
|
||||
|
||||
/*** 徽标入参 ***/
|
||||
export interface BadgeProps {
|
||||
label: string; // 徽标文案
|
||||
variant?: 'default' | 'success' | 'warning' | 'danger'; // 语义色;缺省 default
|
||||
}
|
||||
|
||||
/*** 提示条入参 ***/
|
||||
export interface AlertProps {
|
||||
message: string; // 提示内容
|
||||
variant?: 'info' | 'success' | 'warning' | 'danger'; // 语义色;缺省 info
|
||||
}
|
||||
|
||||
/*** 加载指示器入参 ***/
|
||||
export interface SpinnerProps {
|
||||
size?: number; // 直径像素;缺省 18
|
||||
}
|
||||
|
||||
/*** 进度条入参 ***/
|
||||
export interface ProgressProps {
|
||||
value: Signal<number>; // 当前进度 0-100 的信号
|
||||
showText?: boolean; // 是否右侧显示百分比;缺省 true
|
||||
}
|
||||
|
||||
/*** 标签页入参 ***/
|
||||
export interface TabsProps {
|
||||
tabs: OptionItem[]; // 页签列表
|
||||
active?: Signal<string>; // 当前激活页签的 value 信号,双向绑定
|
||||
onChange?: (value: string) => void; // 切换回调
|
||||
}
|
||||
|
||||
/*** 弹窗入参 ***/
|
||||
export interface ModalProps {
|
||||
title: string; // 标题
|
||||
open: Signal<boolean>; // 开关信号:true 显示 / false 隐藏
|
||||
onClose?: () => void; // 关闭回调(点遮罩、× 或 Esc 触发)
|
||||
}
|
||||
|
||||
/*** 表单校验规则:按序执行,首个失败即停止 ***/
|
||||
export interface FormRule {
|
||||
required?: boolean; // 非空校验(trim 后判定)
|
||||
min?: number; // 最小长度
|
||||
max?: number; // 最大长度
|
||||
pattern?: RegExp; // 正则格式校验
|
||||
validator?: (value: string) => boolean | string; // 自定义校验:true 通过 / 字符串为错误消息
|
||||
message?: string; // 该规则的错误消息;缺省按规则类型给默认文案
|
||||
}
|
||||
|
||||
/*** 表单字段入参 ***/
|
||||
export interface FormItemProps {
|
||||
label: string; // 字段标签
|
||||
value: Signal<string>; // 字段值信号(校验数据源)
|
||||
required?: boolean; // 必填:显示红星并自动追加非空规则
|
||||
rules?: FormRule[]; // 附加校验规则,按序执行
|
||||
validateOn?: 'input' | 'blur' | 'submit'; // 校验时机;缺省 blur
|
||||
}
|
||||
|
||||
/*** 表单字段句柄:form 聚合校验用 ***/
|
||||
export interface FormItemHandle {
|
||||
el: HTMLElement; // 字段根元素(label + 控件 + 错误区)
|
||||
validate(): string | null; // 执行校验:null 通过 / 字符串为错误消息
|
||||
}
|
||||
|
||||
/*** 表单容器入参 ***/
|
||||
export interface FormProps {
|
||||
layout?: 'vertical' | 'horizontal'; // 布局方向;缺省 vertical
|
||||
submitText?: string; // 提交按钮文案;缺省「提交」
|
||||
resetText?: string; // 重置按钮文案;缺省不渲染重置按钮
|
||||
onSubmit?: () => void; // 全部字段校验通过后的提交回调
|
||||
onReset?: () => void; // 重置回调
|
||||
}
|
||||
|
||||
export type { ColumnDef, RowKey }; // 再导出常用内核类型,方便调用方
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "dist",
|
||||
"paths": {
|
||||
"@ui-arch/frame": ["../../core/frame/dist/index.d.ts"],
|
||||
"@ui-arch/table-core": ["../../core/table-core/dist/index.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
{
|
||||
"name": "@ui-arch/ui",
|
||||
"type": "skin-dom",
|
||||
"description": "DOM 皮肤组件库:button / textField / textarea / checkbox / switch / select / radioGroup / card / badge / alert / spinner / progress / tabs / modal / form / formItem / dataTable,构建在 @ui-arch/frame 与 @ui-arch/table-core 之上,样式只消费 --ua-* 令牌。",
|
||||
"tags": [
|
||||
"components",
|
||||
"dom",
|
||||
"skin",
|
||||
"table",
|
||||
"form",
|
||||
"feedback"
|
||||
],
|
||||
"api": {
|
||||
"entry": "src/index.ts",
|
||||
"exports": [
|
||||
"button(props):按钮,variant = primary | ghost | danger",
|
||||
"textField(props):文本输入,value 信号双向绑定",
|
||||
"textarea(props):多行输入,value 信号双向绑定",
|
||||
"checkbox(props):复选框,checked 信号双向绑定",
|
||||
"switchButton(props):开关,checked 信号双向绑定",
|
||||
"select(props):自绘下拉选择(非原生 select),value 信号双向绑定,点外部 / Esc 关闭,选项弹层带微动画",
|
||||
"radioGroup(props):单选组,options + value 信号双向绑定",
|
||||
"card(props, ...children):卡片容器",
|
||||
"badge(props):徽标,variant = default | success | warning | danger",
|
||||
"alert(props):提示条,variant = info | success | warning | danger",
|
||||
"spinner(props):加载指示器,size 控制直径",
|
||||
"progress(props):进度条,轨道 + 渐变流动填充,value 信号驱动 0-100,满格切 success 色",
|
||||
"tabs(props):标签页,active 信号双向绑定,滑动指示条 + 方向键环形导航,支持禁用页签",
|
||||
"modal(props, ...children):弹窗,open 信号驱动显隐,Esc / 遮罩 / × 关闭",
|
||||
"form(props, items):表单容器,布局 + 聚合校验,全部通过才触发 onSubmit",
|
||||
"formItem(props, control):表单字段,label + 必填红星 + 规则校验 + 错误区,返回 { el, validate } 句柄",
|
||||
"dataTable(props):数据表格,粘性表头 / 行选择(全选三态)/ 排序 / 筛选 / 分页 / 空状态,内核为 table-core"
|
||||
],
|
||||
"keyTypes": [
|
||||
"ButtonProps",
|
||||
"TextFieldProps",
|
||||
"TextareaProps",
|
||||
"CheckboxProps",
|
||||
"SwitchProps",
|
||||
"SelectProps",
|
||||
"RadioGroupProps",
|
||||
"OptionItem",
|
||||
"CardProps",
|
||||
"BadgeProps",
|
||||
"AlertProps",
|
||||
"SpinnerProps",
|
||||
"ProgressProps",
|
||||
"TabsProps",
|
||||
"ModalProps",
|
||||
"FormRule",
|
||||
"FormItemProps",
|
||||
"FormItemHandle",
|
||||
"FormProps",
|
||||
"DataTableProps"
|
||||
]
|
||||
},
|
||||
"usage": "const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')",
|
||||
"conventions": [
|
||||
"皮肤不持有业务状态,一律委托 headless 内核或调用方信号",
|
||||
"表单类组件统一约定:*Props.value/checked/active 为 Signal 双向绑定,onChange/onInput 为回调",
|
||||
"语义色 variant 四档:default|info / success / warning / danger,一律来自令牌,禁止硬编码颜色"
|
||||
],
|
||||
"docs": {
|
||||
"intro": "16 个 DOM 组件,统一用法:组件是普通函数,传入 props 返回 DOM 节点;需要状态时先创建 signal 再双向绑定。引入 CSS:import '@ui-arch/ui/ui.css'(内部已消费 tokens)。",
|
||||
"steps": [
|
||||
"安装依赖:pnpm add @ui-arch/ui @ui-arch/frame(workspace 内自动链接)",
|
||||
"引入样式:在应用入口 import '@ui-arch/ui/ui.css',暗色主题在根元素设 data-theme='dark'",
|
||||
"创建状态:import { signal } from '@ui-arch/frame',如 const name = signal('')",
|
||||
"调用组件:const el = textField({ value: name }),把 el 挂到你的容器或配合 h() 组合",
|
||||
"读写状态:name() 读、name('张三') 写,界面自动同步,无需手动操作 DOM"
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"id": "button",
|
||||
"title": "按钮 Button",
|
||||
"demo": "sec-counter",
|
||||
"props": [
|
||||
{
|
||||
"n": "label",
|
||||
"t": "string",
|
||||
"r": 1,
|
||||
"d": "按钮文案"
|
||||
},
|
||||
{
|
||||
"n": "variant",
|
||||
"t": "'primary' | 'ghost' | 'danger'",
|
||||
"d": "视觉强度;缺省 primary"
|
||||
},
|
||||
{
|
||||
"n": "onClick",
|
||||
"t": "() => void",
|
||||
"k": 1,
|
||||
"d": "点击回调"
|
||||
}
|
||||
],
|
||||
"example": "button({ label: '提交', variant: 'primary', onClick: () => console.log('clicked') })",
|
||||
"steps": [
|
||||
"确定语义强度:主操作用 primary(默认),次要操作用 ghost,危险操作用 danger",
|
||||
"绑定 onClick 回调处理点击逻辑",
|
||||
"需要禁用态时自行在回调首行卫语句判断,或外层包条件渲染"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "textField",
|
||||
"title": "文本输入 TextField",
|
||||
"demo": "sec-bind",
|
||||
"props": [
|
||||
{
|
||||
"n": "value",
|
||||
"t": "Signal<string>",
|
||||
"d": "双向绑定信号:输入写回信号,信号变化写回输入框"
|
||||
},
|
||||
{
|
||||
"n": "placeholder",
|
||||
"t": "string",
|
||||
"d": "占位文案"
|
||||
},
|
||||
{
|
||||
"n": "onInput",
|
||||
"t": "(value: string) => void",
|
||||
"k": 1,
|
||||
"d": "输入回调,每次按键触发"
|
||||
}
|
||||
],
|
||||
"example": "const name = signal(''); const el = textField({ value: name, placeholder: '输入名字…', onInput: (v) => console.log(v) })",
|
||||
"steps": [
|
||||
"先用 signal('') 创建字符串信号",
|
||||
"传入 value 完成双向绑定:用户输入自动写回,外部赋值自动回显",
|
||||
"在任意响应式位置用 name() 读取最新值,如 () => `你好,${name()}`"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "textarea",
|
||||
"title": "多行输入 Textarea",
|
||||
"demo": "sec-form",
|
||||
"props": [
|
||||
{
|
||||
"n": "value",
|
||||
"t": "Signal<string>",
|
||||
"d": "双向绑定信号"
|
||||
},
|
||||
{
|
||||
"n": "rows",
|
||||
"t": "number",
|
||||
"d": "初始行数;缺省 3"
|
||||
},
|
||||
{
|
||||
"n": "placeholder",
|
||||
"t": "string",
|
||||
"d": "占位文案"
|
||||
},
|
||||
{
|
||||
"n": "onInput",
|
||||
"t": "(value: string) => void",
|
||||
"k": 1,
|
||||
"d": "输入回调"
|
||||
}
|
||||
],
|
||||
"example": "const bio = signal(''); textarea({ value: bio, rows: 4, placeholder: '一句话介绍…' })",
|
||||
"steps": [
|
||||
"创建信号并传入 value",
|
||||
"用 rows 控制初始高度,用户可纵向拉伸",
|
||||
"字数统计等派生逻辑直接在响应式文本里读 bio().length"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "checkbox",
|
||||
"title": "复选框 Checkbox",
|
||||
"demo": "sec-form",
|
||||
"props": [
|
||||
{
|
||||
"n": "checked",
|
||||
"t": "Signal<boolean>",
|
||||
"d": "勾选状态信号,双向绑定"
|
||||
},
|
||||
{
|
||||
"n": "label",
|
||||
"t": "string",
|
||||
"d": "右侧文案;缺省只渲染勾选框"
|
||||
},
|
||||
{
|
||||
"n": "onChange",
|
||||
"t": "(checked: boolean) => void",
|
||||
"k": 1,
|
||||
"d": "变更回调"
|
||||
}
|
||||
],
|
||||
"example": "const agreed = signal(false); checkbox({ label: '同意条款', checked: agreed, onChange: (v) => console.log(v) })",
|
||||
"steps": [
|
||||
"创建布尔信号 agreed = signal(false)",
|
||||
"传入 checked 与 label,勾选即写回信号",
|
||||
"表单提交前用 agreed() 判断是否勾选"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "switchButton",
|
||||
"title": "开关 Switch",
|
||||
"demo": "sec-form",
|
||||
"props": [
|
||||
{
|
||||
"n": "checked",
|
||||
"t": "Signal<boolean>",
|
||||
"d": "开关状态信号,双向绑定"
|
||||
},
|
||||
{
|
||||
"n": "label",
|
||||
"t": "string",
|
||||
"d": "左侧文案"
|
||||
},
|
||||
{
|
||||
"n": "onChange",
|
||||
"t": "(checked: boolean) => void",
|
||||
"k": 1,
|
||||
"d": "变更回调"
|
||||
}
|
||||
],
|
||||
"example": "const night = signal(true); switchButton({ label: '夜间模式', checked: night })",
|
||||
"steps": [
|
||||
"创建布尔信号并传入 checked",
|
||||
"轨道与滑块的动画由 CSS 完成,无需干预",
|
||||
"状态变化在 onChange 里做副作用(如切换主题)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "select",
|
||||
"title": "下拉选择 Select(自绘弹层)",
|
||||
"demo": "sec-form",
|
||||
"props": [
|
||||
{
|
||||
"n": "options",
|
||||
"t": "OptionItem[]",
|
||||
"r": 1,
|
||||
"d": "选项列表,每项 { value, label }"
|
||||
},
|
||||
{
|
||||
"n": "value",
|
||||
"t": "Signal<string>",
|
||||
"d": "选中值信号,双向绑定"
|
||||
},
|
||||
{
|
||||
"n": "placeholder",
|
||||
"t": "string",
|
||||
"d": "未选中时触发器显示的占位文案"
|
||||
},
|
||||
{
|
||||
"n": "onChange",
|
||||
"t": "(value: string) => void",
|
||||
"k": 1,
|
||||
"d": "选中回调"
|
||||
}
|
||||
],
|
||||
"example": "const city = signal(''); select({ options: [{ value: 'bj', label: '北京' }], value: city, placeholder: '选择城市…' })",
|
||||
"steps": [
|
||||
"准备 options 数组,value 是提交用值、label 是展示文案",
|
||||
"创建信号并传入 value;未传时组件内部自持状态",
|
||||
"点击触发器展开弹层,点选项写回信号并收起;点外部或按 Esc 也会收起",
|
||||
"选中项在弹层中用淡色弱化标记,不抢视觉焦点"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "radioGroup",
|
||||
"title": "单选组 RadioGroup",
|
||||
"demo": "sec-form",
|
||||
"props": [
|
||||
{
|
||||
"n": "options",
|
||||
"t": "OptionItem[]",
|
||||
"r": 1,
|
||||
"d": "选项列表"
|
||||
},
|
||||
{
|
||||
"n": "value",
|
||||
"t": "Signal<string>",
|
||||
"d": "选中值信号,双向绑定"
|
||||
},
|
||||
{
|
||||
"n": "onChange",
|
||||
"t": "(value: string) => void",
|
||||
"k": 1,
|
||||
"d": "变更回调"
|
||||
}
|
||||
],
|
||||
"example": "const size = signal('m'); radioGroup({ options: [{ value: 's', label: '小' }, { value: 'm', label: '中' }], value: size })",
|
||||
"steps": [
|
||||
"创建字符串信号,初值设为默认选项的 value",
|
||||
"传入 options 与 value,组内自动互斥",
|
||||
"读取 size() 获取当前选中值"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card",
|
||||
"title": "卡片 Card",
|
||||
"demo": "sec-card",
|
||||
"props": [
|
||||
{
|
||||
"n": "title",
|
||||
"t": "string",
|
||||
"d": "标题;缺省不渲染标题行"
|
||||
},
|
||||
{
|
||||
"n": "...children",
|
||||
"t": "unknown[]",
|
||||
"d": "任意子节点:字符串 / DOM / 响应式函数"
|
||||
}
|
||||
],
|
||||
"example": "card({ title: '用户表' }, dataTable({ data, columns }))",
|
||||
"steps": [
|
||||
"card 是容器组件:第一个参数是 props,其余参数是任意子节点",
|
||||
"传 title 显示标题行,否则只有内容区",
|
||||
"子节点可以混合字符串、DOM 节点与响应式函数"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "badge",
|
||||
"title": "徽标 Badge",
|
||||
"demo": "sec-feedback",
|
||||
"props": [
|
||||
{
|
||||
"n": "label",
|
||||
"t": "string",
|
||||
"r": 1,
|
||||
"d": "徽标文案"
|
||||
},
|
||||
{
|
||||
"n": "variant",
|
||||
"t": "'default' | 'success' | 'warning' | 'danger'",
|
||||
"d": "语义色;缺省 default"
|
||||
}
|
||||
],
|
||||
"example": "badge({ label: '成功', variant: 'success' })",
|
||||
"steps": [
|
||||
"选择语义色:default 中性、success 成功、warning 警告、danger 危险",
|
||||
"传入 label 即可,纯展示组件无状态"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "alert",
|
||||
"title": "提示条 Alert",
|
||||
"demo": "sec-feedback",
|
||||
"props": [
|
||||
{
|
||||
"n": "message",
|
||||
"t": "string",
|
||||
"r": 1,
|
||||
"d": "提示内容"
|
||||
},
|
||||
{
|
||||
"n": "variant",
|
||||
"t": "'info' | 'success' | 'warning' | 'danger'",
|
||||
"d": "语义色;缺省 info"
|
||||
}
|
||||
],
|
||||
"example": "alert({ message: '操作可能存在风险', variant: 'warning' })",
|
||||
"steps": [
|
||||
"按信息级别选 variant:info 提示、success 成功、warning 警告、danger 错误",
|
||||
"传入 message 即可,入场自带下滑动画"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "spinner",
|
||||
"title": "加载指示器 Spinner",
|
||||
"demo": "sec-feedback",
|
||||
"props": [
|
||||
{
|
||||
"n": "size",
|
||||
"t": "number",
|
||||
"d": "直径像素;缺省 18"
|
||||
}
|
||||
],
|
||||
"example": "spinner(); spinner({ size: 26 })",
|
||||
"steps": [
|
||||
"数据加载时插入 spinner(),加载完成移除",
|
||||
"用 size 匹配上下文尺寸(按钮内用小号,整页 loading 用大号)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "progress",
|
||||
"title": "进度条 Progress",
|
||||
"demo": "sec-feedback",
|
||||
"props": [
|
||||
{
|
||||
"n": "value",
|
||||
"t": "Signal<number>",
|
||||
"r": 1,
|
||||
"d": "进度 0-100 信号,变化自动平滑过渡"
|
||||
},
|
||||
{
|
||||
"n": "showText",
|
||||
"t": "boolean",
|
||||
"d": "是否显示百分比文字;缺省 true"
|
||||
}
|
||||
],
|
||||
"example": "const pct = signal(0); progress({ value: pct }); setInterval(() => pct((pct() + 5) % 105), 400)",
|
||||
"steps": [
|
||||
"创建数值信号 pct = signal(0),范围 0-100(越界自动钳制)",
|
||||
"传入 value,填充宽度与百分比文字随信号平滑过渡",
|
||||
"进行中填充带流动条纹,到 100% 自动变 success 色并停止条纹",
|
||||
"不需要百分比文字时传 showText: false"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tabs",
|
||||
"title": "标签页 Tabs",
|
||||
"demo": "sec-tabs-modal",
|
||||
"props": [
|
||||
{
|
||||
"n": "tabs",
|
||||
"t": "OptionItem[]",
|
||||
"r": 1,
|
||||
"d": "页签列表,每项 { value, label }"
|
||||
},
|
||||
{
|
||||
"n": "active",
|
||||
"t": "Signal<string>",
|
||||
"d": "当前激活页签 value 信号,双向绑定"
|
||||
},
|
||||
{
|
||||
"n": "onChange",
|
||||
"t": "(value: string) => void",
|
||||
"k": 1,
|
||||
"d": "切换回调"
|
||||
}
|
||||
],
|
||||
"example": "const tab = signal('a'); tabs({ tabs: [{ value: 'a', label: '概览' }, { value: 'b', label: '明细', disabled: true }], active: tab })",
|
||||
"steps": [
|
||||
"创建信号 tab = signal('a'),初值为默认页签",
|
||||
"tabs 只渲染页签头;页签内容用响应式函数按信号切换,如 () => tab() === 'a' ? panelA : panelB",
|
||||
"点击或方向键(←→ 环形)切换,滑动指示条自动跟随;disabled: true 的页签置灰不可点",
|
||||
"键盘可访问:Tab 聚焦激活页签,←→ 在启用页签间环形移动"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "modal",
|
||||
"title": "弹窗 Modal",
|
||||
"demo": "sec-tabs-modal",
|
||||
"props": [
|
||||
{
|
||||
"n": "open",
|
||||
"t": "Signal<boolean>",
|
||||
"r": 1,
|
||||
"d": "开关信号:true 显示 / false 隐藏"
|
||||
},
|
||||
{
|
||||
"n": "title",
|
||||
"t": "string",
|
||||
"r": 1,
|
||||
"d": "标题"
|
||||
},
|
||||
{
|
||||
"n": "onClose",
|
||||
"t": "() => void",
|
||||
"k": 1,
|
||||
"d": "关闭回调(点遮罩、× 或 Esc 触发)"
|
||||
},
|
||||
{
|
||||
"n": "...children",
|
||||
"t": "unknown[]",
|
||||
"d": "弹窗内容子节点"
|
||||
}
|
||||
],
|
||||
"example": "const open = signal(false); modal({ title: '确认操作', open, onClose: () => open(false) }, h('p', null, '内容'))",
|
||||
"steps": [
|
||||
"创建布尔信号 open = signal(false),弹窗初始隐藏",
|
||||
"把 open 传给 modal,其余参数为弹窗内容子节点",
|
||||
"打开:open(true);关闭:open(false),点遮罩或 × 会自动写回 false",
|
||||
"入场自带遮罩淡入 + 弹窗上浮动画"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "form",
|
||||
"title": "表单 Form / 表单字段 FormItem",
|
||||
"demo": "sec-formlab",
|
||||
"props": [
|
||||
{
|
||||
"n": "layout",
|
||||
"t": "'vertical' | 'horizontal'",
|
||||
"d": "布局方向;缺省 vertical(horizontal 为标签左置)"
|
||||
},
|
||||
{
|
||||
"n": "submitText",
|
||||
"t": "string",
|
||||
"d": "提交按钮文案;缺省「提交」"
|
||||
},
|
||||
{
|
||||
"n": "resetText",
|
||||
"t": "string",
|
||||
"d": "重置按钮文案;缺省不渲染"
|
||||
},
|
||||
{
|
||||
"n": "onSubmit",
|
||||
"t": "() => void",
|
||||
"k": 1,
|
||||
"d": "全部字段校验通过后的提交回调"
|
||||
},
|
||||
{
|
||||
"n": "(formItem)label",
|
||||
"t": "string",
|
||||
"r": 1,
|
||||
"d": "字段标签"
|
||||
},
|
||||
{
|
||||
"n": "(formItem)value",
|
||||
"t": "Signal<string>",
|
||||
"r": 1,
|
||||
"d": "字段值信号,校验数据源"
|
||||
},
|
||||
{
|
||||
"n": "(formItem)required",
|
||||
"t": "boolean",
|
||||
"d": "必填:显示红星并自动追加非空规则"
|
||||
},
|
||||
{
|
||||
"n": "(formItem)rules",
|
||||
"t": "FormRule[]",
|
||||
"d": "校验规则:required / min / max / pattern / validator,按序执行取首个失败"
|
||||
},
|
||||
{
|
||||
"n": "(formItem)validateOn",
|
||||
"t": "'input' | 'blur' | 'submit'",
|
||||
"d": "校验时机;缺省 blur(输入时自动清已报错误)"
|
||||
}
|
||||
],
|
||||
"example": "const name = signal(''); const f = form({ onSubmit: submit }, [formItem({ label: '用户名', required: true, value: name, rules: [{ min: 2, max: 12 }] }, textField({ value: name }))])",
|
||||
"steps": [
|
||||
"为每个字段创建 signal 与控件,如 const name = signal(''); textField({ value: name })",
|
||||
"用 formItem 包住控件:formItem({ label, required, value, rules }, control),返回 { el, validate } 句柄",
|
||||
"把句柄数组交给 form:form({ onSubmit }, items),form 渲染全部字段 + 提交/重置按钮",
|
||||
"提交时逐项校验:全过触发 onSubmit;有错则标红控件、显示错误文案并聚焦首个出错项",
|
||||
"规则语义:required 非空(trim)、min/max 长度、pattern 正则、validator 自定义(true 通过 / 字符串为错误)",
|
||||
"非必填字段为空时跳过其余规则;Enter 键触发表单原生 submit(已拦截走校验流程)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dataTable",
|
||||
"title": "数据表格 DataTable",
|
||||
"demo": "sec-table",
|
||||
"props": [
|
||||
{
|
||||
"n": "data",
|
||||
"t": "T[]",
|
||||
"r": 1,
|
||||
"d": "全量行数据,每行须含 id 字段作 rowKey"
|
||||
},
|
||||
{
|
||||
"n": "columns",
|
||||
"t": "ColumnDef<T>[]",
|
||||
"r": 1,
|
||||
"d": "列定义:{ key, header?, sortable? }"
|
||||
},
|
||||
{
|
||||
"n": "filterKey",
|
||||
"t": "string",
|
||||
"d": "工具栏筛选的列;缺省取第一列"
|
||||
},
|
||||
{
|
||||
"n": "pageSize",
|
||||
"t": "number",
|
||||
"d": "每页行数;缺省 5"
|
||||
},
|
||||
{
|
||||
"n": "selectable",
|
||||
"t": "boolean",
|
||||
"d": "启用行选择列:全选三态(含半选)、行 checkbox、已选计数;缺省 false"
|
||||
}
|
||||
],
|
||||
"example": "dataTable({ data: users, selectable: true, columns: [{ key: 'name', header: '姓名', sortable: true }], pageSize: 5 })",
|
||||
"steps": [
|
||||
"准备行数据,确保每行有唯一 id(跨页选择与刷新不丢行靠它)",
|
||||
"定义 columns:sortable: true 的列表头可点击排序(升 → 降 → 取消循环),标记箭头 ↑↓↕",
|
||||
"selectable: true 开启行选择:表头全选框带 indeterminate 半选态,工具栏显示已选计数",
|
||||
"选中行用低视觉比重样式(淡色底 + 弱化文字),选择状态存 rowKey,跨页不丢失",
|
||||
"工具栏输入框即筛选,输入即时过滤并回第 1 页;无匹配时显示空状态行",
|
||||
"表头粘性固定(滚动容器 max-height 420px),斑马纹 + 悬停高亮由 CSS 提供"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
/* @ui-arch 组件皮肤:只消费 @ui-arch/tokens 的 --ua-* 变量 */
|
||||
.ua-btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--ua-color-accent);
|
||||
border-radius: var(--ua-radius-md);
|
||||
background: var(--ua-color-accent);
|
||||
color: var(--ua-color-accent-contrast);
|
||||
font-size: var(--ua-font-size-md);
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s, transform 0.12s, box-shadow 0.15s;
|
||||
}
|
||||
.ua-btn:hover {
|
||||
filter: brightness(1.08);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 10px color-mix(in srgb, var(--ua-color-accent) 25%, transparent);
|
||||
}
|
||||
.ua-btn:active {
|
||||
transform: translateY(0) scale(0.97);
|
||||
box-shadow: none;
|
||||
}
|
||||
.ua-btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--ua-color-accent);
|
||||
}
|
||||
.ua-btn-ghost:hover {
|
||||
background: color-mix(in srgb, var(--ua-color-accent) 8%, transparent);
|
||||
}
|
||||
.ua-btn-danger {
|
||||
background: var(--ua-color-danger);
|
||||
border-color: var(--ua-color-danger);
|
||||
color: #fff;
|
||||
}
|
||||
.ua-input {
|
||||
min-width: 200px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--ua-color-border);
|
||||
border-radius: var(--ua-radius-md);
|
||||
background: var(--ua-color-bg);
|
||||
color: var(--ua-color-text);
|
||||
font-size: var(--ua-font-size-md);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.ua-input:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--ua-color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent);
|
||||
}
|
||||
.ua-card {
|
||||
padding: var(--ua-space-4);
|
||||
border: 1px solid var(--ua-color-border);
|
||||
border-radius: var(--ua-radius-lg);
|
||||
background: var(--ua-color-surface);
|
||||
}
|
||||
.ua-card-title {
|
||||
margin: 0 0 var(--ua-space-3);
|
||||
font-size: var(--ua-font-size-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
.ua-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.ua-table th,
|
||||
.ua-table td {
|
||||
padding: var(--ua-space-table-cell);
|
||||
border-bottom: 1px solid var(--ua-color-border);
|
||||
text-align: left;
|
||||
font-size: var(--ua-font-size-md);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 粘性表头:滚动容器内固定,背景必须实色否则透视 */
|
||||
.ua-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--ua-color-surface);
|
||||
font-weight: 500;
|
||||
color: var(--ua-color-text-muted);
|
||||
box-shadow: inset 0 -1px 0 var(--ua-color-border);
|
||||
}
|
||||
.ua-table th.ua-sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.ua-table th.ua-sortable:hover {
|
||||
color: var(--ua-color-text);
|
||||
}
|
||||
.ua-sort-mark {
|
||||
margin-left: 4px;
|
||||
font-size: var(--ua-font-size-sm);
|
||||
color: var(--ua-color-accent);
|
||||
}
|
||||
/* 斑马纹 + 悬停高亮 + 选中行(低视觉比重:淡色底 + 弱化文字) */
|
||||
.ua-table tbody tr {
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.ua-table tbody tr:nth-child(even) {
|
||||
background: color-mix(in srgb, var(--ua-color-border) 18%, transparent);
|
||||
}
|
||||
.ua-table tbody tr:hover {
|
||||
background: color-mix(in srgb, var(--ua-color-accent) 6%, transparent);
|
||||
}
|
||||
.ua-table tr.ua-row-selected,
|
||||
.ua-table tr.ua-row-selected:hover {
|
||||
background: color-mix(in srgb, var(--ua-color-accent) 10%, transparent);
|
||||
}
|
||||
.ua-table tr.ua-row-selected td {
|
||||
color: var(--ua-color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.ua-cell-check {
|
||||
width: 36px;
|
||||
text-align: center !important;
|
||||
}
|
||||
.ua-empty-row td {
|
||||
text-align: center;
|
||||
color: var(--ua-color-text-muted);
|
||||
padding: 28px 12px;
|
||||
}
|
||||
.ua-table-scroll {
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--ua-color-border);
|
||||
border-radius: var(--ua-radius-md);
|
||||
background: var(--ua-color-bg);
|
||||
}
|
||||
.ua-table-toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: var(--ua-space-3);
|
||||
}
|
||||
.ua-sel-count {
|
||||
font-size: var(--ua-font-size-sm);
|
||||
color: var(--ua-color-accent);
|
||||
}
|
||||
.ua-pager {
|
||||
display: flex;
|
||||
gap: var(--ua-space-3);
|
||||
align-items: center;
|
||||
margin-top: var(--ua-space-3);
|
||||
}
|
||||
.ua-page-info {
|
||||
color: var(--ua-color-text-muted);
|
||||
font-size: var(--ua-font-size-sm);
|
||||
}
|
||||
|
||||
/* —— 复选框 / 单选 —— */
|
||||
.ua-check-row {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ua-checkbox,
|
||||
.ua-radio {
|
||||
accent-color: var(--ua-color-accent);
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s;
|
||||
}
|
||||
.ua-checkbox:active,
|
||||
.ua-radio:active {
|
||||
transform: scale(0.88);
|
||||
}
|
||||
.ua-radio-group {
|
||||
display: inline-flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* —— 开关 —— */
|
||||
.ua-switch {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ua-switch-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.ua-switch-track {
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: var(--ua-color-border);
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.ua-switch-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: left 0.2s;
|
||||
}
|
||||
.ua-switch-input:checked + .ua-switch-track {
|
||||
background: var(--ua-color-accent);
|
||||
}
|
||||
.ua-switch-input:checked + .ua-switch-track .ua-switch-thumb {
|
||||
left: 18px;
|
||||
}
|
||||
.ua-switch-label {
|
||||
font-size: var(--ua-font-size-md);
|
||||
}
|
||||
|
||||
/* —— 下拉选择(自绘弹层) —— */
|
||||
.ua-select {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
min-width: 160px;
|
||||
}
|
||||
.ua-select-trigger {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--ua-color-border);
|
||||
border-radius: var(--ua-radius-md);
|
||||
background: var(--ua-color-bg);
|
||||
color: var(--ua-color-text);
|
||||
font-size: var(--ua-font-size-md);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.ua-select-trigger:hover {
|
||||
border-color: color-mix(in srgb, var(--ua-color-accent) 55%, var(--ua-color-border));
|
||||
}
|
||||
.ua-select-trigger:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--ua-color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent);
|
||||
}
|
||||
.ua-select-value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ua-select-value.is-empty {
|
||||
color: var(--ua-color-text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.ua-select-arrow {
|
||||
font-size: 10px;
|
||||
color: var(--ua-color-text-muted);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.ua-select.ua-open .ua-select-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.ua-select-pop {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--ua-color-border);
|
||||
border-radius: var(--ua-radius-md);
|
||||
background: var(--ua-color-surface);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14);
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
visibility: hidden;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
|
||||
}
|
||||
.ua-select-pop.ua-open {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
visibility: visible;
|
||||
}
|
||||
.ua-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: var(--ua-radius-sm);
|
||||
background: none;
|
||||
color: var(--ua-color-text);
|
||||
font-size: var(--ua-font-size-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.ua-option:hover {
|
||||
background: color-mix(in srgb, var(--ua-color-accent) 8%, transparent);
|
||||
}
|
||||
/* 选中态:低视觉比重——淡色底 + 弱化文字,不抢焦点 */
|
||||
.ua-option-selected {
|
||||
background: color-mix(in srgb, var(--ua-color-accent) 10%, transparent);
|
||||
color: var(--ua-color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.ua-option-selected:hover {
|
||||
background: color-mix(in srgb, var(--ua-color-accent) 14%, transparent);
|
||||
}
|
||||
|
||||
/* —— 多行输入 —— */
|
||||
.ua-textarea {
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--ua-color-border);
|
||||
border-radius: var(--ua-radius-md);
|
||||
background: var(--ua-color-bg);
|
||||
color: var(--ua-color-text);
|
||||
font: inherit;
|
||||
font-size: var(--ua-font-size-md);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* —— 徽标 —— */
|
||||
.ua-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: var(--ua-font-size-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.ua-badge-default {
|
||||
background: var(--ua-color-border);
|
||||
color: var(--ua-color-text);
|
||||
}
|
||||
.ua-badge-success {
|
||||
background: var(--ua-color-success);
|
||||
color: #fff;
|
||||
}
|
||||
.ua-badge-warning {
|
||||
background: var(--ua-color-warning);
|
||||
color: #fff;
|
||||
}
|
||||
.ua-badge-danger {
|
||||
background: var(--ua-color-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* —— 提示条 —— */
|
||||
.ua-alert {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--ua-radius-md);
|
||||
border: 1px solid;
|
||||
font-size: var(--ua-font-size-md);
|
||||
animation: ua-slide-in 0.25s ease-out;
|
||||
}
|
||||
@keyframes ua-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
.ua-alert-info {
|
||||
border-color: var(--ua-color-accent);
|
||||
background: color-mix(in srgb, var(--ua-color-accent) 10%, transparent);
|
||||
}
|
||||
.ua-alert-success {
|
||||
border-color: var(--ua-color-success);
|
||||
background: color-mix(in srgb, var(--ua-color-success) 12%, transparent);
|
||||
}
|
||||
.ua-alert-warning {
|
||||
border-color: var(--ua-color-warning);
|
||||
background: color-mix(in srgb, var(--ua-color-warning) 12%, transparent);
|
||||
}
|
||||
.ua-alert-danger {
|
||||
border-color: var(--ua-color-danger);
|
||||
background: color-mix(in srgb, var(--ua-color-danger) 12%, transparent);
|
||||
}
|
||||
|
||||
/* —— 加载指示器 —— */
|
||||
.ua-spinner {
|
||||
display: inline-block;
|
||||
border: 2px solid var(--ua-color-border);
|
||||
border-top-color: var(--ua-color-accent);
|
||||
border-radius: 50%;
|
||||
animation: ua-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes ua-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* —— 进度条:轨道 + 渐变流动填充 —— */
|
||||
.ua-progress {
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
.ua-progress-track {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--ua-color-border) 40%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ua-progress-fill {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, color-mix(in srgb, var(--ua-color-accent) 65%, transparent), var(--ua-color-accent));
|
||||
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.ua-progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: repeating-linear-gradient(45deg, rgba(255, 255, 255, 0.18) 0 6px, transparent 6px 12px);
|
||||
background-size: 17px 100%;
|
||||
animation: ua-stripe 0.6s linear infinite;
|
||||
}
|
||||
.ua-progress-fill.ua-done {
|
||||
background: var(--ua-color-success);
|
||||
}
|
||||
.ua-progress-fill.ua-done::after {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
@keyframes ua-stripe {
|
||||
to {
|
||||
background-position: 17px 0;
|
||||
}
|
||||
}
|
||||
.ua-progress-text {
|
||||
font-size: var(--ua-font-size-sm);
|
||||
color: var(--ua-color-text-muted);
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* —— 标签页:滑动指示条 + 可滚动页签头 —— */
|
||||
.ua-tabs {
|
||||
width: 100%;
|
||||
}
|
||||
.ua-tabs-head {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid var(--ua-color-border);
|
||||
}
|
||||
.ua-tab {
|
||||
padding: 7px 14px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--ua-color-text-muted);
|
||||
font-size: var(--ua-font-size-md);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.ua-tab:hover {
|
||||
color: var(--ua-color-text);
|
||||
background: color-mix(in srgb, var(--ua-color-accent) 5%, transparent);
|
||||
}
|
||||
.ua-tab:focus-visible {
|
||||
outline: none;
|
||||
color: var(--ua-color-accent);
|
||||
}
|
||||
.ua-tab:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ua-tab-active {
|
||||
color: var(--ua-color-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.ua-tabs-indicator {
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--ua-color-accent);
|
||||
transition: left 0.25s cubic-bezier(0.4, 0, 0.2, 1), width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* —— 表单 —— */
|
||||
.ua-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
.ua-form-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
/* 水平布局:标签左置右对齐 */
|
||||
.ua-form-h .ua-form-item {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.ua-form-h .ua-form-label {
|
||||
min-width: 80px;
|
||||
padding-top: 6px;
|
||||
text-align: right;
|
||||
}
|
||||
.ua-form-h .ua-form-control {
|
||||
flex: 1;
|
||||
}
|
||||
.ua-form-label {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
font-size: var(--ua-font-size-sm);
|
||||
color: var(--ua-color-text-muted);
|
||||
}
|
||||
.ua-form-req {
|
||||
color: var(--ua-color-danger);
|
||||
font-style: normal;
|
||||
}
|
||||
.ua-form-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.ua-form-error {
|
||||
color: var(--ua-color-danger);
|
||||
font-size: var(--ua-font-size-sm);
|
||||
animation: ua-slide-in 0.2s ease-out;
|
||||
}
|
||||
.ua-form-error:empty {
|
||||
display: none;
|
||||
}
|
||||
/* 校验失败:控件描红 + 危险色光环 */
|
||||
.ua-invalid {
|
||||
border-color: var(--ua-color-danger) !important;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-danger) 15%, transparent) !important;
|
||||
}
|
||||
.ua-form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* —— 弹窗 —— */
|
||||
.ua-modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ua-modal-overlay.ua-open {
|
||||
display: flex;
|
||||
animation: ua-fade 0.2s ease-out;
|
||||
}
|
||||
.ua-modal {
|
||||
width: min(420px, 90vw);
|
||||
border-radius: var(--ua-radius-lg);
|
||||
background: var(--ua-color-surface);
|
||||
border: 1px solid var(--ua-color-border);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
animation: ua-pop-in 0.22s cubic-bezier(0.2, 0.9, 0.3, 1.2);
|
||||
}
|
||||
@keyframes ua-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes ua-pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.96);
|
||||
}
|
||||
}
|
||||
.ua-modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--ua-color-border);
|
||||
}
|
||||
.ua-modal-title {
|
||||
margin: 0;
|
||||
font-size: var(--ua-font-size-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
.ua-modal-close {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--ua-color-text-muted);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.ua-modal-body {
|
||||
padding: 16px;
|
||||
}
|
||||
.ua-textarea:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--ua-color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent);
|
||||
}
|
||||
|
||||
/* —— 无障碍:用户偏好减少动效时关闭全部动画 —— */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ua-btn,
|
||||
.ua-checkbox,
|
||||
.ua-radio,
|
||||
.ua-select-trigger,
|
||||
.ua-select-arrow,
|
||||
.ua-select-pop,
|
||||
.ua-option,
|
||||
.ua-switch-track,
|
||||
.ua-switch-thumb,
|
||||
.ua-tab,
|
||||
.ua-tabs-indicator,
|
||||
.ua-table tbody tr,
|
||||
.ua-progress-fill,
|
||||
.ua-progress-fill::after,
|
||||
.ua-alert,
|
||||
.ua-form-error,
|
||||
.ua-modal,
|
||||
.ua-modal-overlay {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user