移动端导航多行文本框

多行文本框Textarea

收集可能超过一行的自由文本(备注、收货说明、退换原因)

数据录入 规格 31 · 多行文本框 Textarea 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-textarea.css">

<!-- ③ 结构照抄下方任一演示块(类名与 6 端实现一致) -->

演示

每个演示都是真实渲染:预览帧加载 frameworks-mobile/Textarea.html?demo=<id>(只显示该演示块),代码是该演示块在演示页里的原文,可复制。全部演示同屏可看 演示页 ↗。

01 组件类型

基础用法size=default

最小高度 3 行:触屏上不能像桌面那样看到输入上下文。

查看代码(演示页原文 · 8 行)
frameworks-mobile/Textarea.html · basic
<section class="demo-block" data-demo="basic">
  <p class="demo-label">基础用法(最小高度 3 行:触屏上不能像桌面那样看到输入上下文)</p>
  <div class="demo-box">
    <div class="kole-m-textarea" data-assert="textarea-basic">
      <textarea class="kole-m-textarea__control" rows="3" placeholder="请填写订单备注(选填)" aria-label="订单备注"></textarea>
    </div>
  </div>
</section>
带计数showCounter=true

showCounter=true:计数随输入实时更新;点「填到上限」可看到 is-full 形态。

查看代码(演示页原文 · 14 行)
frameworks-mobile/Textarea.html · counter
<section class="demo-block" data-demo="counter">
  <p class="demo-label">带计数(showCounter:计数随输入实时更新;「填到上限」会把内容补满并让计数转红)</p>
  <div class="demo-box">
    <div class="kole-m-textarea" id="ta-counter" data-limit="50" data-assert="textarea-counter">
      <textarea class="kole-m-textarea__control" rows="3" maxlength="50" placeholder="退换原因(不超过 50 字)"
                aria-label="退换原因" id="ta-counter-control">外包装在运输途中破损,商品本体完好。</textarea>
      <div class="kole-m-textarea__foot">
        <span class="kole-m-textarea__counter" id="ta-counter-num" aria-live="polite">0/50</span>
        <button class="demo-mini" type="button" id="ta-counter-fill"
                data-behavior="click-toggles-class:#ta-counter|is-full">填到上限</button>
      </div>
    </div>
  </div>
</section>
尺寸两档size=default|large

default 最小 3 行;large 最小 5 行。两者都只允许纵向拉伸。

查看代码(演示页原文 · 11 行)
frameworks-mobile/Textarea.html · size
<section class="demo-block" data-demo="size">
  <p class="demo-label">尺寸两档(size=default 最小 3 行 / size=large 最小 5 行;都只允许纵向拉伸)</p>
  <div class="demo-box" data-assert="textarea-size">
    <div class="kole-m-textarea">
      <textarea class="kole-m-textarea__control" rows="3" placeholder="default(最小 3 行)" aria-label="默认多行文本"></textarea>
    </div>
    <div class="kole-m-textarea kole-m-textarea--large" style="margin-top: var(--kole-space-16)">
      <textarea class="kole-m-textarea__control" rows="5" placeholder="large(最小 5 行,长文本场景)" aria-label="长多行文本"></textarea>
    </div>
  </div>
</section>

02 组件状态

到达上限状态 full

is-full:计数变错误色并停止接收新字符,不静默截断。

查看代码(演示页原文 · 11 行)
frameworks-mobile/Textarea.html · full
<section class="demo-block" data-demo="full">
  <p class="demo-label">到达上限(is-full:计数变错误色并停止接收新字符,不静默截断)</p>
  <div class="demo-box">
    <div class="kole-m-textarea is-full" id="ta-full" data-limit="12" data-assert="textarea-full">
      <textarea class="kole-m-textarea__control" rows="3" maxlength="12" aria-label="简短说明">这是已经写满的十二个字符</textarea>
      <div class="kole-m-textarea__foot">
        <span class="kole-m-textarea__counter" aria-live="polite">12/12</span>
      </div>
    </div>
  </div>
</section>
错误态状态 error

is-error + aria-invalid;错误说明与计数同一行,两者都不消失。

查看代码(演示页原文 · 13 行)
frameworks-mobile/Textarea.html · error
<section class="demo-block" data-demo="error">
  <p class="demo-label">错误态(is-error + aria-invalid:错误说明与计数同一行,两者都不消失)</p>
  <div class="demo-box">
    <div class="kole-m-textarea is-error" data-assert="textarea-error">
      <textarea class="kole-m-textarea__control" rows="3" aria-label="收货说明"
                aria-invalid="true" aria-describedby="ta-error-msg">送到门口</textarea>
      <div class="kole-m-textarea__foot">
        <span class="kole-m-textarea__error" id="ta-error-msg" role="alert">收货说明至少 10 个字</span>
        <span class="kole-m-textarea__counter" aria-live="polite">4/200</span>
      </div>
    </div>
  </div>
</section>
禁用disabled=true

置灰且不可聚焦,读屏会跳过。

