Files
UI/packages/skin/ui/src/form.ts
T

185 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { effect, h, signal } from '@ui-arch/frame';
import { button } from './button.js';
import { checkRule, checkRuleAsync } from './formRules.js';
import { grid } from './grid.js';
import type { FormItemHandle, FormItemProps, FormProps } from './types.js';
let uid = 0; // 字段序号:错误区 / 辅助说明的 id 必须全页唯一,否则多表单时读屏关联到别人家的文案
/*** 字段容器里的焦点落点:自绘控件(非搜索式下拉、评分、开关)没有原生 input,只查 input 会拿到 null,退到按钮 / 带 tabindex 的节点 ***/
const focusableOf = (box: HTMLElement): HTMLElement | null =>
box.querySelector<HTMLElement>('input:not([type="hidden"]), textarea, select, button, [tabindex]:not([tabindex="-1"])');
/*** 表单字段:label + 控件插槽 + 错误区;返回句柄(同步 / 异步两种校验)供 form 聚合 ***/
export function formItem(props: FormItemProps, control: HTMLElement): FormItemHandle {
// —— 状态集中声明 ——
const mode = props.validateOn ?? 'blur'; // 校验时机;缺省失焦校验
const rules = [...(props.required ? [{ required: true }] : []), ...(props.rules ?? [])]; // 必填规则排最前
const fid = `ua-form-${(uid += 1)}`; // 本字段唯一后缀:错误区 / 辅助说明 / 标签各自派生 id
// 标签归属:恰好一个原生控件才用 for(单选组的 for 会变成「点标签选中第一项」),组类控件改用 aria-labelledby
const native = Array.from(control.querySelectorAll<HTMLElement>('input:not([type="hidden"]), textarea, select'));
const only = (control.matches('input:not([type="hidden"]), textarea, select') ? [control] : native).length === 1
? (control.matches('input:not([type="hidden"]), textarea, select') ? control : native[0])
: null;
if (only && !only.id) only.id = `${fid}-control`; // 组件自带的 id 不动:可能是它内部逻辑的锚
if (!only) (control.hasAttribute('role') ? control : focusableOf(control))?.setAttribute('aria-labelledby', `${fid}-label`); // 自绘下拉 / 单选组靠标签 id 取名
const error = h('div', { class: 'ua-form-error', id: `${fid}-error` }); // 错误区:id 供 aria-describedby 指过来
const hint = props.hint ? h('div', { class: 'ua-form-hint', id: `${fid}-hint` }, props.hint) : null; // 辅助说明:报错时隐藏,避免与错误挤在一起
const label = h('label', { class: 'ua-form-label', for: only?.id ?? null, id: `${fid}-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, hint, error));
if (props.span) el.style.gridColumn = `span ${props.span}`; // 多列布局里跨列;单列容器下由 CSS 还原为整行
// 条件显示:隐藏时顺带清掉错误态,否则「看不见的红框」会拦住提交
if (props.visible) effect(() => { el.hidden = !props.visible?.(); if (el.hidden) paint(null); });
let seq = 0; // 校验轮次:异步结论回来时若已有更新的一轮,丢弃旧结果(防乱序覆盖)
// 初始值快照:重置要回到「刚渲染时」而不是空串(带默认值的字段不能被清成空)
const initial = Array.from(control.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>('input, textarea, select'))
.map((node) => ({ node, value: node.value, checked: (node as HTMLInputElement).checked }));
/*** 错误态落点:控件根 + 内部原生控件,保证 select / checkbox 这类包裹式控件也能标红 ***/
const marked = (): HTMLElement[] => [
control,
...Array.from(control.querySelectorAll<HTMLElement>('input, textarea, select, .ua-select-trigger')),
];
/*** 落笔:错误文案 + 控件红框 + aria-invalid + 说明关联,视觉与读屏同源 ***/
const paint = (msg: string | null): void => {
error.textContent = msg ?? '';
if (hint) hint.hidden = !!msg; // 有错时让位:同一位置只留一条提示
control.classList.toggle('ua-invalid', !!msg);
const described = msg ? error.id : hint && !hint.hidden ? hint.id : ''; // 读屏该念哪条:报错优先,其次辅助说明
for (const node of marked()) {
node.setAttribute('aria-invalid', String(!!msg));
if (described) node.setAttribute('aria-describedby', described); // 只说「无效」不够,要把原因一起念出来
else node.removeAttribute('aria-describedby'); // 无文案可念时摘掉,别留一个指空的关联
}
el.dispatchEvent(new CustomEvent('ua:field-validated')); // 不冒泡:只让归属本字段的表单重算汇总条
};
/*** 同步校验:异步规则不结论,保证 blur / input 路径不被 Promise 拖慢 ***/
const validate = (): string | null => {
if (el.hidden) return null; // 隐藏项不参与校验:没显示的字段不该拦住提交
const value = props.value();
let msg: string | null = null;
for (const rule of rules) {
msg = checkRule(value, rule);
if (msg) break; // 首个失败即停止
}
paint(msg);
return msg;
};
/*** 异步校验:按序 await 每条规则,结论按轮次取舍,过期结果不动 UI ***/
const validateAsync = async (): Promise<string | null> => {
if (el.hidden) return null; // 隐藏项不参与异步校验,与同步版同一口径
seq += 1;
const mine = seq; // 本轮编号
const value = props.value();
let msg: string | null = null;
for (const rule of rules) {
msg = await checkRuleAsync(value, rule);
if (msg) break;
}
if (mine === seq) paint(msg); // 期间又有新校验:交给最新一轮落笔
return msg;
};
/*** 复位:先跑调用方的自绘控件钩子,再把原生控件还原到初始值并同步信号,最后清错误态 ***/
const reset = (): void => {
props.reset?.(); // 自绘控件(无原生 input)拿不到值,只能交给调用方回写信号
for (const { node, value, checked } of initial) {
const boxed = node instanceof HTMLInputElement && (node.type === 'checkbox' || node.type === 'radio'); // 勾选类走 checked
if (boxed) (node as HTMLInputElement).checked = checked;
else node.value = value;
node.dispatchEvent(new Event('input', { bubbles: true })); // 冒泡给组件监听器:信号跟着改,否则下次渲染又把旧值写回 DOM
if (boxed) node.dispatchEvent(new Event('change', { bubbles: true })); // 勾选类只认 change,input 未必触发
}
paint(null); // 红框与错误文案一起退场
};
control.addEventListener('input', () => {
if (mode === 'input' || error.textContent) void validateAsync(); // input 模式即时校验;其余模式仅清已报错误
});
control.addEventListener('change', () => {
if (mode === 'input') void validateAsync(); // select / checkbox / radio 变更即校验(原生 input 事件不冒泡)
});
control.addEventListener('focusout', () => {
if (mode !== 'submit') void validateAsync(); // 用会冒泡的 focusout 取代 blur,包裹式控件同样生效
});
return { el, validate, validateAsync, reset };
}
/*** 表单容器:布局 + 原生 submit 拦截 + 聚合校验(全部通过才触发 onSubmit,异步规则会等待) ***/
export function form(props: FormProps, items: FormItemHandle[]): HTMLElement {
// —— 状态集中声明 ——
const summary = h('div', { class: 'ua-form-summary', role: 'alert' }) as HTMLElement; // 错误汇总:读屏能一次听到共几处要改
summary.hidden = true;
let quiet = false; // 重算中标记:validate → paint → 事件 → 重算 会自激,必须挡住
/*** 汇总条实时重算:字段改了错误数要跟着掉,否则「有 2 项需要修改」会一直挂到下次提交 ***/
const refreshSummary = (): void => {
if (quiet || summary.hidden) return; // 卫语句:只有条子已经亮着才需要刷新
quiet = true;
const bad = items.filter((item) => item.validate()).length; // 同步校验足够:异步规则本就到提交时才下结论
quiet = false;
summary.hidden = bad === 0;
summary.textContent = bad ? `有 ${bad} 项需要修改,已定位到第一处` : '';
};
for (const item of items) item.el.addEventListener('ua:field-validated', refreshSummary); // 逐字段订阅
/*** 重置:逐项复位 + 收起汇总条,最后交给调用方回调(只跑回调等于点了没反应) ***/
const doReset = (): void => {
for (const item of items) item.reset();
summary.hidden = true;
summary.textContent = '';
props.onReset?.();
};
const busy = signal(false); // 校验进行中:按钮转圈 + aria-busy,异步规则跑 400ms 时用户看得见在等
const submitBtn = button({ label: props.submitText ?? '提交', type: 'submit', loading: busy }) as HTMLButtonElement; // 走原生 submit,避免 click + submit 双触发
const resetBtn = props.resetText ? button({ label: props.resetText, variant: 'ghost', type: 'button', onClick: doReset }) : null; // 重置不得提交表单
const actions = h('div', { class: 'ua-form-actions' }, submitBtn, resetBtn);
let running = false; // 提交中标记:异步校验期间屏蔽重复提交
/*** 整体提交:逐项 await 校验(全部跑完以显示所有错误),有错聚焦首个并中止 ***/
const submit = async (): Promise<void> => {
if (running) return; // 卫语句:上一轮异步校验还没结束
running = true;
busy(true); // loading 自带禁点,不必再手写 disabled(两处都设反而容易漏复位)
let firstBad: HTMLElement | null = null;
let bad = 0; // 不通过的字段数
for (const item of items) {
const msg = await item.validateAsync();
if (!msg) continue; // 卫语句:本项通过
bad += 1;
if (firstBad) continue; // 只把首个错误控件留作焦点目标
const target = focusableOf(item.el);
if (!target) item.el.tabIndex = -1; // 整块无可聚焦控件时把字段容器本身设为焦点落点(-1 不进 Tab 序,只接受程序聚焦)
firstBad = target ?? item.el;
}
busy(false);
running = false;
summary.hidden = bad === 0;
summary.textContent = bad ? `有 ${bad} 项需要修改,已定位到第一处` : '';
if (bad > 0) { firstBad?.focus(); props.onError?.(bad); return; } // 有错即中止:判据用错误数而非焦点目标,否则自绘控件字段会漏过去直接提交
props.onSubmit?.();
};
const fields = items.map((i) => i.el); // 字段节点:单列直接铺,多列交给栅格
const board = props.columns && props.columns > 1 ? grid({ columns: props.columns, gutter: 'lg' }, ...fields) : fields; // 多列布局复用 grid
const el = h(
'form',
{ class: props.layout === 'horizontal' ? 'ua-form ua-form-h' : 'ua-form' },
props.errorSummary === false ? null : summary, // 汇总条:关掉时连节点都不插
board, // 多列时是一层 grid,单列时是字段数组(h 自动扁平化)
actions,
);
el.addEventListener('submit', (e) => {
e.preventDefault(); // 拦截原生提交,走校验流程
void submit();
});
el.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' || e.target instanceof HTMLTextAreaElement) return; // 多行输入框允许换行
if (!(e.target instanceof HTMLInputElement)) return; // 只接管单行输入框
e.preventDefault(); // 拦下浏览器隐式提交,统一走校验
void submit();
});
return el;
}