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>

实现

相关推荐
踩着两条虫2 小时前
VTJ.PRO 在线应用开发平台的代码生成与模板系统
前端·低代码·ai编程
前端小崽子2 小时前
线上复制按钮失效?也许是这个原因
前端
张元清2 小时前
React 滚动效果:告别第三方库
前端·javascript·面试
有志2 小时前
Vue 学习总结(Java 后端工程师视角)
前端
踩着两条虫2 小时前
VTJ.PRO 在线应用开发平台的DSL生命周期
前端·低代码·ai编程
我是伪码农2 小时前
JS 复习
开发语言·前端·javascript
小碗细面2 小时前
Claude Code 很强,但为什么我越来越常打开 Codex App?
前端·chatgpt·ai编程
愿你如愿2 小时前
React Fiber 的主要目标是什么
前端·react.js
漂移的电子2 小时前
【echarts 细节】
前端·javascript·echarts