Vue实现类似chrome浏览器地址栏补全功能

先讲需求:

我需要实现一个类似chrome浏览器地址栏的补全功能:


image.png

功能点

  • 输入“jian”, 会自动补全“jianshu.com”, 后面补全部分的“shu.com”蓝底白字显示
  • 下拉选项第一个是补全的或正在输入的内容
  • 存在补全(蓝底白字),按删除键,会先删除补全文字(蓝底白字)
  • 按上下键,选中的选项,内容会补全到输入框
  • 按右键,会使用该补全,光标在文字尾部
  • 按左键,会使用该补全,光标在当前位置
  • 存在补全时(存在蓝底白字),光标隐藏

实现

1、输入框补全文字样式

image.png

这是有两部分,即正在输入的文字,和 提示部分(蓝底白字), 这个我是用两个部分实现,一个是输入框(正常输入),一个是背景层(透明文字占位 + 蓝底白字块)

  • 代码:
<div class="input__content">
    <input
      class="input__input"
      type="text"
      v-model="keyword"
    />
    <!-- 背景 -->
    <div class="input__bg">
      <span class="input__bg--1">{{ keyword }}</span>
      <span class="input__bg--2">{{ remainStr }}</span>
    </div>
</div>

<script>
data() {
    return {
        keyword: '',
        remainStr: 'shu.com', // 提示的字符,先写在这
    }
}
</script>

<style lang="scss" scoped>
.input {
  &__content {
    background: #fff;
    position: relative;
    border: 1px solid #ccc;
    height: 32px;
  }
  &__input {
    height: 100%;
    width: 100%;
    border: 0;
    background: none;
    position: absolute;
    top: 0;
    left: 0;
    z-index: 1;
  }
  &__bg {
    color: #fff;
    position: absolute;
    top: 0;
    left: -2px;
    z-index: 0;
    line-height: 30px;
    &--1 {
      color: #fff;
      opacity: 0;
      white-space: pre;
    }
    &--2 {
      color: #fff;
      background-color: #40638a;
    }
  }
}
</style>

注意个细节,就是输入多个空格的时候,实际渲染只有一个空格,这样作为背景现实,就会错位。所以需要使用样式white-space: pre;

2、提示文字
假设补全的文字是“jianshu.com”, 会发现,每输入一个字字符,提示的部分就减少一个字符,这里就可以拆成两个部分:输入文字 + 提示字符 = “jianshu.com”。我们可以借用compute来实现

  • 代码:
<script>
data() {
    return {
        completeWord: "jianshu.com"; // 假设补全的文字,暂时放在这
    }
}
computed: {
   /**
     * 补全提示的字符
     * 没有输入文字,就不需要提示字符
     */
    remainStr() {
      if (
        !this.completeWord ||
        !this.keyword
      ) {
        return ''
      }
      
      // 提示字符 = 补全文字 -  已经输入的文字
      return this.completeWord.slice(this.keyword.length)
    },
}
</script>

3、按下删除键
按删除键不是删除已输入的文字,而是先删除提示块,再按一次之后才是删除已输入的文字

  • 代码
<template>
  <input  @keydown="handleKeydown" />
</template>

<script>
methods: {
  /*
   * 键盘点击事件
   * @param {Event} event 事件
   */
  handleKeydown(event) {
    const { keyCode } = event

    // 删除键码: 8
    if (keyCode === 8 && this.remainStr) {
      this.completeWord = this.keyword
      event.preventDefault()

      return
    }
  },
}
</script>

4、按左右键逻辑
左右键是应用提示补全的字符,不同的是,右键是光标在末尾,左键光标在原来位置

  • 代码
<template>
<input
  ref="input"
  @keydown="handleKeydown"
/>
</template>

