185 lines
11 KiB
TypeScript
185 lines
11 KiB
TypeScript
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;
|
||
}
|