VUE3+element-plus MultiSelect 多选下拉组件

复制代码
<!--
  @file MultiSelect 多选下拉组件
  @description 支持模糊搜索、全选/取消全选/反选的多选下拉组件,参考 xm-select 风格
  @module components/MultiSelect/MultiSelect

  @example 基础用法
  <template>
    <MultiSelect
      v-model="selectedValues"
      :options="options"
      placeholder="请选择"
    />
  </template>

  <script setup>
  import MultiSelect from '@/components/MultiSelect'

  const selectedValues = ref([])
  const options = [
    { label: '选项1', value: 1 },
    { label: '选项2', value: 2 },
    { label: '选项3', value: 3 }
  ]
  </script>

  @example 完整功能(全选/反选/搜索/清空)
  <MultiSelect
    v-model="selected"
    :options="options"
    show-toolbar
    filterable
    clearable
    placeholder="请选择"
    @change="handleChange"
  />

  @description 已选项以逗号分隔的文本形式展示,超出宽度自动显示省略号;hover 触发器可查看完整内容
-->
<template>
  <div class="multi-select-wrapper" ref="wrapperRef">
    <el-popover
      v-model:visible="popoverVisible"
      placement="bottom-start"
      :width="dropdownWidth"
      trigger="click"
      :show-arrow="false"
      :offset="4"
      :disabled="props.disabled"
      popper-class="multi-select-popper"
      @show="handlePopoverShow"
      @hide="handlePopoverHide"
    >
      <!-- 触发器:输入框样式的展示区域 -->
      <template #reference>
        <div
          class="multi-select-trigger"
          :class="[
            `multi-select-trigger--${props.size}`,
            {
              'is-disabled': props.disabled,
              'is-focus': popoverVisible,
              'is-empty': isEmpty
            }
          ]"
          @click="handleTriggerClick"
        >
          <!-- 已选项展示区域:使用逗号分隔的文本形式展示,超出省略号 -->
          <div class="multi-select-display">
            <span v-if="displayText" class="multi-select-display-text" :title="displayText">
              {{ displayText }}
            </span>
            <!-- 占位文字 -->
            <span v-else class="multi-select-placeholder">
              {{ props.placeholder }}
            </span>
          </div>

          <!-- 右侧图标区域 -->
          <div class="multi-select-icons">
            <!-- 清空图标 -->
            <el-icon
              v-if="props.clearable && !isEmpty && !props.disabled"
              class="multi-select-clear-icon"
              @click.stop="handleClear"
            >
              <CircleClose />
            </el-icon>
            <!-- 下拉箭头 -->
            <el-icon class="multi-select-arrow-icon" :class="{ 'is-reverse': popoverVisible }">
              <ArrowDown />
            </el-icon>
          </div>
        </div>
      </template>

      <!-- 下拉面板内容 -->
      <div class="multi-select-dropdown">
        <!-- 搜索框 -->
        <div v-if="props.filterable" class="multi-select-search">
          <el-input
            v-model="searchQuery"
            size="small"
            placeholder="输入关键字搜索"
            clearable
            :prefix-icon="Search"
          />
        </div>

        <!-- 操作工具栏:全选 / 取消全选 / 反选 -->
        <div v-if="props.showToolbar" class="multi-select-toolbar">
          <el-button
            type="primary"
            link
            size="small"
            :disabled="filteredOptions.length === 0"
            @click="handleSelectAll"
          >
            全选
          </el-button>
          <el-button
            type="primary"
            link
            size="small"
            :disabled="filteredOptions.length === 0"
            @click="handleDeselectAll"
          >
            取消全选
          </el-button>
          <el-button
            type="primary"
            link
            size="small"
            :disabled="filteredOptions.length === 0"
            @click="handleInvert"
          >
            反选
          </el-button>
        </div>

        <!-- 选项列表 -->
        <div
          class="multi-select-options"
          :style="{ maxHeight: (props.maxHeight || 260) + 'px' }"
        >
          <!-- 空状态 -->
          <div v-if="filteredOptions.length === 0" class="multi-select-empty">
            无匹配数据
          </div>
          <!-- 选项渲染 -->
          <div
            v-for="option in filteredOptions"
            :key="option.value"
            class="multi-select-option"
            :class="{
              'is-selected': isSelected(option),
              'is-disabled': option.disabled
            }"
            @click="handleOptionClick(option)"
          >
            <el-checkbox
              :model-value="isSelected(option)"
              :disabled="option.disabled"
              @click.stop
              @change="handleOptionClick(option)"
            />
            <span class="multi-select-option-label">{{ option.label }}</span>
          </div>
        </div>
      </div>
    </el-popover>
  </div>
