uniapp纯css实现基础多选组件

基础多选,支持模糊搜索,单项删除

html 复制代码
<template>
  <view class="multi-select-container" ref="containerRef">
    <!-- 选中标签 + 搜索输入框 + 右侧图标 -->
    <view class="select-input-box">
      <view class="selected-tags">
        <view
          class="tag-item"
          v-for="(item, index) in selectedList"
          :key="index"
        >
          <text class="tag-label">{{ item[labelProp] }}</text>
          <text class="tag-close" @click.stop="deleteTag(item)">&times;</text>
        </view>
      </view>

      <input
        v-model="searchKey"
        class="search-input"
        placeholder="请搜索"
        @input="handleSearch"
        @focus="showDropdown = true"
        @click.stop
      />

      <!-- 右侧官方 uni-icons 切换 -->
      <uni-icons
        :type="showDropdown ? 'up' : 'down'"
        size="26"
        color="#999"
        class="select-icon"
      />
    </view>

    <!-- 下拉选项面板 -->
    <view class="dropdown-panel" v-show="showDropdown">
      <view class="empty-tip" v-if="filterList.length === 0"> 暂无数据 </view>
      <view
        class="option-item"
        v-for="(item, index) in filterList"
        :key="index"
        :class="{ active: isSelected(item) }"
        @click.stop="handleSelect(item)"
      >
        {{ item[labelProp] }}
      </view>
    </view>
  </view>
</template>
  
  <script setup>
import { ref, computed, watch, onMounted, onUnmounted } from "vue";

const props = defineProps({
  options: { type: Array, default: () => [] },
  labelProp: { type: String, default: "label" },
  valueProp: { type: String, default: "value" },
  modelValue: { type: Array, default: () => [] },
});

const emit = defineEmits(["update:modelValue"]);

const containerRef = ref(null);
const showDropdown = ref(false);
const searchKey = ref("");
const selectedList = ref([...props.modelValue]);

// 搜索过滤
const filterList = computed(() => {
  if (!searchKey.value) return props.options;
  return props.options.filter((item) => {
    return item[props.labelProp]
      ?.toLowerCase()
      .includes(searchKey.value.toLowerCase());
  });
});

// 判断是否选中
const isSelected = (item) => {
  return selectedList.value.some(
    (s) => s[props.valueProp] === item[props.valueProp]
  );
};

// 选择选项
const handleSelect = (item) => {
  const index = selectedList.value.findIndex(
    (s) => s[props.valueProp] === item[props.valueProp]
  );
  if (index > -1) {
    selectedList.value.splice(index, 1);
  } else {
    selectedList.value.push(item);
  }
  emit("update:modelValue", selectedList.value);
};

// 删除标签
const deleteTag = (item) => {
  selectedList.value = selectedList.value.filter(
    (s) => s[props.valueProp] !== item[props.valueProp]
  );
  emit("update:modelValue", selectedList.value);
};

// 输入搜索
const handleSearch = () => {
  showDropdown.value = true;
};

// 点击外部关闭下拉
const handleClickOutside = (e) => {
  // #ifndef H5
  return;
  // #endif
  if (!showDropdown.value) return;
  try {
    const el = containerRef.value.$el || containerRef.value;
    if (el && !el.contains(e.target)) {
      showDropdown.value = false;
    }
  } catch {
    showDropdown.value = false;
  }
};

onMounted(() => {
  // #ifdef H5
  document.addEventListener("click", handleClickOutside);
  // #endif
});

onUnmounted(() => {
  // #ifdef H5
  document.removeEventListener("click", handleClickOutside);
  // #endif
});

watch(
  () => props.modelValue,
  (val) => {
    selectedList.value = [...val];
  },
  { deep: true }
);
</script>
  
  <style scoped>
/* 全部使用 rpx 移动端适配 */
.multi-select-container {
  position: relative;
  width: 100%;
}

.select-input-box {
  min-height: 80rpx;
  padding: 12rpx 20rpx;
  border: 1rpx solid #e5e7eb;
  border-radius: 10rpx;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  background: #fff;
  position: relative;
  box-sizing: border-box;
}

.selected-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 10rpx;
  margin-right: 10rpx;
}

.tag-item {
  display: flex;
  align-items: center;
  padding: 8rpx 16rpx;
  background-color: #f4f6f8;
  border-radius: 6rpx;
  font-size: 26rpx;
  color: #333;
}

.tag-close {
  margin-left: 8rpx;
  color: #999;
  font-size: 28rpx;
  font-weight: bold;
}
.tag-close:active {
  color: #f56c6c;
}

.search-input {
  flex: 1;
  height: 60rpx;
  font-size: 28rpx;
  padding: 0 10rpx;
  border: none;
  outline: none;
  background: transparent;
}

/* 右侧图标间距 */
.select-icon {
  margin-left: 10rpx;
}

/* 下拉面板 */
.dropdown-panel {
  position: absolute;
  top: calc(100% + 8rpx);
  left: 0;
  right: 0;
  max-height: 400rpx;
  background: #fff;
  border: 1rpx solid #e5e7eb;
  border-radius: 10rpx;
  z-index: 999;
  overflow-y: auto;
  box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
}

.option-item {
  padding: 22rpx 24rpx;
  font-size: 28rpx;
  color: #333;
}
.option-item.active {
  background-color: #e8f3ff;
  color: #409eff;
}
.option-item:active {
  background-color: #f5f7fa;
}

.empty-tip {
  padding: 30rpx;
  text-align: center;
  color: #999;
  font-size: 26rpx;
}
</style>

实现

相关推荐
拉拉肥_King1 分钟前
Ant Design Vue a-image 图片预览充满全屏?为啥?
前端
OpenTiny社区14 分钟前
生成式UI,AI交互的下一个十年?OpenTiny在QCon 2026的深度分享
前端·开源·github
gyx_这个杀手不太冷静22 分钟前
大人工智能时代下前端界面全新开发模式的思考(六)
前端·架构·ai编程
yngsqq23 分钟前
编译的dll自动复制到指定目录并重命名
java·服务器·前端
研☆香37 分钟前
聊一聊js中的正则表达式的应用
前端·javascript·正则表达式
开心就好202539 分钟前
使用Edge和ADB进行Android Webview远程调试的完整教程
前端·ios
用泥种荷花1 小时前
从 0 到 1 做一个支持 NFC 写入的小程序,需要哪些 API?
前端
90程序员1 小时前
纯浏览器解析 APK 信息,不用服务器 | 开源了一个小工具
前端·apk
用户11481867894841 小时前
Vosk-Browser 实现浏览器离线语音转文字
前端·javascript
江上清风山间明月1 小时前
Vite现代化的前端构建工具详解
前端·webpack·nodejs·vite