Files
my-website-v2/frontend/islands/ProjectModal.tsx

157 lines
4.6 KiB
TypeScript
Raw Normal View History

import { useEffect, useRef, useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
export type ModalAction = {
label: string;
onClick: () => void;
variant?: "primary" | "secondary" | "link";
};
type ModalProps = {
title?: string;
/**
* Called after the modal has finished its exit animation.
* The Modal will run the exit animation internally and then call onClose().
*/
onClose: () => void;
children: ComponentChildren;
ariaLabel?: string;
actions?: ModalAction[]; // rendered in footer; each button will be given flex-1 so buttons fill the width together
/**
* Optional: duration (ms) of enter/exit animation. Defaults to 200.
* Keep this in sync with the CSS transition-duration classes used below.
*/
animationDurationMs?: number;
};
export const ProjectModal = function ProjectModal({
title,
onClose,
children,
ariaLabel,
actions,
animationDurationMs = 200,
}: ModalProps) {
const [isVisible, setIsVisible] = useState(false);
const closingRef = useRef(false);
const timeoutRef = useRef<number | null>(null);
useEffect(() => {
const raf = requestAnimationFrame(() => setIsVisible(true));
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
startCloseFlow();
}
}
document.addEventListener("keydown", onKey);
return () => {
cancelAnimationFrame(raf);
document.removeEventListener("keydown", onKey);
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const footerActions: ModalAction[] =
actions && actions.length > 0
? [
...actions,
{
label: "Close",
onClick: startCloseFlow,
variant: "secondary",
},
]
: [{ label: "Close", onClick: startCloseFlow, variant: "primary" }];
function startCloseFlow() {
if (closingRef.current) return;
closingRef.current = true;
setIsVisible(false);
timeoutRef.current = globalThis.setTimeout(() => {
timeoutRef.current = null;
onClose();
}, animationDurationMs);
}
const panelBase =
"relative z-10 max-w-lg w-full bg-white dark:bg-[#1f2937] rounded-lg shadow-xl p-6 mx-4 transform transition-all";
const panelVisible = "opacity-100 translate-y-0 scale-100";
const panelHidden = "opacity-0 translate-y-2 scale-95";
const backdropBase =
"absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity";
const backdropVisible = "opacity-100";
const backdropHidden = "opacity-0";
const renderActionButton = (action: ModalAction) => {
const base =
"flex-1 w-full px-4 py-2 rounded-md font-semibold focus:outline-none";
const styles =
action.variant === "primary"
? "bg-[#94e2d5] text-black hover:brightness-95 border-b-4 border-b-[#364153] shadow-xl"
: action.variant === "link"
? "bg-transparent text-[#075985] underline"
: "bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:brightness-95 border-b-4 border-b-[#94e2d5] shadow-xl";
return (
<button
key={action.label}
onClick={action.onClick}
class={`${base} ${styles}`}
type="button"
>
{action.label}
</button>
);
};
return (
<div
class="fixed inset-0 z-50 flex items-center justify-center"
aria-modal="true"
role="dialog"
aria-label={ariaLabel ?? title ?? "Modal dialog"}
>
<div
style={{ transitionDuration: `${animationDurationMs}ms` }}
class={`${backdropBase} ${
isVisible ? backdropVisible : backdropHidden
}`}
/>
<div
style={{ transitionDuration: `${animationDurationMs}ms` }}
class={`${panelBase} ${isVisible ? panelVisible : panelHidden}`}
onClick={(e) => e.stopPropagation()}
>
<div class="flex items-start justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h3>
<button
onClick={startCloseFlow}
aria-label="Close modal"
class="ml-4 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 p-1"
type="button"
>
</button>
</div>
<div class="mt-4 text-sm text-gray-700 dark:text-gray-300">
{children}
</div>
<div class="mt-6">
<div class="flex gap-3 w-full">
{footerActions.map(renderActionButton)}
</div>
</div>
</div>
</div>
);
};