当然!下面我将为您提供一个功能完备的 Vue 对话框组件(CustomDialog.vue),该组件封装了原生的 <dialog> 标签,支持用户自定义样式,并解决了在不同浏览器中的兼容性问题。随后,我将为您提供详细的组件使用文档,帮助您快速集成和使用该组件。


完整的组件代码:CustomDialog.vue

<template>
  <transition name="dialog-fade">
    <dialog
      v-if="isOpen"
      ref="dialog"
      :class="['custom-dialog', customClass]"
      :role="role"
      aria-modal="true"
      :aria-labelledby="ariaLabelledby"
      :aria-describedby="ariaDescribedby"
      @cancel="handleCancel"
    >
      <button
        v-if="showCloseButton"
        class="close-btn"
        @click="closeDialog"
        aria-label="Close dialog"
      >
        &times;
      </button>
      <div class="dialog-content" ref="content">
        <slot></slot>
      </div>
    </dialog>
  </transition>
</template>

<script>
import 'dialog-polyfill/dist/dialog-polyfill.css';
import dialogPolyfill from 'dialog-polyfill';

export default {
  name: 'CustomDialog',
  props: {
    /** 
     * 控制对话框的显示与隐藏
     * @type {Boolean}
     * @default false
     */
    modelValue: {
      type: Boolean,
      default: false,
    },
    /**
     * 用户自定义的 CSS 类名
     * @type {String}
     * @default ''
     */
    customClass: {
      type: String,
      default: '',
    },
    /**
     * 是否为模态对话框
     * @type {Boolean}
     * @default true
     */
    modal: {
      type: Boolean,
      default: true,
    },
    /**
     * 是否显示关闭按钮
     * @type {Boolean}
     * @default true
     */
    showCloseButton: {
      type: Boolean,
      default: true,
    },
    /**
     * ARIA 标题元素的 ID
     * @type {String|null}
     * @default null
     */
    ariaLabelledby: {
      type: String,
      default: null,
    },
    /**
     * ARIA 描述元素的 ID
     * @type {String|null}
     * @default null
     */
    ariaDescribedby: {
      type: String,
      default: null,
    },
    /**
     * 对话框的角色
     * @type {String}
     * @default 'dialog'
     */
    role: {
      type: String,
      default: 'dialog',
    },
  },
  data() {
    return {
      isOpen: false,
      previousActiveElement: null,
      focusableElements: [],
      handleKeydown: null,
      handleClickOutside: null,
    };
  },
  watch: {
    modelValue: {
      immediate: true,
      handler(val) {
        if (val) {
          this.showDialog();
        } else {
          this.closeDialog();
        }
      },
    },
  },
  methods: {
    /**
     * 打开对话框
     */
    showDialog() {
      this.isOpen = true;
      this.$nextTick(() => {
        this.initDialog();
        this.handleFocus();
        this.lockScroll();
      });
    },
    /**
     * 关闭对话框
     */
    closeDialog() {
      this.isOpen = false;
      this.unlockScroll();
      this.$emit('update:modelValue', false);
      this.removeEventListeners();
      if (this.previousActiveElement) {
        this.previousActiveElement.focus();
      }
    },
    /**
     * 处理取消事件(例如按下 ESC 键)
     * @param {Event} event 
     */
    handleCancel(event) {
      event.preventDefault();
      this.closeDialog();
    },
    /**
     * 管理焦点
     */
    handleFocus() {
      this.previousActiveElement = document.activeElement;
      this.focusableElements = this.getFocusableElements();
      if (this.focusableElements.length) {
        this.focusableElements[0].focus();
      } else {
        this.$refs.dialog.focus();
      }
    },
    /**
     * 获取对话框内所有可聚焦的元素
     * @returns {Array<Element>}
     */
    getFocusableElements() {
      const selectors = [
        'a[href]',
        'area[href]',
        'input:not([disabled]):not([type="hidden"])',
        'select:not([disabled])',
        'textarea:not([disabled])',
        'button:not([disabled])',
        'iframe',
        'object',
        'embed',
        '[contenteditable]',
        '[tabindex]:not([tabindex="-1"])',
      ];
      return Array.from(
        this.$refs.dialog.querySelectorAll(selectors.join(','))
      ).filter(
        (el) =>
          !el.hasAttribute('disabled') &&
          !el.getAttribute('aria-hidden') &&
          el.offsetParent !== null
      );
    },
    /**
     * 初始化对话框
     */
    initDialog() {
      if (typeof this.$refs.dialog.showModal === 'function' && this.modal) {
        this.$refs.dialog.showModal();
      } else if (typeof this.$refs.dialog.show === 'function') {
        this.$refs.dialog.show();
      } else {
        this.$refs.dialog.setAttribute('open', '');
      }
      this.addEventListeners();
    },
    /**
     * 添加事件监听器
     */
    addEventListeners() {
      this.handleKeydown = this.handleKeydownEvent.bind(this);
      this.handleClickOutside = this.handleClickOutsideEvent.bind(this);
      this.$refs.dialog.addEventListener('keydown', this.handleKeydown);
      document.addEventListener('click', this.handleClickOutside);
    },
    /**
     * 移除事件监听器
     */
    removeEventListeners() {
      if (this.handleKeydown) {
        this.$refs.dialog.removeEventListener('keydown', this.handleKeydown);
      }
      if (this.handleClickOutside) {
        document.removeEventListener('click', this.handleClickOutside);
      }
    },
    /**
     * 处理键盘事件
     * @param {KeyboardEvent} e 
     */
    handleKeydownEvent(e) {
      if (e.key === 'Tab' || e.keyCode === 9) {
        this.trapFocus(e);
      } else if (e.key === 'Escape' || e.keyCode === 27) {
        this.closeDialog();
      }
    },
    /**
     * 焦点捕获,确保焦点在对话框内循环
     * @param {KeyboardEvent} e 
     */
    trapFocus(e) {
      const focusable = this.focusableElements;
      if (focusable.length === 0) {
        e.preventDefault();
        return;
      }
      const firstElement = focusable[0];
      const lastElement = focusable[focusable.length - 1];
      if (e.shiftKey) {
        if (document.activeElement === firstElement) {
          e.preventDefault();
          lastElement.focus();
        }
      } else {
        if (document.activeElement === lastElement) {
          e.preventDefault();
          firstElement.focus();
        }
      }
    },
    /**
     * 处理点击遮罩关闭对话框
     * @param {MouseEvent} e 
     */
    handleClickOutsideEvent(e) {
      if (!this.modal) return;
      const rect = this.$refs.dialog.getBoundingClientRect();
      if (
        e.clientX < rect.left ||
        e.clientX > rect.right ||
        e.clientY < rect.top ||
        e.clientY > rect.bottom
      ) {
        this.closeDialog();
      }
    },
    /**
     * 禁止背景滚动
     */
    lockScroll() {
      document.body.style.overflow = 'hidden';
    },
    /**
     * 解除背景滚动限制
     */
    unlockScroll() {
      document.body.style.overflow = '';
    },
  },
  mounted() {
    // 注册 dialog-polyfill,如果浏览器不支持 showModal
    if (!('showModal' in HTMLDialogElement.prototype)) {
      dialogPolyfill.registerDialog(this.$refs.dialog);
    }
  },
  beforeUnmount() {
    this.removeEventListeners();
    this.unlockScroll();
  },
};
</script>

