表单Form
把一组字段(标签 + 控件 + 错误提示)组织成一次可提交的操作(下单、开票、认证)
数据录入 规格 32 · 表单 Form 6 端实现 触摸优先
<!-- ① 令牌:PC 令牌 + 移动端 --kole-m-* 合成单文件,引一次 -->
<link rel="stylesheet" href="kole-ui/mobile/tokens.css">
<!-- ② 本组件样式(全量则用 kole-ui/mobile/components/index.css) -->
<link rel="stylesheet" href="kole-ui/mobile/components/mobile-form.css">
<!-- ③ 结构照抄下方任一演示块(类名与 6 端实现一致) -->
演示
每个演示都是真实渲染:预览帧加载 frameworks-mobile/Form.html?demo=<id>(只显示该演示块),代码是该演示块在演示页里的原文,可复制。全部演示同屏可看 演示页 ↗。
01 组件类型
置顶标签 + 必填星号 + 提示行;点提交执行校验并写入 data-submitted。
查看代码(演示页原文 · 46 行)
<section class="demo-block" data-demo="basic">
<p class="demo-label">可提交表单(置顶标签 + 必填星号 + 提示行;点提交即校验通过并写入 data-submitted)</p>
<div class="demo-pad">
<form class="kole-m-form" id="form-basic" data-assert="form-basic" novalidate>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-basic-name-label">
<span class="kole-m-form__star" aria-hidden="true">*</span>收货人
</span>
<span class="kole-m-form__main">
<input class="kole-m-form__control" type="text" name="name" value="陈立" required
aria-labelledby="f-basic-name-label" aria-required="true">
</span>
</div>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-basic-phone-label">
<span class="kole-m-form__star" aria-hidden="true">*</span>手机号
</span>
<span class="kole-m-form__main">
<input class="kole-m-form__control" type="text" name="phone" inputmode="numeric" value="13800138000" required
aria-labelledby="f-basic-phone-label" aria-required="true" aria-describedby="f-basic-phone-hint">
<span class="kole-m-form__hint" id="f-basic-phone-hint">用于接收发货与签收通知</span>
</span>
</div>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-basic-region-label">所在地区</span>
<span class="kole-m-form__main">
<button class="kole-m-form__hit" type="button" aria-labelledby="f-basic-region-label" aria-haspopup="dialog">
<span>浙江省 杭州市 余杭区</span>
<span aria-hidden="true">›</span>
</button>
</span>
</div>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-basic-remark-label">备注</span>
<span class="kole-m-form__main">
<textarea class="kole-m-form__control kole-m-form__control--multiline" name="remark" rows="2"
aria-labelledby="f-basic-remark-label" placeholder="选填:放快递柜、工作日送达"></textarea>
</span>
</div>
<div class="kole-m-form__item">
<button class="kole-m-form__submit" type="submit" id="form-basic-submit"
data-behavior="click-sets-attr:#form-basic|data-submitted|true">提交订单</button>
</div>
</form>
</div>
</section>labelPosition=left:标签定宽 72px 与控件同行,适合一组纵向对齐的字段。
查看代码(演示页原文 · 32 行)
<section class="demo-block" data-demo="left">
<p class="demo-label">标签位置两态(labelPosition=left:标签定宽 72px 与控件同行,适合一组纵向对齐的字段)</p>
<div class="demo-pad">
<form class="kole-m-form kole-m-form--left" data-assert="form-left" novalidate>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-left-title-label">
<span class="kole-m-form__star" aria-hidden="true">*</span>发票抬头
</span>
<span class="kole-m-form__main">
<input class="kole-m-form__control" type="text" value="杭州某科技有限公司" required
aria-labelledby="f-left-title-label" aria-required="true">
</span>
</div>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-left-no-label">纳税人识别号</span>
<span class="kole-m-form__main">
<input class="kole-m-form__control" type="text" inputmode="numeric" value="91330100MA2XXXXX0A"
aria-labelledby="f-left-no-label">
</span>
</div>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-left-type-label">票种</span>
<span class="kole-m-form__main">
<button class="kole-m-form__hit" type="button" aria-labelledby="f-left-type-label" aria-haspopup="dialog">
<span>电子普通发票</span>
<span aria-hidden="true">›</span>
</button>
</span>
</div>
</form>
</div>
</section>borderless=true:字段之间靠留白分组,嵌在卡片里时用。
查看代码(演示页原文 · 25 行)
<section class="demo-block" data-demo="borderless">
<p class="demo-label">无分隔线(borderless=true:字段之间靠留白分组,嵌在卡片里时用)</p>
<div class="demo-pad">
<form class="kole-m-form kole-m-form--borderless" data-assert="form-borderless" novalidate>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-bl-company-label">
<span class="kole-m-form__star" aria-hidden="true">*</span>公司名称
</span>
<span class="kole-m-form__main">
<input class="kole-m-form__control" type="text" value="杭州某科技有限公司" required
aria-labelledby="f-bl-company-label" aria-required="true">
</span>
</div>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-bl-size-label">公司规模</span>
<span class="kole-m-form__main">
<button class="kole-m-form__hit" type="button" aria-labelledby="f-bl-size-label" aria-haspopup="dialog">
<span>50~200 人</span>
<span aria-hidden="true">›</span>
</button>
</span>
</div>
</form>
</div>
</section>02 组件状态
is-error + aria-invalid 双写;错误文案在控件下方,标签与星号同时变错误色。
查看代码(演示页原文 · 25 行)
<section class="demo-block" data-demo="error">
<p class="demo-label">校验错误态(is-error + aria-invalid 双写;错误文案在控件下方,标签与星号同时变色)</p>
<div class="demo-pad">
<form class="kole-m-form" id="form-error" data-assert="form-error" novalidate>
<div class="kole-m-form__item is-error" id="form-error-item" data-invalid="true">
<span class="kole-m-form__label" id="f-err-phone-label">
<span class="kole-m-form__star" aria-hidden="true">*</span>手机号
</span>
<span class="kole-m-form__main">
<input class="kole-m-form__control" type="text" inputmode="numeric" value="138" required
aria-labelledby="f-err-phone-label" aria-required="true"
aria-invalid="true" aria-describedby="f-err-phone-msg">
<span class="kole-m-form__error" id="f-err-phone-msg" role="alert">请输入 11 位手机号</span>
</span>
</div>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-err-code-label">验证码</span>
<span class="kole-m-form__main">
<input class="kole-m-form__control" type="text" inputmode="numeric" placeholder="6 位数字"
aria-labelledby="f-err-code-label">
</span>
</div>
</form>
</div>
</section>字段置灰且不可聚焦,提交按钮一并置灰;读屏会播报不可用。
查看代码(演示页原文 · 25 行)
<section class="demo-block" data-demo="disabled">
<p class="demo-label">整表禁用(字段置灰且不可聚焦;提交按钮一并置灰,读屏会播报不可用)</p>
<div class="demo-pad">
<form class="kole-m-form is-disabled" data-assert="form-disabled" novalidate>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-dis-id-label">企业认证编号</span>
<span class="kole-m-form__main">
<input class="kole-m-form__control" type="text" value="CERT-2026-0912" disabled
aria-labelledby="f-dis-id-label">
</span>
</div>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-dis-owner-label">认证主体</span>
<span class="kole-m-form__main">
<input class="kole-m-form__control" type="text" value="杭州某科技有限公司" disabled
aria-labelledby="f-dis-owner-label" aria-describedby="f-dis-hint">
<span class="kole-m-form__hint" id="f-dis-hint">认证信息由管理员维护,暂不可修改</span>
</span>
</div>
<div class="kole-m-form__item">
<button class="kole-m-form__submit" type="submit" disabled>提交</button>
</div>
</form>
</div>
</section>API
props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs
Props
| 名称 | 类型 | 默认值 | 说明 | 必传 |
|---|---|---|---|---|
fields | Array | [] | 字段数组,每项 { name, label, value, type, required, error, hint, placeholder }(规格 §32.2) | N |
labelPosition | 'top' | 'left' | 'top' | 变体 labelPosition:标签置顶还是定宽在控件左侧(规格 §32.3) | N |
borderless | boolean | false | 变体 borderless:字段之间是否画 1px 分隔线(规格 §32.3) | N |
disabled | boolean | false | 状态 disabled:整表置灰且不可聚焦(规格 §32.4) | N |
submitText | string | '提交' | 提交行按钮文案;空字符串表示不渲染提交行(规格 §32.5) | N |
「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。
事件
| 名称 | 参数 | 说明 |
|---|---|---|
submit | (fields) | 提交时触发,回传字段数组供宿主校验与请求(规格 §32.5) |
change | (name, value) | 字段值变化时触发,回传字段名与当前值(规格 §32.5) |
插槽
| 名称 | 说明 |
|---|---|
default | 提交行之前的自定义字段区(宿主插入自己的字段结构,规格 §32.2 item) |
CSS 变量
组件级变量(在组件样式表里定义)。业务侧可在自己的作用域内覆盖,不必改组件源码。
| 名称 | 默认值 | 说明 |
|---|---|---|
--kole-m-form-label-width | 72px | labelPosition=left 时的标签列宽 |
--kole-m-form-gap | var(--kole-space-8) | 标签与控件之间的间距 |
何时使用
- 把一组字段(标签 + 控件 + 错误提示)组织成一次可提交的操作(下单、开票、认证)
- 移动端窄屏放不下左右两列时标签默认置顶,只有需要一组字段纵向对齐时才退回到定宽左标签
- 每个字段的错误提示必须贴在它自己的控件下方 —— 触屏上用户看不到「页头汇总错误」
- 控件高度不小于 44px;非输入型控件(选择器一类)整行都是热区
- 失焦即校验(触屏上软键盘收起会触发 blur),提交时再全量校验一次
交互与触控
- 控件高度不小于 44px;非输入型控件(选择器一类)整行都是热区,按下反馈是整行背景变化
- 错误提示出现在控件正下方,不挤占输入区,也不跨到标签列
- 失焦即校验(触屏上软键盘收起会触发 blur),提交时再全量校验一次
- 校验失败时不跳转、不滚动到页首,错误就落在出错的字段上
- 提交成功后写 data-submitted="true" 作为可断言的提交凭证;星号与错误色不单独承载语义
无障碍
- 标签用 aria-labelledby 关联到控件(标签不是占位符的替代品)
- 必填用控件的 aria-required="true" 与原生 required 双写;星号本身 aria-hidden
- 错误文案用 aria-describedby 关联到控件,并带 role="alert" 播报
- 禁用态用原生 disabled,读屏会播报不可用且键盘会跳过
相似组件
从「该用哪一个」的角度区分;PC 端的对应实现见 PC 文档站。
| 组件 | 何时用它而不是本组件 |
|---|---|
| 输入框Input | 单个字段的输入框(高度、清除按钮、错误态)由 Input 负责;Form 只管字段的组织与提交 |
| 多行文本框Textarea | 多行文本字段用 Textarea(计数、自动增高);Form 的 item 只是把它包进标签与错误提示里 |
| 按钮Button | 表单外的独立动作(取消、暂存)用 Button;只有「提交本表单」才用 Form 内置的提交行 |
规格未定 / 禁止发明
| 类别 | 条目 |
|---|---|
| 禁止发明 | 字段内容的业务校验规则(正则、长度、合法性判断都在宿主,本组件只表达结构) |
| 禁止发明 | 提交请求、失败重试与「提交中」的按钮态 |
| 禁止发明 | 字段的联动显隐与动态增删(由宿主决定渲染什么) |
| 禁止发明 | 页首错误汇总条(移动端用字段级提示,不做汇总) |
| 规格未定 | 左标签模式下标签列宽是否随字号变化(当前固定 72px) |
| 规格未定 | 提交行是否允许放两个并列动作(如「保存草稿 + 提交」) |
| 规格未定 | 只读(readonly)字段是否需要独立的视觉层,还是复用 disabled |
结构(anatomy)
| 字段 | 说明 |
|---|---|
form | 根元素,包住全部字段与提交行 |
item | 单个字段,一列一个,字段之间用 1px 分隔线划分 |
label | 字段标签;required=true 时前面带一个错误色星号 |
star | 必填星号,装饰性(aria-hidden),语义由控件的 aria-required 承担 |
control | 字段控件(原生 input / textarea,或包一层的可点区域) |
errorText | 字段级错误提示,落在该字段控件下方 |
变体维度与类名映射
类名映射由构建脚本从契约 variantClasses 生成,并被 verify:mobile-docs 逐条对照组件 CSS 校验(类/变量必须真实存在)。
| 维度 | 取值 | 对应类名 / 变量 |
|---|---|---|
labelPosition | top / left | top (由数据驱动,无专属类) left .kole-m-form--left |
borderless | false / true | false (由数据驱动,无专属类) true .kole-m-form--borderless |
代表变体
| 变体 | 标签 |
|---|---|
labelPosition=top · borderless=false | 默认(置顶标签 + 字段间分隔线) |
labelPosition=left · borderless=false | 左标签(定宽 72px 与控件同行) |
labelPosition=top · borderless=true | 无分隔线(靠留白分组,嵌在卡片里) |
用到的令牌
构建时从本组件样式表扫描得出。蓝色为移动端自有令牌,绿色为继承的 PC 令牌(改一处两端生效)。
6 端源码
同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。
frameworks-mobile/Form.css · 纯样式(CSS) · 191 行
/* Kole UI Mobile · Form 样式 — 对齐移动端规格 §32
表单:每个字段由「标签 + 必填星号 + 控件 + 错误提示」四段组成。
移动端与桌面端的关键差别是**标签位置**:窄屏放不下定宽左标签时用置顶标签(默认),
需要纵向对齐的一组字段才用 labelPosition=left。
错误提示在控件下方(不在标签旁),错误态用 is-error 与 aria-invalid 双写。 */
.kole-m-form {
/* 组件级变量:业务侧可在容器上覆盖 */
--kole-m-form-label-width: 72px; /* labelPosition=left 时的标签列宽 */
--kole-m-form-gap: var(--kole-space-8); /* 标签与控件之间的间距 */
box-sizing: border-box;
width: 100%;
background: var(--kole-color-card-bg);
color: var(--kole-color-text-body);
font-family: inherit;
font-size: var(--kole-m-font-size-body);
line-height: 1.4;
}
/* 字段:一列一个字段,字段之间用 1px 分隔线划分(变体 borderless=false,默认) */
.kole-m-form__item {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: var(--kole-m-form-gap);
padding: var(--kole-space-12) var(--kole-m-gutter);
}
.kole-m-form:not(.kole-m-form--borderless) .kole-m-form__item + .kole-m-form__item {
border-top: 1px solid var(--kole-color-border);
}
/* 变体 borderless=true:不画分隔线,靠留白分组(贴在卡片里时用) */
.kole-m-form--borderless .kole-m-form__item + .kole-m-form__item { border-top: 0; }
/* 变体 labelPosition=left:标签与控件同一行,标签列定宽、控件自适应 */
.kole-m-form--left .kole-m-form__item {
flex-direction: row;
align-items: flex-start;
}
.kole-m-form--left .kole-m-form__label {
flex: 0 0 auto;
width: var(--kole-m-form-label-width);
/* 标签首行与控件首行文字对齐(控件自身有 8px 内边距与 1px 边框) */
padding-top: calc(var(--kole-space-8) + 1px);
}
.kole-m-form__label {
display: flex;
align-items: center;
gap: var(--kole-space-4);
color: var(--kole-color-text-title);
font-size: var(--kole-m-font-size-label);
}
/* 必填星号:语义由控件的 aria-required 承担,星号本身对读屏隐藏 */
.kole-m-form__star {
flex: 0 0 auto;
color: var(--kole-color-error);
font-size: var(--kole-m-font-size-label);
line-height: 1;
}
/* 控件列:控件 + 下方错误提示(错误提示跟着控件走,不跨到标签列) */
.kole-m-form__main {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--kole-space-4);
}
.kole-m-form__control {
box-sizing: border-box;
display: block;
width: 100%;
min-height: var(--kole-m-touch-target);
padding: var(--kole-space-8) var(--kole-space-12);
border: 1px solid var(--kole-color-border);
border-radius: var(--kole-radius-base);
background: var(--kole-color-card-bg);
color: var(--kole-color-text-body);
font-family: inherit;
font-size: var(--kole-m-font-size-body);
line-height: 1.4;
}
.kole-m-form__control::placeholder { color: var(--kole-color-text-placeholder); }
/* 多行字段:控件高度由宿主决定(min-height 仍是 44px 触控下限) */
.kole-m-form__control--multiline {
min-height: calc(var(--kole-m-touch-target) * 2);
resize: none;
}
.kole-m-form__control:focus-visible {
outline: 2px solid var(--kole-color-focus-ring);
outline-offset: -2px;
}
/* 非输入型控件(选择器一类)包一层可点区域:整行热区,按下有反馈 */
.kole-m-form__hit {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--kole-space-8);
width: 100%;
min-height: var(--kole-m-touch-target);
padding: var(--kole-space-8) var(--kole-space-12);
border: 1px solid var(--kole-color-border);
border-radius: var(--kole-radius-base);
background: var(--kole-color-card-bg);
color: var(--kole-color-text-body);
font-family: inherit;
font-size: var(--kole-m-font-size-body);
line-height: 1.4;
text-align: start;
}
.kole-m-form__hit:active { background: var(--kole-color-table-header-bg); }
/* 状态 error:字段级 —— 边框、标签与星号一起变错误色(不只靠一处提示) */
.kole-m-form__item.is-error .kole-m-form__control,
.kole-m-form__item.is-error .kole-m-form__hit { border-color: var(--kole-color-error); }
.kole-m-form__item.is-error .kole-m-form__label { color: var(--kole-color-error); }
.kole-m-form__error {
display: block;
color: var(--kole-color-error);
font-size: var(--kole-m-font-size-label);
line-height: 1.4;
}
/* 提示行:未出错时是辅助说明(如「用于接收发货通知」),出错时换成错误文案 */
.kole-m-form__hint {
display: block;
color: var(--kole-color-text-secondary);
font-size: var(--kole-m-font-size-label);
line-height: 1.4;
}
/* 状态 disabled:整表置灰且不可聚焦(子控件是原生 disabled,这里补视觉) */
.kole-m-form.is-disabled { background: var(--kole-color-disabled-bg); }
.kole-m-form.is-disabled .kole-m-form__label,
.kole-m-form.is-disabled .kole-m-form__hint { color: var(--kole-color-text-disabled); }
.kole-m-form.is-disabled .kole-m-form__control,
.kole-m-form.is-disabled .kole-m-form__hit {
background: var(--kole-color-disabled-bg);
color: var(--kole-color-text-disabled);
cursor: not-allowed;
}
/* 提交行:整行一个主操作按钮(高度 ≥ 44px 触控边长;禁用时置灰) */
.kole-m-form__submit {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: var(--kole-m-touch-target);
padding: 0 var(--kole-space-16);
border: 0;
border-radius: var(--kole-radius-base);
background: var(--kole-color-brand);
color: var(--kole-color-text-inverse);
font-family: inherit;
font-size: var(--kole-m-font-size-body);
line-height: 1;
cursor: pointer;
touch-action: manipulation;
}
.kole-m-form__submit:active { background: var(--kole-color-brand-active); }
.kole-m-form__submit:disabled,
.kole-m-form.is-disabled .kole-m-form__submit {
background: var(--kole-color-disabled-bg);
color: var(--kole-color-text-disabled);
cursor: not-allowed;
}
.kole-m-form__submit:focus-visible {
outline: 2px solid var(--kole-color-focus-ring);
outline-offset: 2px;
}
frameworks-mobile/Form.html · H5 原生(无框架) · 275 行
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Kole UI Mobile · Form(H5)</title>
<link rel="stylesheet" href="../.design_library/kole-ui-mobile/colors_and_type.css">
<link rel="stylesheet" href="Form.css">
<style>
body { margin: 0; background: var(--kole-color-page-bg); font-family: var(--kole-font-family); color: var(--kole-color-text-body); }
.demo { max-width: 375px; margin: 0 auto; padding: var(--kole-m-gutter) 0; }
.demo-label { margin: 0; padding: var(--kole-space-12) var(--kole-m-gutter) var(--kole-space-8);
font-size: var(--kole-m-font-size-label); color: var(--kole-color-text-secondary); }
.demo-pad { padding: var(--kole-m-gutter); background: var(--kole-color-card-bg); border-block: 1px solid var(--kole-color-border); }
.demo-block[hidden] { display: none; }
</style>
</head>
<body>
<div class="demo">
<section class="demo-block" data-demo="basic">
<p class="demo-label">可提交表单(置顶标签 + 必填星号 + 提示行;点提交即校验通过并写入 data-submitted)</p>
<div class="demo-pad">
<form class="kole-m-form" id="form-basic" data-assert="form-basic" novalidate>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-basic-name-label">
<span class="kole-m-form__star" aria-hidden="true">*</span>收货人
</span>
<span class="kole-m-form__main">
<input class="kole-m-form__control" type="text" name="name" value="陈立" required
aria-labelledby="f-basic-name-label" aria-required="true">
</span>
</div>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-basic-phone-label">
<span class="kole-m-form__star" aria-hidden="true">*</span>手机号
</span>
<span class="kole-m-form__main">
<input class="kole-m-form__control" type="text" name="phone" inputmode="numeric" value="13800138000" required
aria-labelledby="f-basic-phone-label" aria-required="true" aria-describedby="f-basic-phone-hint">
<span class="kole-m-form__hint" id="f-basic-phone-hint">用于接收发货与签收通知</span>
</span>
</div>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-basic-region-label">所在地区</span>
<span class="kole-m-form__main">
<button class="kole-m-form__hit" type="button" aria-labelledby="f-basic-region-label" aria-haspopup="dialog">
<span>浙江省 杭州市 余杭区</span>
<span aria-hidden="true">›</span>
</button>
</span>
</div>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-basic-remark-label">备注</span>
<span class="kole-m-form__main">
<textarea class="kole-m-form__control kole-m-form__control--multiline" name="remark" rows="2"
aria-labelledby="f-basic-remark-label" placeholder="选填:放快递柜、工作日送达"></textarea>
</span>
</div>
<div class="kole-m-form__item">
<button class="kole-m-form__submit" type="submit" id="form-basic-submit"
data-behavior="click-sets-attr:#form-basic|data-submitted|true">提交订单</button>
</div>
</form>
</div>
</section>
<section class="demo-block" data-demo="left">
<p class="demo-label">标签位置两态(labelPosition=left:标签定宽 72px 与控件同行,适合一组纵向对齐的字段)</p>
<div class="demo-pad">
<form class="kole-m-form kole-m-form--left" data-assert="form-left" novalidate>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-left-title-label">
<span class="kole-m-form__star" aria-hidden="true">*</span>发票抬头
</span>
<span class="kole-m-form__main">
<input class="kole-m-form__control" type="text" value="杭州某科技有限公司" required
aria-labelledby="f-left-title-label" aria-required="true">
</span>
</div>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-left-no-label">纳税人识别号</span>
<span class="kole-m-form__main">
<input class="kole-m-form__control" type="text" inputmode="numeric" value="91330100MA2XXXXX0A"
aria-labelledby="f-left-no-label">
</span>
</div>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-left-type-label">票种</span>
<span class="kole-m-form__main">
<button class="kole-m-form__hit" type="button" aria-labelledby="f-left-type-label" aria-haspopup="dialog">
<span>电子普通发票</span>
<span aria-hidden="true">›</span>
</button>
</span>
</div>
</form>
</div>
</section>
<section class="demo-block" data-demo="borderless">
<p class="demo-label">无分隔线(borderless=true:字段之间靠留白分组,嵌在卡片里时用)</p>
<div class="demo-pad">
<form class="kole-m-form kole-m-form--borderless" data-assert="form-borderless" novalidate>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-bl-company-label">
<span class="kole-m-form__star" aria-hidden="true">*</span>公司名称
</span>
<span class="kole-m-form__main">
<input class="kole-m-form__control" type="text" value="杭州某科技有限公司" required
aria-labelledby="f-bl-company-label" aria-required="true">
</span>
</div>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-bl-size-label">公司规模</span>
<span class="kole-m-form__main">
<button class="kole-m-form__hit" type="button" aria-labelledby="f-bl-size-label" aria-haspopup="dialog">
<span>50~200 人</span>
<span aria-hidden="true">›</span>
</button>
</span>
</div>
</form>
</div>
</section>
<section class="demo-block" data-demo="error">
<p class="demo-label">校验错误态(is-error + aria-invalid 双写;错误文案在控件下方,标签与星号同时变色)</p>
<div class="demo-pad">
<form class="kole-m-form" id="form-error" data-assert="form-error" novalidate>
<div class="kole-m-form__item is-error" id="form-error-item" data-invalid="true">
<span class="kole-m-form__label" id="f-err-phone-label">
<span class="kole-m-form__star" aria-hidden="true">*</span>手机号
</span>
<span class="kole-m-form__main">
<input class="kole-m-form__control" type="text" inputmode="numeric" value="138" required
aria-labelledby="f-err-phone-label" aria-required="true"
aria-invalid="true" aria-describedby="f-err-phone-msg">
<span class="kole-m-form__error" id="f-err-phone-msg" role="alert">请输入 11 位手机号</span>
</span>
</div>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-err-code-label">验证码</span>
<span class="kole-m-form__main">
<input class="kole-m-form__control" type="text" inputmode="numeric" placeholder="6 位数字"
aria-labelledby="f-err-code-label">
</span>
</div>
</form>
</div>
</section>
<section class="demo-block" data-demo="disabled">
<p class="demo-label">整表禁用(字段置灰且不可聚焦;提交按钮一并置灰,读屏会播报不可用)</p>
<div class="demo-pad">
<form class="kole-m-form is-disabled" data-assert="form-disabled" novalidate>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-dis-id-label">企业认证编号</span>
<span class="kole-m-form__main">
<input class="kole-m-form__control" type="text" value="CERT-2026-0912" disabled
aria-labelledby="f-dis-id-label">
</span>
</div>
<div class="kole-m-form__item">
<span class="kole-m-form__label" id="f-dis-owner-label">认证主体</span>
<span class="kole-m-form__main">
<input class="kole-m-form__control" type="text" value="杭州某科技有限公司" disabled
aria-labelledby="f-dis-owner-label" aria-describedby="f-dis-hint">
<span class="kole-m-form__hint" id="f-dis-hint">认证信息由管理员维护,暂不可修改</span>
</span>
</div>
<div class="kole-m-form__item">
<button class="kole-m-form__submit" type="submit" disabled>提交</button>
</div>
</form>
</div>
</section>
</div>
<script>
/* 演示页脚本:最小可运行的校验与提交。
- 提交:逐个必填控件检查;空或不合法 → 该项加 is-error + aria-invalid + data-invalid,错误文案落到框下
- 全部通过 → 在 form 上写 data-submitted="true"(真实业务里是发请求)
判据只做「必填 + 手机号 11 位」两件事 —— 正则与业务规则属于宿主(规格 §32.7)。 */
(function () {
var PHONE = /^[0-9]{11}$/;
function itemOf(control) { return control.closest('.kole-m-form__item'); }
function setError(control, message) {
var item = itemOf(control);
if (!item) return;
item.classList.add('is-error');
item.setAttribute('data-invalid', 'true');
control.setAttribute('aria-invalid', 'true');
var block = item.querySelector('.kole-m-form__main');
var msg = item.querySelector('.kole-m-form__error');
if (!msg && block && message) {
msg = document.createElement('span');
msg.className = 'kole-m-form__error';
msg.id = control.name ? 'f-auto-' + control.name + '-msg' : '';
msg.setAttribute('role', 'alert');
if (msg.id) control.setAttribute('aria-describedby', msg.id);
block.appendChild(msg);
}
if (msg) msg.textContent = message || '';
}
function clearError(control) {
var item = itemOf(control);
if (!item) return;
item.classList.remove('is-error');
item.removeAttribute('data-invalid');
control.removeAttribute('aria-invalid');
var msg = item.querySelector('.kole-m-form__error');
if (msg) msg.remove();
}
function check(control) {
var value = String(control.value || '').trim();
if (control.hasAttribute('required') && !value) {
setError(control, '此项为必填');
return false;
}
if (control.getAttribute('inputmode') === 'numeric' && control.name === 'phone' && !PHONE.test(value)) {
setError(control, '请输入 11 位手机号');
return false;
}
clearError(control);
return true;
}
Array.prototype.forEach.call(document.querySelectorAll('.kole-m-form'), function (form) {
if (form.classList.contains('is-disabled')) return;
var controls = Array.prototype.slice.call(form.querySelectorAll('.kole-m-form__control'));
/* 失焦即校验(触屏上软键盘收起会触发 blur) */
controls.forEach(function (c) {
c.addEventListener('blur', function () { check(c); });
});
form.addEventListener('submit', function (e) {
e.preventDefault();
var ok = true;
controls.forEach(function (c) { if (!check(c)) ok = false; });
if (!ok) {
form.removeAttribute('data-submitted');
return;
}
form.setAttribute('data-submitted', 'true');
});
});
})();
</script>
<script>
/* ?demo=<id> → 只显示该演示块(文档站按块预览用;无参数时全部显示,测试与回归走无参数路径) */
(function () {
var id = new URLSearchParams(location.search).get('demo');
if (!id) return;
var blocks = Array.prototype.slice.call(document.querySelectorAll('.demo-block'));
var hit = false;
blocks.forEach(function (b) {
var on = b.getAttribute('data-demo') === id;
if (on) hit = true;
b.hidden = !on;
});
if (!hit) { blocks.forEach(function (b) { b.hidden = false; }); return; }
document.body.classList.add('demo-single');
blocks.forEach(function (b) {
var label = b.querySelector('.demo-label');
if (label && !b.hidden) label.hidden = true;
});
})();
</script>
</body>
</html>
frameworks-mobile/Form.jsx · React · 134 行
import React, { useRef } from 'react';
import './Form.css';
/* 表单(移动端)— 规格 §32
结构:item(字段)> label(标签 + 必填星号)/ main(控件 + 错误提示)。
移动端与桌面端的关键差别是**标签位置**:窄屏默认置顶标签(labelPosition=top),
需要一组纵向对齐的字段时才用 left(定宽标签列)。
必填星号是装饰(aria-hidden),语义由控件的 aria-required / required 承担;
错误态由字段级 is-error + 控件 aria-invalid 双写,错误文案用 aria-describedby 关联。
本端只做「必填」的结构表达,不发请求、不做正则校验(业务规则在宿主,规格 §32.7)。 */
let seq = 0;
export default function Form({
fields = [],
labelPosition = 'top',
borderless = false,
disabled = false,
submitText = '提交',
onSubmit,
onChange,
children = null,
}) {
const idRef = useRef(null);
if (idRef.current === null) {
seq += 1;
idRef.current = 'kole-m-form-' + seq;
}
const base = idRef.current;
const cls =
'kole-m-form' +
(labelPosition === 'left' ? ' kole-m-form--left' : '') +
(borderless ? ' kole-m-form--borderless' : '') +
(disabled ? ' is-disabled' : '');
return (
<form
className={cls}
noValidate
onSubmit={(e) => {
e.preventDefault();
if (disabled) return;
if (onSubmit) onSubmit(fields);
}}
>
{fields.map((f, i) => {
const item = f || {};
const key = item.name || String(i);
const labelId = `${base}-label-${key}`;
const errorId = `${base}-error-${key}`;
const hintId = `${base}-hint-${key}`;
const describedBy = [item.error ? errorId : '', item.hint ? hintId : ''].filter(Boolean).join(' ') || undefined;
return (
<div className={'kole-m-form__item' + (item.error ? ' is-error' : '')} key={String(key)}>
<span className="kole-m-form__label" id={labelId}>
{item.required ? (
<span className="kole-m-form__star" aria-hidden="true">
*
</span>
) : null}
{item.label}
</span>
<span className="kole-m-form__main">
{item.type === 'select' ? (
/* 非输入型控件:整行可点(原生 button,键盘可达) */
<button className="kole-m-form__hit" type="button" aria-labelledby={labelId} aria-haspopup="dialog" disabled={disabled}>
<span>{item.value || item.placeholder || ''}</span>
<span aria-hidden="true">›</span>
</button>
) : item.type === 'textarea' ? (
<textarea
className="kole-m-form__control kole-m-form__control--multiline"
name={item.name}
rows={item.rows || 2}
value={item.value || ''}
placeholder={item.placeholder || ''}
disabled={disabled}
required={!!item.required}
aria-labelledby={labelId}
aria-required={item.required ? 'true' : undefined}
aria-invalid={item.error ? 'true' : undefined}
aria-describedby={describedBy}
onChange={(e) => {
if (disabled) return;
if (onChange) onChange(item.name, e.target.value);
}}
/>
) : (
<input
className="kole-m-form__control"
type="text"
inputMode={item.inputMode || undefined}
name={item.name}
value={item.value || ''}
placeholder={item.placeholder || ''}
disabled={disabled}
required={!!item.required}
aria-labelledby={labelId}
aria-required={item.required ? 'true' : undefined}
aria-invalid={item.error ? 'true' : undefined}
aria-describedby={describedBy}
onChange={(e) => {
if (disabled) return;
if (onChange) onChange(item.name, e.target.value);
}}
/>
)}
{item.error ? (
<span className="kole-m-form__error" id={errorId} role="alert">
{item.error}
</span>
) : null}
{item.hint ? (
<span className="kole-m-form__hint" id={hintId}>
{item.hint}
</span>
) : null}
</span>
</div>
);
})}
{children}
{submitText ? (
<div className="kole-m-form__item">
<button className="kole-m-form__submit" type="submit" disabled={disabled}>
{submitText}
</button>
</div>
) : null}
</form>
);
}
frameworks-mobile/Form.vue2.vue · Vue 2 · 130 行
<template>
<form class="kole-m-form" :class="formClass" novalidate @submit.prevent="onSubmit">
<div
v-for="(item, i) in fields"
:key="String(keyOf(item, i))"
class="kole-m-form__item"
:class="{ 'is-error': !!(item && item.error) }"
>
<span class="kole-m-form__label" :id="labelId(item, i)">
<span v-if="item && item.required" class="kole-m-form__star" aria-hidden="true">*</span>{{ item && item.label }}
</span>
<span class="kole-m-form__main">
<button
v-if="item && item.type === 'select'"
class="kole-m-form__hit"
type="button"
:aria-labelledby="labelId(item, i)"
aria-haspopup="dialog"
:disabled="disabled"
>
<span>{{ item.value || item.placeholder || '' }}</span>
<span aria-hidden="true">›</span>
</button>
<textarea
v-else-if="item && item.type === 'textarea'"
class="kole-m-form__control kole-m-form__control--multiline"
:name="item.name"
:rows="item.rows || 2"
:value="item.value || ''"
:placeholder="item.placeholder || ''"
:disabled="disabled"
:required="!!item.required"
:aria-labelledby="labelId(item, i)"
:aria-required="item.required ? 'true' : null"
:aria-invalid="item.error ? 'true' : null"
:aria-describedby="describedBy(item, i)"
@input="onInput(item, $event)"
></textarea>
<input
v-else
class="kole-m-form__control"
type="text"
:inputmode="(item && item.inputMode) || null"
:name="item && item.name"
:value="(item && item.value) || ''"
:placeholder="(item && item.placeholder) || ''"
:disabled="disabled"
:required="!!(item && item.required)"
:aria-labelledby="labelId(item, i)"
:aria-required="item && item.required ? 'true' : null"
:aria-invalid="item && item.error ? 'true' : null"
:aria-describedby="describedBy(item, i)"
@input="onInput(item, $event)"
/>
<span v-if="item && item.error" class="kole-m-form__error" :id="errorId(item, i)" role="alert">{{ item.error }}</span>
<span v-if="item && item.hint" class="kole-m-form__hint" :id="hintId(item, i)">{{ item.hint }}</span>
</span>
</div>
<slot></slot>
<div v-if="submitText" class="kole-m-form__item">
<button class="kole-m-form__submit" type="submit" :disabled="disabled">{{ submitText }}</button>
</div>
</form>
</template>
<script>
/* 表单(移动端)— 规格 §32
结构:item(字段)> label(标签 + 必填星号)/ main(控件 + 错误提示)。
移动端与桌面端的关键差别是**标签位置**:窄屏默认置顶标签(labelPosition=top),
需要一组纵向对齐的字段时才用 left(定宽标签列)。
必填星号是装饰(aria-hidden),语义由控件的 aria-required / required 承担;
错误态由字段级 is-error + 控件 aria-invalid 双写,错误文案用 aria-describedby 关联。
本端只做「必填」的结构表达,不发请求、不做正则校验(业务规则在宿主,规格 §32.7)。 */
var seq = 0;
export default {
name: 'KoleMForm',
props: {
fields: { type: Array, default: function () { return []; } },
labelPosition: { type: String, default: 'top' },
borderless: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
submitText: { type: String, default: '提交' }
},
data: function () {
seq += 1;
return { base: 'kole-m-form-' + seq };
},
computed: {
formClass: function () {
return [
this.labelPosition === 'left' ? 'kole-m-form--left' : '',
this.borderless ? 'kole-m-form--borderless' : '',
this.disabled ? 'is-disabled' : ''
].filter(Boolean);
}
},
methods: {
keyOf: function (item, i) {
return item && item.name !== undefined ? item.name : i;
},
labelId: function (item, i) {
return this.base + '-label-' + this.keyOf(item, i);
},
errorId: function (item, i) {
return this.base + '-error-' + this.keyOf(item, i);
},
hintId: function (item, i) {
return this.base + '-hint-' + this.keyOf(item, i);
},
describedBy: function (item, i) {
var ids = [];
if (item && item.error) ids.push(this.errorId(item, i));
if (item && item.hint) ids.push(this.hintId(item, i));
return ids.length ? ids.join(' ') : null;
},
onInput: function (item, e) {
if (this.disabled) return;
this.$emit('change', item && item.name, e.target.value);
},
onSubmit: function () {
if (this.disabled) return;
this.$emit('submit', this.fields);
}
}
};
</script>
<style src="./Form.css"></style>
frameworks-mobile/Form.vue3.vue · Vue 3 · 126 行
<template>
<form class="kole-m-form" :class="formClass" novalidate @submit.prevent="onSubmit">
<div
v-for="(item, i) in fields"
:key="String(item && item.name !== undefined ? item.name : i)"
class="kole-m-form__item"
:class="{ 'is-error': !!(item && item.error) }"
>
<span class="kole-m-form__label" :id="labelId(item, i)">
<span v-if="item && item.required" class="kole-m-form__star" aria-hidden="true">*</span>{{ item && item.label }}
</span>
<span class="kole-m-form__main">
<button
v-if="item && item.type === 'select'"
class="kole-m-form__hit"
type="button"
:aria-labelledby="labelId(item, i)"
aria-haspopup="dialog"
:disabled="disabled"
>
<span>{{ item.value || item.placeholder || '' }}</span>
<span aria-hidden="true">›</span>
</button>
<textarea
v-else-if="item && item.type === 'textarea'"
class="kole-m-form__control kole-m-form__control--multiline"
:name="item.name"
:rows="item.rows || 2"
:value="item.value || ''"
:placeholder="item.placeholder || ''"
:disabled="disabled"
:required="!!item.required"
:aria-labelledby="labelId(item, i)"
:aria-required="item.required ? 'true' : null"
:aria-invalid="item.error ? 'true' : null"
:aria-describedby="describedBy(item, i)"
@input="onInput(item, $event)"
></textarea>
<input
v-else
class="kole-m-form__control"
type="text"
:inputmode="(item && item.inputMode) || null"
:name="item && item.name"
:value="(item && item.value) || ''"
:placeholder="(item && item.placeholder) || ''"
:disabled="disabled"
:required="!!(item && item.required)"
:aria-labelledby="labelId(item, i)"
:aria-required="item && item.required ? 'true' : null"
:aria-invalid="item && item.error ? 'true' : null"
:aria-describedby="describedBy(item, i)"
@input="onInput(item, $event)"
/>
<span v-if="item && item.error" class="kole-m-form__error" :id="errorId(item, i)" role="alert">{{ item.error }}</span>
<span v-if="item && item.hint" class="kole-m-form__hint" :id="hintId(item, i)">{{ item.hint }}</span>
</span>
</div>
<slot></slot>
<div v-if="submitText" class="kole-m-form__item">
<button class="kole-m-form__submit" type="submit" :disabled="disabled">{{ submitText }}</button>
</div>
</form>
</template>
<script setup>
/* 表单(移动端)— 规格 §32
结构:item(字段)> label(标签 + 必填星号)/ main(控件 + 错误提示)。
移动端与桌面端的关键差别是**标签位置**:窄屏默认置顶标签(labelPosition=top),
需要一组纵向对齐的字段时才用 left(定宽标签列)。
必填星号是装饰(aria-hidden),语义由控件的 aria-required / required 承担;
错误态由字段级 is-error + 控件 aria-invalid 双写,错误文案用 aria-describedby 关联。
本端只做「必填」的结构表达,不发请求、不做正则校验(业务规则在宿主,规格 §32.7)。 */
import { computed, ref } from 'vue';
const props = defineProps({
fields: { type: Array, default: () => [] },
labelPosition: { type: String, default: 'top' },
borderless: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
submitText: { type: String, default: '提交' }
});
const emit = defineEmits(['submit', 'change']);
/* 字段 / 提示的 id:同页多实例不撞号(模块级自增即可) */
let seq = 0;
const base = ref('');
seq += 1;
base.value = 'kole-m-form-' + seq;
const formClass = computed(() => [
props.labelPosition === 'left' ? 'kole-m-form--left' : '',
props.borderless ? 'kole-m-form--borderless' : '',
props.disabled ? 'is-disabled' : ''
].filter(Boolean));
function keyOf(item, i) {
return item && item.name !== undefined ? item.name : i;
}
function labelId(item, i) {
return base.value + '-label-' + keyOf(item, i);
}
function errorId(item, i) {
return base.value + '-error-' + keyOf(item, i);
}
function hintId(item, i) {
return base.value + '-hint-' + keyOf(item, i);
}
function describedBy(item, i) {
const ids = [];
if (item && item.error) ids.push(errorId(item, i));
if (item && item.hint) ids.push(hintId(item, i));
return ids.length ? ids.join(' ') : null;
}
function onInput(item, e) {
if (props.disabled) return;
emit('change', item && item.name, e.target.value);
}
function onSubmit() {
if (props.disabled) return;
emit('submit', props.fields);
}
</script>
<style src="./Form.css"></style>
frameworks-mobile/Form.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 253 行
<template>
<view class="kole-m-form" :class="formClass">
<view
v-for="(item, i) in fields"
:key="String(keyOf(item, i))"
class="kole-m-form__item"
:class="{ 'is-error': !!(item && item.error) }"
>
<view class="kole-m-form__label">
<text v-if="item && item.required" class="kole-m-form__star">*</text>
<text>{{ item && item.label }}</text>
</view>
<view class="kole-m-form__main">
<view
v-if="item && item.type === 'select'"
class="kole-m-form__hit"
:role="disabled ? '' : 'button'"
:aria-disabled="disabled ? 'true' : 'false'"
@tap="onSelect(item)"
>
<text>{{ item.value || item.placeholder || '' }}</text>
<text class="kole-m-form__arrow">›</text>
</view>
<textarea
v-else-if="item && item.type === 'textarea'"
class="kole-m-form__control kole-m-form__control--multiline"
:name="item.name"
:value="item.value || ''"
:placeholder="item.placeholder || ''"
:disabled="disabled"
@input="onInput(item, $event)"
/>
<input
v-else
class="kole-m-form__control"
type="text"
:name="item && item.name"
:value="(item && item.value) || ''"
:placeholder="(item && item.placeholder) || ''"
:disabled="disabled"
:aria-required="item && item.required ? 'true' : 'false'"
:aria-invalid="item && item.error ? 'true' : 'false'"
@input="onInput(item, $event)"
/>
<text v-if="item && item.error" class="kole-m-form__error">{{ item.error }}</text>
<text v-if="item && item.hint" class="kole-m-form__hint">{{ item.hint }}</text>
</view>
</view>
<slot></slot>
<view v-if="submitText" class="kole-m-form__item">
<view
class="kole-m-form__submit"
:role="disabled ? '' : 'button'"
:aria-disabled="disabled ? 'true' : 'false'"
@tap="onSubmit"
>
<text>{{ submitText }}</text>
</view>
</view>
</view>
</template>
<script setup>
/* uni-app 端 · 表单(移动端)— 规格 §32
跨端差异:
① 表单根与字段用 view(小程序没有 <form> 的原生校验语义),提交用 view + role="button" + @tap;
② 控件是 uni 的 <input> / <textarea>,事件对象是 { detail: { value } },没有 DOM event.target;
③ 必填星号用 <text>,语义由 :aria-required 承担(小程序读屏读 aria-*);
④ 不支持 :focus-visible,聚焦反馈改由控件自身边框承担。
尺寸用 rpx:88rpx = 375pt 下的 44px 触控最小边长。 */
import { computed, ref } from 'vue';
const props = defineProps({
fields: { type: Array, default: () => [] },
labelPosition: { type: String, default: 'top' },
borderless: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
submitText: { type: String, default: '提交' }
});
const emit = defineEmits(['submit', 'change']);
/* 唯一实例基名:同页多实例不撞号(模块级自增即可) */
let seq = 0;
const base = ref('');
seq += 1;
base.value = 'kole-m-form-' + seq;
const formClass = computed(() => [
props.labelPosition === 'left' ? 'kole-m-form--left' : '',
props.borderless ? 'kole-m-form--borderless' : '',
props.disabled ? 'is-disabled' : ''
].filter(Boolean));
function keyOf(item, i) {
return item && item.name !== undefined ? item.name : i;
}
/* uni 的 input / textarea 事件对象为 { detail: { value } },无 DOM event.target */
function onInput(item, e) {
if (props.disabled) return;
emit('change', item && item.name, e && e.detail ? e.detail.value : '');
}
function onSelect(item) {
if (props.disabled) return;
emit('change', item && item.name, item && item.value);
}
function onSubmit() {
if (props.disabled) return;
emit('submit', props.fields);
}
</script>
<style>
.kole-m-form {
--kole-m-form-label-width: 144rpx; /* labelPosition=left 时的标签列宽(72px @375pt) */
--kole-m-form-gap: 16rpx; /* 标签与控件之间的间距 */
--kole-m-touch-target: 88rpx;
--kole-m-font-size-body: 32rpx;
--kole-m-font-size-label: 28rpx;
--kole-m-gutter: 32rpx;
box-sizing: border-box;
width: 100%;
background-color: var(--kole-color-card-bg);
color: var(--kole-color-text-body);
font-size: var(--kole-m-font-size-body);
}
.kole-m-form__item {
box-sizing: border-box;
display: flex;
flex-direction: column;
padding: 24rpx var(--kole-m-gutter);
}
.kole-m-form:not(.kole-m-form--borderless) .kole-m-form__item + .kole-m-form__item {
border-top: 1rpx solid var(--kole-color-border);
}
.kole-m-form--borderless .kole-m-form__item + .kole-m-form__item { border-top: 0; }
.kole-m-form--left .kole-m-form__item { flex-direction: row; }
.kole-m-form--left .kole-m-form__label {
flex-shrink: 0;
width: var(--kole-m-form-label-width);
padding-top: 18rpx;
}
.kole-m-form__label {
display: flex;
align-items: center;
margin-bottom: var(--kole-m-form-gap);
color: var(--kole-color-text-title);
font-size: var(--kole-m-font-size-label);
}
.kole-m-form--left .kole-m-form__label { margin-bottom: 0; }
.kole-m-form__star {
padding-right: 8rpx;
color: var(--kole-color-error);
}
.kole-m-form__main { flex: 1; }
.kole-m-form__control {
box-sizing: border-box;
width: 100%;
min-height: var(--kole-m-touch-target);
padding: 16rpx 24rpx;
border: 1rpx solid var(--kole-color-border);
border-radius: 8rpx;
background-color: var(--kole-color-card-bg);
color: var(--kole-color-text-body);
font-size: var(--kole-m-font-size-body);
}
.kole-m-form__control--multiline { min-height: 176rpx; }
/* 非输入型控件:整行热区,按下有反馈 */
.kole-m-form__hit {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
min-height: var(--kole-m-touch-target);
padding: 16rpx 24rpx;
border: 1rpx solid var(--kole-color-border);
border-radius: 8rpx;
background-color: var(--kole-color-card-bg);
color: var(--kole-color-text-body);
}
.kole-m-form__hit:active { background-color: var(--kole-color-table-header-bg); }
.kole-m-form__arrow { color: var(--kole-color-text-placeholder); }
/* 状态 error:边框、标签与星号一起变错误色(不只靠一处提示) */
.kole-m-form__item.is-error .kole-m-form__control,
.kole-m-form__item.is-error .kole-m-form__hit { border-color: var(--kole-color-error); }
.kole-m-form__item.is-error .kole-m-form__label { color: var(--kole-color-error); }
.kole-m-form__error {
display: block;
padding-top: 8rpx;
color: var(--kole-color-error);
font-size: var(--kole-m-font-size-label);
}
.kole-m-form__hint {
display: block;
padding-top: 8rpx;
color: var(--kole-color-text-secondary);
font-size: var(--kole-m-font-size-label);
}
/* 状态 disabled:整表置灰 */
.kole-m-form.is-disabled { background-color: var(--kole-color-disabled-bg); }
.kole-m-form.is-disabled .kole-m-form__label,
.kole-m-form.is-disabled .kole-m-form__hint { color: var(--kole-color-text-disabled); }
.kole-m-form.is-disabled .kole-m-form__control,
.kole-m-form.is-disabled .kole-m-form__hit {
background-color: var(--kole-color-disabled-bg);
color: var(--kole-color-text-disabled);
}
/* 提交行:整行主操作(高度 ≥ 88rpx 触控边长) */
.kole-m-form__submit {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: var(--kole-m-touch-target);
border-radius: 8rpx;
background-color: var(--kole-color-brand);
color: var(--kole-color-text-inverse);
font-size: var(--kole-m-font-size-body);
}
.kole-m-form__submit:active { background-color: var(--kole-color-brand-active); }
.kole-m-form.is-disabled .kole-m-form__submit {
background-color: var(--kole-color-disabled-bg);
color: var(--kole-color-text-disabled);
}
</style>
测试与回归
断言在真实的 375×640 设备帧里跑(引擎与 PC 侧共用 tests/_runtime.js,触控行为动词来自移动端 tests/mobile/_behaviors.js)。
断言 16 条 · 全部通过 报告 2026-09-22 23:01:05
node site/dev-server.js &
REG_BASE=http://127.0.0.1:3311 node tools/run-mobile-regression.mjs # 全量 5 个组件
npm run verify:mobile-docs # 本页内容完整性 + API 与源码一致性
设计契约
components/mobile-form.json(点击展开原始 JSON)
{
"schemaVersion": 1,
"sourceKind": "authored-spec",
"provenance": "authored-in-repo",
"specFile": "spec/移动端规格.md",
"specSection": "32 · 表单 Form",
"confidence": "high",
"slug": "mobile-form",
"name": "表单 Form",
"semanticTypeCandidates": [
"form",
"form-item",
"field-group"
],
"variantDimensions": [
{
"name": "labelPosition",
"values": [
"top",
"left"
]
},
{
"name": "borderless",
"values": [
"false",
"true"
]
}
],
"representativeVariants": [
{
"labelPosition": "top",
"borderless": "false",
"label": "默认(置顶标签 + 字段间分隔线)"
},
{
"labelPosition": "left",
"borderless": "false",
"label": "左标签(定宽 72px 与控件同行)"
},
{
"labelPosition": "top",
"borderless": "true",
"label": "无分隔线(靠留白分组,嵌在卡片里)"
}
],
"anatomy": {
"form": "根元素,包住全部字段与提交行",
"item": "单个字段,一列一个,字段之间用 1px 分隔线划分",
"label": "字段标签;required=true 时前面带一个错误色星号",
"star": "必填星号,装饰性(aria-hidden),语义由控件的 aria-required 承担",
"control": "字段控件(原生 input / textarea,或包一层的可点区域)",
"errorText": "字段级错误提示,落在该字段控件下方"
},
"structurePatterns": {
"labelPosition": "top(标签在控件上方,默认)/ left(标签定宽 72px 与控件同行)",
"borderless": "false(字段之间有分隔线)/ true(不画线,靠留白分组)"
},
"usageHints": [
"把一组字段(标签 + 控件 + 错误提示)组织成一次可提交的操作(下单、开票、认证)",
"移动端窄屏放不下左右两列时标签默认置顶,只有需要一组字段纵向对齐时才退回到定宽左标签",
"每个字段的错误提示必须贴在它自己的控件下方 —— 触屏上用户看不到「页头汇总错误」",
"控件高度不小于 44px;非输入型控件(选择器一类)整行都是热区",
"失焦即校验(触屏上软键盘收起会触发 blur),提交时再全量校验一次"
],
"doNotInvent": [
"字段内容的业务校验规则(正则、长度、合法性判断都在宿主,本组件只表达结构)",
"提交请求、失败重试与「提交中」的按钮态",
"字段的联动显隐与动态增删(由宿主决定渲染什么)",
"页首错误汇总条(移动端用字段级提示,不做汇总)"
],
"unknowns": [
"左标签模式下标签列宽是否随字号变化(当前固定 72px)",
"提交行是否允许放两个并列动作(如「保存草稿 + 提交」)",
"只读(readonly)字段是否需要独立的视觉层,还是复用 disabled"
],
"interaction": [
"控件高度不小于 44px;非输入型控件(选择器一类)整行都是热区,按下反馈是整行背景变化",
"错误提示出现在控件正下方,不挤占输入区,也不跨到标签列",
"失焦即校验(触屏上软键盘收起会触发 blur),提交时再全量校验一次",
"校验失败时不跳转、不滚动到页首,错误就落在出错的字段上",
"提交成功后写 data-submitted=\"true\" 作为可断言的提交凭证;星号与错误色不单独承载语义"
],
"accessibility": [
"标签用 aria-labelledby 关联到控件(标签不是占位符的替代品)",
"必填用控件的 aria-required=\"true\" 与原生 required 双写;星号本身 aria-hidden",
"错误文案用 aria-describedby 关联到控件,并带 role=\"alert\" 播报",
"禁用态用原生 disabled,读屏会播报不可用且键盘会跳过"
],
"api": {
"source": "implementation",
"note": "props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs",
"requiredNote": "「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。",
"props": [
{
"name": "fields",
"type": "Array",
"default": "[]",
"desc": "字段数组,每项 { name, label, value, type, required, error, hint, placeholder }(规格 §32.2)",
"required": false
},
{
"name": "labelPosition",
"type": "'top' | 'left'",
"default": "'top'",
"desc": "变体 labelPosition:标签置顶还是定宽在控件左侧(规格 §32.3)",
"required": false
},
{
"name": "borderless",
"type": "boolean",
"default": "false",
"desc": "变体 borderless:字段之间是否画 1px 分隔线(规格 §32.3)",
"required": false
},
{
"name": "disabled",
"type": "boolean",
"default": "false",
"desc": "状态 disabled:整表置灰且不可聚焦(规格 §32.4)",
"required": false
},
{
"name": "submitText",
"type": "string",
"default": "'提交'",
"desc": "提交行按钮文案;空字符串表示不渲染提交行(规格 §32.5)",
"required": false
}
],
"events": [
{
"name": "submit",
"params": "(fields)",
"desc": "提交时触发,回传字段数组供宿主校验与请求(规格 §32.5)"
},
{
"name": "change",
"params": "(name, value)",
"desc": "字段值变化时触发,回传字段名与当前值(规格 §32.5)"
}
],
"slots": [
{
"name": "default",
"desc": "提交行之前的自定义字段区(宿主插入自己的字段结构,规格 §32.2 item)"
}
]
},
"variantClasses": {
"labelPosition": {
"top": [],
"left": [
".kole-m-form--left"
]
},
"borderless": {
"false": [],
"true": [
".kole-m-form--borderless"
]
}
},
"demos": [
{
"id": "basic",
"group": "01 组件类型",
"title": "可提交表单",
"desc": "置顶标签 + 必填星号 + 提示行;点提交执行校验并写入 data-submitted。",
"variant": "labelPosition=top"
},
{
"id": "left",
"group": "01 组件类型",
"title": "标签在左",
"desc": "labelPosition=left:标签定宽 72px 与控件同行,适合一组纵向对齐的字段。",
"variant": "labelPosition=left"
},
{
"id": "borderless",
"group": "01 组件类型",
"title": "无分隔线",
"desc": "borderless=true:字段之间靠留白分组,嵌在卡片里时用。",
"variant": "borderless=true"
},
{
"id": "error",
"group": "02 组件状态",
"title": "校验错误",
"desc": "is-error + aria-invalid 双写;错误文案在控件下方,标签与星号同时变错误色。",
"variant": "状态 error"
},
{
"id": "disabled",
"group": "02 组件状态",
"title": "整表禁用",
"desc": "字段置灰且不可聚焦,提交按钮一并置灰;读屏会播报不可用。",
"variant": "disabled=true"
}
],
"related": [
{
"slug": "mobile-input",
"why": "单个字段的输入框(高度、清除按钮、错误态)由 Input 负责;Form 只管字段的组织与提交"
},
{
"slug": "mobile-textarea",
"why": "多行文本字段用 Textarea(计数、自动增高);Form 的 item 只是把它包进标签与错误提示里"
},
{
"slug": "mobile-button",
"why": "表单外的独立动作(取消、暂存)用 Button;只有「提交本表单」才用 Form 内置的提交行"
}
]
}