</template>

<script setup lang="ts">
/**
 * MultiSelect 多选下拉组件
 * @description 参考 xm-select 风格,基于 element-plus 实现
 *   - 支持模糊搜索过滤
 *   - 支持全选 / 取消全选 / 反选
 *   - 支持折叠 tag 展示
 *   - 支持清空、禁用
 */
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { Search, ArrowDown, CircleClose } from '@element-plus/icons-vue'
import type { MultiSelectProps, MultiSelectOption } from './types'

/**
 * 组件属性定义
 */
const props = withDefaults(defineProps<MultiSelectProps>(), {
  modelValue: () => [],
  options: () => [],
  placeholder: '请选择',
  disabled: false,
  showToolbar: true,
  filterable: true,
  size: 'small',
  clearable: true,
  maxHeight: 260
})

/**
 * 组件事件定义
 */
const emit = defineEmits<{
  'update:modelValue': [value: (string | number)[]]
  'change': [value: (string | number)[], items: MultiSelectOption[]]
  'clear': []
  'visibleChange': [visible: boolean]
}>()

/** 包装容器引用 */
const wrapperRef = ref<HTMLElement>()
/** 弹出层显示状态 */
const popoverVisible = ref(false)
/** 搜索关键字 */
const searchQuery = ref('')
/** 下拉面板宽度 */
const dropdownWidth = ref(240)

/** 当前选中的值(内部维护) */
const selectedValues = ref<(string | number)[]>([...props.modelValue])

/**
 * 监听外部 modelValue 变化,同步到内部
 */
watch(
  () => props.modelValue,
  (val) => {
    selectedValues.value = [...val]
  },
  { deep: true }
)

/**
 * 是否为空(未选择任何项)
 */
const isEmpty = computed(() => selectedValues.value.length === 0)

/**
 * 过滤后的选项列表
 * @description 根据搜索关键字进行模糊匹配
 */
const filteredOptions = computed(() => {
  if (!searchQuery.value) return props.options
  const query = searchQuery.value.toLowerCase()
  return props.options.filter((option) => {
    if (props.filterMethod) {
      return props.filterMethod(searchQuery.value, option)
    }
    return (
      String(option.label).toLowerCase().includes(query) ||
      String(option.value).toLowerCase().includes(query)
    )
  })
})

/**
 * 已选项对象列表
 */
const selectedItems = computed(() => {
  return selectedValues.value
    .map((val) => props.options.find((opt) => opt.value === val))
    .filter(Boolean) as MultiSelectOption[]
})

/**
 * 触发器展示文本
 * @description 将所有已选项 label 用逗号拼接,超出宽度时由 CSS 显示省略号
 */
const displayText = computed(() => {
  return selectedItems.value.map((item) => item.label).join(',')
})

/**
 * 判断选项是否被选中
 * @param option - 选项对象
 */
const isSelected = (option: MultiSelectOption) => {
  return selectedValues.value.includes(option.value)
}

/**
 * 同步选中值到外部
 */
const emitChange = () => {
  emit('update:modelValue', [...selectedValues.value])
  const items = selectedValues.value
    .map((val) => props.options.find((opt) => opt.value === val))
    .filter(Boolean) as MultiSelectOption[]
  emit('change', [...selectedValues.value], items)
}

/**
 * 处理选项点击
 * @param option - 被点击的选项
 */
const handleOptionClick = (option: MultiSelectOption) => {
  if (option.disabled) return
  const index = selectedValues.value.indexOf(option.value)
  if (index > -1) {
    selectedValues.value.splice(index, 1)
  } else {
    selectedValues.value.push(option.value)
  }
  emitChange()
}

/**
 * 全选:选中当前过滤后的所有未禁用选项
 */
const handleSelectAll = () => {
  const selectableValues = filteredOptions.value
    .filter((opt) => !opt.disabled)
    .map((opt) => opt.value)
  // 合并原有选中值与新选中值(去重)
  const merged = Array.from(new Set([...selectedValues.value, ...selectableValues]))
  selectedValues.value = merged
  emitChange()
}

