日常学习开发记录-select组件(2)

news2025/3/25 21:50:08

日常学习开发记录-select组件(2)

  • 第二阶段:增强功能

给现有select组件新增功能

第二阶段:增强功能

  1. 键盘操作支持
    • 支持键盘上下箭头选择选项
    • 支持回车键确认选择
    • 支持Esc键关闭下拉菜单
<template>
  <div
    :class="['my-select', { 'is-disabled': disabled }]"
    @click.stop="toggleDropdown"
    @mouseenter="handleMouseEnter"
    @mouseleave="handleMouseLeave"
    v-click-outside="closeDropdown"
    tabindex="0"
    @keydown="handleKeydown"
    ref="select"
  >
    <!-- 选择器触发器 -->
    <div class="my-select__trigger">
      <span v-if="!currentValue && !multiple" class="my-select__placeholder">
        {{ placeholder }}
      </span>
      <span v-else-if="!multiple" class="my-select__label">
        {{ getSelectedLabel() }}
      </span>
      <div v-else class="my-select__tags">
        <span
          v-for="item in selected"
          :key="typeof item === 'object' ? item.value : item"
          class="my-select__tag"
        >
          {{ typeof item === 'object' ? item.label : item }}
          <i class="my-select__tag-close" @click.stop="removeTag(item)">×</i>
        </span>
      </div>
      <i
        v-if="clearable && currentValue && !visible && hover"
        class="my-select__clear"
        @click.stop="clearSelection"
      >
        ×
      </i>
      <i v-else class="my-select__arrow" :class="{ 'is-reverse': visible }"></i>
    </div>

    <!-- 下拉菜单 -->
    <div v-show="visible" class="my-select__dropdown">
      <div
        v-for="(item, index) in options"
        :key="typeof item === 'object' ? item.value : item"
        class="my-select__option"
        :class="{
          'is-selected': isSelected(item),
          'is-disabled': item.disabled,
          'is-highlighted': highlightedIndex === index,
        }"
        @click.stop="handleOptionClick(item)"
        @mouseenter="highlightedIndex = index"
      >
        <slot name="option" :item="item" :index="index">
          {{ typeof item === 'object' ? item.label : item }}
        </slot>
      </div>
      <div v-if="options.length === 0" class="my-select__empty">
        <slot name="empty">无数据</slot>
      </div>
    </div>
  </div>
</template>

