refactor(core): share modal portal logic
This commit is contained in:
@@ -11,9 +11,9 @@
|
|||||||
* - 简单 slide-up 动画(与原 Dart 行为接近)
|
* - 简单 slide-up 动画(与原 Dart 行为接近)
|
||||||
* - 调用方只关心内容布局
|
* - 调用方只关心内容布局
|
||||||
*/
|
*/
|
||||||
import { type ReactNode, useEffect } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
|
|
||||||
|
import { ModalPortal } from "./modal-portal";
|
||||||
import styles from "./bottom-sheet.module.css";
|
import styles from "./bottom-sheet.module.css";
|
||||||
|
|
||||||
export interface BottomSheetProps {
|
export interface BottomSheetProps {
|
||||||
@@ -36,40 +36,17 @@ export function BottomSheet({
|
|||||||
persistent = false,
|
persistent = false,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
}: BottomSheetProps) {
|
}: BottomSheetProps) {
|
||||||
useEffect(() => {
|
return (
|
||||||
if (!open || typeof document === "undefined") return;
|
<ModalPortal
|
||||||
const onKey = (e: KeyboardEvent) => {
|
open={open}
|
||||||
if (e.key === "Escape" && !persistent) onClose();
|
onClose={onClose}
|
||||||
};
|
scrimClassName={styles.scrim}
|
||||||
document.addEventListener("keydown", onKey);
|
panelClassName={styles.panel}
|
||||||
const prevOverflow = document.body.style.overflow;
|
scrimOpacity={scrimOpacity}
|
||||||
document.body.style.overflow = "hidden";
|
persistent={persistent}
|
||||||
return () => {
|
ariaLabel={ariaLabel}
|
||||||
document.removeEventListener("keydown", onKey);
|
|
||||||
document.body.style.overflow = prevOverflow;
|
|
||||||
};
|
|
||||||
}, [open, onClose, persistent]);
|
|
||||||
|
|
||||||
if (!open || typeof document === "undefined") return null;
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div
|
|
||||||
className={styles.scrim}
|
|
||||||
style={{ background: `rgba(0, 0, 0, ${scrimOpacity})` }}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (!persistent && e.target === e.currentTarget) onClose();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
{children}
|
||||||
role="dialog"
|
</ModalPortal>
|
||||||
aria-modal="true"
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
className={styles.panel}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
*
|
*
|
||||||
* 注意:项目不引入 Radix/shadcn,纯依赖 React + CSS Modules。
|
* 注意:项目不引入 Radix/shadcn,纯依赖 React + CSS Modules。
|
||||||
*/
|
*/
|
||||||
import { type ReactNode, useEffect } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
|
|
||||||
|
import { ModalPortal } from "./modal-portal";
|
||||||
import styles from "./dialog.module.css";
|
import styles from "./dialog.module.css";
|
||||||
|
|
||||||
export interface DialogProps {
|
export interface DialogProps {
|
||||||
@@ -39,41 +39,18 @@ export function Dialog({
|
|||||||
persistent = false,
|
persistent = false,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
}: DialogProps) {
|
}: DialogProps) {
|
||||||
useEffect(() => {
|
return (
|
||||||
if (!open || typeof document === "undefined") return;
|
<ModalPortal
|
||||||
const onKey = (e: KeyboardEvent) => {
|
open={open}
|
||||||
if (e.key === "Escape" && !persistent) onClose();
|
onClose={onClose}
|
||||||
};
|
scrimClassName={styles.scrim}
|
||||||
document.addEventListener("keydown", onKey);
|
panelClassName={styles.panel}
|
||||||
const prevOverflow = document.body.style.overflow;
|
panelStyle={{ maxWidth }}
|
||||||
document.body.style.overflow = "hidden";
|
scrimOpacity={scrimOpacity}
|
||||||
return () => {
|
persistent={persistent}
|
||||||
document.removeEventListener("keydown", onKey);
|
ariaLabel={ariaLabel}
|
||||||
document.body.style.overflow = prevOverflow;
|
|
||||||
};
|
|
||||||
}, [open, onClose, persistent]);
|
|
||||||
|
|
||||||
if (!open || typeof document === "undefined") return null;
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div
|
|
||||||
className={styles.scrim}
|
|
||||||
style={{ background: `rgba(0, 0, 0, ${scrimOpacity})` }}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (!persistent && e.target === e.currentTarget) onClose();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
{children}
|
||||||
role="dialog"
|
</ModalPortal>
|
||||||
aria-modal="true"
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
className={styles.panel}
|
|
||||||
style={{ maxWidth }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CSSProperties,
|
||||||
|
type MouseEvent,
|
||||||
|
type ReactNode,
|
||||||
|
useEffect,
|
||||||
|
} from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
let bodyScrollLockDepth = 0;
|
||||||
|
let previousBodyOverflow = "";
|
||||||
|
|
||||||
|
export interface ModalPortalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
scrimClassName: string;
|
||||||
|
panelClassName: string;
|
||||||
|
scrimOpacity: number;
|
||||||
|
panelStyle?: CSSProperties;
|
||||||
|
persistent?: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModalPortal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
scrimClassName,
|
||||||
|
panelClassName,
|
||||||
|
scrimOpacity,
|
||||||
|
panelStyle,
|
||||||
|
persistent = false,
|
||||||
|
ariaLabel,
|
||||||
|
}: ModalPortalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || typeof document === "undefined") return;
|
||||||
|
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape" && !persistent) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
lockBodyScroll();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
unlockBodyScroll();
|
||||||
|
};
|
||||||
|
}, [open, onClose, persistent]);
|
||||||
|
|
||||||
|
if (!open || typeof document === "undefined") return null;
|
||||||
|
|
||||||
|
const handleScrimClick = (event: MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!persistent && event.target === event.currentTarget) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className={scrimClassName}
|
||||||
|
style={{ background: `rgba(0, 0, 0, ${scrimOpacity})` }}
|
||||||
|
onClick={handleScrimClick}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={panelClassName}
|
||||||
|
style={panelStyle}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockBodyScroll(): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
if (bodyScrollLockDepth === 0) {
|
||||||
|
previousBodyOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
}
|
||||||
|
bodyScrollLockDepth += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlockBodyScroll(): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
bodyScrollLockDepth = Math.max(0, bodyScrollLockDepth - 1);
|
||||||
|
if (bodyScrollLockDepth === 0) {
|
||||||
|
document.body.style.overflow = previousBodyOverflow;
|
||||||
|
previousBodyOverflow = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user