Skip to content

Commit 3005927

Browse files
committed
stub: modal-dialog improvements
- feat: modal transitions on open/close
1 parent e5ab3b5 commit 3005927

File tree

3 files changed

+146
-0
lines changed

3 files changed

+146
-0
lines changed

src/modal/Modal.tsx

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { X } from "lucide-react";
2+
import {
3+
FC,
4+
PropsWithChildren,
5+
useContext,
6+
useEffect,
7+
useRef,
8+
forwardRef,
9+
useId,
10+
} from "react";
11+
import { ModalContext, ModalContextType } from "./ModalContext";
12+
13+
export const Modal: FC<PropsWithChildren<Omit<ModalContextType, "id">>> = ({
14+
children,
15+
onEscapeClose = true,
16+
onBackdropClose = true,
17+
open,
18+
onOpenChange,
19+
}) => {
20+
const dialogId = useId();
21+
const dialogRef = useRef<HTMLDialogElement>(null);
22+
23+
useEffect(() => {
24+
const dialog = dialogRef.current!;
25+
26+
if (open) {
27+
dialog.showModal();
28+
requestAnimationFrame(() => {
29+
dialog.dataset.open = "";
30+
delete dialog.dataset.closing;
31+
});
32+
33+
const handleEscape = (e: KeyboardEvent) => {
34+
if (!onEscapeClose) return;
35+
if (e.key === "Escape") {
36+
e.preventDefault();
37+
onOpenChange(false);
38+
}
39+
};
40+
41+
window.addEventListener("keydown", handleEscape);
42+
return () => void window.removeEventListener("keydown", handleEscape);
43+
} else {
44+
dialog.dataset.closing = "";
45+
delete dialog.dataset.open;
46+
47+
const backdrop = dialog.firstElementChild as HTMLElement;
48+
const content = backdrop?.firstElementChild as HTMLElement;
49+
50+
let transitionsComplete = 0;
51+
52+
const handleTransitionEnd = () => {
53+
// Increment counter to track when both backdrop and content transitions are complete
54+
transitionsComplete++;
55+
56+
// If both transitions are completee the dialog
57+
if (transitionsComplete >= 2) {
58+
dialog.close();
59+
backdrop?.removeEventListener("transitionend", handleTransitionEnd);
60+
content?.removeEventListener("transitionend", handleTransitionEnd);
61+
}
62+
};
63+
64+
backdrop?.addEventListener("transitionend", handleTransitionEnd);
65+
content?.addEventListener("transitionend", handleTransitionEnd);
66+
67+
return () => {
68+
backdrop.removeEventListener("transitionend", handleTransitionEnd);
69+
content?.removeEventListener("transitionend", handleTransitionEnd);
70+
};
71+
}
72+
}, [onEscapeClose, onOpenChange, open]);
73+
74+
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
75+
if (!onBackdropClose) return;
76+
if (e.target === e.currentTarget) {
77+
onOpenChange(false);
78+
}
79+
};
80+
81+
return (
82+
<ModalContext.Provider value={{ id: dialogId, open, onOpenChange }}>
83+
<dialog id={dialogId} ref={dialogRef} className="group">
84+
<div className="fixed w-full h-full overflow-y inset-0 grid place-content-center bg-black/30 backdrop-blur-sm opacity-0 transition-all duration-300 ease-in-out group-data-[open]:opacity-100 group-data-[closing]:opacity-0">
85+
<div
86+
className="overflow-y-auto w-screen h-screen place-content-center scale-75 py-10 opacity-0 shadow-lg transition-all duration-300 ease-out group-data-[open]:scale-100 group-data-[open]:opacity-100 group-data-[closing]:scale-75 group-data-[closing]:opacity-0"
87+
onClick={handleBackdropClick}
88+
>
89+
{children}
90+
</div>
91+
</div>
92+
</dialog>
93+
</ModalContext.Provider>
94+
);
95+
};
96+
97+
type ModalContentProps = PropsWithChildren<{
98+
className?: string;
99+
showCloseButton?: boolean;
100+
}>;
101+
102+
export const ModalContent = forwardRef<HTMLDivElement, ModalContentProps>(
103+
function ModalContent({ children, className, showCloseButton = true }, ref) {
104+
const { onOpenChange } = useContext(ModalContext);
105+
return (
106+
<div
107+
ref={ref}
108+
className={[
109+
"relative m-auto rounded-lg bg-base-100 text-base-content min-w-96 p-4",
110+
className,
111+
].join(" ")}
112+
>
113+
{children}
114+
{showCloseButton && (
115+
<button
116+
className="absolute top-3.5 right-3.5 text-base-content opacity-50 hover:opacity-100 transition-opacity duration-300"
117+
onClick={() => onOpenChange(false)}
118+
>
119+
<span className="sr-only">Close</span>
120+
<X className="size-4" />
121+
</button>
122+
)}
123+
</div>
124+
);
125+
},
126+
);

src/modal/ModalContext.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import React from "react";
2+
import { Dispatch, PropsWithChildren, SetStateAction } from "react";
3+
4+
export type ModalContextType = PropsWithChildren<{
5+
id: string;
6+
open: boolean;
7+
onEscapeClose?: boolean;
8+
onBackdropClose?: boolean;
9+
onOpenChange: (open: boolean) => void | Dispatch<SetStateAction<boolean>>;
10+
}>;
11+
12+
export const ModalContext = React.createContext<ModalContextType>(
13+
{} as ModalContextType,
14+
);

src/modal/useModalRef.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { ModalContext, ModalContextType } from "./ModalContext";
2+
import { useContext } from "react";
3+
4+
export function useModalRef(): ModalContextType {
5+
return useContext(ModalContext);
6+
}

0 commit comments

Comments
 (0)