找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1746

积分

0

好友

226

主题
发表于 13 小时前 | 查看: 4| 回复: 0

当屏幕变暗,焦点汇聚于一点,世界暂时隐去,只留下一段必须完成的对话。这不仅是技术实现,更是数字文明中建立边界与焦点的仪式艺术。

从1998年JavaScriptwindow.open()方法首次被滥用,到2004年浏览器厂商内置弹窗拦截器的集体反抗,模态弹窗(modal)这个曾经的“麻烦制造者”,已经完成了它的自我救赎。如今,它已成为现代Web应用中引导专注对话的核心交互方式。

这个看似简单的练习——显示和隐藏一个覆盖层——实则触及了人机交互的根本矛盾:如何在保持上下文的同时,引导用户完成特定任务? 模态弹窗提供了完美的答案:它尊重背景的存在,但要求对当前任务保持绝对专注。

display: nonedisplay: flex:屏幕的舞台剧

最基础的切换,构成了界面叙事的基本语法:

modal.style.display = 'flex'; // 幕启
modal.style.display = 'none'; // 幕落

这像剧场中的聚光灯,通过CSS属性的切换,在数字舞台上精确引导用户的注意力:

/* 聚光灯的三个层次 */
.modal {
  display: none; /* 幕后的准备 */
  position: fixed; /* 脱离文档流 */
  z-index: 1000; /* 垂直堆叠的制高点 */
  inset: 0; /* 占领整个舞台 */
  background-color: rgba(0, 0, 0, 0.5); /* 暗化背景 */
}

/* 当幕布拉开时 */
.modal.active {
  display: flex; /* 不仅仅是显示,而是布局 */
  animation: fadeIn 0.3s ease-out; /* 优雅登场 */
}

/* 舞台中央的主角 */
.modal-content {
  background: white;
  border-radius: 12px; /* 柔和的边缘 */
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); /* 立体感与分离感 */
  animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1); /* 精心编排的登场 */
}

现代前端框架将这种切换抽象为更语义化的状态管理。以React为例:

// React中的模态弹窗状态
const [isModalOpen, setIsModalOpen] = useState(false);

return (
  <>
    <button onClick={() => setIsModalOpen(true)}>打开弹窗</button>
    {isModalOpen && (
      <Modal onClose={() => setIsModalOpen(false)}>
        <p>这是一个模态弹窗!</p>
      </Modal>
    )}
  </>
);

模态(Modal)的本质:数字世界的强制对话

“模态”一词意味着它建立了一个模态上下文——用户必须完成此上下文内的特定交互才能继续。这背后是一套复杂的管理机制:

class ModalContext {
  constructor() {
    this.stack = []; // 弹窗堆栈,支持嵌套弹窗
    this.focusTraps = new Map(); // 焦点陷阱集合
    this.scrollLocks = new Set(); // 滚动锁定集合
  }

  // 打开弹窗时创建模态上下文
  openModal(modalElement) {
    // 1. 将当前焦点状态压栈
    const previousFocus = document.activeElement;
    this.stack.push({
      modal: modalElement,
      previousFocus,
      scrollPosition: window.scrollY
    });

    // 2. 锁定背景交互
    this.lockBackground();

    // 3. 建立焦点陷阱
    this.setupFocusTrap(modalElement);

    // 4. 宣布模态上下文的开始(对屏幕阅读器)
    this.announceModalStart(modalElement);
  }

  // 创建真正的模态屏障
  lockBackground() {
    // 禁用背景滚动
    document.body.style.overflow = 'hidden';
    document.documentElement.style.overflow = 'hidden';

    // 为背景添加inert属性(如果支持)
    const mainContent = document.querySelector('main');
    if (mainContent && 'inert' in mainContent) {
      mainContent.inert = true;
    }

    // 捕获背景点击(可选,根据设计需求)
    document.addEventListener('click', this.handleBackgroundClick, true);
  }

  setupFocusTrap(modalElement) {
    const focusableElements = modalElement.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );

    if (focusableElements.length === 0) {
      // 如果没有可聚焦元素,聚焦弹窗本身
      modalElement.setAttribute('tabindex', '-1');
      modalElement.focus();
    } else {
      // 聚焦第一个可聚焦元素
      focusableElements[0].focus();
    }

    // 键盘Tab键焦点陷阱
    const firstFocusable = focusableElements[0];
    const lastFocusable = focusableElements[focusableElements.length - 1];

    const trapHandler = (event) => {
      if (event.key !== 'Tab') return;

      if (event.shiftKey) {
        // Shift + Tab
        if (document.activeElement === firstFocusable) {
          event.preventDefault();
          lastFocusable.focus();
        }
      } else {
        // Tab
        if (document.activeElement === lastFocusable) {
          event.preventDefault();
          firstFocusable.focus();
        }
      }
    };