查看代码(演示页原文 · 8 行)
frameworks-mobile/Textarea.html · disabled
<section class="demo-block" data-demo="disabled">
  <p class="demo-label">禁用(置灰且不可聚焦:读屏会跳过)</p>
  <div class="demo-box">
    <div class="kole-m-textarea is-disabled" data-assert="textarea-disabled">
      <textarea class="kole-m-textarea__control" rows="3" aria-label="系统备注" disabled>该备注由系统生成,不可编辑。</textarea>
    </div>
  </div>
</section>

API

props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs

Props

名称类型默认值说明必传
valuestring''受控文本;长度决定计数与 is-full(规格 §31.5)N
size'default' | 'large''default'变体 size:default 最小 3 行,large 最小 5 行(规格 §31.3)N
placeholderstring''占位文字;它不算标签,标签由 label 给出(规格 §31.6)N
maxlengthnumber200字数上限;0 表示不限。到上限时计数转错误色并停止接收(规格 §31.5)N
showCounterbooleanfalse变体 showCounter:右下角是否显示「已输入/上限」(规格 §31.3)N
disabledbooleanfalse状态 disabled:置灰且不可聚焦(规格 §31.4)N
errorstring''状态 error:错误文案,非空时写 aria-invalid 并显示在框外下方(规格 §31.6)N
labelstring'多行文本'无障碍名称,落到 aria-label(规格 §31.6)N
rowsnumber3textarea 的初始行数(规格 §31.2 label 行)N

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

事件

名称参数说明
input(value)输入时触发,回传当前文本(计数与 is-full 由此派生)(规格 §31.5)

插槽

名称说明
default文本框下方的追加内容(如提示语),排在计数行之后(规格 §31.2 field)

CSS 变量

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

名称默认值说明
--kole-m-textarea-row-height24px单行高度(字号 16 × 行高 1.5)
--kole-m-textarea-min-heightcalc(var(--kole-m-textarea-row-height) * 3 + var(--kole-space-12) * 2)组件内部默认值,可在业务侧覆盖
--kole-m-textarea-duration150ms边框与聚焦外发光的过渡时长
--kole-m-textarea-min-heightcalc(var(--kole-m-textarea-row-height) * 5 + var(--kole-space-12) * 2)组件内部默认值,可在业务侧覆盖

何时使用

  • 收集可能超过一行的自由文本(备注、收货说明、退换原因)
  • 文本框本身要够高(至少 3 行),因为触屏不能像桌面那样在输入过程中看到上下文
  • 要给出实时字数反馈,超限时是「止写 + 报错」而不是静默截断
  • 只允许纵向伸缩(resize: vertical),不允许横向拉宽破坏 375 宽的布局
  • 不使用自动增高(高度随内容跳动会让上下文错位),需要更长文本时用 size=large

交互与触控

  • 文本框最小高度 3 行(约 88px);只允许纵向伸缩(resize: vertical),不允许横向拉宽破坏 375 宽的布局
  • 计数随输入实时更新;达到上限时计数置错误色并停止接收新字符(原生 maxlength 兜底,超限靠宿主提示)
  • 文本框整体可点即聚焦(外层不出可点装饰);错误提示与计数都在框外,不挤占输入区
  • 聚焦反馈是边框色 + 2px 外发光,150ms 过渡;不改变高度
  • 不使用自动增高(高度随内容跳动会让上下文错位),需要更长文本时用 size=large

无障碍

  • 控件是原生 textarea,名称由 aria-label 给出(占位文字不算标签)
  • 错误态用 aria-invalid="true" + aria-describedby 关联错误文案
  • 计数是参考信息,用 aria-live="polite" 播报(不要每敲一个字都播报,只在接近上限时提示)
  • 禁用态用原生 disabled,读屏会跳过

从「该用哪一个」的角度区分;PC 端的对应实现见 PC 文档站。

组件何时用它而不是本组件
输入框Input内容只占一行时用输入框;可能超过一行(备注、说明)时用多行文本框
数字键盘NumberKeyboard金额、验证码这类数字内容配数字键盘;自由文本用多行文本框,无需自定义键盘
对话框Dialog长文本要在提交前整体确认时用对话框,把多行文本框放进对话框内容区

规格未定 / 禁止发明

类别条目
禁止发明自动增高(随内容撑高)的实现细节
禁止发明富文本 / Markdown 的编辑与渲染
禁止发明内容敏感词过滤与提交前的业务校验
规格未定maxlength 缺省时上限取多少(本实现默认 200)
规格未定是否需要在接近上限时提前变色(本实现只在到达上限时变色)
规格未定计数是否包含空格与换行

结构(anatomy)

字段说明
field字段容器,包裹文本框、计数行与错误提示
control原生 textarea,多行输入,行高不小于 1.5 倍字号
counter右下角字数计数(已输入/上限),maxlength>0 时出现
errorText字段下方的错误提示文字(与计数同行时计数不消失)
label无障碍名称(aria-label),textarea 的初始高度由 rows 决定

变体维度与类名映射

类名映射由构建脚本从契约 variantClasses 生成,并被 verify:mobile-docs 逐条对照组件 CSS 校验(类/变量必须真实存在)。

