TDesign React Dialog 技巧:从自定义位置到外部悬浮按钮的深度定制

发布于

在日常开发中,我们经常使用 TDesign 的 Dialog 组件。虽然默认的居中显示和标准样式能满足大部分需求,但在某些特定的 UI/UX 场景下(例如:类似抽屉的顶部弹窗、展示长报表、或者需要极具个性化的关闭按钮),我们需要对组件进行深度定制。

本文将分享三个实用的定制技巧:

  1. 修改弹窗出现的位置(顶部偏移)。
  2. 实现固定头部 + 内部滚动的长内容弹窗。
  3. 高阶技巧:监听动画状态,在弹窗外部挂载自定义 DOM(如悬浮关闭按钮)。

一、 基础定制:修改弹窗位置与尺寸

默认情况下,TDesign 的 Dialog 是垂直居中的。如果产品设计要求弹窗从顶部向下偏移一定距离显示,我们可以通过 CSS 覆盖来实现。

1. 修改位置

在 Less 文件中,我们可以针对 .t-dialog__position.t-dialog--top 进行样式覆盖。

/* 将弹窗固定在距离顶部 3vh 的位置 */
.t-dialog__position.t-dialog--top {
  padding-top: 3vh;
}

或者如果你已经定义了弹窗的className, 还可以

.guide-dialog-plugin {
  &.t-dialog__ctx .t-dialog__position.t-dialog--top {
    padding-top: 3vh;
  }
}

如果只是想居中(原版默认的是20vh不是居中)

  &.t-dialog__ctx .t-dialog__position.t-dialog--top {
    display: flex;
    align-items: center;
    justify-content: center;
    padding-top: unset;
    padding-bottom: unset;
  }

2. 控制宽高度

对于宽度的控制,最简单的方法是在调用 DialogPlugin 或使用组件时,通过 DialogOptions 传入:

DialogPlugin({
  width: '83%', // 支持百分比或 px
  // ...
});

但对于高度和内部布局的精细控制,我们需要配合 CSS 类名来实现,这引出了下一部分的“长内容”场景。


二、 进阶布局:固定头部与内部滚动

场景描述:点击“查看详细报告”按钮,弹出一个包含大量文字的大型弹窗。要求弹窗高度固定(或最大高度固定),头部标题栏不动,仅内容区域滚动。

1. 定义 ClassName

首先,在调用插件时给弹窗打上唯一的标记:

DialogPlugin({
  className: 'error-detail-dialog-plugin', // 关键标记
  header: false, // 隐藏默认头部,使用自定义内容实现
  footer: false,
  body: <YourContentComponent />,
  // ...
});

2. 使用 Flex 布局控制滚动

利用 Less 控制弹窗结构。核心思路是将 .t-dialog 设为 Flex 容器,内容区域 flex: 1 并开启 overflow-y: auto。

.error-detail-dialog-plugin {
  .t-dialog {
    height: 60rem;
    max-height: 90vh; /* 避免超出屏幕 */
    display: flex;
    flex-direction: column;
    overflow: hidden; /* 裁剪圆角 */
    border-radius: 0.5rem;
    background: #FFF;

    .t-dialog__body {
      height: 100%;
      padding: 0;
      display: flex;
      flex-direction: column;
      overflow: hidden;

      /* 自定义头部:高度由内容撑开 */
      .dialog_header {
        flex-shrink: 0; /* 防止被压缩 */
        padding: 1rem 2rem;
        background: #F3F3F3;
        // ... 其他样式
      }

      /* 自定义内容区:占据剩余空间并滚动 */
      .dialog_content {
        flex: 1; 
        overflow-y: auto; /* 关键:允许纵向滚动 */
        overflow-x: hidden;
        padding: 1.5rem 2rem;
      }
    }
  }
}

三、 高阶技巧:监听 Portal 动画并挂载外部元素

这是一个比较 hack 的需求:设计要求在弹窗 右上角外部 悬浮一个大的圆形关闭按钮。

由于 TDesign 的 Dialog 渲染在 document.body 下的 t-portal-wrapper 中,且自带“从小变大”的 entry 动画。如果直接渲染关闭按钮,可能会跟随弹窗一起缩放,或者位置计算不准。

解决方案:使用 MutationObserver 监听 TDesign Portal 的 class 变化。只有当弹窗容器具备 .appear-done 和 .enter-done(即动画结束,处于稳定显示状态)时,再将关闭按钮挂载上去。

核心代码实现

import { useRef, useCallback } from 'react';
import { DialogPlugin } from 'tdesign-react';