    modalElement.addEventListener('keydown', trapHandler);
    this.focusTraps.set(modalElement, trapHandler);
  }
}

可访问性深度设计:为所有人关闭弹窗

模态弹窗对可访问性(A11y)的要求极高。一个不完整的实现可能完全阻断屏幕阅读器用户的操作。完整的可访问结构如下:

<!-- 完整的可访问模态弹窗结构 -->
<div
  id="dialog1"
  class="modal"
  role="dialog"
  aria-modal="true"
  aria-labelledby="dialog1-title"
  aria-describedby="dialog1-description"
  hidden
>
  <div class="modal-dialog">
    <!-- 标题区域 -->
    <div class="modal-header">
      <h2 id="dialog1-title" class="modal-title">
        确认删除
      </h2>
      <button
        type="button"
        class="modal-close"
        aria-label="关闭对话框"
        data-dismiss="modal"
      >
        <span aria-hidden="true">×</span>
      </button>
    </div>

    <!-- 内容区域 -->
    <div class="modal-body">
      <p id="dialog1-description">
        您确定要删除这个项目吗?此操作不可撤销。
      </p>
    </div>

    <!-- 操作区域 -->
    <div class="modal-footer">
      <button
        type="button"
        class="btn btn-secondary"
        data-dismiss="modal"
        autofocus
      >
        取消
      </button>
      <button
        type="button"
        class="btn btn-danger"
        id="confirm-delete"
      >
        确认删除
      </button>
    </div>
  </div>
</div>

<!-- 对话框触发按钮 -->
<button
  type="button"
  class="btn btn-danger"
  aria-haspopup="dialog"
  aria-controls="dialog1"
  id="open-dialog1"
>
  删除项目
</button>

相应的JavaScript管理类需要处理焦点、滚动、屏幕阅读器公告等细节:

class AccessibleModal {
  constructor(modalElement, options = {}) {
    this.modal = modalElement;
    this.options = {
      backdrop: true,
      keyboard: true,
      focus: true,
      ...options
    };

    // 查找触发元素
    this.trigger = document.querySelector(
      `[aria-controls="${modalElement.id}"]`
    );

    this.previousFocus = null;
    this.isShown = false;

    this.init();
  }

  init() {
    // 绑定关闭按钮
    const closeButtons = this.modal.querySelectorAll('[data-dismiss="modal"]');
    closeButtons.forEach(btn => {
      btn.addEventListener('click', () => this.hide());
    });

    // 背景点击关闭
    if (this.options.backdrop) {
      this.modal.addEventListener('click', (event) => {
        if (event.target === this.modal) {
          this.hide();
        }
      });
    }

    // ESC键关闭
    if (this.options.keyboard) {
      document.addEventListener('keydown', (event) => {
        if (event.key === 'Escape' && this.isShown) {
          this.hide();
        }
      });
    }
  }

  show() {
    if (this.isShown) return;

    // 保存当前焦点
    this.previousFocus = document.activeElement;

    // 显示模态弹窗
    this.modal.hidden = false;
    this.modal.removeAttribute('aria-hidden');

    // 为屏幕阅读器隐藏背景内容
    this.setBackgroundInert(true);

    // 锁定滚动
    this.lockScroll();

    // 设置焦点
    if (this.options.focus) {
      const focusTarget = this.modal.querySelector('[autofocus]') ||
                         this.modal.querySelector('.modal-content') ||
                         this.modal;
      focusTarget.focus();
    }

    // 宣布弹窗打开
    this.announceModalOpen();

    this.isShown = true;
  }

  hide() {
    if (!this.isShown) return;

    // 隐藏模态弹窗
    this.modal.hidden = true;
    this.modal.setAttribute('aria-hidden', 'true');

    // 恢复背景内容的可访问性
    this.setBackgroundInert(false);

    // 恢复滚动
    this.unlockScroll();

    // 恢复焦点到触发元素
    if (this.previousFocus && this.previousFocus.focus) {
      this.previousFocus.focus();
    }

    // 宣布弹窗关闭
    this.announceModalClose();

    this.isShown = false;
  }

  setBackgroundInert(inert) {
    // 为除模态弹窗外的所有内容设置inert
    const allElements = Array.from(document.body.children);

    allElements.forEach(element => {
      if (element !== this.modal && 'inert' in element) {
        element.inert = inert;
      }
    });

    // 对于不支持inert的浏览器,使用aria-hidden作为降级方案
    if (!('inert' in document.createElement('div'))) {
      const siblings = allElements.filter(el => el !== this.modal);
      siblings.forEach(el => {
        if (inert) {
          el.setAttribute('aria-hidden', 'true');
        } else {
          el.removeAttribute('aria-hidden');
        }
      });
    }
  }

