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

Vue3/Nuxt3复用HeadlessUI Dialog多实例触发FocusTrap报错

问题描述

在Vue3/Nuxt3应用中基于HeadlessUI开发了可复用的Dialog模态框组件,单独使用时功能正常,但在页面中引入多个实例后,点击打开模态框会触发错误:There are no focusable elements inside the <FocusTrap />。

组件代码(ExtensionModal.vue):

<template>
  <div class="flex-col items-center">
    <div class="mb-2">
      <button
        type="button"
        @click="openModal"
        class="block rounded-full text-blue-700 hover:text-white border border-blue-700 hover:bg-blue-800 focus:ring-0 focus:outline-none focus:ring-blue-300 font-medium text-sm px-5 py-2.5 text-center mr-2 mb-2 dark:border-blue-500 dark:text-blue-500 dark:hover:text-white dark:hover:bg-blue-600 dark:focus:ring-blue-800"
      >
        {{ buttonLabel }}
      </button>
    </div>
  </div>

  <TransitionRoot appear :show="showExtensionModal" as="template">
    <Dialog as="div" @close="closeModal" class="relative z-10">
      <TransitionChild
        as="template"
        enter="duration-300 ease-out"
        enter-from="opacity-0"
        enter-to="opacity-100"
        leave="duration-200 ease-in"
        leave-from="opacity-100"
        leave-to="opacity-0"
      >
        <div class="fixed inset-0 bg-black bg-opacity-25" />
      </TransitionChild>
      <div
        class="fixed h-fit inset-0 overflow-y-auto flex items-center justify-center"
      >
        <div
          class="fixed inset-0 overflow-y-auto flex items-center justify-center"
        >
          <TransitionChild
            as="template"
            enter="duration-300 ease-out"
            enter-from="opacity-0 scale-95"
            enter-to="opacity-100 scale-100"
            leave="duration-200 ease-in"
            leave-from="opacity-100 scale-100"
            leave-to="opacity-0 scale-95"
          >
            <DialogPanel
              class="flex-grow w-full h-fit transform overflow-visible rounded-2xl bg-gray-200 dark:bg-slate-800 p-6 text-left align-middle shadow-xl transition-all max-w-[90vw] sm:max-w-[80vw] md:max-w-[70vw] lg:max-w-[60vw] xl:max-w-[50vw] 2xl:max-w-[40vw]"
            >
              <DialogTitle
                as="h3"
                class="flex text-lg font-medium leading-6 text-gray-900 justify-center dark:text-white"
              >
                {{ buttonTitle }}
              </DialogTitle>

              <form @submit="onSubmit($event)">
                  <div class="flex mt-5">
                    <div class="w-full z-40">
                      <select
                        v-model="element"
                        class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
                      >
                        <option
                          v-for="option in extensionTypes"
                          :value="option.value"
                          :key="option.value"
                        >
                          {{ option.text }}
                        </option>
                      </select>
                    </div>
                  </div>

                <!-- Submit/Cancel button for the modal -->
                <FormSubmit @submit="onSubmit" @close="closeModal" />
              </form>
            </DialogPanel>
          </TransitionChild>
        </div>
      </div>
    </Dialog>
  </TransitionRoot>
</template>

<script setup>
import {
  TransitionRoot,
  TransitionChild,
  Dialog,
  DialogPanel,
  DialogTitle,
  Switch,
} from "@headlessui/vue";

const props = defineProps({
  buttonTitle: {
    type: String,
    required: false,
  },
  buttonLabel: {
    type: String,
    required: false,
  },
});

const emits = defineEmits(["onSubmit"]);

const extension = ref({});
const showExtensionModal = useState("showExtensionModal", () => false);
const extensionTypes = useState("extensionTypes", () => [
  { value: "extensionElement", text: "Element" },
]);
const dataTypes = useState("dataTypes", () => [
  { value: "string", text: "String" },
  { value: "complex", text: "Complex" },
]);