function ErrorDialog() {
  const dialogInstanceRef = useRef(null);
  const closeButtonElementRef = useRef<HTMLDivElement | null>(null);
  const observerRef = useRef<MutationObserver | null>(null);

  // 1. 清理函数:销毁 Dialog 同时清理按钮和观察器
  const closeDialog = useCallback(() => {
    // 移除手动添加的关闭按钮
    if (closeButtonElementRef.current) {
      closeButtonElementRef.current.remove();
      closeButtonElementRef.current = null;
    }
    // 断开观察器
    if (observerRef.current) {
      observerRef.current.disconnect();
      observerRef.current = null;
    }
    // 销毁 TDesign 实例
    if (dialogInstanceRef.current) {
      dialogInstanceRef.current.hide();
      dialogInstanceRef.current.destroy();
      dialogInstanceRef.current = null;
    }
  }, []);

  const handleShowDetail = useCallback(() => {
    // 2. 创建自定义关闭按钮 DOM
    const createCloseButton = () => {
      const btn = document.createElement('div');
      btn.className = 'custom-close-button'; // 在 Less 中定义 fixed 定位
      btn.innerHTML = `<div class="close-circle">X</div>`; // 这里可以是 SVG
      btn.onclick = closeDialog;
      closeButtonElementRef.current = btn;
    };

    // 3. 检查并挂载按钮的逻辑
    const checkAndAddCloseButton = () => {
      // 找到当前的 Portal 包装器
      const portalWrapper = document.querySelector('.t-portal-wrapper.appear-done.enter-done');
      // 找到我们自定义 class 的弹窗容器
      const dialogWrapper = portalWrapper?.querySelector('.error-detail-dialog-plugin');

      if (dialogWrapper && !closeButtonElementRef.current) {
        createCloseButton();
        if (closeButtonElementRef.current) {
          dialogWrapper.appendChild(closeButtonElementRef.current);
        }
      }
    };

    // 4. 使用 MutationObserver 监听动画类名变化
    const observePortalWrapper = () => {
      const targetNode = document.querySelector('.t-portal-wrapper');
      if (!targetNode) return;

      observerRef.current = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
          if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
            const target = mutation.target as HTMLElement;
            // 当动画类名标识为完成时
            if (target.classList.contains('appear-done') && target.classList.contains('enter-done')) {
              checkAndAddCloseButton();
              // 只需要添加一次,添加后即可断开观察
              observerRef.current?.disconnect();
            }
          }
        });
      });

      observerRef.current.observe(targetNode, {
        attributes: true,
        attributeFilter: ['class'],
      });
      
      // Edge case: 如果代码执行时动画已经结束,直接添加
      if (targetNode.classList.contains('appear-done') && targetNode.classList.contains('enter-done')) {
        checkAndAddCloseButton();
        observerRef.current.disconnect();
      }
    };

    // 5. 调用插件显示弹窗
    dialogInstanceRef.current = DialogPlugin({
      className: 'error-detail-dialog-plugin',
      // ... 其他配置
      onClose: closeDialog, // 确保 ESC 或遮罩点击也能触发清理
    });

    // 延迟执行以确保 Portal DOM 已生成
    setTimeout(() => {
      observePortalWrapper();
    }, 0);
  }, [closeDialog]);

  return <button onClick={handleShowDetail}>查看详情</button>;
}

配套的 Less 样式

关闭按钮使用了 position: fixed,虽然它是 append 到弹窗容器里的,但 fixed 会相对于视口定位(除非父级有 transform,这里要注意 TDesign 的动画层级),或者可以使用 absolute 相对于弹窗定位。

.error-detail-dialog-plugin {
  // 必须保证父级没有 overflow: hidden 截断了外部按钮,
  // 或者直接使用 fixed 定位在屏幕上
  .custom-close-button {
    position: fixed; // 悬浮在页面固定位置
    top: 3vh;
    right: 3vh;
    z-index: 9999;
    cursor: pointer;
    
    .close-circle {
      /* 你的按钮样式 */
      width: 3rem; 
      height: 3rem;
      border-radius: 50%;
      background: rgba(0, 0, 0, 0.6);
      /* ... */
    }
  }
}

总结

通过组合使用 CSS 覆盖、Flex 布局以及 MutationObserver,我们突破了 UI 组件库原有的限制。这种“监听 DOM 状态后注入元素”的模式虽然略显复杂,但在处理第三方组件库的强定制动画场景时非常有效。

评论区 (0)