维度取值对应类名 / 变量
sizedefault / large
default (由数据驱动,无专属类)
large .kole-m-textarea--large
showCounterfalse / true
false (由数据驱动,无专属类)
true .kole-m-textarea__counter

代表变体

变体标签
size=default · showCounter=false默认(最小 3 行,无计数)
size=default · showCounter=true带计数(右下角「已输入/上限」)
size=large · showCounter=true长文本(最小 5 行)+ 计数

用到的令牌

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

--kole-m-font-size-body --kole-m-font-size-label --kole-color-border --kole-color-brand --kole-color-card-bg --kole-color-disabled-bg --kole-color-error --kole-color-focus-ring --kole-color-text-body --kole-color-text-disabled --kole-color-text-placeholder --kole-color-text-secondary --kole-ease-standard --kole-radius-base --kole-space-12 --kole-space-8 --kole-m-textarea-duration --kole-m-textarea-min-height --kole-m-textarea-row-height

6 端源码

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

frameworks-mobile/Textarea.css · 纯样式(CSS) · 90 行
frameworks-mobile/Textarea.css
/* Kole UI Mobile · Textarea 样式 — 对齐移动端规格 §31
   多行文本框:最小高度 3 行(触屏上不能像桌面那样看到输入上下文);
   只允许纵向伸缩(resize: vertical)—— 横向拉宽会破坏 375 宽的布局;
   计数与错误提示都在框外下方,不挤占输入区;到达上限时计数变错误色(is-full 状态)。 */

.kole-m-textarea {
  /* 组件级变量:业务侧可在容器上覆盖 */
  --kole-m-textarea-row-height: 24px;  /* 单行高度(字号 16 × 行高 1.5) */
  /* 最小高度按「行数 × 行高 + 上下内边距」算:3 行 = 96px,与 rows=3 的自然高度一致 */
  --kole-m-textarea-min-height: calc(var(--kole-m-textarea-row-height) * 3 + var(--kole-space-12) * 2);
  --kole-m-textarea-duration: 150ms;   /* 边框与聚焦外发光的过渡时长 */
  box-sizing: border-box;
  display: block;
}

.kole-m-textarea__control {
  display: block;
  box-sizing: border-box;
  width: 100%;
  min-height: var(--kole-m-textarea-min-height);
  padding: 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.5;
  /* 只允许纵向拉伸:横向拉宽在 375 宽下有溢出风险 */
  resize: vertical;
  transition: border-color var(--kole-m-textarea-duration) var(--kole-ease-standard),
              box-shadow var(--kole-m-textarea-duration) var(--kole-ease-standard);
}

/* 变体 size=large:最小高度 5 行 */
.kole-m-textarea--large {
  --kole-m-textarea-min-height: calc(var(--kole-m-textarea-row-height) * 5 + var(--kole-space-12) * 2);
}

.kole-m-textarea__control::placeholder { color: var(--kole-color-text-placeholder); }

.kole-m-textarea__control:focus {
  outline: none;
  border-color: var(--kole-color-brand);
  box-shadow: 0 0 0 2px var(--kole-color-focus-ring);
}

/* 状态 error:边框变错误色;聚焦时保留错误色 */
.kole-m-textarea.is-error .kole-m-textarea__control { border-color: var(--kole-color-error); }

.kole-m-textarea.is-error .kole-m-textarea__control:focus {
  border-color: var(--kole-color-error);
  box-shadow: 0 0 0 2px var(--kole-color-focus-ring);
}

/* 状态 disabled:置灰且不可聚焦 */
.kole-m-textarea.is-disabled .kole-m-textarea__control {
  background: var(--kole-color-disabled-bg);
  color: var(--kole-color-text-disabled);
  cursor: not-allowed;
}

/* 计数行:与错误提示同一行时左错误右计数,两者都不消失 */
.kole-m-textarea__foot {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--kole-space-8);
  margin-top: var(--kole-space-8);
}

.kole-m-textarea__error {
  font-size: var(--kole-m-font-size-label);
  line-height: 1.4;
  color: var(--kole-color-error);
}

.kole-m-textarea__counter {
  flex: 0 0 auto;
  margin-left: auto;
  font-size: var(--kole-m-font-size-label);
  line-height: 1.4;
  color: var(--kole-color-text-secondary);
  /* 数字用等宽数字:计数跳动时宽度不抖 */
  font-variant-numeric: tabular-nums;
}

/* 状态 full:到达上限时计数变错误色(不靠隐藏表达「写不进去了」) */
.kole-m-textarea.is-full .kole-m-textarea__counter { color: var(--kole-color-error); }
frameworks-mobile/Textarea.html · H5 原生(无框架) · 156 行
frameworks-mobile/Textarea.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 · Textarea(H5)</title>
<link rel="stylesheet" href="../.design_library/kole-ui-mobile/colors_and_type.css">
<link rel="stylesheet" href="Textarea.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-box { padding: var(--kole-m-gutter); background: var(--kole-color-card-bg); border-block: 1px solid var(--kole-color-border); }
  .demo-mini { margin-left: var(--kole-space-8); padding: 0 var(--kole-space-8); min-height: 24px; border: 1px solid var(--kole-color-border);
    border-radius: var(--kole-radius-base); background: var(--kole-color-card-bg); color: var(--kole-color-brand);
    font-family: inherit; font-size: var(--kole-m-font-size-label); cursor: pointer; }
  .demo-block[hidden] { display: none; }
