多行文本框Textarea
收集可能超过一行的自由文本(备注、收货说明、退换原因)
数据录入 规格 31 · 多行文本框 Textarea 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-textarea.css">
<!-- ③ 结构照抄下方任一演示块(类名与 6 端实现一致) -->
演示
每个演示都是真实渲染:预览帧加载 frameworks-mobile/Textarea.html?demo=<id>(只显示该演示块),代码是该演示块在演示页里的原文,可复制。全部演示同屏可看 演示页 ↗。
01 组件类型
最小高度 3 行:触屏上不能像桌面那样看到输入上下文。
查看代码(演示页原文 · 8 行)
<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:计数随输入实时更新;点「填到上限」可看到 is-full 形态。
查看代码(演示页原文 · 14 行)
<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>default 最小 3 行;large 最小 5 行。两者都只允许纵向拉伸。
查看代码(演示页原文 · 11 行)
<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 组件状态
is-full:计数变错误色并停止接收新字符,不静默截断。
查看代码(演示页原文 · 11 行)
<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>is-error + aria-invalid;错误说明与计数同一行,两者都不消失。
查看代码(演示页原文 · 13 行)
<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>置灰且不可聚焦,读屏会跳过。
查看代码(演示页原文 · 8 行)
<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
| 名称 | 类型 | 默认值 | 说明 | 必传 |
|---|---|---|---|---|
value | string | '' | 受控文本;长度决定计数与 is-full(规格 §31.5) | N |
size | 'default' | 'large' | 'default' | 变体 size:default 最小 3 行,large 最小 5 行(规格 §31.3) | N |
placeholder | string | '' | 占位文字;它不算标签,标签由 label 给出(规格 §31.6) | N |
maxlength | number | 200 | 字数上限;0 表示不限。到上限时计数转错误色并停止接收(规格 §31.5) | N |
showCounter | boolean | false | 变体 showCounter:右下角是否显示「已输入/上限」(规格 §31.3) | N |
disabled | boolean | false | 状态 disabled:置灰且不可聚焦(规格 §31.4) | N |
error | string | '' | 状态 error:错误文案,非空时写 aria-invalid 并显示在框外下方(规格 §31.6) | N |
label | string | '多行文本' | 无障碍名称,落到 aria-label(规格 §31.6) | N |
rows | number | 3 | textarea 的初始行数(规格 §31.2 label 行) | N |
「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。
事件
| 名称 | 参数 | 说明 |
|---|---|---|
input | (value) | 输入时触发,回传当前文本(计数与 is-full 由此派生)(规格 §31.5) |
插槽
| 名称 | 说明 |
|---|---|
default | 文本框下方的追加内容(如提示语),排在计数行之后(规格 §31.2 field) |
CSS 变量
组件级变量(在组件样式表里定义)。业务侧可在自己的作用域内覆盖,不必改组件源码。
| 名称 | 默认值 | 说明 |
|---|---|---|
--kole-m-textarea-row-height | 24px | 单行高度(字号 16 × 行高 1.5) |
--kole-m-textarea-min-height | calc(var(--kole-m-textarea-row-height) * 3 + var(--kole-space-12) * 2) | 组件内部默认值,可在业务侧覆盖 |
--kole-m-textarea-duration | 150ms | 边框与聚焦外发光的过渡时长 |
--kole-m-textarea-min-height | calc(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 校验(类/变量必须真实存在)。
| 维度 | 取值 | 对应类名 / 变量 |
|---|---|---|
size | default / large | default (由数据驱动,无专属类) large .kole-m-textarea--large |
showCounter | false / true | false (由数据驱动,无专属类) true .kole-m-textarea__counter |
代表变体
| 变体 | 标签 |
|---|---|
size=default · showCounter=false | 默认(最小 3 行,无计数) |
size=default · showCounter=true | 带计数(右下角「已输入/上限」) |
size=large · showCounter=true | 长文本(最小 5 行)+ 计数 |
用到的令牌
构建时从本组件样式表扫描得出。蓝色为移动端自有令牌,绿色为继承的 PC 令牌(改一处两端生效)。
6 端源码
同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。
frameworks-mobile/Textarea.css · 纯样式(CSS) · 90 行
/* 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 行
<!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 行
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 行
<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 行
<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 行
<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": "长文本要在提交前整体确认时用对话框,把多行文本框放进对话框内容区"
}
]
}