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,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 }; // 再导出常用内核类型,方便调用方
|
||||
Reference in New Issue
Block a user