</style>
</head>
<body>
<div class="demo">
  <section class="demo-block" data-demo="basic">
    <p class="demo-label">基础用法(最小高度 3 行:触屏上不能像桌面那样看到输入上下文)</p>
    <div class="demo-box">
      <div class="kole-m-textarea" data-assert="textarea-basic">
        <textarea class="kole-m-textarea__control" rows="3" placeholder="请填写订单备注(选填)" aria-label="订单备注"></textarea>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="counter">
    <p class="demo-label">带计数(showCounter:计数随输入实时更新;「填到上限」会把内容补满并让计数转红)</p>
    <div class="demo-box">
      <div class="kole-m-textarea" id="ta-counter" data-limit="50" data-assert="textarea-counter">
        <textarea class="kole-m-textarea__control" rows="3" maxlength="50" placeholder="退换原因(不超过 50 字)"
                  aria-label="退换原因" id="ta-counter-control">外包装在运输途中破损,商品本体完好。</textarea>
        <div class="kole-m-textarea__foot">
          <span class="kole-m-textarea__counter" id="ta-counter-num" aria-live="polite">0/50</span>
          <button class="demo-mini" type="button" id="ta-counter-fill"
                  data-behavior="click-toggles-class:#ta-counter|is-full">填到上限</button>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="full">
    <p class="demo-label">到达上限(is-full:计数变错误色并停止接收新字符,不静默截断)</p>
    <div class="demo-box">
      <div class="kole-m-textarea is-full" id="ta-full" data-limit="12" data-assert="textarea-full">
        <textarea class="kole-m-textarea__control" rows="3" maxlength="12" aria-label="简短说明">这是已经写满的十二个字符</textarea>
        <div class="kole-m-textarea__foot">
          <span class="kole-m-textarea__counter" aria-live="polite">12/12</span>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="size">
    <p class="demo-label">尺寸两档(size=default 最小 3 行 / size=large 最小 5 行;都只允许纵向拉伸)</p>
    <div class="demo-box" data-assert="textarea-size">
      <div class="kole-m-textarea">
        <textarea class="kole-m-textarea__control" rows="3" placeholder="default(最小 3 行)" aria-label="默认多行文本"></textarea>
      </div>
      <div class="kole-m-textarea kole-m-textarea--large" style="margin-top: var(--kole-space-16)">
        <textarea class="kole-m-textarea__control" rows="5" placeholder="large(最小 5 行,长文本场景)" aria-label="长多行文本"></textarea>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="error">
    <p class="demo-label">错误态(is-error + aria-invalid:错误说明与计数同一行,两者都不消失)</p>
    <div class="demo-box">
      <div class="kole-m-textarea is-error" data-assert="textarea-error">
        <textarea class="kole-m-textarea__control" rows="3" aria-label="收货说明"
                  aria-invalid="true" aria-describedby="ta-error-msg">送到门口</textarea>
        <div class="kole-m-textarea__foot">
          <span class="kole-m-textarea__error" id="ta-error-msg" role="alert">收货说明至少 10 个字</span>
          <span class="kole-m-textarea__counter" aria-live="polite">4/200</span>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="disabled">
    <p class="demo-label">禁用(置灰且不可聚焦:读屏会跳过)</p>
    <div class="demo-box">
      <div class="kole-m-textarea is-disabled" data-assert="textarea-disabled">
        <textarea class="kole-m-textarea__control" rows="3" aria-label="系统备注" disabled>该备注由系统生成,不可编辑。</textarea>
      </div>
    </div>
  </section>