<script>
  export default {
    name: 'MySelect',
    directives: {
      clickOutside: {
        bind(el, binding) {
          el.clickOutsideEvent = event => {
            if (!(el == event.target || el.contains(event.target))) {
              binding.value(event)
            }
          }
          document.addEventListener('click', el.clickOutsideEvent)
        },
        unbind(el) {
          document.removeEventListener('click', el.clickOutsideEvent)
        },
      },
    },
    props: {
      value: {
        type: [String, Array],
        default: '',
      },
      options: {
        type: Array,
        default: () => [],
      },
      multiple: {
        type: Boolean,
        default: false,
      },
      clearable: {
        type: Boolean,
        default: false,
      },
      placeholder: {
        type: String,
        default: '请选择',
      },
      disabled: {
        type: Boolean,
        default: false,
      },
    },
    data() {
      return {
        visible: false,
        currentValue: this.value,
        selected: [],
        hover: false,
        highlightedIndex: -1, // 当前高亮的选项索引
      }
    },
    watch: {
      value: {
        handler(newVal) {
          this.currentValue = newVal
          if (this.multiple) {
            this.selected = Array.isArray(newVal) ? [...newVal] : []
          }
        },
        immediate: true,
      },
      visible(val) {
        if (val) {
          // 当下拉菜单打开时,重置高亮索引或设置为当前选中项
          this.initHighlightIndex()
        } else {
          // 关闭时重置高亮索引
          this.highlightedIndex = -1
        }
      },
      options() {
        // 当选项变化时,重置高亮索引
        if (this.visible) {
          this.initHighlightIndex()
        }
      },
    },
    methods: {
      handleMouseEnter() {
        this.hover = true
      },
      handleMouseLeave() {
        this.hover = false
      },
      isSelected(item) {
        return this.multiple ? this.selected.includes(item) : this.currentValue === item.value
      },
      toggleDropdown() {
        if (this.disabled) return
        this.visible = !this.visible
        if (this.visible) {
          // 切换后聚焦以便捕获键盘事件
          this.$nextTick(() => {
            this.$refs.select.focus()
          })
        }
      },
      closeDropdown() {
        this.visible = false
      },
      getSelectedLabel() {
        return (
          this.options.find(item => item.value === this.currentValue)?.label || this.placeholder
        )
      },
      handleOptionClick(item) {
        if (item.disabled) return

        if (this.multiple) {
          if (this.selected.includes(item)) {
            this.selected = this.selected.filter(i => i !== item)
          } else {
            this.selected.push(item)
          }
          this.$emit('input', this.selected)
          this.$emit('change', this.selected)
        } else {
          this.currentValue = item.value
          this.$emit('input', item.value)
          this.$emit('change', item.value)
          this.closeDropdown()
        }
      },
      clearSelection(event) {
        event.stopPropagation()
        this.currentValue = ''
        this.selected = []
        this.$emit('input', this.multiple ? [] : '')
        this.$emit('clear')
      },
      removeTag(item) {
        this.selected = this.selected.filter(i => i !== item)
        this.$emit('input', this.selected)
        this.$emit('remove-tag', item)
      },
      // 键盘事件处理
      handleKeydown(event) {
        if (this.disabled) return

        // 只有在下拉菜单打开或按下方向键、回车键和ESC键时才处理
        const keyCode = event.keyCode

        if (this.visible) {
          // 下拉菜单已打开
          switch (keyCode) {
            case 38: // 上箭头
              event.preventDefault()
              this.navigateOptions('prev')
              break
            case 40: // 下箭头
              event.preventDefault()
              this.navigateOptions('next')
              break
            case 13: // 回车键
              event.preventDefault()
              if (this.highlightedIndex > -1 && this.options[this.highlightedIndex]) {
                this.handleOptionClick(this.options[this.highlightedIndex])
              }
              break
            case 27: // ESC键
              event.preventDefault()
              this.closeDropdown()
              break
          }
        } else {
          // 下拉菜单未打开
          switch (keyCode) {
            case 38: // 上箭头
            case 40: // 下箭头
              event.preventDefault()
              this.toggleDropdown()
              break
            case 13: // 回车键
              event.preventDefault()
              this.toggleDropdown()
              break
          }
        }
      },
      // 导航选项
      navigateOptions(direction) {
        if (this.options.length === 0) return

        let newIndex
        if (direction === 'next') {
          // 如果当前高亮选项是最后一个,则跳转到第一个
          if (this.highlightedIndex >= this.options.length - 1) {
            newIndex = 0
          } else {
            newIndex = this.highlightedIndex + 1
          }
        } else if (direction === 'prev') {
          // 如果当前高亮选项是第一个,则跳转到最后一个
          if (this.highlightedIndex <= 0) {
            newIndex = this.options.length - 1
          } else {
            newIndex = this.highlightedIndex - 1
          }
        }

        // 跳过禁用的选项
        const numOptions = this.options.length
        let counter = 0
        // 如果当前高亮选项是禁用的,则继续跳转
        while (
          counter++ < numOptions &&
          this.options[newIndex] &&
          this.options[newIndex].disabled
        ) {
          if (direction === 'next') {
            newIndex = newIndex >= this.options.length - 1 ? 0 : newIndex + 1
          } else {
            newIndex = newIndex <= 0 ? this.options.length - 1 : newIndex - 1
          }
        }

        this.highlightedIndex = newIndex
        this.scrollToOption()
      },
      // 滚动到当前高亮的选项
      scrollToOption() {
        this.$nextTick(() => {
          const dropdown = this.$el.querySelector('.my-select__dropdown')
          if (!dropdown) return

          const options = dropdown.querySelectorAll('.my-select__option')
          if (this.highlightedIndex < 0 || !options[this.highlightedIndex]) return

          const highlighted = options[this.highlightedIndex]
          const scrollTop = dropdown.scrollTop
          const offsetTop = highlighted.offsetTop
          // 如果高亮选项在可视区域上方,则滚动到高亮选项上方
          if (offsetTop < scrollTop) {
            dropdown.scrollTop = offsetTop
          } else if (offsetTop > scrollTop + dropdown.clientHeight - highlighted.offsetHeight) {
            // 如果高亮选项在可视区域下方,则滚动到高亮选项下方
            dropdown.scrollTop = offsetTop - dropdown.clientHeight + highlighted.offsetHeight
          }
        })
      },
      // 初始化高亮索引
      initHighlightIndex() {
        this.highlightedIndex = -1

        // 尝试高亮当前选中项
        if (!this.multiple && this.currentValue) {
          const selectedIndex = this.options.findIndex(item => item.value === this.currentValue)
          if (selectedIndex > -1) {
            this.highlightedIndex = selectedIndex
            this.scrollToOption()
            return
          }
        }

        // 否则高亮第一个非禁用选项
        for (let i = 0; i < this.options.length; i++) {
          if (!this.options[i].disabled) {
            this.highlightedIndex = i
            this.scrollToOption()
            break
          }
        }
      },
    },
  }