  lockScroll() {
    // 保存当前滚动位置
    this.savedScrollPosition = window.scrollY;

    // 锁定body滚动
    document.body.style.top = `-${this.savedScrollPosition}px`;
    document.body.style.position = 'fixed';
    document.body.style.width = '100%';
    document.body.style.overflow = 'hidden';
  }

  unlockScroll() {
    // 恢复body样式
    document.body.style.position = '';
    document.body.style.top = '';
    document.body.style.width = '';
    document.body.style.overflow = '';

    // 恢复滚动位置
    window.scrollTo(0, this.savedScrollPosition);
  }

  announceModalOpen() {
    // 创建屏幕阅读器专用公告
    const announcement = document.createElement('div');
    announcement.setAttribute('role', 'status');
    announcement.setAttribute('aria-live', 'assertive');
    announcement.setAttribute('aria-atomic', 'true');
    announcement.className = 'sr-only';

    const title = this.modal.querySelector('.modal-title');
    const titleText = title ? title.textContent : '对话框';

    announcement.textContent = `${titleText} 已打开。按ESC键或使用关闭按钮退出。`;

    document.body.appendChild(announcement);

    // 短暂存在后移除
    setTimeout(() => {
      announcement.remove();
    }, 1000);
  }
}

动画进阶:从简单显示到电影级转场

现代模态弹窗的动画是精心编排的视觉叙事,可以运用Web Animations API获得更精细的控制:

class AnimatedModal {
  constructor(modalElement) {
    this.modal = modalElement;
    this.backdrop = modalElement.querySelector('.modal-backdrop');
    this.dialog = modalElement.querySelector('.modal-dialog');
    this.isAnimating = false;
    // 检测动画支持
    this.supportsAnimations = 'animate' in document.documentElement;
    this.init();
  }

  init() {
    // 监听动画结束事件
    this.dialog.addEventListener('animationend', this.handleAnimationEnd.bind(this));
    this.backdrop.addEventListener('animationend', this.handleAnimationEnd.bind(this));
  }

  async show() {
    if (this.isAnimating) return;
    this.isAnimating = true;
    // 移除隐藏状态
    this.modal.hidden = false;
    this.modal.style.display = 'flex';
    // 重置动画类
    this.backdrop.classList.remove('exiting');
    this.dialog.classList.remove('exiting');
    // 应用进入动画
    this.backdrop.classList.add('entering');
    this.dialog.classList.add('entering');
    // 使用Web Animations API获得更多控制
    if (this.supportsAnimations) {
      await this.showWithWebAnimations();
    }
    // 动画结束后清理
    this.backdrop.classList.remove('entering');
    this.dialog.classList.remove('entering');
    this.isAnimating = false;
  }

  async showWithWebAnimations() {
    const backdropAnimation = this.backdrop.animate([
      { opacity: 0 },
      { opacity: 1 }
    ], {
      duration: 300,
      easing: 'cubic-bezier(0.16, 1, 0.3, 1)'
    });
    const dialogAnimation = this.dialog.animate([
      {
        opacity: 0,
        transform: 'translateY(20px) scale(0.95)'
      },
      {
        opacity: 1,
        transform: 'translateY(0) scale(1)'
      }
    ], {
      duration: 400,
      easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
      delay: 50 // 稍微延迟,创建错落感
    });
    // 等待两个动画都完成
    await Promise.all([
      backdropAnimation.finished,
      dialogAnimation.finished
    ]);
  }

  async hide() {
    if (this.isAnimating) return;
    this.isAnimating = true;
    // 应用退出动画
    this.backdrop.classList.add('exiting');
    this.dialog.classList.add('exiting');
    if (this.supportsAnimations) {
      await this.hideWithWebAnimations();
    }
    // 动画完成后隐藏元素
    this.modal.hidden = true;
    this.modal.style.display = 'none';
    // 清理动画类
    this.backdrop.classList.remove('exiting');
    this.dialog.classList.remove('exiting');
    this.isAnimating = false;
  }

  async hideWithWebAnimations() {
    const backdropAnimation = this.backdrop.animate([
      { opacity: 1 },
      { opacity: 0 }
    ], {
      duration: 250,
      easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
      fill: 'forwards'
    });
    const dialogAnimation = this.dialog.animate([
      {
        opacity: 1,
        transform: 'translateY(0) scale(1)'
      },
      {
        opacity: 0,
        transform: 'translateY(-20px) scale(0.95)'
      }
    ], {
      duration: 300,
      easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
      fill: 'forwards'
    });
    // 等待退出动画完成
    await Promise.all([
      backdropAnimation.finished,
      dialogAnimation.finished
    ]);
  }

