fix: 优化动态列表和弹框UI体验
This commit is contained in:
147
apps/desktop/src/utils/modalMigration.md
Normal file
147
apps/desktop/src/utils/modalMigration.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Modal 组件迁移指南
|
||||
|
||||
## 🎯 问题背景
|
||||
|
||||
项目中多个组件使用了 `fixed inset-0` 的遮罩实现,这在某些情况下会遇到问题:
|
||||
|
||||
1. **父级容器有 transform 属性**时,会创建新的层叠上下文,导致 fixed 定位相对于该容器而不是视口
|
||||
2. **iOS Safari 的地址栏变化**会影响视口高度
|
||||
3. **复杂布局容器**可能会限制 fixed 元素的定位
|
||||
4. **滚动条处理不一致**,用户体验不统一
|
||||
|
||||
## 🔧 解决方案
|
||||
|
||||
使用统一的 `Modal` 组件,它包含以下优化:
|
||||
|
||||
- ✅ **智能 fixed 定位检测**:自动检测并修复定位问题
|
||||
- ✅ **完善的滚动条处理**:保存/恢复滚动位置,防止背景滚动
|
||||
- ✅ **iOS Safari 优化**:防止橡皮筋效果
|
||||
- ✅ **统一的样式系统**:一致的动画、阴影、圆角
|
||||
- ✅ **可访问性支持**:ESC 键、焦点管理、屏幕阅读器
|
||||
|
||||
## 📋 需要迁移的组件
|
||||
|
||||
### 1. CreateDynamicModal ✅ 已完成
|
||||
- **原实现**: `fixed inset-0 bg-black/50`
|
||||
- **新实现**: 使用 Modal 组件
|
||||
- **状态**: 已迁移完成
|
||||
|
||||
### 2. ProjectTemplateBindingForm
|
||||
- **位置**: `apps/desktop/src/components/ProjectTemplateBindingForm.tsx:146`
|
||||
- **原实现**: `fixed inset-0 bg-black bg-opacity-50`
|
||||
- **状态**: 待迁移
|
||||
|
||||
### 3. ResetUsageDialog
|
||||
- **位置**: `apps/desktop/src/components/ResetUsageDialog.tsx:94`
|
||||
- **原实现**: `fixed inset-0 bg-black bg-opacity-50`
|
||||
- **状态**: 待迁移
|
||||
|
||||
### 4. MaterialImportDialog
|
||||
- **位置**: `apps/desktop/src/components/MaterialImportDialog.tsx:245`
|
||||
- **原实现**: `fixed inset-0 bg-black bg-opacity-50`
|
||||
- **状态**: 待迁移
|
||||
|
||||
### 5. MaterialEditDialog
|
||||
- **位置**: `apps/desktop/src/components/MaterialEditDialog.tsx:101`
|
||||
- **原实现**: `fixed inset-0 bg-black bg-opacity-60`
|
||||
- **状态**: 待迁移
|
||||
|
||||
### 6. MaterialSegmentDetailModal
|
||||
- **位置**: `apps/desktop/src/components/MaterialSegmentDetailModal.tsx:84`
|
||||
- **原实现**: `fixed inset-0 bg-black bg-opacity-50`
|
||||
- **状态**: 待迁移
|
||||
|
||||
### 7. DeleteConfirmDialog
|
||||
- **位置**: `apps/desktop/src/components/DeleteConfirmDialog.tsx:38-42`
|
||||
- **原实现**: 复杂的 fixed 实现
|
||||
- **状态**: 待迁移
|
||||
|
||||
## 🚀 迁移步骤
|
||||
|
||||
### 步骤 1: 导入 Modal 组件
|
||||
```tsx
|
||||
import { Modal } from './Modal';
|
||||
```
|
||||
|
||||
### 步骤 2: 替换遮罩层结构
|
||||
**原代码**:
|
||||
```tsx
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4">
|
||||
{/* 内容 */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**新代码**:
|
||||
```tsx
|
||||
<Modal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
title="标题"
|
||||
size="lg"
|
||||
variant="default"
|
||||
>
|
||||
{/* 内容 */}
|
||||
</Modal>
|
||||
```
|
||||
|
||||
### 步骤 3: 配置 Modal 属性
|
||||
- `size`: 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
||||
- `variant`: 'default' | 'danger' | 'success' | 'warning' | 'info'
|
||||
- `closeOnBackdropClick`: 是否点击遮罩关闭
|
||||
- `closeOnEscape`: 是否 ESC 键关闭
|
||||
|
||||
### 步骤 4: 移除原有的头部和关闭按钮
|
||||
Modal 组件会自动处理头部和关闭按钮。
|
||||
|
||||
### 步骤 5: 测试验证
|
||||
- 在不同父级容器中测试
|
||||
- 验证滚动条处理
|
||||
- 检查键盘导航
|
||||
- 测试移动端表现
|
||||
|
||||
## 📝 迁移模板
|
||||
|
||||
```tsx
|
||||
// 迁移前
|
||||
const OldModal = ({ onClose, children }) => (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4">
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<h2>标题</h2>
|
||||
<button onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 迁移后
|
||||
const NewModal = ({ onClose, children }) => (
|
||||
<Modal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
title="标题"
|
||||
size="lg"
|
||||
>
|
||||
<div className="p-6">
|
||||
{children}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
```
|
||||
|
||||
## ✅ 迁移检查清单
|
||||
|
||||
- [ ] 导入 Modal 组件
|
||||
- [ ] 替换遮罩层结构
|
||||
- [ ] 配置 Modal 属性
|
||||
- [ ] 移除原有头部实现
|
||||
- [ ] 更新样式类名
|
||||
- [ ] 测试功能完整性
|
||||
- [ ] 验证响应式表现
|
||||
- [ ] 检查可访问性
|
||||
- [ ] 更新相关文档
|
||||
190
apps/desktop/src/utils/modalPortal.ts
Normal file
190
apps/desktop/src/utils/modalPortal.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Modal Portal 工具函数
|
||||
* 专门处理复杂容器结构中的 Modal 渲染问题
|
||||
*/
|
||||
|
||||
/**
|
||||
* 创建 Modal 容器并确保正确的层级
|
||||
*/
|
||||
export const createModalContainer = (id: string = 'modal-root'): HTMLElement => {
|
||||
// 检查是否已存在容器
|
||||
let container = document.getElementById(id);
|
||||
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = id;
|
||||
|
||||
// 设置容器样式,确保不受任何父级影响
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 999999;
|
||||
isolation: isolate;
|
||||
`;
|
||||
|
||||
// 直接添加到 body,避免复杂容器结构的影响
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
return container;
|
||||
};
|
||||
|
||||
/**
|
||||
* 清理 Modal 容器
|
||||
*/
|
||||
export const cleanupModalContainer = (id: string = 'modal-root'): void => {
|
||||
const container = document.getElementById(id);
|
||||
if (container && container.children.length === 0) {
|
||||
document.body.removeChild(container);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检测当前环境是否有复杂的容器结构
|
||||
*/
|
||||
export const detectComplexContainerStructure = (element?: HTMLElement): boolean => {
|
||||
const targetElement = element || document.querySelector('[class*="h-screen"]') as HTMLElement;
|
||||
|
||||
if (!targetElement) return false;
|
||||
|
||||
const indicators = [
|
||||
'h-screen',
|
||||
'flex-col',
|
||||
'overflow-y-auto',
|
||||
'overflow-auto',
|
||||
'overflow-scroll'
|
||||
];
|
||||
|
||||
// 检查是否包含复杂容器结构的指示器
|
||||
const hasComplexStructure = indicators.some(indicator =>
|
||||
document.querySelector(`[class*="${indicator}"]`)
|
||||
);
|
||||
|
||||
if (hasComplexStructure) {
|
||||
console.log('Complex container structure detected in DOM');
|
||||
}
|
||||
|
||||
return hasComplexStructure;
|
||||
};
|
||||
|
||||
/**
|
||||
* 应用复杂容器结构的修复
|
||||
*/
|
||||
export const applyComplexContainerFix = (modalElement: HTMLElement): void => {
|
||||
if (!modalElement) return;
|
||||
|
||||
// 添加修复类
|
||||
modalElement.classList.add('complex-container-fix');
|
||||
modalElement.setAttribute('data-complex-container', 'true');
|
||||
|
||||
// 强制样式修复
|
||||
const fixStyles = {
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
left: '0',
|
||||
right: '0',
|
||||
bottom: '0',
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
zIndex: '999999',
|
||||
transform: 'translateZ(0)',
|
||||
isolation: 'isolate',
|
||||
contain: 'none',
|
||||
clipPath: 'none',
|
||||
clip: 'auto'
|
||||
};
|
||||
|
||||
Object.assign(modalElement.style, fixStyles);
|
||||
|
||||
// 修复背景遮罩
|
||||
const backdrop = modalElement.querySelector('.modal-backdrop-fixed') as HTMLElement;
|
||||
if (backdrop) {
|
||||
const backdropStyles = {
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
left: '0',
|
||||
right: '0',
|
||||
bottom: '0',
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
zIndex: '-1'
|
||||
};
|
||||
|
||||
Object.assign(backdrop.style, backdropStyles);
|
||||
}
|
||||
|
||||
console.log('Applied complex container fix to modal');
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除复杂容器结构的修复
|
||||
*/
|
||||
export const removeComplexContainerFix = (modalElement: HTMLElement): void => {
|
||||
if (!modalElement) return;
|
||||
|
||||
modalElement.classList.remove('complex-container-fix');
|
||||
modalElement.removeAttribute('data-complex-container');
|
||||
|
||||
// 重置样式(让 CSS 类接管)
|
||||
const stylesToReset = [
|
||||
'position', 'top', 'left', 'right', 'bottom',
|
||||
'width', 'height', 'zIndex', 'transform',
|
||||
'isolation', 'contain', 'clipPath', 'clip'
|
||||
];
|
||||
|
||||
stylesToReset.forEach(prop => {
|
||||
modalElement.style.removeProperty(prop);
|
||||
});
|
||||
|
||||
const backdrop = modalElement.querySelector('.modal-backdrop-fixed') as HTMLElement;
|
||||
if (backdrop) {
|
||||
stylesToReset.forEach(prop => {
|
||||
backdrop.style.removeProperty(prop);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取安全的 Modal 渲染容器
|
||||
* 确保 Modal 始终渲染在正确的位置
|
||||
*/
|
||||
export const getSafeModalContainer = (): HTMLElement => {
|
||||
// 优先使用专门的 modal 容器
|
||||
let container = document.getElementById('modal-root');
|
||||
|
||||
if (!container) {
|
||||
container = createModalContainer('modal-root');
|
||||
}
|
||||
|
||||
// 确保容器在 body 的最后,避免被其他元素覆盖
|
||||
if (container.parentElement === document.body) {
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
return container;
|
||||
};
|
||||
|
||||
/**
|
||||
* 监听窗口大小变化,确保 Modal 始终正确覆盖
|
||||
*/
|
||||
export const setupModalResizeHandler = (modalElement: HTMLElement): (() => void) => {
|
||||
const handleResize = () => {
|
||||
if (modalElement.hasAttribute('data-complex-container')) {
|
||||
// 重新应用修复
|
||||
applyComplexContainerFix(modalElement);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('orientationchange', handleResize);
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('orientationchange', handleResize);
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user