</script>

<style lang="scss" scoped>
  .my-select {
    position: relative;
    display: inline-block;
    width: 240px;
    font-size: 14px;
    cursor: pointer;
    outline: none; // 移除默认的焦点轮廓,可以添加自定义样式

    &:focus {
      .my-select__trigger {
        border-color: #409eff;
        outline: 0;
        box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
      }
    }

    &.is-disabled {
      .my-select__trigger {
        background-color: rgb(245, 247, 250);
        color: rgb(192, 196, 204);
        cursor: not-allowed;
        border-color: rgb(228, 231, 237);
      }
    }

    &__trigger {
      display: flex;
      align-items: center;
      background-color: #fff;
      border: 1px solid #dcdfe6;
      border-radius: 4px;
      padding: 0 35px 0 15px;
      min-height: 40px;
      line-height: 40px;
      position: relative;
      transition: border-color 0.2s;
      box-sizing: border-box;

      &:hover {
        border-color: #c0c4cc;
      }
    }

    &__placeholder {
      color: #909399;
    }

    &__arrow {
      position: absolute;
      right: 15px;
      top: 50%;
      transform: translateY(-50%);
      transition: transform 0.3s;
      width: 0;
      height: 0;
      border-style: solid;
      border-width: 5px 5px 0 5px;
      border-color: #c0c4cc transparent transparent transparent;

      &.is-reverse {
        transform: translateY(-50%) rotate(180deg);
      }
    }

    &__clear {
      position: absolute;
      right: 15px;
      top: 50%;
      transform: translateY(-50%);
      color: #c0c4cc;
      font-size: 14px;

      &:hover {
        color: #909399;
      }
    }

    &__dropdown {
      position: absolute;
      top: 100%;
      left: 0;
      margin-top: 5px;
      background-color: #fff;
      border: 1px solid #e4e7ed;
      border-radius: 4px;
      box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
      box-sizing: border-box;
      z-index: 1000;
      width: 100%;
      max-height: 274px;
      overflow-y: auto;
    }

    &__option {
      padding: 0 20px;
      position: relative;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      height: 34px;
      line-height: 34px;
      box-sizing: border-box;

      &:hover {
        background-color: #f5f7fa;
      }

      &.is-selected {
        color: #409eff;
        font-weight: 700;
      }

      &.is-highlighted {
        background-color: #f5f7fa;
      }

      &.is-disabled {
        color: #c0c4cc;
        cursor: not-allowed;
      }
    }

    &__empty {
      padding: 10px 0;
      text-align: center;
      color: #909399;
    }

    &__tags {
      display: flex;
      flex-wrap: wrap;
      line-height: normal;
      max-width: 100%;
      overflow: hidden;
    }

    &__tag {
      display: inline-flex;
      align-items: center;
      max-width: 100%;
      margin: 2px 0 2px 6px;
      padding: 0 5px 0 10px;
      background-color: #f0f2f5;
      border-radius: 4px;
      height: 24px;
      line-height: 24px;
      white-space: nowrap;
      overflow: hidden;
      box-sizing: border-box;
    }

    &__tag-close {
      margin-left: 5px;
      color: #909399;
      font-size: 12px;
      cursor: pointer;

      &:hover {
        color: #606266;
      }
    }
  }
</style>

箭头键导航:
当下拉菜单关闭时,按上下箭头键可以打开下拉菜单
当下拉菜单打开时,按上下箭头键可以在选项中导航
导航会自动跳过禁用的选项
当到达列表顶部或底部时会循环到另一端
回车键确认:
当下拉菜单关闭时,按回车键会打开下拉菜单
当下拉菜单打开时,按回车键会选中当前高亮的选项
Esc键关闭:
按Esc键会关闭下拉菜单
此外,我还添加了以下增强功能:
高亮状态:
添加了视觉高亮效果,显示当前键盘导航位置
鼠标悬停时也会更新高亮状态
滚动同步:
当使用键盘导航时,会自动滚动到当前高亮的选项位置
确保高亮选项始终在可视区域内
焦点样式:
添加了组件获得焦点时的视觉反馈
使用蓝色边框和轻微阴影提示当前可以使用键盘操作
以上修改使Select组件的可访问性大大提高,既提供了键盘操作支持,又保留了原有的鼠标操作功能。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2321560.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【Linux】VMware17 安装 Ubuntu24.04 虚拟机

