移动端导航搜索框

搜索框Search

用关键词从长列表里取回一小段结果

数据录入 规格 28 · 搜索框 Search 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-search.css">

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

演示

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

01 组件类型

基础用法round=true

页面顶部一行:放大镜 + 输入区,输入即过滤(不等回车)。

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

showCancel=true:触屏没有 Esc 键,退出搜索需要一个明确的文字动作。

查看代码(演示页原文 · 15 行)
frameworks-mobile/Search.html · cancel
<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|false

round=true 胶囊形用于页面顶部;round=false 方角嵌在卡片或工具栏里。

查看代码(演示页原文 · 24 行)
frameworks-mobile/Search.html · round
<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>
清除clearable=true

有值时清除按钮才出现;点它清空并把焦点留在输入框。

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

禁用disabled=true

置灰且不可聚焦,键盘与读屏都会跳过。

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

名称类型默认值说明必传
valuestring''受控关键词;有值时清除按钮才出现(规格 §28.5)N
placeholderstring''占位文字,提示搜索范围(如「搜索商品」)(规格 §28.2 control)N
roundbooleantrue变体 round:true 胶囊形,false 方角(规格 §28.3)N
showCancelbooleanfalse变体 showCancel:右侧是否出现取消动作(规格 §28.3)N
clearablebooleantrue有值时是否允许清除(规格 §28.5)N
disabledbooleanfalse状态 disabled:置灰且不可聚焦(规格 §28.4)N
cancelTextstring'取消'取消动作文案;不用图标代替文字(规格 §28.6)N
labelstring'搜索'无障碍名称,落到 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-heightvar(--kole-m-touch-target)搜索框高度(≥ 44px 触控最小边长)
--kole-m-search-radiusvar(--kole-radius-base)round=false 时的圆角
--kole-m-search-icon-size14px放大镜视觉尺寸

何时使用

  • 用关键词从长列表里取回一小段结果
  • 移动端的搜索框几乎总是页面顶部的独立一行
  • 输入即过滤(不等回车)
  • 触屏没有 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 校验(类/变量必须真实存在)。

维度取值对应类名 / 变量
roundtrue / false
true .kole-m-search--round
false (由数据驱动,无专属类)
showCancelfalse / true
false (由数据驱动,无专属类)
true .kole-m-search__cancel

代表变体

变体标签
round=true · showCancel=true胶囊形 + 取消(页面顶部常用)
round=false · showCancel=false方角无取消(嵌在卡片或工具栏里)
round=true · showCancel=false胶囊形无取消

用到的令牌

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

--kole-m-font-size-body --kole-m-font-size-label --kole-m-touch-target --kole-color-border --kole-color-brand --kole-color-brand-active --kole-color-card-bg --kole-color-disabled-bg --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-search-height --kole-m-search-icon-size --kole-m-search-radius

6 端源码

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

frameworks-mobile/Search.css · 纯样式(CSS) · 177 行
frameworks-mobile/Search.css
/* 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 行
frameworks-mobile/Search.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 · 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 行
frameworks-mobile/Search.jsx
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 行
frameworks-mobile/Search.vue2.vue
<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 行
frameworks-mobile/Search.vue3.vue
<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 行
frameworks-mobile/Search.uniapp.vue
<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": "搜索结果用列表承载;搜索框只负责取关键词"
    }
  ]
}