  handleAnimationEnd(event) {
    // 防止事件冒泡
    event.stopPropagation();
  }

  // 基于触发位置的方向性动画
  showFromElement(triggerElement) {
    if (!triggerElement) return this.show();
    const triggerRect = triggerElement.getBoundingClientRect();
    const viewportCenterX = window.innerWidth / 2;
    const viewportCenterY = window.innerHeight / 2;
    // 根据触发位置决定动画方向
    if (triggerRect.left < viewportCenterX) {
      this.dialog.classList.add('slide-from-right');
    } else {
      this.dialog.classList.add('slide-from-left');
    }
    if (triggerRect.top < viewportCenterY) {
      this.dialog.style.setProperty('--slide-distance', `${triggerRect.bottom}px`);
    } else {
      this.dialog.style.setProperty('--slide-distance', `${window.innerHeight - triggerRect.top}px`);
    }
    return this.show();
  }
}

弹窗管理系统:复杂应用的多层对话

在真实世界的复杂应用中,我们需要一个中央管理系统来处理多个弹窗的堆叠、焦点恢复和状态同步:

class ModalManager {
  constructor() {
    this.modals = new Map(); // id -> modal实例
    this.stack = []; // 打开的弹窗堆栈
    this.history = []; // 弹窗打开历史(用于分析)
    this.config = {
      maxStackSize: 3, // 最大弹窗堆叠数
      autoCloseOnNavigate: true, // 导航时自动关闭
      backdropBlur: true, // 背景模糊效果
      dimBackground: 0.5 // 背景暗化程度
    };
    this.init();
  }

  init() {
    // 监听导航事件
    if (this.config.autoCloseOnNavigate) {
      window.addEventListener('popstate', () => this.closeAll());
      window.addEventListener('hashchange', () => this.closeAll());
    }
    // 监听网络状态变化
    window.addEventListener('online', () => this.handleNetworkChange(true));
    window.addEventListener('offline', () => this.handleNetworkChange(false));
    // 监听系统主题变化
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
    prefersDark.addEventListener('change', (e) => this.updateTheme(e.matches));
  }

  register(modalElement, options = {}) {
    const modal = new ManagedModal(modalElement, options);
    this.modals.set(modal.id, modal);
    // 监听打开事件
    modal.on('open', () => this.handleModalOpen(modal));
    modal.on('close', () => this.handleModalClose(modal));
    return modal;
  }

  open(modalId, data = {}) {
    const modal = this.modals.get(modalId);
    if (!modal) {
      console.warn(`Modal ${modalId} not found`);
      return null;
    }
    // 检查堆栈限制
    if (this.stack.length >= this.config.maxStackSize) {
      // 关闭最早打开的弹窗
      const oldestModal = this.stack.shift();
      oldestModal.close();
    }
    // 传递数据
    modal.setData(data);
    // 打开弹窗
    modal.open();
    return modal;
  }

  handleModalOpen(modal) {
    // 添加到堆栈
    this.stack.push(modal);
    // 记录历史
    this.history.push({
      id: modal.id,
      timestamp: Date.now(),
      data: modal.getData()
    });
    // 限制历史记录长度
    if (this.history.length > 100) {
      this.history.shift();
    }
    // 更新背景效果
    this.updateBackdrop();
    // 派发全局事件
    document.dispatchEvent(new CustomEvent('modal:open', {
      detail: { modal, stack: this.stack }
    }));
  }

  handleModalClose(modal) {
    // 从堆栈中移除
    const index = this.stack.indexOf(modal);
    if (index > -1) {
      this.stack.splice(index, 1);
    }
    // 更新背景效果
    this.updateBackdrop();
    // 派发全局事件
    document.dispatchEvent(new CustomEvent('modal:close', {
      detail: { modal, stack: this.stack }
    }));
  }

  updateBackdrop() {
    const backdropLayer = document.getElementById('modal-backdrop-layer') ||
                         this.createBackdropLayer();
    if (this.stack.length === 0) {
      backdropLayer.style.display = 'none';
      return;
    }
    backdropLayer.style.display = 'block';
    // 根据堆叠深度调整效果
    const depth = this.stack.length;
    const blurAmount = Math.min(20, depth * 5);
    const dimAmount = Math.min(0.8, this.config.dimBackground * (0.7 + depth * 0.1));
    backdropLayer.style.backdropFilter = this.config.backdropBlur
      ? `blur(${blurAmount}px)`
      : 'none';
    backdropLayer.style.backgroundColor = `rgba(0, 0, 0, ${dimAmount})`;
  }

