You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

自定义Vue多选组件中Blur事件无法正常工作

问题描述

自定义Vue多选组件存在Blur事件异常:

  • 点击组件内除输入框外的区域时,外层div的Blur事件可正常触发以关闭选项列表;
  • 点击输入框后再点击组件外部,该Blur事件无法触发,导致选项列表无法关闭;
  • 要求:点击输入框后再点击组件外部时,外层div的Blur事件正常触发关闭列表;点击组件内选项列表时不触发该事件,因此不能仅在输入框上绑定Blur事件。
解决方案

核心思路是利用blur事件的relatedTarget属性,判断失去焦点后焦点是否转移到组件内部元素,只有当焦点转移到组件外部时才关闭选项列表:

  1. 添加组件根元素引用:给外层可聚焦的div添加ref,用于后续判断元素是否属于组件内部。
  2. 修改Blur事件处理逻辑:不再直接设置showOptions = false,而是通过函数判断焦点去向:
    • 如果relatedTarget是组件内部元素(包括输入框、选项列表),则不关闭列表;
    • 如果relatedTarget是外部元素或null,则关闭列表。
  3. 给选项列表元素添加tabindex:让选项li可以接收焦点,确保点击选项时relatedTarget能正确指向该元素,被识别为内部元素。
修改后的完整代码
<template>
  <div class="flex flex-col relative w-full">
    <span v-if="label" class="font-jost-medium mb-2">{{ label }}</span>
    <div>
      <!-- 添加ref,并修改blur事件处理函数 -->
      <div @blur="handleBlur" :tabindex="tabIndex" ref="componentRoot">
        <div
          class="border border-[#EAEAEA] bg-white rounded-md flex flex-col w-full"
        >
          <div
            v-if="selectedOptions.length"
            class="flex flex-wrap px-4 py-2 border-b gap-2"
          >
            <div
              v-for="option in selectedOptions"
              class="border bg-secondary rounded-full py-1 px-2 flex items-center"
            >
              <span>{{ option.text }}</span>
              <vue-feather
                type="x"
                class="h-3 w-3 ml-1.5 cursor-pointer"
                @click="onDeleteOption(option)"
              />
            </div>
          </div>
          <div
            class="flex flex-row justify-end items-center px-4 cursor-pointer"
            :class="selectedOptions.length ? 'py-2' : 'p-4'"
            @click="showOptions = !showOptions"
          >
            <MagnifyingGlassIcon class="h-5 w-5 mr-2" />
            <input
              class="focus:outline-0 w-full"
              type="text"
              v-model="searchInput"
            />
            <vue-feather type="chevron-down" class="h-5 w-5" />
          </div>
        </div>
        <div v-if="showOptions && optionsMap.length" class="options-list">
          <ul role="listbox" class="w-full overflow-auto">
            <!-- 给li添加tabindex="-1",使其可接收焦点 -->
            <li
              class="hover:bg-primary-light px-4 py-2 rounded-md cursor-pointer"
              role="option"
              v-for="option in optionsMap"
              @mousedown="onOptionClick(option)"
              :tabindex="-1"
            >
              {{ option.text }}
            </li>
          </ul>
        </div>
        <div
          id="not-found"
          class="absolute w-full italic text-center text-inactive-grey"
          v-else-if="!optionsMap.length"
        >
          No records found
        </div>
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, PropType, ref, watch } from "vue";
import { IconNameTypes } from "@/types/enums/IconNameTypes";
import { AppIcon } from "@/components/base/index";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";

export default defineComponent({
  name: "AppAutocomplete",
  components: {
    AppIcon,
    MagnifyingGlassIcon,
  },
  props: {
    modelValue: {
      type: String,
    },
    label: {
      type: String,
      default: "",
    },
    tabIndex: {
      type: Number,
      default: 0,
    },
    options: {
      type: Array as PropType<{ text: string; value: string }[]>,
      required: true,
    },
  },
  setup(props, { emit }) {
    const showOptions = ref(false);
    // 定义组件根元素的ref
    const componentRoot = ref<HTMLElement | null>(null);

    const optionsMap = ref(props.options);
    const selectedOptions = ref<{ text: string; value: string }[]>([]);
    const searchInput = ref("");
    watch(searchInput, () => {
      optionsMap.value = props.options.filter((option1) => {
        return (
          !selectedOptions.value.some((option2) => {
            return option1.text === option2.text;
          }) &&
          option1.text.toLowerCase().includes(searchInput.value.toLowerCase())
        );
      });
      sortOptionsMapList();
    });

    const onOptionClick = (option: { text: string; value: string }) => {
      searchInput.value = "";
      selectedOptions.value.push(option);
      optionsMap.value = optionsMap.value.filter((option1) => {
        return !selectedOptions.value.some((option2) => {
          return option1.text === option2.text;
        });
      });
      sortOptionsMapList();
      emit("update:modelValue", option.value);
    };

    const onDeleteOption = (option: { text: string; value: string }) => {
      selectedOptions.value = selectedOptions.value.filter((option2) => {
        return option2.text !== option.text;
      });
      optionsMap.value.push(option);
      sortOptionsMapList();
    };

    const sortOptionsMapList = () => {
      optionsMap.value.sort(function (a, b) {
        return a.text.localeCompare(b.text);
      });
    };

    // 处理blur事件的函数
    const handleBlur = (event: FocusEvent) => {
      const relatedTarget = event.relatedTarget as HTMLElement;
      // 判断relatedTarget是否是组件内部元素,如果不是则关闭选项列表
      if (!componentRoot.value || !componentRoot.value.contains(relatedTarget)) {
        showOptions.value = false;
      }
    };

    sortOptionsMapList();

    return {
      showOptions,
      optionsMap,
      searchInput,
      selectedOptions,
      IconNameTypes,
      onOptionClick,
      onDeleteOption,
      componentRoot,
      handleBlur,
    };
  },
});
</script>

<style scoped lang="scss">
.options-list,
#not-found {
  box-shadow: 0 0 50px 0 rgb(19 19 28 / 12%);

  @apply border border-[#EAEAEA] rounded-md p-4 mt-1 absolute bg-white z-10 w-full;
}
ul {
  @apply max-h-52 #{!important};
}
</style>
关键说明
  • handleBlur函数通过componentRoot.value.contains(relatedTarget)判断焦点是否仍在组件内部,只有当焦点完全离开组件时才关闭选项列表;
  • 给选项li添加tabindex="-1",确保点击选项时焦点会转移到li上,此时relatedTarget属于组件内部,不会触发列表关闭;
  • 输入框获取焦点时,relatedTarget是输入框,属于组件内部,因此不会关闭列表,符合预期行为。

内容的提问来源于stack exchange,提问作者Kevin Garcia

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.20 12:30:55