<script>
methods: {
  handleKeydown(event) {
    // 按左,按右
    if (this.remainStr && (keyCode === 37 || keyCode === 39)) {
      this.keyword = this.completeWord // 应用补全

      // 按右键
      if (keyCode === 37) {
        const inputElm = this.$refs.input
        let caretPosition = null // 光标位置
      
        if (inputElm) {
          caretPosition = inputElm.selectionStart
        }

        setTimeout(() => {
          // 还原光标位置
          inputElm.setSelectionRange(caretPosition, caretPosition)
        }, 20)
      }

      event.preventDefault()
    }
  }
}
</script>

5、按下“上下键”逻辑
按下“上下键”,下拉选项会被选中,但会发现,光标还是聚焦在输入框。而且选中的项的值会填充到输入框

  • 代码:
<template>
  <!-- 选项 -->
  <ul>
    <li
      v-for="(item, index) in list"
      :key="item"
      :class="{
        active: focusIndex === index,
      }"
    >
      {{ item }}
    </li>
  </ul>
</template>

<script>
data() {
  return {
    // 实际中,数据从localstorage 中获取用户曾经输入过的数据
    list: [
        'jianhsu.com',
        'jianshu.com/p/1000001',
        'jianshu.com/p/1000002',
        'jianshu.com/p/1000003'
    ],
    focusIndex: -1
  }
},
methods: {
  /* 选择列表 */
  selectList(moveNumber) {
    let index = this.focusIndex

    if (this.focusIndex < 0) {
      index = -1
    }

    index += moveNumber

    if (index >= 0 && index < this.list.length) {
      this.focusIndex = index
      // 选中的项,赋值给keyword 和 补全词
      this.keyword = this.completeWord = this.list[index]
    }
  },
  
  handleKeydown(event) {
    // 向上按键
    if (keyCode === 38) {
      this.selectList(-1)
      event.preventDefault()
    }

    // 向下按键
    if (keyCode === 40) {
      this.selectList(+1)
      event.preventDefault()
    }
  }
}
</script>

6、第一个选项为补全或输入框输入的
输入值是,第一选项默认选中,并且值为提示补全后的值,或者是正在输入的值(没有补全时)

  • 代码
<template>
<input
  @input="handleChange"
/>
</template>

<script>
// 原始数据,实际应用中,从缓存获取
const originList = [
  'jianhsu.com',
  'jianshu.com/p/1000001',
  'jianshu.com/p/1000002',
  'jianshu.com/p/1000003'
]

export default {
  methods: {
    handleChange(e) {
      const { value } = v.target
      this.completeWord = value
      
      // 输入为空
      if (!value) {
        this.focusIndex = -1
        this.list = originList

        return
      }

      // 获取value 开头的第一个符合项
      const word = originList.find((w) => w.startsWith(value))

      if (word) {
        this.completeWord = word

        // 补全的单词挪到第一个
        this.list = [word, ...originList.filter((w) => w !== word)]
      } else {
        // 没有补全词,输入的放在第一个
        this.list = [value, ...originList]
      }
      
      // 默认选中第一个
      this.focusIndex = 0
    }
  }
}
</script>

一个细节,按下删除键的时候,删除的是提示字符,第一选项也是非补全词;可以加个判断字段canComplete, 在按下删除键的时候,该值为false, 上面代码就加个判断

if (word) {
  if (this.canComplete) {
    this.completeWord = word
  }  
}

最后全部代码

<template>
  <div class="input">
    input:
    <div class="input__content">
      <input
        ref="input"
        class="input__input"
        type="text"
        v-model="keyword"
        @input="handleChange"
        @keydown="handleKeydown"
        :class="{
          'hide-caret': remainStr,
        }"
      />
      <!-- 背景 -->
      <div class="input__bg">
        <span class="input__bg--1">{{ keyword }}</span>
        <span class="input__bg--2">{{ remainStr }}</span>
      </div>
    </div>

    <ul class="list">
      <li
        v-for="(item, index) in list"
        :key="item"
        :class="{
          active: focusIndex === index,
        }"
      >
        {{ item }}
      </li>
    </ul>
  </div>
</template>