/**
 * 取消全选:取消当前过滤后的所有选项
 */
const handleDeselectAll = () => {
  const filteredValues = filteredOptions.value.map((opt) => opt.value)
  selectedValues.value = selectedValues.value.filter((val) => !filteredValues.includes(val))
  emitChange()
}

/**
 * 反选:在当前过滤后的选项范围内反转选中状态
 */
const handleInvert = () => {
  const filteredValues = filteredOptions.value.map((opt) => opt.value)
  const newSelected = filteredValues.filter((val) => !selectedValues.value.includes(val))
  // 保留未在过滤结果中的原有选中值
  const retained = selectedValues.value.filter((val) => !filteredValues.includes(val))
  selectedValues.value = [...retained, ...newSelected]
  emitChange()
}

/**
 * 清空所有选中
 */
const handleClear = () => {
  selectedValues.value = []
  emitChange()
  emit('clear')
}

/**
 * 触发器点击
 */
const handleTriggerClick = () => {
  if (props.disabled) return
}

/**
 * 弹出层显示时
 */
const handlePopoverShow = () => {
  // 重置搜索关键字
  searchQuery.value = ''
  // 计算下拉宽度,最小与触发器同宽
  nextTick(() => {
    if (wrapperRef.value) {
      const width = wrapperRef.value.offsetWidth
      dropdownWidth.value = Math.max(width, 0)
    }
  })
  emit('visibleChange', true)
}

/**
 * 弹出层隐藏时
 */
const handlePopoverHide = () => {
  searchQuery.value = ''
  emit('visibleChange', false)
}

/**
 * 挂载时初始化下拉宽度
 */
onMounted(() => {
  nextTick(() => {
    if (wrapperRef.value) {
      dropdownWidth.value = Math.max(wrapperRef.value.offsetWidth, 0)
    }
  })
})
</script>

<style scoped lang="scss">
/**
 * 多选下拉组件样式
 * @description 参考 xm-select 风格,与 element-plus 主题色保持一致
 */
.multi-select-wrapper {
  width: 100%;
}

.multi-select-trigger {
  display: flex;
  align-items: center;
  width: 100%;
  min-height: 32px;
  padding: 0 8px;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  background-color: #fff;
  cursor: pointer;
  box-sizing: border-box;
  font-size: 14px;
  transition: border-color 0.2s ease, box-shadow 0.2s ease;
  overflow: hidden;

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

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

  &.is-disabled {
    background-color: #f5f7fa;
    border-color: #e4e7ed;
    color: #c0c4cc;
    cursor: not-allowed;
  }

  /** 尺寸变体:高度与字号对齐 element-plus 同尺寸 el-input */
  &--large {
    min-height: 40px;
    padding: 4px 10px;
    font-size: 16px;
  }

  &--small {
    min-height: 24px;
    padding: 0 8px;
    font-size: 12px;
  }
}