<style scoped>
.custom-dialog {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  padding: 0;
  border: none;
  max-width: 90%;
  max-height: 90%;
  overflow: auto;
  z-index: 1000;
  background-color: var(--dialog-background, #fff);
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  border-radius: 8px;
}

.dialog-content {
  padding: 20px;
}

.close-btn {
  position: absolute;
  top: 10px;
  right: 10px;
  background: transparent;
  border: none;
  font-size: 1.5rem;
  cursor: pointer;
  line-height: 1;
}

.dialog-fade-enter-active,
.dialog-fade-leave-active {
  transition: opacity 0.3s;
}

.dialog-fade-enter-from,
.dialog-fade-leave-to {
  opacity: 0;
}
</style>

组件使用文档

目录

  1. 介绍
  2. 安装
  3. 快速开始
  4. 组件 API
  5. 高级用法
  6. 注意事项
  7. 示例

1. 介绍

CustomDialog 是一个基于 Vue 的对话框组件,封装了原生的 <dialog> 标签,提供了丰富的功能和高度的可定制性。该组件支持用户自定义内容和样式,兼容不支持原生 <dialog> 标签的浏览器,并解决了焦点管理、可访问性等一系列常见问题。

2. 安装

2.1 安装 dialog-polyfill

为了确保组件在所有浏览器中都能正常工作,我们需要安装 dialog-polyfill

使用 npm

npm install dialog-polyfill

使用 yarn

yarn add dialog-polyfill
2.2 引入组件

CustomDialog.vue 组件文件放入您的项目中,例如在 src/components 目录下。

3. 快速开始

以下是如何在 Vue 应用中快速集成和使用 CustomDialog 组件的示例。

3.1 注册组件

在需要使用对话框的父组件中引入并注册 CustomDialog

<template>
  <div>
    <button @click="isDialogOpen = true">打开对话框</button>
    <CustomDialog
      v-model="isDialogOpen"
      custom-class="my-dialog"
      aria-labelledby="dialogTitle"
      aria-describedby="dialogDesc"
    >
      <h2 id="dialogTitle">自定义对话框标题</h2>
      <p id="dialogDesc">这是对话框的内容。</p>
      <button @click="doSomething">执行操作</button>
    </CustomDialog>
  </div>
</template>

<script>
import CustomDialog from './components/CustomDialog.vue';

export default {
  components: { CustomDialog },
  data() {
    return {
      isDialogOpen: false,
    };
  },
  methods: {
    doSomething() {
      // 执行某些操作
      this.isDialogOpen = false;
    },
  },
};
</script>

<style>
.my-dialog {
  --dialog-background: #f0f0f0;
  border-radius: 10px;
}
</style>

4. 组件 API

Props
Prop类型默认值描述
modelValueBooleanfalse控制对话框的显示与隐藏。使用 v-model 进行双向绑定。
customClassString''用户自定义的 CSS 类名,用于自定义对话框样式。
modalBooleantrue是否为模态对话框。true 为模态,false 为非模态。
showCloseButtonBooleantrue是否显示关闭按钮。
ariaLabelledbyStringnullARIA 标题元素的 ID,用于辅助技术。
ariaDescribedbyStringnullARIA 描述元素的 ID,用于辅助技术。
roleString'dialog'对话框的角色,默认值为 'dialog'。可以根据需要自定义。
Events
事件名称描述回调参数
update:modelValue当对话框的显示状态改变时触发新的 Boolean
Slots
插槽名称描述
默认插槽用于插入自定义的对话框内容,如标题、正文、按钮等。

5. 高级用法

多层弹窗

CustomDialog 组件支持多层弹窗(嵌套对话框)。只需在一个对话框内再使用一个 CustomDialog 组件即可。

<template>
  <div>
    <button @click="isFirstDialogOpen = true">打开第一个对话框</button>
    
    <CustomDialog v-model="isFirstDialogOpen" custom-class="first-dialog">
      <h2 id="firstDialogTitle">第一个对话框</h2>
      <p>这是第一个对话框的内容。</p>
      <button @click="isSecondDialogOpen = true">打开第二个对话框</button>
      
      <CustomDialog v-model="isSecondDialogOpen" custom-class="second-dialog">
        <h2 id="secondDialogTitle">第二个对话框</h2>
        <p>这是第二个对话框的内容。</p>
      </CustomDialog>
    </CustomDialog>
  </div>
</template>

<script>
import CustomDialog from './components/CustomDialog.vue';

export default {
  components: { CustomDialog },
  data() {
    return {
      isFirstDialogOpen: false,
      isSecondDialogOpen: false,
    };
  },
};
</script>

<style>
.first-dialog {
  --dialog-background: #e0f7fa;
}

.second-dialog {
  --dialog-background: #ffe0b2;
}
</style>
自定义样式

通过 customClass 属性,您可以为对话框添加自定义样式。例如,修改背景颜色、边框、圆角等。

<template>
  <CustomDialog v-model="isDialogOpen" custom-class="fancy-dialog">
    <!-- 自定义内容 -->
  </CustomDialog>
</template>

<style>
.fancy-dialog {
  --dialog-background: #ffffff;
  border: 2px solid #3f51b5;
  border-radius: 12px;
}
</style>
ARIA 属性

为了增强可访问性,您可以使用 ariaLabelledbyariaDescribedby 属性,关联对话框的标题和描述。

<template>
  <CustomDialog
    v-model="isDialogOpen"
    aria-labelledby="dialogTitle"
    aria-describedby="dialogDescription"
  >
    <h2 id="dialogTitle">对话框标题</h2>
    <p id="dialogDescription">对话框的详细描述内容。</p>
  </CustomDialog>
</template>

6. 注意事项

  1. 浏览器兼容性:虽然我们已经引入了 dialog-polyfill 来支持不支持原生 <dialog> 的浏览器,但请确保在项目中正确安装和引入 dialog-polyfill 的 CSS 文件。

  2. 事件清理:组件在销毁时会自动移除事件监听器和解除滚动锁定,无需手动处理。

  3. 可访问性:请确保为 ariaLabelledbyariaDescribedby 提供正确的元素 ID,以增强辅助技术的支持。

  4. 焦点管理:确保对话框内至少有一个可聚焦元素(如按钮、链接等),以便焦点能够正确捕获和循环。

  5. 多层弹窗:在使用多层弹窗时,注意管理各个弹窗的 z-index 和焦点,避免焦点被错误地锁定在最外层弹窗。

  6. 样式覆盖:当自定义样式时,避免覆盖关键的样式变量(如 --dialog-background),以防止影响组件的核心功能。

7. 示例

7.1 基本用法
<template>
  <div>
    <button @click="isDialogOpen = true">打开对话框</button>
    <CustomDialog
      v-model="isDialogOpen"
      custom-class="basic-dialog"
      aria-labelledby="basicDialogTitle"
      aria-describedby="basicDialogDesc"
    >
      <h2 id="basicDialogTitle">基本对话框</h2>
      <p id="basicDialogDesc">这是一个基本的对话框示例。</p>
      <button @click="isDialogOpen = false">关闭</button>
    </CustomDialog>
  </div>
</template>

<script>
import CustomDialog from './components/CustomDialog.vue';

export default {
  components: { CustomDialog },
  data() {
    return {
      isDialogOpen: false,
    };
  },
};
</script>

<style>
.basic-dialog {
  --dialog-background: #fffbe6;
}
</style>
7.2 带有表单的对话框
<template>
  <div>
    <button @click="isFormDialogOpen = true">打开表单对话框</button>
    <CustomDialog
      v-model="isFormDialogOpen"
      custom-class="form-dialog"
      aria-labelledby="formDialogTitle"
      aria-describedby="formDialogDesc"
    >
      <h2 id="formDialogTitle">用户信息</h2>
      <p id="formDialogDesc">请输入您的用户信息。</p>
      <form @submit.prevent="submitForm">
        <div>
          <label for="username">用户名:</label>
          <input id="username" type="text" v-model="form.username" required />
        </div>
        <div>
          <label for="email">邮箱:</label>
          <input id="email" type="email" v-model="form.email" required />
        </div>
        <button type="submit">提交</button>
        <button type="button" @click="isFormDialogOpen = false">取消</button>
      </form>
    </CustomDialog>
  </div>
</template>

<script>
import CustomDialog from './components/CustomDialog.vue';

export default {
  components: { CustomDialog },
  data() {
    return {
      isFormDialogOpen: false,
      form: {
        username: '',
        email: '',
      },
    };
  },
  methods: {
    submitForm() {
      // 处理表单提交
      console.log('提交的表单数据:', this.form);
      this.isFormDialogOpen = false;
    },
  },
};
</script>

<style>
.form-dialog {
  --dialog-background: #e6f7ff;
}

.form-dialog form {
  display: flex;
  flex-direction: column;
}

.form-dialog form div {
  margin-bottom: 15px;
}

.form-dialog form label {
  margin-bottom: 5px;
}

.form-dialog form input {
  padding: 8px;
  border: 1px solid #ccc;
  border-radius: 4px;
}
</style>
7.3 嵌套弹窗
<template>
  <div>
    <button @click="isParentDialogOpen = true">打开父级对话框</button>
    
    <CustomDialog
      v-model="isParentDialogOpen"
      custom-class="parent-dialog"
      aria-labelledby="parentDialogTitle"
      aria-describedby="parentDialogDesc"
    >
      <h2 id="parentDialogTitle">父级对话框</h2>
      <p id="parentDialogDesc">这是父级对话框的内容。</p>
      <button @click="isChildDialogOpen = true">打开子级对话框</button>
      
      <CustomDialog
        v-model="isChildDialogOpen"
        custom-class="child-dialog"
        aria-labelledby="childDialogTitle"
        aria-describedby="childDialogDesc"
      >
        <h2 id="childDialogTitle">子级对话框</h2>
        <p id="childDialogDesc">这是子级对话框的内容。</p>
        <button @click="isChildDialogOpen = false">关闭子级</button>
      </CustomDialog>
    </CustomDialog>
  </div>
</template>

<script>
import CustomDialog from './components/CustomDialog.vue';

export default {
  components: { CustomDialog },
  data() {
    return {
      isParentDialogOpen: false,
      isChildDialogOpen: false,
    };
  },
};
</script>

<style>
.parent-dialog {
  --dialog-background: #f0f8ff;
}

.child-dialog {
  --dialog-background: #ffe4e1;
}
</style>

总结

通过上述 CustomDialog.vue 组件及其详细的使用文档,您可以轻松地在 Vue 项目中集成一个功能强大、可定制且兼容性良好的对话框组件。该组件不仅支持用户自定义内容和样式,还确保了在不同浏览器中的一致性和可访问性,极大地提升了用户体验。

关键特性

  • 可访问性:遵循 ARIA 标准,支持键盘导航和焦点管理。
  • 兼容性:集成 dialog-polyfill,确保在不支持原生 <dialog> 的浏览器中正常工作。
  • 高度可定制:通过 customClass 和 CSS 变量,自定义对话框的样式。
  • 多层弹窗支持:允许在对话框内嵌套更多对话框。
  • 模态与非模态:灵活控制对话框的模态行为。
  • 过渡动画:使用 Vue 的 <transition> 组件,实现平滑的打开和关闭动画。

希望这个组件和文档能帮助您在项目中快速实现和优化对话框功能!

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部