  createBackdropLayer() {
    const backdrop = document.createElement('div');
    backdrop.id = 'modal-backdrop-layer';
    backdrop.style.cssText = `
      position: fixed;
      inset: 0;
      z-index: 999;
      pointer-events: none;
      transition: all 0.3s ease;
      display: none;
    `;
    document.body.appendChild(backdrop);
    return backdrop;
  }

  closeAll() {
    // 从后往前关闭,避免焦点问题
    const modalsToClose = [...this.stack].reverse();
    modalsToClose.forEach(modal => modal.close());
    this.stack.length = 0;
  }

  closeTop() {
    if (this.stack.length > 0) {
      const topModal = this.stack[this.stack.length - 1];
      topModal.close();
    }
  }

  // 根据条件批量关闭
  closeByCondition(condition) {
    this.stack.filter(condition).forEach(modal => modal.close());
  }

  // 获取当前弹窗状态
  getState() {
    return {
      openModals: this.stack.map(m => m.id),
      totalOpened: this.history.length,
      currentDepth: this.stack.length
    };
  }
}

class ManagedModal {
  constructor(element, options) {
    this.element = element;
    this.id = element.id || `modal-${Date.now()}`;
    this.options = {
      closeOnBackdropClick: true,
      closeOnEsc: true,
      restoreFocus: true,
      autoFocus: true,
      ...options
    };
    this.isOpen = false;
    this.previousFocus = null;
    this.data = {};
    this.eventListeners = new Map();
    this.init();
  }

  init() {
    // 确保有正确的role属性
    if (!this.element.hasAttribute('role')) {
      this.element.setAttribute('role', 'dialog');
    }
    // 设置初始状态
    this.element.hidden = true;
    this.element.setAttribute('aria-hidden', 'true');
    // 查找关闭按钮
    this.closeButtons = this.element.querySelectorAll('[data-modal-close]');
    this.closeButtons.forEach(btn => {
      btn.addEventListener('click', () => this.close());
    });
    // 背景点击关闭
    if (this.options.closeOnBackdropClick) {
      this.element.addEventListener('click', (event) => {
        if (event.target === this.element) {
          this.close();
        }
      });
    }
    // ESC键关闭
    if (this.options.closeOnEsc) {
      this.escHandler = (event) => {
        if (event.key === 'Escape' && this.isOpen) {
          this.close();
        }
      };
      document.addEventListener('keydown', this.escHandler);
    }
  }

  open() {
    if (this.isOpen) return;
    // 保存当前焦点
    this.previousFocus = document.activeElement;
    // 显示弹窗
    this.element.hidden = false;
    this.element.removeAttribute('aria-hidden');
    this.element.style.display = 'flex';
    // 设置焦点
    if (this.options.autoFocus) {
      const focusTarget = this.element.querySelector('[autofocus]') ||
                         this.element.querySelector('button, [href], input, select, textarea') ||
                         this.element;
      focusTarget.focus();
    }
    this.isOpen = true;
    this.emit('open', this);
  }

  close() {
    if (!this.isOpen) return;
    // 隐藏弹窗
    this.element.hidden = true;
    this.element.setAttribute('aria-hidden', 'true');
    this.element.style.display = 'none';
    // 恢复焦点
    if (this.options.restoreFocus && this.previousFocus) {
      this.previousFocus.focus();
    }
    this.isOpen = false;
    this.emit('close', this);
  }

  setData(data) {
    this.data = { ...this.data, ...data };
  }

  getData() {
    return { ...this.data };
  }

  // 简单的事件系统
  on(event, handler) {
    if (!this.eventListeners.has(event)) {
      this.eventListeners.set(event, []);
    }
    this.eventListeners.get(event).push(handler);
  }

  emit(event, data) {
    const listeners = this.eventListeners.get(event);
    if (listeners) {
      listeners.forEach(handler => handler(data));
    }
  }

  destroy() {
    // 清理事件监听器
    document.removeEventListener('keydown', this.escHandler);
    this.closeButtons.forEach(btn => {
      btn.removeEventListener('click', () => this.close());
    });
  }
}

现代框架实现:React Portal与状态管理

在React生态中,我们可以利用Portal、Hooks和动画库构建一个功能完整且优雅的模态弹窗组件:

import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useId, useTransition, animated } from '@react-spring/web';