.multi-select-display {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  height: 100%;
  overflow: hidden;

  .multi-select-display-text {
    flex: 1;
    min-width: 0;
    font-size: inherit;
    color: #303133;
    line-height: 1.4;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
}

.multi-select-placeholder {
  color: #a8abb2;
  font-size: inherit;
  line-height: 1.4;
  user-select: none;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.multi-select-icons {
  display: flex;
  align-items: center;
  gap: 4px;
  flex-shrink: 0;
  margin-left: 4px;
  color: #a8abb2;
}

.multi-select-clear-icon {
  cursor: pointer;
  font-size: 14px;
  transition: color 0.2s;

  &:hover {
    color: #909399;
  }
}

.multi-select-arrow-icon {
  font-size: 12px;
  transition: transform 0.3s ease;

  &.is-reverse {
    transform: rotate(180deg);
  }
}
</style>

<style lang="scss">
/**
 * 下拉面板全局样式(popper 渲染在 body 上,需要非 scoped)
 */
.multi-select-popper.el-popover.el-popper {
  padding: 0 !important;
  border: 1px solid #e4e7ed;
  border-radius: 4px;
  box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
}

.multi-select-dropdown {
  .multi-select-search {
    padding: 8px;
    border-bottom: 1px solid #f0f0f0;
  }

  .multi-select-toolbar {
    display: flex;
    align-items: center;
    gap: 4px;
    padding: 6px 8px;
    border-bottom: 1px solid #f0f0f0;
    background-color: #fafafa;
  }

  .multi-select-options {
    overflow-y: auto;
    padding: 4px 0;

    &::-webkit-scrollbar {
      width: 6px;
    }

    &::-webkit-scrollbar-thumb {
      background-color: #c0c4cc;
      border-radius: 3px;
    }
  }

  .multi-select-option {
    display: flex;
    align-items: center;
    padding: 6px 12px;
    cursor: pointer;
    font-size: 13px;
    color: #606266;
    transition: background-color 0.2s;
    user-select: none;

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

    &.is-selected {
      color: #409eff;
      background-color: #ecf5ff;
    }

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

    /** 覆盖 el-checkbox 默认的 margin-right: 30px,缩小与 label 的间距 */
    .el-checkbox {
      margin-right: 4px;
      height: auto;
    }

    .multi-select-option-label {
      margin-left: 0;
      flex: 1;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
  }

  .multi-select-empty {
    padding: 16px;
    text-align: center;
    color: #909399;
    font-size: 13px;
  }
}
</style>

import MultiSelect from './MultiSelect.vue'

export { MultiSelect }
export default MultiSelect

// 导出类型
export type { MultiSelectOption, MultiSelectProps, MultiSelectEmits } from './types'

/**
 * @file MultiSelect 多选下拉组件类型定义
 * @description 定义多选下拉组件的选项、属性、事件等类型
 * @module components/MultiSelect/types
 */

/**
 * 选项数据结构
 */
export interface MultiSelectOption {
  /** 选项标签(显示文字) */
  label: string
  /** 选项值 */
  value: string | number
  /** 是否禁用该选项 */
  disabled?: boolean
  /** 其他自定义属性 */
  [key: string]: any
}

/**
 * 组件属性接口
 */
export interface MultiSelectProps {
  /** v-model 绑定值(选中值的数组) */
  modelValue: (string | number)[]
  /** 下拉选项列表 */
  options: MultiSelectOption[]
  /** 占位提示文字 */
  placeholder?: string
  /** 是否禁用整个组件 */
  disabled?: boolean
  /** 是否显示全选/取消全选/反选操作栏 */
  showToolbar?: boolean
  /** 是否显示模糊搜索框 */
  filterable?: boolean
  /** 自定义尺寸:large / default / small */
  size?: 'large' | 'default' | 'small'
  /** 是否清空支持 */
  clearable?: boolean
  /** 下拉面板最大高度(px) */
  maxHeight?: number
  /** 自定义过滤方法,返回 true 表示匹配 */
  filterMethod?: (query: string, option: MultiSelectOption) => boolean
}

/**
 * 组件事件接口
 */
export interface MultiSelectEmits {
  /** 更新 v-model */
  'update:modelValue': [value: (string | number)[]]
  /** 选中值变化时触发 */
  change: [value: (string | number)[], items: MultiSelectOption[]]
  /** 清空时触发 */
  clear: []
  /** 下拉框显示/隐藏切换 */
  visibleChange: [visible: boolean]
}
相关推荐
其美杰布-富贵-李1 小时前
04 watch 与 Vue 响应式数据流
前端·javascript·vue.js
赵大仁3 小时前
生成式 UI 实战:用 JSON Schema + React 动态渲染 AI 界面
前端·ai·react·next.js·前端架构·生成式ui
kyriewen4 小时前
我排查了一个React内存泄漏——罪魁祸首是这3个被忽略的清理函数
前端·javascript·面试
IT_陈寒4 小时前
我又被JavaScript的隐式类型转换坑了
前端·人工智能·后端
其美杰布-富贵-李4 小时前
03 ref、reactive 与 computed 响应式数据
前端·javascript·vue.js
OpenTiny社区4 小时前
GenUI SDK v1.3.0 发布|多框架兼容,一键换物料,渲染器 & 演练场全面增强!
前端·ai编程
用户938515635074 小时前
useRef + Web Worker 实战:React 如何优雅地拥抱多线程
前端·javascript·react.js
嘟嘟07174 小时前
useRef + Web Worker:React 中的耗时计算不卡页面
javascript·vue.js