目录 安装教程 一、下载 Ubuntu 桌面版iso映像 二、安装 VMware 三、安装 Ubuntu 桌面版 VMware 创建虚拟机 挂载 Ubuntu ISO 安装 Ubuntu 系统 安装教程 一、下载 Ubuntu 桌面版iso映像 链接来自 清华大学开源软件镜像站 ISO文件地址&#xff1a;ubuntu-24.04.2-des…

WPS宏开发手册——JSA语法

目录 系列文章2、JSA语法2.1、打印输出2.2、注释2.3、变量2.4、数据类型2.5、函数2.6、运算符2.7、比较2.8、if else条件语句2.9、for循环2.10、Math对象&#xff08;数字常用方法&#xff09;2.11、字符串常用方法2.12、数组常用方法 系列文章 使用、工程、模块介绍 JSA语…

word中指定页面开始添加页码

第一步&#xff1a; 插入页码 第二步&#xff1a; 把光标放到指定起始页码处 第三步&#xff1a; 取消链接到前一节 此时关掉页脚先添加分节符 添加完分节符后恢复点击 第四步&#xff1a; 设置页码格式&#xff0c;从1开始 第五步&#xff1a; 删掉不要的页码&#xff0c…

Python实现deepseek接口的调用

简介&#xff1a;DeepSeek 是一个强大的大语言模型&#xff0c;提供 API 接口供开发者调用。在 Python 中&#xff0c;可以使用 requests 或 httpx 库向 DeepSeek API 发送请求&#xff0c;实现文本生成、代码补全&#xff0c;知识问答等功能。本文将介绍如何在 Python 中调用 …

文档处理控件Aspose.Words 教程:.NET版中增强的 AI 文档摘要功能

Aspose.Words是一个功能强大的 Word 文档处理库。它可以帮助开发人员自动编辑、转换和处理文档。 自 24.11 版以来&#xff0c;Aspose.Words for .NET 提供了 AI 驱动的文档摘要功能&#xff0c;使用户能够从冗长的文本中快速提取关键见解。在 25.2 版中&#xff0c;我们通过使…

19,C++——11

目录 一、 C11简介 二、 新增的列表初始化 三、 新增的STL容器 四、 简化声明 1&#xff0c;auto 2&#xff0c;decltype 3&#xff0c;nullptr 五、右值引用 1&#xff0c;左值引用和右值引用 2&#xff0c;两种引用的比较 3&#xff0c;左值引用的使用场景 4&…

风尚云网|前端|前后端分离架构深度剖析:技术革新还是过度设计?

前后端分离架构深度剖析&#xff1a;技术革新还是过度设计&#xff1f; 作者&#xff1a;风尚云网 在数字化转型浪潮中&#xff0c;前后端分离架构已成为现代Web开发的主流模式。但这项技术真的是银弹吗&#xff1f;本文将从工程实践角度&#xff0c;剖析其优势与潜在风险&am…

CMS网站模板设计与用户定制化实战评测

内容概要 在数字化转型背景下&#xff0c;CMS平台作为企业内容管理的核心载体&#xff0c;其模板架构的灵活性与用户定制能力直接影响运营效率。通过对WordPress、Baklib等主流系统的技术解构发现&#xff0c;模块化设计理念已成为行业基准——WordPress依托超过6万款主题库实…

搭建个人博客教程(Hexo)

如何快速搭建一套本地的博客系统呢&#xff1f;这里有一套gitNode.jsHexo的部署方案来进行解决。 安装git Git 是一款免费开源的分布式版本控制系统&#xff0c;由 Linus Torvalds 于 2005 年为 Linux 内核开发设计。它通过本地仓库和远程仓库实现代码管理&#xff0c;支持分支…

Docker 可视化工具 Portainer

Docker 可视化工具 Portainer安装 官方安装地址&#xff1a;https://docs.portainer.io/start/install-ce/server/docker/wsl 一&#xff0c;首先&#xff0c;创建 Portainer Server 用来存储数据库的卷&#xff1a; docker volume create portainer_data二&#xff0c;然后…