// 自定义Hook:焦点陷阱
const useFocusTrap = (isActive) => {
  const trapRef = useRef(null);
  useEffect(() => {
    if (!isActive || !trapRef.current) return;
    const focusableElements = trapRef.current.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    if (focusableElements.length === 0) return;
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];
    const handleKeyDown = (event) => {
      if (event.key !== 'Tab') return;
      if (event.shiftKey) {
        // Shift + Tab
        if (document.activeElement === firstElement) {
          event.preventDefault();
          lastElement.focus();
        }
      } else {
        // Tab
        if (document.activeElement === lastElement) {
          event.preventDefault();
          firstElement.focus();
        }
      }
    };
    trapRef.current.addEventListener('keydown', handleKeyDown);
    return () => {
      if (trapRef.current) {
        trapRef.current.removeEventListener('keydown', handleKeyDown);
      }
    };
  }, [isActive]);
  return trapRef;
};

// 自定义Hook:滚动锁定
const useScrollLock = (shouldLock) => {
  useEffect(() => {
    if (!shouldLock) return;
    const originalOverflow = document.body.style.overflow;
    const originalPaddingRight = document.body.style.paddingRight;
    // 计算滚动条宽度
    const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
    document.body.style.overflow = 'hidden';
    if (scrollbarWidth > 0) {
      document.body.style.paddingRight = `${scrollbarWidth}px`;
    }
    return () => {
      document.body.style.overflow = originalOverflow;
      document.body.style.paddingRight = originalPaddingRight;
    };
  }, [shouldLock]);
};

// 模态弹窗组件
const Modal = React.forwardRef(({
  isOpen,
  onClose,
  children,
  title,
  size = 'md',
  animation = 'fade',
  closeOnBackdropClick = true,
  closeOnEsc = true,
  showCloseButton = true,
  className = '',
  style,
  ...props
}, ref) => {
  const modalId = useId();
  const titleId = `${modalId}-title`;
  const descriptionId = `${modalId}-description`;
  const modalRef = useRef(null);
  const previousFocusRef = useRef(null);
  const trapRef = useFocusTrap(isOpen);
  // 锁定滚动
  useScrollLock(isOpen);
  // 保存之前的焦点
  useEffect(() => {
    if (isOpen) {
      previousFocusRef.current = document.activeElement;
    } else if (previousFocusRef.current) {
      previousFocusRef.current.focus();
    }
  }, [isOpen]);
  // ESC键关闭
  useEffect(() => {
    if (!closeOnEsc || !isOpen) return;
    const handleKeyDown = (event) => {
      if (event.key === 'Escape') {
        onClose?.();
      }
    };
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen, closeOnEsc, onClose]);
  // 点击背景关闭
  const handleBackdropClick = useCallback((event) => {
    if (closeOnBackdropClick && event.target === modalRef.current) {
      onClose?.();
    }
  }, [closeOnBackdropClick, onClose]);
  // 动画
  const transitions = useTransition(isOpen, {
    from: {
      opacity: 0,
      scale: 0.95,
      y: 20
    },
    enter: {
      opacity: 1,
      scale: 1,
      y: 0
    },
    leave: {
      opacity: 0,
      scale: 0.95,
      y: -20
    },
    config: {
      tension: 300,
      friction: 25
    }
  });
  const sizeClasses = useMemo(() => {
    const sizes = {
      sm: 'max-w-sm',
      md: 'max-w-md',
      lg: 'max-w-lg',
      xl: 'max-w-xl',
      '2xl': 'max-w-2xl',
      full: 'max-w-full mx-4'
    };
    return sizes[size] || sizes.md;
  }, [size]);
  if (!isOpen) return null;
  return createPortal(
    transitions((styleProps, item) => item && (
      <animated.div
        ref={mergeRefs(modalRef, ref)}
        id={modalId}
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        aria-describedby={descriptionId}
        tabIndex={-1}
        className={`fixed inset-0 z-50 overflow-y-auto ${className}`}
        onClick={handleBackdropClick}
        style={style}
        {...props}
      >
        <animated.div
          className="fixed inset-0 bg-black/50"
          style={{ opacity: styleProps.opacity }}
          aria-hidden="true"
        />
        <div className="flex min-h-full items-center justify-center p-4">
          <animated.div
            ref={trapRef}
            className={`relative bg-white rounded-lg shadow-xl ${sizeClasses} w-full`}
            style={{
              opacity: styleProps.opacity,
              transform: styleProps.scale.to(s => `scale(${s}) translateY(${styleProps.y}px)`)
            }}
          >
            {/* 头部 */}
            <div className="flex items-center justify-between p-6 border-b">
              {title && (
                <h3 id={titleId} className="text-lg font-semibold">
                  {title}
                </h3>
              )}
              {showCloseButton && (
                <button
                  type="button"
                  className="text-gray-400 hover:text-gray-600 transition-colors"
                  onClick={onClose}
                  aria-label="关闭弹窗"
                >
                  <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
                  </svg>
                </button>
              )}
            </div>
            {/* 内容 */}
            <div id={descriptionId} className="p-6">
              {children}
            </div>
            {/* 底部(可选) */}
            {(props.footer || props.actions) && (
              <div className="p-6 border-t bg-gray-50 rounded-b-lg">
                {props.footer || props.actions}
              </div>
            )}
          </animated.div>
        </div>
      </animated.div>
    )),
    document.body
  );
});
Modal.displayName = 'Modal';

