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('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('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('input, textarea, select')) .map((node) => ({ node, value: node.value, checked: (node as HTMLInputElement).checked })); /*** 错误态落点:控件根 + 内部原生控件,保证 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); 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 => { 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 => { 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; }