Skip to content

Commit 18e2e53

Browse files
committed
stub: modal-dialog improvements
- feat: improve modal transitions on open/close - feat: modal dialog stories+docs
1 parent e5ab3b5 commit 18e2e53

5 files changed

Lines changed: 261 additions & 0 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { Modal as ModalComponent, ModalContent as Content } from "./Modal";
3+
4+
import { useState } from "react";
5+
import { ModalContent } from "./ModalContent.stories";
6+
7+
const meta = {
8+
title: "UI/Modal",
9+
component: ModalComponent,
10+
subcomponents: {
11+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
12+
// @ts-ignore
13+
ModalContent: Content,
14+
},
15+
tags: ["autodocs"],
16+
parameters: {
17+
layout: "centered",
18+
},
19+
args: {
20+
open: false,
21+
onOpenChange: () => {},
22+
onBackdropClose: false,
23+
onEscapeClose: true,
24+
},
25+
argTypes: {
26+
open: {
27+
control: {
28+
type: "boolean",
29+
},
30+
},
31+
onBackdropClose: {
32+
control: {
33+
type: "boolean",
34+
},
35+
},
36+
onEscapeClose: {
37+
control: {
38+
type: "boolean",
39+
},
40+
},
41+
},
42+
} satisfies Meta<typeof ModalComponent>;
43+
44+
export default meta;
45+
46+
type Story = StoryObj<typeof meta>;
47+
48+
export const Modal: Story = {
49+
args: {
50+
onBackdropClose: false,
51+
onEscapeClose: false,
52+
},
53+
54+
render: (args) => {
55+
const [open, setOpen] = useState(args.open);
56+
return (
57+
<>
58+
<button onClick={() => setOpen(true)}>Open</button>
59+
<ModalComponent
60+
open={open}
61+
onOpenChange={setOpen}
62+
onBackdropClose={args.onBackdropClose}
63+
onEscapeClose={args.onEscapeClose}
64+
>
65+
<Content {...ModalContent.args}>
66+
<div>Hello World</div>
67+
</Content>
68+
</ModalComponent>
69+
</>
70+
);
71+
},
72+
};

src/components/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+
e.preventDefault();
35+
36+
if (!onEscapeClose) return;
37+
if (e.key !== "Escape") return;
38+
onOpenChange(false);
39+
};
40+
41+
dialog.addEventListener("keydown", handleEscape);
42+
return () => void dialog.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+
);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { Modal as ModalComponent, ModalContent as Content } from "./Modal";
3+
4+
const meta = {
5+
title: "UI/Modal/ModalContent",
6+
component: Content,
7+
parameters: {
8+
layout: "centered",
9+
},
10+
tags: [],
11+
args: {
12+
showCloseButton: true,
13+
className: "max-w-2xl min-h-48",
14+
},
15+
argTypes: {
16+
className: {
17+
control: {
18+
type: "text",
19+
},
20+
},
21+
showCloseButton: {
22+
defaultValue: true,
23+
control: {
24+
type: "boolean",
25+
},
26+
},
27+
},
28+
} satisfies Meta<typeof Content>;
29+
30+
export default meta;
31+
type ModalContentStory = StoryObj<typeof meta>;
32+
33+
export const ModalContent: ModalContentStory = {
34+
args: {
35+
showCloseButton: true,
36+
className: "max-w-2xl min-h-48",
37+
},
38+
render: (args) => (
39+
<ModalComponent open={true} onOpenChange={() => {}}>
40+
<Content {...args}>Hello</Content>
41+
</ModalComponent>
42+
),
43+
};
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+
);
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)