移动端导航表单

表单Form

把一组字段(标签 + 控件 + 错误提示)组织成一次可提交的操作(下单、开票、认证)

数据录入 规格 32 · 表单 Form 6 端实现 触摸优先

引入(H5 原生;其余 5 端见「快速开始」)
<!-- ① 令牌: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 组件类型

可提交表单labelPosition=top

置顶标签 + 必填星号 + 提示行;点提交执行校验并写入 data-submitted。

查看代码(演示页原文 · 46 行)
frameworks-mobile/Form.html · basic
<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

labelPosition=left:标签定宽 72px 与控件同行,适合一组纵向对齐的字段。

查看代码(演示页原文 · 32 行)
frameworks-mobile/Form.html · left
<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

borderless=true:字段之间靠留白分组,嵌在卡片里时用。

查看代码(演示页原文 · 25 行)
frameworks-mobile/Form.html · borderless
<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 组件状态

校验错误状态 error

is-error + aria-invalid 双写;错误文案在控件下方,标签与星号同时变错误色。

查看代码(演示页原文 · 25 行)
frameworks-mobile/Form.html · error
<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>
整表禁用disabled=true

字段置灰且不可聚焦,提交按钮一并置灰;读屏会播报不可用。

查看代码(演示页原文 · 25 行)
frameworks-mobile/Form.html · disabled
<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

名称类型默认值说明必传
fieldsArray[]字段数组,每项 { name, label, value, type, required, error, hint, placeholder }(规格 §32.2)N
labelPosition'top' | 'left''top'变体 labelPosition:标签置顶还是定宽在控件左侧(规格 §32.3)N
borderlessbooleanfalse变体 borderless:字段之间是否画 1px 分隔线(规格 §32.3)N
disabledbooleanfalse状态 disabled:整表置灰且不可聚焦(规格 §32.4)N
submitTextstring'提交'提交行按钮文案;空字符串表示不渲染提交行(规格 §32.5)N

「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。

事件

名称参数说明
submit(fields)提交时触发,回传字段数组供宿主校验与请求(规格 §32.5)
change(name, value)字段值变化时触发,回传字段名与当前值(规格 §32.5)

插槽

名称说明
default提交行之前的自定义字段区(宿主插入自己的字段结构,规格 §32.2 item)

CSS 变量

组件级变量(在组件样式表里定义)。业务侧可在自己的作用域内覆盖,不必改组件源码。

名称默认值说明
--kole-m-form-label-width72pxlabelPosition=left 时的标签列宽
--kole-m-form-gapvar(--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 校验(类/变量必须真实存在)。

维度取值对应类名 / 变量
labelPositiontop / left
top (由数据驱动,无专属类)
left .kole-m-form--left
borderlessfalse / true
false (由数据驱动,无专属类)
true .kole-m-form--borderless

代表变体

变体标签
labelPosition=top · borderless=false默认(置顶标签 + 字段间分隔线)
labelPosition=left · borderless=false左标签(定宽 72px 与控件同行)
labelPosition=top · borderless=true无分隔线(靠留白分组,嵌在卡片里)

用到的令牌

构建时从本组件样式表扫描得出。蓝色为移动端自有令牌,绿色为继承的 PC 令牌(改一处两端生效)。

--kole-m-font-size-body --kole-m-font-size-label --kole-m-gutter --kole-m-touch-target --kole-color-border --kole-color-brand --kole-color-brand-active --kole-color-card-bg --kole-color-disabled-bg --kole-color-error --kole-color-focus-ring --kole-color-table-header-bg --kole-color-text-body --kole-color-text-disabled --kole-color-text-inverse --kole-color-text-placeholder --kole-color-text-secondary --kole-color-text-title --kole-radius-base --kole-space-12 --kole-space-16 --kole-space-4 --kole-space-8 --kole-m-form-gap --kole-m-form-label-width

6 端源码

同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。

frameworks-mobile/Form.css · 纯样式(CSS) · 191 行
frameworks-mobile/Form.css
/* 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 行
frameworks-mobile/Form.html
<!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 行
frameworks-mobile/Form.jsx
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 行
frameworks-mobile/Form.vue2.vue
<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 行
frameworks-mobile/Form.vue3.vue
<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 行
frameworks-mobile/Form.uniapp.vue
<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 内置的提交行"
    }
  ]
}