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>

实现

相关推荐
里欧跑得慢10 分钟前
CSS 模块化架构的演进:BEM、CSS Modules 到 CSS-in-JS 的反思
前端·css·flutter·web·css-in-js
IT_陈寒22 分钟前
Vue的computed属性把我坑惨了,原来我一直用错姿势
前端·人工智能·后端
小灰灰搞电子1 小时前
Rust+Slint 实现温度计源码分享
前端·rust·slint
计算机魔术师1 小时前
面壁智能 OpenBMB 推出 MathForm,面向 Lean 4 数学自动形式化的开源框架、数据集与模型
前端
NeilCarmack2 小时前
Deepseek-harness增加桌面版端序列:第 2 讲 · spawn Electron:当前进程如何“交棒“
前端·javascript·electron
上海魁鲸科技有限公司2 小时前
APS高级排产系统到底有什么用?一文讲清功能、选型与落地建议
前端·microsoft·excel
陈随易2 小时前
Bun v1.4 更新总结:把浏览器、图片、定时任务和工程工具都装进一个运行时
前端·后端·程序员
东风破_4 小时前
TypeScript 高级类型进阶:keyof、Exclude、Record 与类型组合思想
前端·后端·typescript
DS随心转插件4 小时前
Grok生成的html怎么导出——AI导出鸭:大模型结构化输出的“最后一公里”工程化解构
前端·人工智能·ai·html·豆包·deepseek·ai导出鸭
এ慕ོ冬℘゜4 小时前
使用 jQuery 动态渲染表格与状态切换
前端·javascript·jquery