import { h } from '@ui-arch/frame'; import { button } from './button.js'; import { grid } from './grid.js'; import type { FormItemHandle, FormItemProps, FormProps, FormRule } from './types.js'; /*** 执行规则的同步部分(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 ?? '格式不正确'; return null; } /*** 同步求值单条规则: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 { 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 error = h('div', { class: 'ua-form-error' }); const hint = props.hint ? h('div', { class: 'ua-form-hint' }, props.hint) : null; // 辅助说明:报错时隐藏,避免与错误挤在一起 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, hint, error)); if (props.span) el.style.gridColumn = `span ${props.span}`; // 多列布局里跨列;单列容器下由 CSS 还原为整行 let seq = 0; // 校验轮次:异步结论回来时若已有更新的一轮,丢弃旧结果(防乱序覆盖) /*** 错误态落点:控件根 + 内部原生控件,保证 select / checkbox 这类包裹式控件也能标红 ***/ const marked = (): HTMLElement[] => [ control, ...Array.from(control.querySelectorAll('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); 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; // 首个失败即停止 } paint(msg); return msg; }; /*** 异步校验:按序 await 每条规则,结论按轮次取舍,过期结果不动 UI ***/ const validateAsync = async (): Promise => { 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) 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 }; } /*** 表单容器:布局 + 原生 submit 拦截 + 聚合校验(全部通过才触发 onSubmit,异步规则会等待) ***/ export function form(props: FormProps, items: FormItemHandle[]): HTMLElement { // —— 状态集中声明 —— 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); const summary = h('div', { class: 'ua-form-summary', role: 'alert' }) as HTMLElement; // 错误汇总:读屏能一次听到共几处要改 summary.hidden = true; let running = false; // 提交中标记:异步校验期间屏蔽重复提交 /*** 整体提交:逐项 await 校验(全部跑完以显示所有错误),有错聚焦首个并中止 ***/ const submit = async (): Promise => { if (running) return; // 卫语句:上一轮异步校验还没结束 running = true; submitBtn.disabled = true; // 校验期间禁用,避免连点重复提交 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; // 只把首个错误控件留作焦点目标 firstBad = item.el.querySelector('input, textarea, select') as HTMLElement | null; } submitBtn.disabled = false; running = false; summary.hidden = bad === 0; summary.textContent = bad ? `有 ${bad} 项需要修改,已定位到第一处` : ''; if (firstBad) { 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; }