</div>
<script>
  /* 演示页脚本:真实的字数统计。
     - 输入时:更新计数「已输入/上限」;到达上限 → 根加 is-full(计数变错误色)
     - 上限取自 data-limit(缺省 200)
     真实业务里这份状态由宿主管理(受控 value + input 事件),此处是最小可运行实现。 */
  (function () {
    Array.prototype.forEach.call(document.querySelectorAll('.kole-m-textarea'), function (root) {
      var control = root.querySelector('.kole-m-textarea__control');
      var num = root.querySelector('.kole-m-textarea__counter');
      if (!control || !num) return;
      var limit = Number(root.getAttribute('data-limit') || 200);
      if (!limit) return;

      function sync() {
        var n = String(control.value || '').length;
        num.textContent = n + '/' + limit;
        root.classList.toggle('is-full', n >= limit);
      }

      control.addEventListener('input', sync);
      sync();
    });

    /* 演示按钮:把内容补到上限(真实业务里用户是手打到的;这里为了能一眼看到 is-full 形态) */
    var fillBtn = document.getElementById('ta-counter-fill');
    if (fillBtn) {
      fillBtn.addEventListener('click', function () {
        var root = document.getElementById('ta-counter');
        var control = document.getElementById('ta-counter-control');
        if (!root || !control) return;
        var limit = Number(root.getAttribute('data-limit') || 200);
        /* 这段文案长于 50 字,截断到上限后必然触发 is-full(演示要能一次点到上限形态) */
        var text = '外包装在运输途中破损,商品本体完好,申请退换并补发同款;'
          + '请安排上门取件,取件时间以下午为准,寄回前请保留全部配件与包装。';
        control.value = text.slice(0, limit);
        control.dispatchEvent(new Event('input', { bubbles: 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/Textarea.jsx · React · 70 行
frameworks-mobile/Textarea.jsx
import React from 'react';
import './Textarea.css';

/* 多行文本框(移动端)— 规格 §31
   控件是原生 textarea:键盘、输入法、autofill 都用系统能力。
   最小高度 3 行(触屏上看不到桌面那样的输入上下文);只允许纵向伸缩,横向拉宽会破坏 375 宽布局。
   计数与错误提示都在框外下方(同一行时两者都不消失);到达上限置 is-full(计数转错误色)。
   不做自动增高:高度随内容跳动会让上下文错位,需要更长时用 size=large(规格 §31.5)。 */

export default function Textarea({
  value = '',
  size = 'default',
  placeholder = '',
  maxlength = 200,
  showCounter = false,
  disabled = false,
  error = '',
  label = '多行文本',
  rows = 3,
  onInput,
  children = null,
}) {
  const text = String(value || '');
  const full = maxlength > 0 && text.length >= maxlength;

  const cls =
    'kole-m-textarea' +
    (size === 'large' ? ' kole-m-textarea--large' : '') +
    (full ? ' is-full' : '') +
    (error ? ' is-error' : '') +
    (disabled ? ' is-disabled' : '');

  const errorId = 'kole-m-textarea-error';

  return (
    <div className={cls}>
      <textarea
        className="kole-m-textarea__control"
        rows={rows}
        value={value}
        placeholder={placeholder}
        maxLength={maxlength > 0 ? maxlength : undefined}
        disabled={disabled}
        aria-label={label}
        aria-invalid={error ? 'true' : undefined}
        aria-describedby={error ? errorId : undefined}
        onChange={(e) => {
          if (disabled) return;
          if (onInput) onInput(e.target.value);
        }}
      />
      {showCounter || error ? (
        <div className="kole-m-textarea__foot">
          {error ? (
            <span className="kole-m-textarea__error" id={errorId} role="alert">
              {error}
            </span>
          ) : null}
          {showCounter ? (
            <span className="kole-m-textarea__counter" aria-live="polite">
              {text.length}/{maxlength}
            </span>
          ) : null}
        </div>
      ) : null}
      {children}
    </div>
  );
}
frameworks-mobile/Textarea.vue2.vue · Vue 2 · 76 行
frameworks-mobile/Textarea.vue2.vue
<template>
  <div class="kole-m-textarea" :class="textareaClass">
    <textarea
      class="kole-m-textarea__control"
      :rows="rows"
      :value="value"
      :placeholder="placeholder"
      :maxlength="maxlength > 0 ? maxlength : null"
      :disabled="disabled"
      :aria-label="label"
      :aria-invalid="error ? 'true' : null"
      :aria-describedby="error ? errorId : null"
      @input="onInput"
    ></textarea>
    <div v-if="showCounter || error" class="kole-m-textarea__foot">
      <span v-if="error" class="kole-m-textarea__error" :id="errorId" role="alert">{{ error }}</span>
      <span v-if="showCounter" class="kole-m-textarea__counter" aria-live="polite">{{ length }}/{{ maxlength }}</span>
    </div>
    <slot></slot>
  </div>
</template>

<script>
/* 多行文本框(移动端)— 规格 §31
   控件是原生 textarea:键盘、输入法、autofill 都用系统能力。
   最小高度 3 行(触屏上看不到桌面那样的输入上下文);只允许纵向伸缩,横向拉宽会破坏 375 宽布局。
   计数与错误提示都在框外下方(同一行时两者都不消失);到达上限置 is-full(计数转错误色)。
   不做自动增高:高度随内容跳动会让上下文错位,需要更长时用 size=large(规格 §31.5)。 */

/* 错误提示元素 id 的自增种子:同页多实例不撞号(不依赖内部实例字段) */
var errorSeed = 0;

export default {
  name: 'KoleMTextarea',
  props: {
    value: { type: String, default: '' },
    size: { type: String, default: 'default' },
    placeholder: { type: String, default: '' },
    maxlength: { type: Number, default: 200 },
    showCounter: { type: Boolean, default: false },
    disabled: { type: Boolean, default: false },
    error: { type: String, default: '' },
    label: { type: String, default: '多行文本' },
    rows: { type: Number, default: 3 }
  },
  data: function () {
    errorSeed += 1;
    return { errorId: 'kole-m-textarea-error-' + errorSeed };
  },
  computed: {
    length: function () {
      return String(this.value || '').length;
    },
    full: function () {
      return this.maxlength > 0 && this.length >= this.maxlength;
    },
    textareaClass: function () {
      return [
        this.size === 'large' ? 'kole-m-textarea--large' : '',
        this.full ? 'is-full' : '',
        this.error ? 'is-error' : '',
        this.disabled ? 'is-disabled' : ''
      ].filter(Boolean);
    }
  },
  methods: {
    onInput: function (e) {
      if (this.disabled) return;
      this.$emit('input', e.target.value);
    }
  }
};
</script>

<style src="./Textarea.css"></style>
frameworks-mobile/Textarea.vue3.vue · Vue 3 · 64 行
frameworks-mobile/Textarea.vue3.vue
<template>
  <div class="kole-m-textarea" :class="textareaClass">
    <textarea
      class="kole-m-textarea__control"
      :rows="rows"
      :value="value"
      :placeholder="placeholder"
      :maxlength="maxlength > 0 ? maxlength : null"
      :disabled="disabled"
      :aria-label="label"
      :aria-invalid="error ? 'true' : null"
      :aria-describedby="error ? errorId : null"
      @input="onInput"
    ></textarea>
    <div v-if="showCounter || error" class="kole-m-textarea__foot">
      <span v-if="error" class="kole-m-textarea__error" :id="errorId" role="alert">{{ error }}</span>
      <span v-if="showCounter" class="kole-m-textarea__counter" aria-live="polite">{{ length }}/{{ maxlength }}</span>
    </div>
    <slot></slot>
  </div>
</template>

<script setup>
/* 多行文本框(移动端)— 规格 §31
   控件是原生 textarea:键盘、输入法、autofill 都用系统能力。
   最小高度 3 行(触屏上看不到桌面那样的输入上下文);只允许纵向伸缩,横向拉宽会破坏 375 宽布局。
   计数与错误提示都在框外下方(同一行时两者都不消失);到达上限置 is-full(计数转错误色)。
   不做自动增高:高度随内容跳动会让上下文错位,需要更长时用 size=large(规格 §31.5)。 */
import { computed, getCurrentInstance } from 'vue';

const props = defineProps({
  value: { type: String, default: '' },
  size: { type: String, default: 'default' },
  placeholder: { type: String, default: '' },
  maxlength: { type: Number, default: 200 },
  showCounter: { type: Boolean, default: false },
  disabled: { type: Boolean, default: false },
  error: { type: String, default: '' },
  label: { type: String, default: '多行文本' },
  rows: { type: Number, default: 3 }
});
const emit = defineEmits(['input']);

/* 错误提示元素 id:取当前实例 uid,保证同页多实例不撞号 */
const errorId = 'kole-m-textarea-error-' + getCurrentInstance().uid;

const length = computed(() => String(props.value || '').length);
const full = computed(() => props.maxlength > 0 && length.value >= props.maxlength);

const textareaClass = computed(() => [
  props.size === 'large' ? 'kole-m-textarea--large' : '',
  full.value ? 'is-full' : '',
  props.error ? 'is-error' : '',
  props.disabled ? 'is-disabled' : ''
].filter(Boolean));

function onInput(e) {
  if (props.disabled) return;
  emit('input', e.target.value);
}
</script>

<style src="./Textarea.css"></style>
frameworks-mobile/Textarea.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 120 行
frameworks-mobile/Textarea.uniapp.vue
<template>
  <view class="kole-m-textarea" :class="textareaClass">
    <textarea
      class="kole-m-textarea__control"
      :value="value"
      :placeholder="placeholder"
      :maxlength="maxlength"
      :disabled="disabled"
      :aria-label="label"
      :aria-invalid="error ? 'true' : 'false'"
      placeholder-class="kole-m-textarea__placeholder"
      :auto-height="false"
      @input="onInput"
    />
    <view v-if="showCounter || error" class="kole-m-textarea__foot">
      <text v-if="error" class="kole-m-textarea__error">{{ error }}</text>
      <text v-if="showCounter" class="kole-m-textarea__counter">{{ length }}/{{ maxlength }}</text>
    </view>
    <slot></slot>
  </view>
</template>

<script setup>
/* uni-app 端 · 多行文本框(移动端)— 规格 §31
   跨端差异:
   ① <textarea> 是 uni 基础组件,事件对象是 { detail: { value } },没有 DOM 的 event.target;
   ② auto-height 显式置 false:高度随内容跳动会让上下文错位(规格 §31.5 不做自动增高);
      uni 的 textarea 是原生层级组件,不使用 resize(App/小程序无此概念),高度由 rows 与 CSS 最小高决定;
   ③ 计数用 <text>,不做 aria-live(小程序读屏不支持该属性,宿主可选播报)。
   尺寸用 rpx:48rpx = 375pt 下的 24px 行高,故默认最小高 264rpx(≈3 行)。 */
import { computed } from 'vue';

const props = defineProps({
  value: { type: String, default: '' },
  size: { type: String, default: 'default' },
  placeholder: { type: String, default: '' },
  maxlength: { type: Number, default: 200 },
  showCounter: { type: Boolean, default: false },
  disabled: { type: Boolean, default: false },
  error: { type: String, default: '' },
  label: { type: String, default: '多行文本' },
  rows: { type: Number, default: 3 }
});
const emit = defineEmits(['input']);

const length = computed(() => String(props.value || '').length);
const full = computed(() => props.maxlength > 0 && length.value >= props.maxlength);

const textareaClass = computed(() => [
  props.size === 'large' ? 'kole-m-textarea--large' : '',
  full.value ? 'is-full' : '',
  props.error ? 'is-error' : '',
  props.disabled ? 'is-disabled' : ''
].filter(Boolean));

/* uni 的 <textarea> 事件对象为 { detail: { value } },无 DOM event.target */
function onInput(e) {
  if (props.disabled) return;
  emit('input', e && e.detail ? e.detail.value : '');
}
</script>

<style>
.kole-m-textarea {
  --kole-m-textarea-min-height: 264rpx;  /* 最小高度(≈3 行 × 48rpx 行高 + 上下内边距) */
  --kole-m-font-size-body: 32rpx;
  --kole-m-font-size-label: 28rpx;
  --kole-m-gutter: 32rpx;
  box-sizing: border-box;
  display: block;
  width: 100%;
}

.kole-m-textarea--large { --kole-m-textarea-min-height: 408rpx; }

.kole-m-textarea__control {
  display: block;
  box-sizing: border-box;
  width: 100%;
  min-height: var(--kole-m-textarea-min-height);
  padding: 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);
  line-height: 1.5;
}

.kole-m-textarea__placeholder { color: var(--kole-color-text-placeholder); }

.kole-m-textarea.is-error .kole-m-textarea__control { border-color: var(--kole-color-error); }

.kole-m-textarea.is-disabled .kole-m-textarea__control {
  background-color: var(--kole-color-disabled-bg);
  color: var(--kole-color-text-disabled);
}

.kole-m-textarea__foot {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  margin-top: 12rpx;
}

.kole-m-textarea__error {
  font-size: var(--kole-m-font-size-label);
  color: var(--kole-color-error);
}

.kole-m-textarea__counter {
  margin-left: auto;
  font-size: var(--kole-m-font-size-label);
  color: var(--kole-color-text-secondary);
}

/* 状态 full:到达上限时计数变错误色 */
.kole-m-textarea.is-full .kole-m-textarea__counter { color: var(--kole-color-error); }
</style>

测试与回归

断言在真实的 375×640 设备帧里跑(引擎与 PC 侧共用 tests/_runtime.js,触控行为动词来自移动端 tests/mobile/_behaviors.js)。

断言 17 条 · 全部通过 报告 2026-09-20 16:50:33

复现命令
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-textarea.json(点击展开原始 JSON)
{
  "schemaVersion": 1,
  "sourceKind": "authored-spec",
  "provenance": "authored-in-repo",
  "specFile": "spec/移动端规格.md",
  "specSection": "31 · 多行文本框 Textarea",
  "confidence": "high",
  "slug": "mobile-textarea",
  "name": "多行文本框 Textarea",
  "semanticTypeCandidates": [
    "textarea",
    "multiline-input",
    "text-field"
  ],
  "variantDimensions": [
    {
      "name": "size",
      "values": [
        "default",
        "large"
      ]
    },
    {
      "name": "showCounter",
      "values": [
        "false",
        "true"
      ]
    }
  ],
  "representativeVariants": [
    {
      "size": "default",
      "showCounter": "false",
      "label": "默认(最小 3 行,无计数)"
    },
    {
      "size": "default",
      "showCounter": "true",
      "label": "带计数(右下角「已输入/上限」)"
    },
    {
      "size": "large",
      "showCounter": "true",
      "label": "长文本(最小 5 行)+ 计数"
    }
  ],
  "anatomy": {
    "field": "字段容器,包裹文本框、计数行与错误提示",
    "control": "原生 textarea,多行输入,行高不小于 1.5 倍字号",
    "counter": "右下角字数计数(已输入/上限),maxlength>0 时出现",
    "errorText": "字段下方的错误提示文字(与计数同行时计数不消失)",
    "label": "无障碍名称(aria-label),textarea 的初始高度由 rows 决定"
  },
  "structurePatterns": {
    "size": "default(最小高度 3 行)/ large(最小高度 5 行,长文本场景)",
    "showCounter": "false(不显示计数)/ true(右下角显示「已输入/上限」)"
  },
  "usageHints": [
    "收集可能超过一行的自由文本(备注、收货说明、退换原因)",
    "文本框本身要够高(至少 3 行),因为触屏不能像桌面那样在输入过程中看到上下文",
    "要给出实时字数反馈,超限时是「止写 + 报错」而不是静默截断",
    "只允许纵向伸缩(resize: vertical),不允许横向拉宽破坏 375 宽的布局",
    "不使用自动增高(高度随内容跳动会让上下文错位),需要更长文本时用 size=large"
  ],
  "doNotInvent": [
    "自动增高(随内容撑高)的实现细节",
    "富文本 / Markdown 的编辑与渲染",
    "内容敏感词过滤与提交前的业务校验"
  ],
  "unknowns": [
    "maxlength 缺省时上限取多少(本实现默认 200)",
    "是否需要在接近上限时提前变色(本实现只在到达上限时变色)",
    "计数是否包含空格与换行"
  ],
  "interaction": [
    "文本框最小高度 3 行(约 88px);只允许纵向伸缩(resize: vertical),不允许横向拉宽破坏 375 宽的布局",
    "计数随输入实时更新;达到上限时计数置错误色并停止接收新字符(原生 maxlength 兜底,超限靠宿主提示)",
    "文本框整体可点即聚焦(外层不出可点装饰);错误提示与计数都在框外,不挤占输入区",
    "聚焦反馈是边框色 + 2px 外发光,150ms 过渡;不改变高度",
    "不使用自动增高(高度随内容跳动会让上下文错位),需要更长文本时用 size=large"
  ],
  "accessibility": [
    "控件是原生 textarea,名称由 aria-label 给出(占位文字不算标签)",
    "错误态用 aria-invalid=\"true\" + aria-describedby 关联错误文案",
    "计数是参考信息,用 aria-live=\"polite\" 播报(不要每敲一个字都播报,只在接近上限时提示)",
    "禁用态用原生 disabled,读屏会跳过"
  ],
  "api": {
    "source": "implementation",
    "note": "props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs",
    "requiredNote": "「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。",
    "props": [
      {
        "name": "value",
        "type": "string",
        "default": "''",
        "desc": "受控文本;长度决定计数与 is-full(规格 §31.5)",
        "required": false
      },
      {
        "name": "size",
        "type": "'default' | 'large'",
        "default": "'default'",
        "desc": "变体 size:default 最小 3 行,large 最小 5 行(规格 §31.3)",
        "required": false
      },
      {
        "name": "placeholder",
        "type": "string",
        "default": "''",
        "desc": "占位文字;它不算标签,标签由 label 给出(规格 §31.6)",
        "required": false
      },
      {
        "name": "maxlength",
        "type": "number",
        "default": "200",
        "desc": "字数上限;0 表示不限。到上限时计数转错误色并停止接收(规格 §31.5)",
        "required": false
      },
      {
        "name": "showCounter",
        "type": "boolean",
        "default": "false",
        "desc": "变体 showCounter:右下角是否显示「已输入/上限」(规格 §31.3)",
        "required": false
      },
      {
        "name": "disabled",
        "type": "boolean",
        "default": "false",
        "desc": "状态 disabled:置灰且不可聚焦(规格 §31.4)",
        "required": false
      },
      {
        "name": "error",
        "type": "string",
        "default": "''",
        "desc": "状态 error:错误文案,非空时写 aria-invalid 并显示在框外下方(规格 §31.6)",
        "required": false
      },
      {
        "name": "label",
        "type": "string",
        "default": "'多行文本'",
        "desc": "无障碍名称,落到 aria-label(规格 §31.6)",
        "required": false
      },
      {
        "name": "rows",
        "type": "number",
        "default": "3",
        "desc": "textarea 的初始行数(规格 §31.2 label 行)",
        "required": false
      }
    ],
    "events": [
      {
        "name": "input",
        "params": "(value)",
        "desc": "输入时触发,回传当前文本(计数与 is-full 由此派生)(规格 §31.5)"
      }
    ],
    "slots": [
      {
        "name": "default",
        "desc": "文本框下方的追加内容(如提示语),排在计数行之后(规格 §31.2 field)"
      }
    ]
  },
  "variantClasses": {
    "size": {
      "default": [],
      "large": [
        ".kole-m-textarea--large"
      ]
    },
    "showCounter": {
      "false": [],
      "true": [
        ".kole-m-textarea__counter"
      ]
    }
  },
  "demos": [
    {
      "id": "basic",
      "group": "01 组件类型",
      "title": "基础用法",
      "desc": "最小高度 3 行:触屏上不能像桌面那样看到输入上下文。",
      "variant": "size=default"
    },
    {
      "id": "counter",
      "group": "01 组件类型",
      "title": "带计数",
      "desc": "showCounter=true:计数随输入实时更新;点「填到上限」可看到 is-full 形态。",
      "variant": "showCounter=true"
    },
    {
      "id": "full",
      "group": "02 组件状态",
      "title": "到达上限",
      "desc": "is-full:计数变错误色并停止接收新字符,不静默截断。",
      "variant": "状态 full"
    },
    {
      "id": "size",
      "group": "01 组件类型",
      "title": "尺寸两档",
      "desc": "default 最小 3 行;large 最小 5 行。两者都只允许纵向拉伸。",
      "variant": "size=default|large"
    },
    {
      "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": "内容只占一行时用输入框;可能超过一行(备注、说明)时用多行文本框"
    },
    {
      "slug": "mobile-numberkeyboard",
      "why": "金额、验证码这类数字内容配数字键盘;自由文本用多行文本框,无需自定义键盘"
    },
    {
      "slug": "mobile-dialog",
      "why": "长文本要在提交前整体确认时用对话框,把多行文本框放进对话框内容区"
    }
  ]
}