<script>
// 原始数据
const originList = [
    'jianhsu.com',
  'jianshu.com/p/1000001',
  'jianshu.com/p/1000002',
  'jianshu.com/p/1000003',
  'bilibilii.com',
  '你好,世界'
]

export default {
  name: 'Input',
  data() {
    return {
      keyword: '',
      list: originList,
      completeWord: '', // 补全的文本
      focusIndex: -1, // 选中
      canComplete: false, // 是否可以补全
    }
  },
  methods: {
    /**
     * 补全
     * */
    complete(value) {
      if (!value) {
        this.focusIndex = -1
        this.list = originList

        return
      }

      const word = originList.find((w) => w.startsWith(value))

      if (word) {
        if (this.canComplete) {
          this.completeWord = word
        }

        this.list = [word, ...originList.filter((w) => w !== word)]
      } else {
        this.list = [value, ...originList]
      }

      this.focusIndex = 0
    },

    /**
     * 输入框输入事件
     * @param {Event} v 事件
     */
    handleChange(v) {
      const { value } = v.target

      this.completeWord = value

      // 计算补全
      this.complete(value)
    },

    /* 选择列表 */
    selectList(moveNumber) {
      let index = this.focusIndex

      if (this.focusIndex < 0) {
        index = -1
      }

      index += moveNumber

      if (index >= 0 && index < this.list.length) {
        this.focusIndex = index
        this.keyword = this.completeWord = this.list[index]
      }
    },

    /*
     * 键盘点击事件
     * @param {Event} event 事件
     */
    handleKeydown(event) {
      const { keyCode } = event

      // 删除单词
      if (keyCode === 8) {
        this.canComplete = false // 不需要补全

        if (this.remainStr) {
          this.completeWord = this.keyword
          event.preventDefault()

          return
        }
      } else {
        this.canComplete = true
      }

      // 按左,按右
      if (this.remainStr && (keyCode === 37 || keyCode === 39)) {
        this.keyword = this.completeWord // 应用补全

        // 按右键
        if (keyCode === 37) {
          // 获取光标的位置
          const inputElm = this.$refs.input
          let caretPosition = null

          if (inputElm) {
            caretPosition = inputElm.selectionStart
          }

          // 需要异步
          setTimeout(() => {
            inputElm.setSelectionRange(caretPosition, caretPosition)
          }, 20)
        }

        event.preventDefault()
      }

      // 向上按键
      if (keyCode === 38) {
        this.selectList(-1)
        event.preventDefault()
      }

      // 向下按键
      if (keyCode === 40) {
        this.selectList(+1)
        event.preventDefault()
      }
    },
  },

  computed: {
    /**
     * 补全提示的字符
     */
    remainStr() {
      if (
        !this.completeWord ||
        !this.keyword ||
        this.keyword.length >= this.completeWord.length
      ) {
        return ''
      }

      return this.completeWord.slice(this.keyword.length)
    },
  },
}
</script>

<style lang="scss" scoped>
.input {
  &__content {
    background: #fff;
    position: relative;
    border: 1px solid #ccc;
    height: 32px;
  }
  &__input {
    height: 100%;
    width: 100%;
    border: 0;
    background: none;
    position: absolute;
    top: 0;
    left: 0;
    z-index: 1;
  }
  &__bg {
    color: #fff;
    position: absolute;
    top: 0;
    left: -2px;
    z-index: 0;
    line-height: 30px;
    &--1 {
      color: #fff;
      opacity: 0;
      white-space: pre;
    }
    &--2 {
      color: #fff;
      background-color: #40638a;
    }
  }
  .hide-caret {
    caret-color: transparent;
  }

  .list {
    padding: 0;
    border: 1px solid #cecece;
    li {
      border-left: 2px solid transparent;
      padding-left: 6px;
      list-style: none;
      line-height: 32px;
      &:hover {
        background: #dedede;
      }
      &.active {
        border-color: #679df3;
        background-color: #dedede;
      }
    }
  }
}
</style>


总结

这只是个简单的示例,需要结合实际项目去实现。部分细节不必过于纠结

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容