iter(g57): form 校验支持异步规则
This commit is contained in:
@@ -1,30 +1,43 @@
|
||||
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 {
|
||||
/*** 执行规则的同步部分(required / min / max / pattern);null 通过,字符串为错误消息 ***/
|
||||
function syncPart(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 聚合校验 ***/
|
||||
/*** 同步求值单条规则:validator 返回 Promise 时视为待定(交给异步版),null 通过 ***/
|
||||
function checkRule(value: string, rule: FormRule): string | null {
|
||||
const syncMsg = syncPart(value, rule);
|
||||
if (syncMsg || !value || !rule.validator) return syncMsg;
|
||||
const r = rule.validator(value);
|
||||
if (typeof r === 'object' && r !== null) return null; // Promise:异步规则,此处不下结论
|
||||
return r === true ? null : typeof r === 'string' ? r : rule.message ?? '校验未通过';
|
||||
}
|
||||
|
||||
/*** 异步求值单条规则:await validator,同步返回值的规则结论与同步版一致 ***/
|
||||
async function checkRuleAsync(value: string, rule: FormRule): Promise<string | null> {
|
||||
const syncMsg = syncPart(value, rule);
|
||||
if (syncMsg || !value || !rule.validator) return syncMsg;
|
||||
const r = await rule.validator(value);
|
||||
return r === true ? null : typeof r === 'string' ? r : rule.message ?? '校验未通过';
|
||||
}
|
||||
|
||||
/*** 表单字段:label + 控件插槽 + 错误区;返回句柄(同步 / 异步两种校验)供 form 聚合 ***/
|
||||
export function formItem(props: FormItemProps, control: HTMLElement): FormItemHandle {
|
||||
// —— 状态集中声明 ——
|
||||
const mode = props.validateOn ?? 'blur'; // 校验时机;缺省失焦校验
|
||||
const rules = [...(props.required ? [{ required: true }] : []), ...(props.rules ?? [])];
|
||||
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));
|
||||
let seq = 0; // 校验轮次:异步结论回来时若已有更新的一轮,丢弃旧结果(防乱序覆盖)
|
||||
|
||||
/*** 错误态落点:控件根 + 内部原生控件,保证 select / checkbox 这类包裹式控件也能标红 ***/
|
||||
const marked = (): HTMLElement[] => [
|
||||
@@ -32,48 +45,75 @@ export function formItem(props: FormItemProps, control: HTMLElement): FormItemHa
|
||||
...Array.from(control.querySelectorAll<HTMLElement>('input, textarea, select, .ua-select-trigger')),
|
||||
];
|
||||
|
||||
/*** 执行全部规则:返回首个错误消息,同时同步错误区、控件态与 aria-invalid ***/
|
||||
const validate = () => {
|
||||
/*** 落笔:错误文案 + 控件红框 + aria-invalid,视觉与读屏同源 ***/
|
||||
const paint = (msg: string | null): void => {
|
||||
error.textContent = msg ?? '';
|
||||
control.classList.toggle('ua-invalid', !!msg);
|
||||
for (const node of marked()) node.setAttribute('aria-invalid', String(!!msg));
|
||||
};
|
||||
|
||||
/*** 同步校验:异步规则不结论,保证 blur / input 路径不被 Promise 拖慢 ***/
|
||||
const validate = (): string | null => {
|
||||
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);
|
||||
for (const node of marked()) node.setAttribute('aria-invalid', String(!!msg)); // 读屏同步错误态
|
||||
paint(msg);
|
||||
return msg;
|
||||
};
|
||||
|
||||
/*** 异步校验:按序 await 每条规则,结论按轮次取舍,过期结果不动 UI ***/
|
||||
const validateAsync = async (): Promise<string | 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;
|
||||
};
|
||||
|
||||
control.addEventListener('input', () => {
|
||||
if (mode === 'input' || error.textContent) validate(); // input 模式即时校验;其余模式仅清已报错误
|
||||
if (mode === 'input' || error.textContent) void validateAsync(); // input 模式即时校验;其余模式仅清已报错误
|
||||
});
|
||||
control.addEventListener('change', () => {
|
||||
if (mode === 'input') validate(); // select / checkbox / radio 变更即校验(原生 input 事件不冒泡)
|
||||
if (mode === 'input') void validateAsync(); // select / checkbox / radio 变更即校验(原生 input 事件不冒泡)
|
||||
});
|
||||
control.addEventListener('focusout', () => {
|
||||
if (mode !== 'submit') validate(); // 用会冒泡的 focusout 取代 blur,包裹式控件同样生效
|
||||
if (mode !== 'submit') void validateAsync(); // 用会冒泡的 focusout 取代 blur,包裹式控件同样生效
|
||||
});
|
||||
return { el, validate };
|
||||
return { el, validate, validateAsync };
|
||||
}
|
||||
|
||||
/*** 表单容器:布局 + 原生 submit 拦截 + 聚合校验(全部通过才触发 onSubmit) ***/
|
||||
/*** 表单容器:布局 + 原生 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 ?? '提交', type: 'submit' }); // 走原生 submit,避免 click + submit 双触发
|
||||
const submitBtn = button({ label: props.submitText ?? '提交', type: 'submit' }) as HTMLButtonElement; // 走原生 submit,避免 click + submit 双触发
|
||||
const resetBtn = props.resetText ? button({ label: props.resetText, variant: 'ghost', type: 'button', onClick: props.onReset }) : 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;
|
||||
submitBtn.disabled = true; // 校验期间禁用,避免连点重复提交
|
||||
let firstBad: HTMLElement | null = null;
|
||||
for (const item of items) {
|
||||
if (!(await item.validateAsync()) || firstBad) continue; // 首错之前的项才记焦点目标
|
||||
firstBad = item.el.querySelector('input, textarea, select') as HTMLElement | null;
|
||||
}
|
||||
submitBtn.disabled = false;
|
||||
running = false;
|
||||
if (firstBad) { firstBad.focus(); return; } // 卫语句:有错误不提交
|
||||
props.onSubmit?.();
|
||||
};
|
||||
|
||||
const el = h(
|
||||
'form',
|
||||
{ class: props.layout === 'horizontal' ? 'ua-form ua-form-h' : 'ua-form' },
|
||||
@@ -82,13 +122,13 @@ export function form(props: FormProps, items: FormItemHandle[]): HTMLElement {
|
||||
);
|
||||
el.addEventListener('submit', (e) => {
|
||||
e.preventDefault(); // 拦截原生提交,走校验流程
|
||||
submit();
|
||||
void submit();
|
||||
});
|
||||
el.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter' || e.target instanceof HTMLTextAreaElement) return; // 多行输入框允许换行
|
||||
if (!(e.target instanceof HTMLInputElement)) return; // 只接管单行输入框
|
||||
e.preventDefault(); // 拦下浏览器隐式提交,统一走校验
|
||||
submit();
|
||||
void submit();
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -337,7 +337,7 @@ export interface FormRule {
|
||||
min?: number; // 最小长度
|
||||
max?: number; // 最大长度
|
||||
pattern?: RegExp; // 正则格式校验
|
||||
validator?: (value: string) => boolean | string; // 自定义校验:true 通过 / 字符串为错误消息
|
||||
validator?: (value: string) => boolean | string | Promise<boolean | string>; // 自定义校验:true 通过 / 字符串为错误消息 / Promise 为异步规则
|
||||
message?: string; // 该规则的错误消息;缺省按规则类型给默认文案
|
||||
}
|
||||
|
||||
@@ -353,7 +353,8 @@ export interface FormItemProps {
|
||||
/*** 表单字段句柄:form 聚合校验用 ***/
|
||||
export interface FormItemHandle {
|
||||
el: HTMLElement; // 字段根元素(label + 控件 + 错误区)
|
||||
validate(): string | null; // 执行校验:null 通过 / 字符串为错误消息
|
||||
validate(): string | null; // 同步校验:null 通过 / 字符串为错误消息(异步规则不在此时下结论)
|
||||
validateAsync(): Promise<string | null>; // 异步校验:逐条 await,异步规则生效(form 提交走这里)
|
||||
}
|
||||
|
||||
/*** 表单容器入参 ***/
|
||||
|
||||
@@ -1439,7 +1439,9 @@
|
||||
"非必填字段为空时跳过其余规则;Enter 键触发表单原生 submit(已拦截走校验流程)",
|
||||
"错误态覆盖包裹式控件:标红作用于控件根与内部原生控件(含自绘 select 的触发器),并同步 aria-invalid,读屏与视觉一致",
|
||||
"触发时机用 input / change / focusout(均会冒泡),因此 checkbox / select / 单选组这类包裹式控件同样能失焦校验",
|
||||
"单行输入框内按 Enter 等同点击提交(textarea 内仍是换行);提交按钮为 type=\"submit\",不会与点击回调重复触发"
|
||||
"单行输入框内按 Enter 等同点击提交(textarea 内仍是换行);提交按钮为 type=\"submit\",不会与点击回调重复触发",
|
||||
"rules.validator 返回 Promise 即为异步规则(如「邀请码是否已被使用」远程校验):提交时逐项 await,校验期间提交按钮禁用,结论按轮次取舍避免乱序覆盖",
|
||||
"formItem 返回 { el, validate, validateAsync } 句柄:validate 只跑同步规则(供 blur/input 即时反馈),validateAsync 逐条 await 供 form 聚合提交"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user