function openModal() {
  showExtensionModal.value = true;
}

function closeModal() {
  showExtensionModal.value = false;
}

function onItemChange(fieldName, value) {
  extension.value = {};
  extension.value[fieldName] = value.value;
}

function onSubmit(event) {
  event.preventDefault();
  console.log("Sumitted : " + JSON.stringify(extension.value, null, 4));
  emits("onSubmit", extension.value);
  closeModal();
}
</script>

<style>
</style>

页面中两次使用组件(pages/view.vue):

<template>
    <div>
  
      <div class="my-2 mx-2 px-2 pb-0">
        <div class="flex gap-2 h-16 float-right">
          <ExtensionModal
            :buttonTitle="$t('pages.modal.userExtension-btt-title')"
            :buttonLabel="$t('pages.modal.userExtension-btt-label')"
          />
  
          <ExtensionModal
            :buttonTitle="$t('pages.modal.ilmd-btt-title')"
            :buttonLabel="$t('pages.modal.ilmd-btt-label')"
          />

        </div>
      </div>
    </div>
  </template>
  
  <script setup>
  import { Icon } from "@iconify/vue";
  </script>
  
  <style>
  </style>

错误原因

核心问题是使用Nuxt的useState管理组件内部模态框状态。useState创建的是全局共享状态,所有组件实例共用同一个showExtensionModal变量。点击任意按钮打开模态框时,所有ExtensionModal的模态框都会被激活,多个Dialog同时存在导致HeadlessUI的FocusTrap焦点陷阱逻辑混乱,触发错误。

此外还有两个小问题:

  1. 模板中v-model="element"绑定的变量未在脚本中声明
  2. extensionTypes和dataTypes是组件固定数据,无需用全局状态useState

解决方案

1. 替换全局状态为组件内部状态

将useState改为Vue的ref,确保每个组件实例拥有独立状态:

// 替换原来的useState声明
const showExtensionModal = ref(false);
const extensionTypes = [
  { value: "extensionElement", text: "Element" },
];
const dataTypes = [
  { value: "string", text: "String" },
  { value: "complex", text: "Complex" },
];

2. 声明缺失的element变量

在脚本中添加响应式变量声明:

const element = ref('');

3. 同步表单数据到提交对象

在onSubmit中同步element的值到extension对象,确保提交数据完整:

function onSubmit(event) {
  event.preventDefault();
  extension.value.element = element.value;
  console.log("Sumitted : " + JSON.stringify(extension.value, null, 4));
  emits("onSubmit", extension.value);
  closeModal();
}

修改后的完整脚本部分

<script setup>
import { ref } from 'vue';
import {
  TransitionRoot,
  TransitionChild,
  Dialog,
  DialogPanel,
  DialogTitle,
  Switch,
} from "@headlessui/vue";

const props = defineProps({
  buttonTitle: {
    type: String,
    required: false,
  },
  buttonLabel: {
    type: String,
    required: false,
  },
});

const emits = defineEmits(["onSubmit"]);

const extension = ref({});
const showExtensionModal = ref(false);
const element = ref('');
const extensionTypes = [
  { value: "extensionElement", text: "Element" },
];
const dataTypes = [
  { value: "string", text: "String" },
  { value: "complex", text: "Complex" },
];

function openModal() {
  showExtensionModal.value = true;
}

function closeModal() {
  showExtensionModal.value = false;
}

function onItemChange(fieldName, value) {
  extension.value = {};
  extension.value[fieldName] = value.value;
}

function onSubmit(event) {
  event.preventDefault();
  extension.value.element = element.value;
  console.log("Sumitted : " + JSON.stringify(extension.value, null, 4));
  emits("onSubmit", extension.value);
  closeModal();
}
</script>

修改后每个ExtensionModal实例拥有独立的显示状态,打开一个模态框不会影响其他实例,FocusTrap可正常识别当前模态框内的可聚焦元素(select、提交按钮等),错误即可解决。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.17 06:22:05