数据库基础知识点(系列二)

1&#xff0e;关系数据模型由哪三个要素组成。 答&#xff1a;关系数据模型由关系数据结构、关系操作集合和关系完整性约束三部分组成。 2&#xff0e;简述关系的性质。&#xff08;关系就是一张二维表格&#xff0c;但不是任何二维表都叫关系&#xff09; 答&#xff1a;(1…

如何进行灌区闸门自动化改造-闸门远程控制系统建设

改造背景 操作效率低‌&#xff1a;人工启闭耗时耗力&#xff0c;单次操作需2-3人配合&#xff0c;耗时长。 ‌水资源浪费‌&#xff1a;依赖经验估算放水量&#xff0c;易导致漫灌或供水不足。 ‌管理滞后‌&#xff1a;无法实时监控水位、流量&#xff0c;故障响应延迟。 …

【算法笔记】图论基础(二):最短路、判环、二分图

目录 最短路松弛操作Dijkstra朴素Dijkstra时间复杂度算法过程例题 堆优化Dijkstra时间按复杂度算法过程例题 bellman-ford时间复杂度为什么dijkstra不能处理负权边&#xff1f;dijkstra的三个步骤&#xff1a;反例失效的原因 算法过程例题 spfa时间复杂度算法过程例题spfa求最短…

EMS小车技术特点与优势:高效灵活的自动化输送解决方案

北成新控伺服技术丨EMS小车调试视频 EMS小车是一种基于单轨运行的电动输送系统&#xff0c;通过电力驱动实现物料的高效搬运和输送&#xff0c;具有高效灵活、节能环保、多功能集成、行业适配性强等特性&#xff0c;广泛应用于汽车制造、工程机械、家电生产、仓储物流等行业自动…

uniapp运行到支付宝开发者工具

使用uniapp编写专有钉钉和浙政钉出现的样式问题 在支付宝开发者工具中启用2.0构建的时候&#xff0c;在开发工具中页面样式正常 但是在真机调试和线上的时候不正常 页面没问题&#xff0c;所有组件样式丢失 解决 在manifest.json mp-alipay中加入 "styleIsolation&qu…

C++ 性能优化隐藏陷阱:从系统调用到并发开销的深度反思

作为一名C++技术专家,我深知性能优化不仅是代码层面的艺术,更是理解硬件与语言交互的科学。在现代计算中,C++的抽象为开发者提供了便利,却也隐藏了硬件的复杂性。如何揭开这些“谎言”,让代码与硬件协同工作?本文将以小案例为载体,通过优化前后的对比,深入剖析每个章节…

Unity 使用 Protobuf(Pb2)二进制数据全流程工具详解

前言 在Unity游戏开发中&#xff0c;高效、快速、安全地读取配置数据是一项重要需求。本文介绍一种完整的解决方案——使用Protobuf二进制格式&#xff08;Pb2&#xff09;存储和读取游戏数据&#xff0c;并详细分享实现全流程的Unity工具。 一、技术流程概览 实现Unity读取…

基于QT(C++)实现绘图程序

绘图程序 1 核心算法 1.1 图元生成 1.1.1 直线 画直线的算法采用了课上讲到的 Bresenhan 算法&#xff0c;采用整数增量运算&#xff0c;精确而有效的光栅设备生成算法。 基本思想是&#xff1a;当直线斜率的绝对值小于 1 时&#xff0c;从左端点开始作为起点&#…

深入剖析ReLU激活函数:特性、优势与梯度消失问题的解决之道,以及Leaky ReLU 和 Parametric ReLU

深入剖析ReLU激活函数&#xff1a;特性、优势与梯度消失问题的解决之道 在深度学习领域&#xff0c;激活函数的选择直接影响神经网络的训练效果和性能。整流线性单元&#xff08;Rectified Linear Unit&#xff0c;简称ReLU&#xff09;因其简单性、高效性以及对梯度消失问题的…

服务注册/服务发现-Eureka

目录 1.引言&#xff1a;如果一个父项目中有多个子项目&#xff0c;但是这些子项目如何如何相互调用彼此的业务呢&#xff1f; 2.什么是注册中心 3.CAP理论 4.EureKa 5.服务注册 6.服务发现 7.负载均衡 1.引言&#xff1a;如果一个父项目中有多个子项目&#xff0c;但是…