搜索框Search
用关键词从长列表里取回一小段结果
数据录入 规格 28 · 搜索框 Search 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-search.css">
<!-- ③ 结构照抄下方任一演示块(类名与 6 端实现一致) -->
演示
每个演示都是真实渲染:预览帧加载 frameworks-mobile/Search.html?demo=<id>(只显示该演示块),代码是该演示块在演示页里的原文,可复制。全部演示同屏可看 演示页 ↗。
01 组件类型
页面顶部一行:放大镜 + 输入区,输入即过滤(不等回车)。
查看代码(演示页原文 · 13 行)
<section class="demo-block" data-demo="basic">
<p class="demo-label">基础用法(页面顶部一行:放大镜 + 输入区,输入即过滤,不等回车)</p>
<div class="demo-box">
<div class="kole-m-search is-filled" data-assert="search-basic">
<div class="kole-m-search__form">
<label class="kole-m-search__hit">
<span class="kole-m-search__icon" aria-hidden="true"></span>
<input class="kole-m-search__control" type="search" value="无线耳机" placeholder="搜索商品" aria-label="搜索商品">
</label>
</div>
</div>
</div>
</section>showCancel=true:触屏没有 Esc 键,退出搜索需要一个明确的文字动作。
查看代码(演示页原文 · 15 行)
<section class="demo-block" data-demo="cancel">
<p class="demo-label">带取消(showCancel:触屏没有 Esc 键,退出搜索要有一个明确动作)</p>
<div class="demo-box">
<div class="kole-m-search is-filled is-cancelable" id="sc-cancel-root" data-assert="search-cancel">
<div class="kole-m-search__form">
<label class="kole-m-search__hit">
<span class="kole-m-search__icon" aria-hidden="true"></span>
<input class="kole-m-search__control" type="search" value="订单" placeholder="搜索订单" aria-label="搜索订单" id="sc-cancel-field">
</label>
</div>
<button class="kole-m-search__cancel" type="button" id="sc-cancel"
data-behavior="click-toggles-class:#sc-cancel-root|is-filled">取消</button>
</div>
</div>
</section>round=true 胶囊形用于页面顶部;round=false 方角嵌在卡片或工具栏里。
查看代码(演示页原文 · 24 行)
<section class="demo-block" data-demo="round">
<p class="demo-label">圆角两态(round=true 胶囊形用于页面顶部 / round=false 方角嵌在卡片或工具栏里)</p>
<div class="demo-box">
<div class="demo-stack" data-assert="search-round">
<div class="kole-m-search kole-m-search--round is-cancelable">
<div class="kole-m-search__form">
<label class="kole-m-search__hit">
<span class="kole-m-search__icon" aria-hidden="true"></span>
<input class="kole-m-search__control" type="search" placeholder="圆角(胶囊,顶部常用)" aria-label="圆角搜索">
</label>
</div>
<button class="kole-m-search__cancel" type="button">取消</button>
</div>
<div class="kole-m-search">
<div class="kole-m-search__form">
<label class="kole-m-search__hit">
<span class="kole-m-search__icon" aria-hidden="true"></span>
<input class="kole-m-search__control" type="search" placeholder="方角(嵌在卡片里)" aria-label="方角搜索">
</label>
</div>
</div>
</div>
</div>
</section>有值时清除按钮才出现;点它清空并把焦点留在输入框。
查看代码(演示页原文 · 15 行)
<section class="demo-block" data-demo="clear">
<p class="demo-label">清除(有值时清除按钮才出现;点它清空并把焦点留在输入框)</p>
<div class="demo-box">
<div class="kole-m-search is-filled" id="sc-clearable" data-assert="search-clear">
<div class="kole-m-search__form">
<label class="kole-m-search__hit">
<span class="kole-m-search__icon" aria-hidden="true"></span>
<input class="kole-m-search__control" type="search" value="蓝牙" placeholder="搜索商品" aria-label="搜索商品" id="sc-clearable-field">
</label>
<button class="kole-m-search__clear" type="button" aria-label="清除" id="sc-clearable-btn"
data-behavior="click-toggles-class:#sc-clearable|is-filled">✕</button>
</div>
</div>
</div>
</section>02 组件状态
置灰且不可聚焦,键盘与读屏都会跳过。
查看代码(演示页原文 · 13 行)
<section class="demo-block" data-demo="disabled">
<p class="demo-label">禁用(置灰且不可聚焦,键盘与读屏都会跳过)</p>
<div class="demo-box">
<div class="kole-m-search is-disabled" data-assert="search-disabled">
<div class="kole-m-search__form">
<label class="kole-m-search__hit">
<span class="kole-m-search__icon" aria-hidden="true"></span>
<input class="kole-m-search__control" type="search" value="暂不可用" placeholder="搜索" aria-label="搜索" disabled>
</label>
</div>
</div>
</div>
</section>API
props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs
Props
| 名称 | 类型 | 默认值 | 说明 | 必传 |
|---|---|---|---|---|
value | string | '' | 受控关键词;有值时清除按钮才出现(规格 §28.5) | N |
placeholder | string | '' | 占位文字,提示搜索范围(如「搜索商品」)(规格 §28.2 control) | N |
round | boolean | true | 变体 round:true 胶囊形,false 方角(规格 §28.3) | N |
showCancel | boolean | false | 变体 showCancel:右侧是否出现取消动作(规格 §28.3) | N |
clearable | boolean | true | 有值时是否允许清除(规格 §28.5) | N |
disabled | boolean | false | 状态 disabled:置灰且不可聚焦(规格 §28.4) | N |
cancelText | string | '取消' | 取消动作文案;不用图标代替文字(规格 §28.6) | N |
label | string | '搜索' | 无障碍名称,落到 aria-label(规格 §28.6) | N |
「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。
事件
| 名称 | 参数 | 说明 |
|---|---|---|
input | (value) | 输入即触发,不等回车(规格 §28.5) |
search | (value) | 键盘「搜索」键触发(规格 §28.5) |
clear | — | 点击清除按钮触发,值由宿主置空(规格 §28.5) |
cancel | — | 点击取消动作触发,由宿主退出搜索态(规格 §28.5) |
插槽
| 名称 | 说明 |
|---|---|
default | 根元素内的追加内容(如右侧的筛选入口)(规格 §28.2 search) |
CSS 变量
组件级变量(在组件样式表里定义)。业务侧可在自己的作用域内覆盖,不必改组件源码。
| 名称 | 默认值 | 说明 |
|---|---|---|
--kole-m-search-height | var(--kole-m-touch-target) | 搜索框高度(≥ 44px 触控最小边长) |
--kole-m-search-radius | var(--kole-radius-base) | round=false 时的圆角 |
--kole-m-search-icon-size | 14px | 放大镜视觉尺寸 |
何时使用
- 用关键词从长列表里取回一小段结果
- 移动端的搜索框几乎总是页面顶部的独立一行
- 输入即过滤(不等回车)
- 触屏没有 Esc 键,用户清空关键词后仍需一键回到列表,因此要有明确的取消动作
- 组件不发请求、不做防抖:只回传关键词,节流策略由宿主决定
交互与触控
- 搜索框高度不小于 44px,取消动作热区不小于 44px
- 输入即触发 input 事件(不等回车);键盘「搜索」键触发 search 事件
- 清除按钮只在有值时出现,点击清空并把焦点留在输入框
- 清除与取消都是 44px 热区;两者同时出现时先清除、再取消(自右向左层级递进)
无障碍
- 输入框用原生 input type="search"(iOS 键盘右下角键位变成「搜索」)
- 搜索图标是装饰性的,置 aria-hidden="true",名称由 aria-label 给出
- 清除按钮是原生 button 且带 aria-label="清除";取消动作是原生 button
- 取消按钮不用图标代替文字(触屏上文字比图标更好点)
相似组件
从「该用哪一个」的角度区分;PC 端的对应实现见 PC 文档站。
| 组件 | 何时用它而不是本组件 |
|---|---|
| 输入框Input | 收集填写内容用输入框;目的就是从列表里取回结果时才用搜索框 |
| 单元格Cell | 「搜索入口」只做跳转时用单元格,点开才是真搜索页 |
| 列表List | 搜索结果用列表承载;搜索框只负责取关键词 |
规格未定 / 禁止发明
| 类别 | 条目 |
|---|---|
| 禁止发明 | 搜索的防抖时值与接口节流策略(由宿主决定) |
| 禁止发明 | 搜索历史的存储与展示 |
| 禁止发明 | 搜索结果的高亮规则 |
| 规格未定 | 取消文案是否允许替换(如「返回」) |
| 规格未定 | 是否需要自动聚焦并拉起键盘 |
| 规格未定 | 语音/扫码等扩展入口是否放进框内 |
结构(anatomy)
| 字段 | 说明 |
|---|---|
search | 根元素,一行里放进「搜索框 + 取消动作」 |
icon | 框内左侧放大镜,纯装饰(不承载语义,读屏由 label 承担) |
control | 原生 input type="search",占满剩余宽度 |
clear | 可选清除按钮,有值且清除可用时出现在图标与取消之间 |
cancel | 可选取消动作,默认文案「取消」,由 showCancel 控制显隐 |
变体维度与类名映射
类名映射由构建脚本从契约 variantClasses 生成,并被 verify:mobile-docs 逐条对照组件 CSS 校验(类/变量必须真实存在)。
| 维度 | 取值 | 对应类名 / 变量 |
|---|---|---|
round | true / false | true .kole-m-search--round false (由数据驱动,无专属类) |
showCancel | false / true | false (由数据驱动,无专属类) true .kole-m-search__cancel |
代表变体
| 变体 | 标签 |
|---|---|
round=true · showCancel=true | 胶囊形 + 取消(页面顶部常用) |
round=false · showCancel=false | 方角无取消(嵌在卡片或工具栏里) |
round=true · showCancel=false | 胶囊形无取消 |
用到的令牌
构建时从本组件样式表扫描得出。蓝色为移动端自有令牌,绿色为继承的 PC 令牌(改一处两端生效)。
6 端源码
同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。
frameworks-mobile/Search.css · 纯样式(CSS) · 177 行
/* Kole UI Mobile · Search 样式 — 对齐移动端规格 §28
搜索框:整行高不小于 44px,输入即过滤(不等回车);
放大镜是 CSS 画的(圆 + 45° 短柄),不引入图标字体(零运行时依赖);
round=false 时是方角,round=true 时是胶囊;取消动作始终 44px 热区。 */
.kole-m-search {
/* 组件级变量:业务侧可在容器上覆盖 */
--kole-m-search-height: var(--kole-m-touch-target); /* 搜索框高度(≥ 44px 触控最小边长) */
--kole-m-search-radius: var(--kole-radius-base); /* round=false 时的圆角 */
--kole-m-search-icon-size: 14px; /* 放大镜视觉尺寸 */
box-sizing: border-box;
display: flex;
align-items: center;
gap: var(--kole-space-8);
width: 100%;
}
/* 变体 round=true:胶囊形(页面顶部常用) */
.kole-m-search--round { --kole-m-search-radius: 999px; }
.kole-m-search__form {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
display: flex;
align-items: center;
gap: var(--kole-space-8);
min-height: var(--kole-m-search-height);
padding: 0 var(--kole-space-12);
border: 1px solid var(--kole-color-border);
border-radius: var(--kole-m-search-radius);
background: var(--kole-color-card-bg);
color: var(--kole-color-text-body);
font-size: var(--kole-m-font-size-body);
transition: border-color 150ms var(--kole-ease-standard);
}
.kole-m-search__form:focus-within { border-color: var(--kole-color-brand); }
/* 整行热区(放大镜 + 输入区):点框内任意位置即聚焦。
label 里不能放 labelable 元素(button 就是),所以清除按钮在 label 外 ——
否则点击清除会连带激活 label,既触发两次语义又是不合法嵌套。 */
.kole-m-search__hit {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: var(--kole-space-8);
cursor: text;
}
/* 放大镜:圆(::before)+ 45° 短柄(::after),用 currentColor 跟随文字色 */
.kole-m-search__icon {
position: relative;
flex: 0 0 auto;
display: inline-block;
width: var(--kole-m-search-icon-size);
height: var(--kole-m-search-icon-size);
color: var(--kole-color-text-secondary);
}
.kole-m-search__icon::before {
content: '';
position: absolute;
top: 0;
left: 0;
box-sizing: border-box;
width: 10px;
height: 10px;
border: 2px solid currentColor;
border-radius: 50%;
}
.kole-m-search__icon::after {
content: '';
position: absolute;
right: 0;
bottom: 1px;
width: 6px;
height: 2px;
background: currentColor;
transform: rotate(45deg);
transform-origin: right center;
}
.kole-m-search__control {
flex: 1 1 auto;
min-width: 0;
height: var(--kole-m-search-height);
padding: 0;
border: 0;
outline: none;
background: transparent;
color: inherit;
font-family: inherit;
font-size: var(--kole-m-font-size-body);
line-height: 1.4;
/* iOS 上 type=search 自带的内阴影与圆角会破坏外框视觉,这里清掉 */
appearance: none;
-webkit-appearance: none;
}
.kole-m-search__control::placeholder { color: var(--kole-color-text-placeholder); }
.kole-m-search__control::-webkit-search-decoration,
.kole-m-search__control::-webkit-search-cancel-button { -webkit-appearance: none; }
.kole-m-search__control:disabled { color: var(--kole-color-text-disabled); cursor: not-allowed; }
/* 清除按钮:视觉 16px、热区补到 44px;有值(is-filled)时才出现 */
.kole-m-search__clear {
flex: 0 0 auto;
display: none;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: var(--kole-m-touch-target);
height: var(--kole-m-touch-target);
margin-right: calc(var(--kole-space-12) * -1);
padding: 0;
border: 0;
background: none;
color: var(--kole-color-text-placeholder);
font-size: var(--kole-m-font-size-label);
line-height: 1;
cursor: pointer;
touch-action: manipulation;
}
.kole-m-search.is-filled .kole-m-search__clear { display: inline-flex; }
.kole-m-search__clear:active { color: var(--kole-color-text-secondary); }
.kole-m-search__clear:focus-visible {
outline: 2px solid var(--kole-color-focus-ring);
outline-offset: -2px;
}
/* 取消动作:只有 showCancel(is-cancelable)时才出现;文字按钮,触屏比图标好点 */
.kole-m-search__cancel {
display: none;
flex: 0 0 auto;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-width: var(--kole-m-touch-target);
min-height: var(--kole-m-touch-target);
padding: 0 var(--kole-space-8);
border: 0;
background: none;
color: var(--kole-color-brand);
font-family: inherit;
font-size: var(--kole-m-font-size-body);
line-height: 1;
cursor: pointer;
touch-action: manipulation;
}
.kole-m-search.is-cancelable .kole-m-search__cancel { display: inline-flex; }
.kole-m-search__cancel:active { color: var(--kole-color-brand-active); }
.kole-m-search__cancel:focus-visible {
outline: 2px solid var(--kole-color-focus-ring);
outline-offset: -2px;
}
/* 状态 disabled:整框置灰 */
.kole-m-search.is-disabled .kole-m-search__form {
background: var(--kole-color-disabled-bg);
color: var(--kole-color-text-disabled);
cursor: not-allowed;
}
/* 禁用态:整行不再是文本光标 */
.kole-m-search.is-disabled .kole-m-search__hit { cursor: not-allowed; }
frameworks-mobile/Search.html · H5 原生(无框架) · 173 行
<!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 · Search(H5)</title>
<link rel="stylesheet" href="../.design_library/kole-ui-mobile/colors_and_type.css">
<link rel="stylesheet" href="Search.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-stack { display: flex; flex-direction: column; gap: var(--kole-space-16); }
.demo-hits { margin: var(--kole-space-8) 0 0; padding: 0; list-style: none; font-size: var(--kole-m-font-size-label);
color: var(--kole-color-text-secondary); }
.demo-hits li { padding: var(--kole-space-8) 0; border-bottom: 1px solid var(--kole-color-border); }
.demo-hits li:last-child { border-bottom: 0; }
.demo-block[hidden] { display: none; }
</style>
</head>
<body>
<div class="demo">
<section class="demo-block" data-demo="basic">
<p class="demo-label">基础用法(页面顶部一行:放大镜 + 输入区,输入即过滤,不等回车)</p>
<div class="demo-box">
<div class="kole-m-search is-filled" data-assert="search-basic">
<div class="kole-m-search__form">
<label class="kole-m-search__hit">
<span class="kole-m-search__icon" aria-hidden="true"></span>
<input class="kole-m-search__control" type="search" value="无线耳机" placeholder="搜索商品" aria-label="搜索商品">
</label>
</div>
</div>
</div>
</section>
<section class="demo-block" data-demo="cancel">
<p class="demo-label">带取消(showCancel:触屏没有 Esc 键,退出搜索要有一个明确动作)</p>
<div class="demo-box">
<div class="kole-m-search is-filled is-cancelable" id="sc-cancel-root" data-assert="search-cancel">
<div class="kole-m-search__form">
<label class="kole-m-search__hit">
<span class="kole-m-search__icon" aria-hidden="true"></span>
<input class="kole-m-search__control" type="search" value="订单" placeholder="搜索订单" aria-label="搜索订单" id="sc-cancel-field">
</label>
</div>
<button class="kole-m-search__cancel" type="button" id="sc-cancel"
data-behavior="click-toggles-class:#sc-cancel-root|is-filled">取消</button>
</div>
</div>
</section>
<section class="demo-block" data-demo="round">
<p class="demo-label">圆角两态(round=true 胶囊形用于页面顶部 / round=false 方角嵌在卡片或工具栏里)</p>
<div class="demo-box">
<div class="demo-stack" data-assert="search-round">
<div class="kole-m-search kole-m-search--round is-cancelable">
<div class="kole-m-search__form">
<label class="kole-m-search__hit">
<span class="kole-m-search__icon" aria-hidden="true"></span>
<input class="kole-m-search__control" type="search" placeholder="圆角(胶囊,顶部常用)" aria-label="圆角搜索">
</label>
</div>
<button class="kole-m-search__cancel" type="button">取消</button>
</div>
<div class="kole-m-search">
<div class="kole-m-search__form">
<label class="kole-m-search__hit">
<span class="kole-m-search__icon" aria-hidden="true"></span>
<input class="kole-m-search__control" type="search" placeholder="方角(嵌在卡片里)" aria-label="方角搜索">
</label>
</div>
</div>
</div>
</div>
</section>
<section class="demo-block" data-demo="clear">
<p class="demo-label">清除(有值时清除按钮才出现;点它清空并把焦点留在输入框)</p>
<div class="demo-box">
<div class="kole-m-search is-filled" id="sc-clearable" data-assert="search-clear">
<div class="kole-m-search__form">
<label class="kole-m-search__hit">
<span class="kole-m-search__icon" aria-hidden="true"></span>
<input class="kole-m-search__control" type="search" value="蓝牙" placeholder="搜索商品" aria-label="搜索商品" id="sc-clearable-field">
</label>
<button class="kole-m-search__clear" type="button" aria-label="清除" id="sc-clearable-btn"
data-behavior="click-toggles-class:#sc-clearable|is-filled">✕</button>
</div>
</div>
</div>
</section>
<section class="demo-block" data-demo="disabled">
<p class="demo-label">禁用(置灰且不可聚焦,键盘与读屏都会跳过)</p>
<div class="demo-box">
<div class="kole-m-search is-disabled" data-assert="search-disabled">
<div class="kole-m-search__form">
<label class="kole-m-search__hit">
<span class="kole-m-search__icon" aria-hidden="true"></span>
<input class="kole-m-search__control" type="search" value="暂不可用" placeholder="搜索" aria-label="搜索" disabled>
</label>
</div>
</div>
</div>
</section>
</div>
<script>
/* 演示页脚本:真实的「输入即过滤」。
- 输入时:有值 → 根加 is-filled(清除按钮出现);无值 → 去掉
- 点清除:清空输入框、去掉 is-filled,并把焦点交还输入框
- 输入即更新下方命中列表(真实业务里这次过滤走接口或本地索引)
真实业务里这份状态由宿主管理(受控 value + input 事件),此处是最小可运行实现。 */
(function () {
Array.prototype.forEach.call(document.querySelectorAll('.kole-m-search'), function (root) {
var control = root.querySelector('.kole-m-search__control');
if (!control) return;
function sync() {
root.classList.toggle('is-filled', String(control.value || '').length > 0);
}
control.addEventListener('input', sync);
sync();
});
var clearBtn = document.getElementById('sc-clearable-btn');
if (clearBtn) {
clearBtn.addEventListener('click', function () {
var root = document.getElementById('sc-clearable');
var control = document.getElementById('sc-clearable-field');
if (control) {
control.value = '';
control.focus();
}
if (root) root.classList.remove('is-filled');
});
}
var cancelBtn = document.getElementById('sc-cancel');
if (cancelBtn) {
cancelBtn.addEventListener('click', function () {
var root = document.getElementById('sc-cancel-root');
var control = document.getElementById('sc-cancel-field');
if (control) control.value = '';
if (root) root.classList.remove('is-filled');
});
}
})();
</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/Search.jsx · React · 86 行
import React from 'react';
import './Search.css';
/* 搜索框(移动端)— 规格 §28
输入即过滤(不等回车);放大镜是纯装饰(aria-hidden),名称由 label 落到 aria-label。
有值且清除可用时出现清除按钮;showCancel=true 时右侧出现取消动作(触屏没有 Esc 键)。
组件不发请求、不做防抖 —— 只回传关键词,节流由宿主决定(规格 §28.7)。 */
export default function Search({
value = '',
placeholder = '',
round = true,
showCancel = false,
clearable = true,
disabled = false,
cancelText = '取消',
label = '搜索',
onInput,
onSearch,
onClear,
onCancel,
children = null,
}) {
const filled = String(value || '').length > 0;
const cls =
'kole-m-search' +
(round ? ' kole-m-search--round' : '') +
(filled ? ' is-filled' : '') +
(showCancel ? ' is-cancelable' : '') +
(disabled ? ' is-disabled' : '');
return (
<div className={cls}>
<div className="kole-m-search__form">
{/* 整行热区是 label;清除按钮是 labelable 元素(button),必须在 label 外 */}
<label className="kole-m-search__hit">
<span className="kole-m-search__icon" aria-hidden="true" />
<input
className="kole-m-search__control"
type="search"
value={value}
placeholder={placeholder}
disabled={disabled}
aria-label={label}
onChange={(e) => {
if (disabled) return;
if (onInput) onInput(e.target.value);
}}
onKeyDown={(e) => {
if (disabled) return;
if (e.key === 'Enter' && onSearch) onSearch(value);
}}
/>
</label>
{clearable && filled && !disabled ? (
<button
className="kole-m-search__clear"
type="button"
aria-label="清除"
onClick={() => {
if (onClear) onClear();
}}
>
✕
</button>
) : null}
</div>
{showCancel ? (
<button
className="kole-m-search__cancel"
type="button"
disabled={disabled}
onClick={() => {
if (disabled) return;
if (onCancel) onCancel();
}}
>
{cancelText}
</button>
) : null}
{children}
</div>
);
}
frameworks-mobile/Search.vue2.vue · Vue 2 · 86 行
<template>
<div class="kole-m-search" :class="searchClass">
<div class="kole-m-search__form">
<!-- 整行热区是 label;清除按钮是 labelable 元素(button),必须在 label 外 -->
<label class="kole-m-search__hit">
<span class="kole-m-search__icon" aria-hidden="true"></span>
<input
class="kole-m-search__control"
type="search"
:value="value"
:placeholder="placeholder"
:disabled="disabled"
:aria-label="label"
@input="onInput"
@keydown.enter="$emit('search', value)"
/>
</label>
<button
v-if="clearable && filled && !disabled"
class="kole-m-search__clear"
type="button"
aria-label="清除"
@click="$emit('clear')"
>
✕
</button>
</div>
<button
v-if="showCancel"
class="kole-m-search__cancel"
type="button"
:disabled="disabled"
@click="onCancel"
>
{{ cancelText }}
</button>
<slot></slot>
</div>
</template>
<script>
/* 搜索框(移动端)— 规格 §28
输入即过滤(不等回车);放大镜是纯装饰(aria-hidden),名称由 label 落到 aria-label。
有值且清除可用时出现清除按钮;showCancel=true 时右侧出现取消动作(触屏没有 Esc 键)。
组件不发请求、不做防抖 —— 只回传关键词,节流由宿主决定(规格 §28.7)。 */
export default {
name: 'KoleMSearch',
props: {
value: { type: String, default: '' },
placeholder: { type: String, default: '' },
round: { type: Boolean, default: true },
showCancel: { type: Boolean, default: false },
clearable: { type: Boolean, default: true },
disabled: { type: Boolean, default: false },
cancelText: { type: String, default: '取消' },
label: { type: String, default: '搜索' }
},
computed: {
filled: function () {
return String(this.value || '').length > 0;
},
searchClass: function () {
return [
this.round ? 'kole-m-search--round' : '',
this.filled ? 'is-filled' : '',
this.showCancel ? 'is-cancelable' : '',
this.disabled ? 'is-disabled' : ''
].filter(Boolean);
}
},
methods: {
onInput: function (e) {
if (this.disabled) return;
this.$emit('input', e.target.value);
},
onCancel: function () {
if (this.disabled) return;
this.$emit('cancel');
}
}
};
</script>
<style src="./Search.css"></style>
frameworks-mobile/Search.vue3.vue · Vue 3 · 81 行
<template>
<div class="kole-m-search" :class="searchClass">
<div class="kole-m-search__form">
<!-- 整行热区是 label;清除按钮是 labelable 元素(button),必须在 label 外 -->
<label class="kole-m-search__hit">
<span class="kole-m-search__icon" aria-hidden="true"></span>
<input
class="kole-m-search__control"
type="search"
:value="value"
:placeholder="placeholder"
:disabled="disabled"
:aria-label="label"
@input="onInput"
@keydown.enter="emit('search', value)"
/>
</label>
<button
v-if="clearable && filled && !disabled"
class="kole-m-search__clear"
type="button"
aria-label="清除"
@click="emit('clear')"
>
✕
</button>
</div>
<button
v-if="showCancel"
class="kole-m-search__cancel"
type="button"
:disabled="disabled"
@click="onCancel"
>
{{ cancelText }}
</button>
<slot></slot>
</div>
</template>
<script setup>
/* 搜索框(移动端)— 规格 §28
输入即过滤(不等回车);放大镜是纯装饰(aria-hidden),名称由 label 落到 aria-label。
有值且清除可用时出现清除按钮;showCancel=true 时右侧出现取消动作(触屏没有 Esc 键)。
组件不发请求、不做防抖 —— 只回传关键词,节流由宿主决定(规格 §28.7)。 */
import { computed } from 'vue';
const props = defineProps({
value: { type: String, default: '' },
placeholder: { type: String, default: '' },
round: { type: Boolean, default: true },
showCancel: { type: Boolean, default: false },
clearable: { type: Boolean, default: true },
disabled: { type: Boolean, default: false },
cancelText: { type: String, default: '取消' },
label: { type: String, default: '搜索' }
});
const emit = defineEmits(['input', 'search', 'clear', 'cancel']);
const filled = computed(() => String(props.value || '').length > 0);
const searchClass = computed(() => [
props.round ? 'kole-m-search--round' : '',
filled.value ? 'is-filled' : '',
props.showCancel ? 'is-cancelable' : '',
props.disabled ? 'is-disabled' : ''
].filter(Boolean));
function onInput(e) {
if (props.disabled) return;
emit('input', e.target.value);
}
function onCancel() {
if (props.disabled) return;
emit('cancel');
}
</script>
<style src="./Search.css"></style>
frameworks-mobile/Search.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 174 行
<template>
<view class="kole-m-search" :class="searchClass">
<view class="kole-m-search__form">
<view class="kole-m-search__icon"></view>
<input
class="kole-m-search__control"
type="text"
confirm-type="search"
:value="value"
:placeholder="placeholder"
:disabled="disabled"
:aria-label="label"
placeholder-class="kole-m-search__placeholder"
@input="onInput"
@confirm="onConfirm"
/>
<view
v-if="clearable && filled && !disabled"
class="kole-m-search__clear"
role="button"
aria-label="清除"
@tap="clear"
>
<text>✕</text>
</view>
</view>
<view
v-if="showCancel"
class="kole-m-search__cancel"
:role="disabled ? '' : 'button'"
:aria-disabled="disabled ? 'true' : 'false'"
@tap="onCancel"
>
<text>{{ cancelText }}</text>
</view>
<slot></slot>
</view>
</template>
<script setup>
/* uni-app 端 · 搜索框(移动端)— 规格 §28
跨端差异:
① <input> 是 uni 基础组件,事件对象是 { detail: { value } },没有 DOM 的 event.target;
② 键盘右下角键位用 confirm-type="search"(对应 @confirm),不是 keydown.enter;
③ 取消动作与清除按钮用 view + role="button"(小程序没有可聚焦的原生 button 语义),点击用 @tap;
④ uni 的 input 没有 type="search" 的样式差异,用 type="text" + confirm-type 表达语义。
尺寸用 rpx:88rpx = 375pt 下的 44px 触控最小边长。 */
import { computed } from 'vue';
const props = defineProps({
value: { type: String, default: '' },
placeholder: { type: String, default: '' },
round: { type: Boolean, default: true },
showCancel: { type: Boolean, default: false },
clearable: { type: Boolean, default: true },
disabled: { type: Boolean, default: false },
cancelText: { type: String, default: '取消' },
label: { type: String, default: '搜索' }
});
const emit = defineEmits(['input', 'search', 'clear', 'cancel']);
const filled = computed(() => String(props.value || '').length > 0);
const searchClass = computed(() => [
props.round ? 'kole-m-search--round' : '',
filled.value ? 'is-filled' : '',
props.showCancel ? 'is-cancelable' : '',
props.disabled ? 'is-disabled' : ''
].filter(Boolean));
/* uni 的 <input> 事件对象为 { detail: { value } },无 DOM event.target */
function onInput(e) {
if (props.disabled) return;
emit('input', e && e.detail ? e.detail.value : '');
}
function onConfirm(e) {
if (props.disabled) return;
emit('search', e && e.detail ? e.detail.value : props.value);
}
function clear() {
if (props.disabled) return;
emit('clear');
}
function onCancel() {
if (props.disabled) return;
emit('cancel');
}
</script>
<style>
.kole-m-search {
--kole-m-search-height: 88rpx; /* 搜索框高度(88rpx = 44px,触控最小边长) */
--kole-m-search-radius: 8rpx; /* round=false 时的圆角 */
--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;
display: flex;
align-items: center;
width: 100%;
}
.kole-m-search--round { --kole-m-search-radius: 999rpx; }
.kole-m-search__form {
flex: 1;
box-sizing: border-box;
display: flex;
align-items: center;
min-height: var(--kole-m-search-height);
padding: 0 24rpx;
border: 1rpx solid var(--kole-color-border);
border-radius: var(--kole-m-search-radius);
background-color: var(--kole-color-card-bg);
color: var(--kole-color-text-body);
font-size: var(--kole-m-font-size-body);
}
.kole-m-search__form:focus-within { border-color: var(--kole-color-brand); }
/* 放大镜:纯装饰方块(uni 端不引图标字体,用边框圆点 + 短柄由 CSS 画) */
.kole-m-search__icon {
flex-shrink: 0;
width: 28rpx;
height: 28rpx;
border: 4rpx solid var(--kole-color-text-secondary);
border-radius: 50%;
}
.kole-m-search__control {
flex: 1;
min-width: 0;
height: var(--kole-m-search-height);
padding-left: 16rpx;
font-size: var(--kole-m-font-size-body);
color: inherit;
}
.kole-m-search__placeholder { color: var(--kole-color-text-placeholder); }
.kole-m-search__clear {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 88rpx;
height: 88rpx;
margin-right: -24rpx;
color: var(--kole-color-text-placeholder);
font-size: var(--kole-m-font-size-label);
}
.kole-m-search__cancel {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
min-width: 88rpx;
min-height: 88rpx;
padding: 0 16rpx;
color: var(--kole-color-brand);
font-size: var(--kole-m-font-size-body);
}
.kole-m-search.is-disabled .kole-m-search__form {
background-color: var(--kole-color-disabled-bg);
color: var(--kole-color-text-disabled);
}
</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-search.json(点击展开原始 JSON)
{
"schemaVersion": 1,
"sourceKind": "authored-spec",
"provenance": "authored-in-repo",
"specFile": "spec/移动端规格.md",
"specSection": "28 · 搜索框 Search",
"confidence": "high",
"slug": "mobile-search",
"name": "搜索框 Search",
"semanticTypeCandidates": [
"search",
"search-field",
"filter-input"
],
"variantDimensions": [
{
"name": "round",
"values": [
"true",
"false"
]
},
{
"name": "showCancel",
"values": [
"false",
"true"
]
}
],
"representativeVariants": [
{
"round": "true",
"showCancel": "true",
"label": "胶囊形 + 取消(页面顶部常用)"
},
{
"round": "false",
"showCancel": "false",
"label": "方角无取消(嵌在卡片或工具栏里)"
},
{
"round": "true",
"showCancel": "false",
"label": "胶囊形无取消"
}
],
"anatomy": {
"search": "根元素,一行里放进「搜索框 + 取消动作」",
"icon": "框内左侧放大镜,纯装饰(不承载语义,读屏由 label 承担)",
"control": "原生 input type=\"search\",占满剩余宽度",
"clear": "可选清除按钮,有值且清除可用时出现在图标与取消之间",
"cancel": "可选取消动作,默认文案「取消」,由 showCancel 控制显隐"
},
"structurePatterns": {
"round": "true(胶囊形,页面顶部常用)/ false(方角,嵌在卡片或工具栏里)",
"showCancel": "false(只有输入框)/ true(右侧出现取消动作)"
},
"usageHints": [
"用关键词从长列表里取回一小段结果",
"移动端的搜索框几乎总是页面顶部的独立一行",
"输入即过滤(不等回车)",
"触屏没有 Esc 键,用户清空关键词后仍需一键回到列表,因此要有明确的取消动作",
"组件不发请求、不做防抖:只回传关键词,节流策略由宿主决定"
],
"doNotInvent": [
"搜索的防抖时值与接口节流策略(由宿主决定)",
"搜索历史的存储与展示",
"搜索结果的高亮规则"
],
"unknowns": [
"取消文案是否允许替换(如「返回」)",
"是否需要自动聚焦并拉起键盘",
"语音/扫码等扩展入口是否放进框内"
],
"interaction": [
"搜索框高度不小于 44px,取消动作热区不小于 44px",
"输入即触发 input 事件(不等回车);键盘「搜索」键触发 search 事件",
"清除按钮只在有值时出现,点击清空并把焦点留在输入框",
"清除与取消都是 44px 热区;两者同时出现时先清除、再取消(自右向左层级递进)"
],
"accessibility": [
"输入框用原生 input type=\"search\"(iOS 键盘右下角键位变成「搜索」)",
"搜索图标是装饰性的,置 aria-hidden=\"true\",名称由 aria-label 给出",
"清除按钮是原生 button 且带 aria-label=\"清除\";取消动作是原生 button",
"取消按钮不用图标代替文字(触屏上文字比图标更好点)"
],
"api": {
"source": "implementation",
"note": "props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs",
"requiredNote": "「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。",
"props": [
{
"name": "value",
"type": "string",
"default": "''",
"desc": "受控关键词;有值时清除按钮才出现(规格 §28.5)",
"required": false
},
{
"name": "placeholder",
"type": "string",
"default": "''",
"desc": "占位文字,提示搜索范围(如「搜索商品」)(规格 §28.2 control)",
"required": false
},
{
"name": "round",
"type": "boolean",
"default": "true",
"desc": "变体 round:true 胶囊形,false 方角(规格 §28.3)",
"required": false
},
{
"name": "showCancel",
"type": "boolean",
"default": "false",
"desc": "变体 showCancel:右侧是否出现取消动作(规格 §28.3)",
"required": false
},
{
"name": "clearable",
"type": "boolean",
"default": "true",
"desc": "有值时是否允许清除(规格 §28.5)",
"required": false
},
{
"name": "disabled",
"type": "boolean",
"default": "false",
"desc": "状态 disabled:置灰且不可聚焦(规格 §28.4)",
"required": false
},
{
"name": "cancelText",
"type": "string",
"default": "'取消'",
"desc": "取消动作文案;不用图标代替文字(规格 §28.6)",
"required": false
},
{
"name": "label",
"type": "string",
"default": "'搜索'",
"desc": "无障碍名称,落到 aria-label(规格 §28.6)",
"required": false
}
],
"events": [
{
"name": "input",
"params": "(value)",
"desc": "输入即触发,不等回车(规格 §28.5)"
},
{
"name": "search",
"params": "(value)",
"desc": "键盘「搜索」键触发(规格 §28.5)"
},
{
"name": "clear",
"params": "—",
"desc": "点击清除按钮触发,值由宿主置空(规格 §28.5)"
},
{
"name": "cancel",
"params": "—",
"desc": "点击取消动作触发,由宿主退出搜索态(规格 §28.5)"
}
],
"slots": [
{
"name": "default",
"desc": "根元素内的追加内容(如右侧的筛选入口)(规格 §28.2 search)"
}
]
},
"variantClasses": {
"round": {
"true": [
".kole-m-search--round"
],
"false": []
},
"showCancel": {
"false": [],
"true": [
".kole-m-search__cancel"
]
}
},
"demos": [
{
"id": "basic",
"group": "01 组件类型",
"title": "基础用法",
"desc": "页面顶部一行:放大镜 + 输入区,输入即过滤(不等回车)。",
"variant": "round=true"
},
{
"id": "cancel",
"group": "01 组件类型",
"title": "带取消",
"desc": "showCancel=true:触屏没有 Esc 键,退出搜索需要一个明确的文字动作。",
"variant": "showCancel=true"
},
{
"id": "round",
"group": "01 组件类型",
"title": "圆角两态",
"desc": "round=true 胶囊形用于页面顶部;round=false 方角嵌在卡片或工具栏里。",
"variant": "round=true|false"
},
{
"id": "clear",
"group": "01 组件类型",
"title": "清除",
"desc": "有值时清除按钮才出现;点它清空并把焦点留在输入框。",
"variant": "clearable=true"
},
{
"id": "disabled",
"group": "02 组件状态",
"title": "禁用",
"desc": "置灰且不可聚焦,键盘与读屏都会跳过。",
"variant": "disabled=true"
}
],
"related": [
{
"slug": "mobile-input",
"why": "收集填写内容用输入框;目的就是从列表里取回结果时才用搜索框"
},
{
"slug": "cell",
"why": "「搜索入口」只做跳转时用单元格,点开才是真搜索页"
},
{
"slug": "mobile-list",
"why": "搜索结果用列表承载;搜索框只负责取关键词"
}
]
}