wip: added project modal
still want to add carousel, summary, etc.
This commit is contained in:
158
frontend/islands/modal.tsx
Normal file
158
frontend/islands/modal.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { ComponentChildren } from "preact";
|
||||
|
||||
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 function Modal({
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
ariaLabel,
|
||||
actions,
|
||||
animationDurationMs = 200,
|
||||
}: ModalProps) {
|
||||
// Controls the entrance/exit animation state. true => visible (enter), false => hidden (exit)
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
// Prevent double-triggering the close flow
|
||||
const closingRef = useRef(false);
|
||||
// Hold the timeout id for cleanup
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Defer to next frame so initial "hidden" styles are applied before animating to visible.
|
||||
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
|
||||
}, []);
|
||||
|
||||
// If no actions provided, render a single Close button
|
||||
const footerActions: ModalAction[] =
|
||||
actions && actions.length > 0
|
||||
? actions
|
||||
: [{ label: "Close", onClick: startCloseFlow, variant: "primary" }];
|
||||
|
||||
// Start exit animation and call onClose after animationDurationMs
|
||||
function startCloseFlow() {
|
||||
if (closingRef.current) return;
|
||||
closingRef.current = true;
|
||||
setIsVisible(false);
|
||||
// Wait for the CSS transition to finish before signalling parent to actually unmount
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
timeoutRef.current = null;
|
||||
onClose();
|
||||
}, animationDurationMs);
|
||||
}
|
||||
|
||||
// Animation classes (enter & exit):
|
||||
// - panel: transitions opacity + transform for a subtle fade + pop
|
||||
// - backdrop: transitions opacity for fade
|
||||
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";
|
||||
// We explicitly set the CSS transition duration inline to keep class + timeout in sync.
|
||||
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";
|
||||
|
||||
// Footer button class generator
|
||||
const renderActionButton = (act: ModalAction) => {
|
||||
const base =
|
||||
"flex-1 w-full px-4 py-2 rounded-md font-semibold focus:outline-none";
|
||||
const styles =
|
||||
act.variant === "primary"
|
||||
? "bg-[#94e2d5] text-black hover:brightness-95"
|
||||
: act.variant === "link"
|
||||
? "bg-transparent text-[#075985] underline"
|
||||
: "bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:brightness-95";
|
||||
|
||||
return (
|
||||
<button key={act.label} onClick={act.onClick} class={`${base} ${styles}`}>
|
||||
{act.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"}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
// inline style for transitionDuration to keep JS timeout and CSS synced
|
||||
style={{ transitionDuration: `${animationDurationMs}ms` }}
|
||||
class={`${backdropBase} ${isVisible ? backdropVisible : backdropHidden}`}
|
||||
onClick={startCloseFlow}
|
||||
/>
|
||||
{/* Modal panel */}
|
||||
<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"
|
||||
>
|
||||
✕
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user