// 辅助函数:合并refs
const mergeRefs = (...refs) => {
  return (value) => {
    refs.forEach((ref) => {
      if (typeof ref === 'function') {
        ref(value);
      } else if (ref != null) {
        ref.current = value;
      }
    });
  };
};

// 使用示例
const App = () => {
  const [isModalOpen, setIsModalOpen] = useState(false);
  return (
    <div>
      <button
        onClick={() => setIsModalOpen(true)}
        className="px-4 py-2 bg-blue-600 text-white rounded"
      >
        打开弹窗
      </button>
      <Modal
        isOpen={isModalOpen}
        onClose={() => setIsModalOpen(false)}
        title="示例弹窗"
        size="lg"
      >
        <p className="mb-4">这是一个现代化的React模态弹窗。</p>
        <p>它支持动画、无障碍访问和响应式设计。</p>
        <div className="mt-6 flex justify-end space-x-3">
          <button
            onClick={() => setIsModalOpen(false)}
            className="px-4 py-2 border rounded"
          >
            取消
          </button>
          <button
            onClick={() => {
              // 处理确认操作
              setIsModalOpen(false);
            }}
            className="px-4 py-2 bg-blue-600 text-white rounded"
          >
            确认
          </button>
        </div>
      </Modal>
    </div>
  );
};

未来趋势:智能弹窗与上下文感知

未来的模态弹窗将更加智能化,能够基于用户上下文(设备、网络、时间、位置、历史行为)自适应调整其外观、行为和内容。

class IntelligentModal {
  constructor() {
    this.aiEndpoint = 'https://api.example.com/ai/modal';
    this.context = {
      user: null,
      device: null,
      environment: null,
      behavior: null
    };
    this.init();
  }

  init() {
    this.collectContext();
    this.setupAdaptiveBehavior();
  }

  collectContext() {
    // 收集用户上下文
    this.context = {
      user: {
        preferences: this.getUserPreferences(),
        history: this.getUserHistory(),
        accessibility: this.getAccessibilitySettings()
      },
      device: {
        type: this.getDeviceType(),
        connection: navigator.connection?.effectiveType,
        capabilities: this.getDeviceCapabilities()
      },
      environment: {
        timeOfDay: this.getTimeOfDay(),
        location: this.getLocationContext(),
        noiseLevel: this.getNoiseLevel()
      },
      behavior: {
        previousInteractions: this.getInteractionHistory(),
        currentTask: this.inferCurrentTask()
      }
    };
  }

  async showModal(modalConfig, triggerElement) {
    // 基于上下文调整弹窗行为
    const adaptedConfig = await this.adaptModalToContext(modalConfig);
    // 智能放置弹窗
    const position = this.calculateOptimalPosition(triggerElement);
    // 智能动画选择
    const animation = this.selectAppropriateAnimation();
    // 智能内容调整
    const content = await this.adaptContentToUser();
    // 显示弹窗
    const modal = new AdaptiveModal({
      ...adaptedConfig,
      position,
      animation,
      content
    });
    modal.show();
    // 监控交互模式
    this.monitorInteraction(modal);
    return modal;
  }

  async adaptModalToContext(config) {
    // 根据上下文调整弹窗配置
    const adapted = { ...config };
    // 网络条件差时简化弹窗
    if (this.context.device.connection === 'slow-2g') {
      adapted.animation = 'none';
      adapted.removeImages = true;
      adapted.simplifyContent = true;
    }
    // 根据可访问性需求调整
    if (this.context.user.accessibility.prefersReducedMotion) {
      adapted.animation = 'none';
    }
    if (this.context.user.accessibility.contrast === 'high') {
      adapted.theme = 'high-contrast';
    }
    // 根据时间调整主题
    if (this.context.environment.timeOfDay === 'night') {
      adapted.theme = 'dark';
    }
    // 根据用户历史个性化
    const aiResponse = await this.getAIRecommendations(config);
    if (aiResponse.suggestions) {
      adapted.content = this.mergeWithAI(adapted.content, aiResponse.suggestions);
    }
    return adapted;
  }

  calculateOptimalPosition(triggerElement) {
    if (!triggerElement) return 'center';
    const viewport = {
      width: window.innerWidth,
      height: window.innerHeight
    };
    const triggerRect = triggerElement.getBoundingClientRect();
    // 计算弹窗在视口中的最佳位置
    const availableSpace = {
      top: triggerRect.top,
      right: viewport.width - triggerRect.right,
      bottom: viewport.height - triggerRect.bottom,
      left: triggerRect.left
    };
    // 选择最大可用空间的方向
    const maxDirection = Object.keys(availableSpace).reduce((a, b) =>
      availableSpace[a] > availableSpace[b] ? a : b
    );
    return this.directionToPosition(maxDirection, triggerRect, viewport);
  }

  directionToPosition(direction, triggerRect, viewport) {
    switch (direction) {
      case 'top':
        return {
          top: Math.max(20, triggerRect.top - 10),
          left: triggerRect.left + triggerRect.width / 2,
          transformOrigin: 'bottom center'
        };
      case 'bottom':
        return {
          top: triggerRect.bottom + 10,
          left: triggerRect.left + triggerRect.width / 2,
          transformOrigin: 'top center'
        };
      case 'left':
        return {
          top: triggerRect.top + triggerRect.height / 2,
          left: Math.max(20, triggerRect.left - 10),
          transformOrigin: 'right center'
        };
      case 'right':
        return {
          top: triggerRect.top + triggerRect.height / 2,
          left: Math.min(viewport.width - 20, triggerRect.right + 10),
          transformOrigin: 'left center'
        };
      default:
        return 'center';
    }
  }

  selectAppropriateAnimation() {
    const { user, device, environment } = this.context;
    // 根据上下文选择动画
    if (user.accessibility.prefersReducedMotion) {
      return 'none';
    }
    if (device.connection === 'slow-2g') {
      return 'fade';
    }
    if (environment.timeOfDay === 'night') {
      return 'subtle'; // 夜间更柔和的动画
    }
    // 根据设备性能选择动画复杂度
    if (this.isLowPerformanceDevice()) {
      return 'simple';
    }
    return 'standard';
  }

  monitorInteraction(modal) {
    // 监控用户与弹窗的交互
    const startTime = Date.now();
    let interactions = [];
    modal.on('interaction', (type, data) => {
      interactions.push({
        type,
        data,
        timestamp: Date.now() - startTime
      });
    });
    modal.on('close', () => {
      // 分析交互模式
      const analysis = this.analyzeInteractions(interactions);
      // 学习用户偏好
      this.learnFromInteraction(modal.config, analysis);
      // 如果用户有困难,提供帮助
      if (analysis.suggestsHelp) {
        this.offerHelp(modal.config.type);
      }
    });
  }

  analyzeInteractions(interactions) {
    // 分析用户交互模式
    const analysis = {
      completionTime: interactions.length > 0
        ? interactions[interactions.length - 1].timestamp
        : 0,
      hesitationCount: interactions.filter(i =>
        i.type === 'focus' && i.data.duration > 1000
      ).length,
      errorCount: interactions.filter(i =>
        i.type === 'validation_error'
      ).length,
      suggestsHelp: false
    };
    // 判断是否需要帮助
    if (analysis.hesitationCount > 3 || analysis.errorCount > 2) {
      analysis.suggestsHelp = true;
    }
    return analysis;
  }

  offerHelp(modalType) {
    // 智能提供帮助
    setTimeout(() => {
      this.showHelpModal(modalType);
    }, 1000);
  }
}

结语:弹窗作为数字礼仪的守护者

从粗暴的window.open()广告弹窗,到如今精心设计的、具备上下文感知能力的对话界面,模态弹窗的演变史就是Web交互礼仪的成熟史。我们实现的不仅是显示/隐藏的功能,更是在定义数字世界中的边界与焦点管理的高级艺术。

每一次modal.style.display = 'flex'的调用,都是一次精心编排的数字礼仪表演。它关乎:

  1. 边界的建立:如何创造临时的专注空间。
  2. 焦点的引导:如何管理用户的注意力流。
  3. 礼仪的遵循:如何尊重用户的控制权与可访问性需求。
  4. 上下文的保持:如何在完成任务后让用户无缝返回。

对于前端开发者而言,深入理解并实现一个优秀的模态弹窗,意味着掌握了构建数字文明中礼貌打断艺术仪式性对话框架的关键技能。这不再仅仅是一个UI组件,而是注意力经济的空间管理者,是人机和谐交互的基石。从原理到实践,从可访问性到智能化,每一步都值得我们深思与精进。




上一篇:提升代码清晰度:在JavaScript表达式中用好括号明确运算顺序
下一篇:Ubuntu 24.04安装Basemark GPU:实测显卡性能的完整指南
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-26 17:49 , Processed in 0.518602 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表