import { Portal } from "solid-js/web"; import { createContext, useContext, createSignal, createEffect, onCleanup, createMemo, Show, For, JSXElement } from "solid-js"; import { Icon } from "./Icons.tsx"; export type ModalSize = "small" | "default" | "medium" | "large" | "xlarge" | "2xlarge" | "3xlarge" | "4xlarge" | "5xlarge" | "full"; export const MODAL_SMALL = "small"; export const MODAL_DEFAULT = "default"; export const MODAL_MEDIUM = "medium"; export const MODAL_LARGE = "large"; export const MODAL_XLARGE = "xlarge"; export const MODAL_2XLARGE = "2xlarge"; export const MODAL_3XLARGE = "3xlarge"; export const MODAL_4XLARGE = "4xlarge"; export const MODAL_5XLARGE = "5xlarge"; export const MODAL_FULL = "full"; const ANIMATION_DURATION = 100; type Reactive = T | (() => T); interface ModalOptions { size?: ModalSize; centerOnScreen?: boolean; } interface ModalContextValue { openModal: (content: JSXElement, options?: ModalOptions) => void; closeModal: () => void; isOpen: () => boolean; } const ModalContext = createContext(null); const resolve = (val: Reactive): T => typeof val === "function" ? (val as () => T)() : val; export function useModal(): ModalContextValue { const context = useContext(ModalContext); if (!context) { throw new Error("useModal must be used within a ModalProvider"); } return context; } // Shared stack of currently-open modals. Each modal pushes a token while open; // Escape only dismisses the top-most one, so nested modals (a modal opened from // inside another) close one layer per press instead of all at once. const openModalStack: object[] = []; // While `isOpen()` is true, registers this modal on the shared stack and wires // up an Escape handler that fires `onEscape` only when this modal is on top. // Must be called inside a component/reactive owner (uses createEffect/onCleanup). function useModalEscape(isOpen: () => boolean, onEscape: () => void) { createEffect(() => { if (!isOpen()) return; const token = {}; openModalStack.push(token); const handleKeyDown = (e: KeyboardEvent) => { if (e.key !== "Escape") return; if (openModalStack[openModalStack.length - 1] !== token) return; e.preventDefault(); onEscape(); }; document.addEventListener("keydown", handleKeyDown); onCleanup(() => { document.removeEventListener("keydown", handleKeyDown); const idx = openModalStack.indexOf(token); if (idx !== -1) openModalStack.splice(idx, 1); }); }); } // -- Tailwind class constants -------------------------------------- const CONTAINER_BASE = "fixed inset-0 z-[100] w-full h-dvh m-0 border-0 bg-transparent flex justify-center max-w-screen max-h-dvh"; const CONTAINER_TOP = "items-start pt-10"; const CONTAINER_CENTER = "items-center"; const BACKDROP = "fixed inset-0 bg-black/30"; const MODAL_BASE = "relative bg-white shadow-md text-sm w-full rounded-default max-h-[calc(100dvh_-_5rem)] flex flex-col overflow-hidden"; const MODAL_SIZES: Record = { small: "max-w-md", default: "max-w-xl", medium: "max-w-2xl", large: "max-w-3xl", xlarge: "max-w-4xl", "2xlarge":"max-w-5xl", "3xlarge":"max-w-6xl", "4xlarge":"max-w-7xl", "5xlarge":"max-w-[90rem]", full: "max-w-none", }; const HEADER = "flex items-center justify-between py-5 px-7 pb-4 border-b border-neutral-200 text-lg font-semibold text-text-heading"; const HEADER_CLOSE_ONLY = "flex items-center justify-end p-4 pb-1"; const CLOSE_BTN = "cursor-pointer text-neutral-500 bg-transparent border-0 p-0 leading-none hover:text-neutral-700"; const BODY = "px-7 py-6 overflow-y-auto flex-1 min-h-0 [background:linear-gradient(var(--color-white),var(--color-white))_bottom_/_100%_3rem_no-repeat_local,linear-gradient(to_bottom,transparent,var(--color-white))_bottom_/_100%_3rem_no-repeat_scroll,var(--color-white)]"; const FOOTER = "py-4 px-7 pb-[calc(1rem_+_env(safe-area-inset-bottom,0px))] flex flex-row justify-end gap-2 border-t border-neutral-200 bg-neutral-50 rounded-b-default"; const FOOTER_SPACER = "h-2"; const WIZARD_ERROR = "flex items-center gap-2 py-3 px-8 text-sm text-red-700 bg-red-50 border-t border-red-200"; const WIZARD_ERROR_ICON = "shrink-0 text-red-500"; // Confirm modal const CONFIRM_WRAP = "flex justify-end gap-2"; const CONFIRM_CANCEL = "py-2 px-4 text-sm border border-neutral-300 rounded-default bg-transparent cursor-pointer hover:bg-neutral-50"; const CONFIRM_OK_BASE = "py-2 px-4 text-sm rounded-default border-0 cursor-pointer text-white"; const CONFIRM_OK_VARIANTS = { danger: "bg-red-600 hover:bg-red-700", primary: "bg-primary hover:bg-primary-hover", }; // Wizard header const WIZARD_HEADER = "flex flex-col items-center gap-2 flex-1"; const WIZARD_TITLE_ROW = "flex items-center justify-between w-full"; const WIZARD_TITLE = "text-xl"; const WIZARD_STEP_NAME = "text-xs font-semibold text-neutral-600 uppercase tracking-wider"; const WIZARD_STEPS = "flex items-center justify-between relative w-full max-w-64"; const WIZARD_TRACK = "absolute top-1/2 left-0 right-0 h-0.5 bg-neutral-200 -translate-y-1/2"; const WIZARD_TRACK_FILL = "h-full bg-primary transition-[width] duration-300 ease-in-out"; const WIZARD_STEP_WRAP = "relative z-[1]"; const STEP_INDICATOR_BASE = "w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold border-2 shrink-0 transition-all duration-300 ease-in-out"; const STEP_INDICATOR_PENDING = "border-neutral-300 text-neutral-400 bg-white"; const STEP_INDICATOR_ACTIVE = "bg-primary text-white border-primary"; const STEP_INDICATOR_COMPLETED = "bg-primary text-white border-primary"; // Wizard footer const WIZARD_FOOTER = "flex items-center justify-between w-full gap-2"; const WIZARD_BTN_BASE = "py-2 px-5 text-sm rounded-default cursor-pointer border-0 disabled:opacity-40 disabled:cursor-not-allowed"; const WIZARD_BTN_BACK = "bg-transparent border border-neutral-300 text-neutral-700 enabled:hover:bg-neutral-50"; const WIZARD_BTN_NEXT = "bg-neutral-800 text-white enabled:hover:bg-neutral-900"; const WIZARD_BTN_FINISH = "bg-primary text-white enabled:hover:bg-red-700"; interface ModalDisplayProps { size?: Reactive; centerOnScreen?: Reactive; onClose?: () => void; children?: JSXElement; } function ModalDisplay(props: ModalDisplayProps) { const [isVisible, setIsVisible] = createSignal(false); createEffect(() => { requestAnimationFrame(() => { requestAnimationFrame(() => { setIsVisible(true); }); }); }); const getBackdropStyle = () => ({ opacity: isVisible() ? 1 : 0, transition: `opacity ${ANIMATION_DURATION}ms ease-out`, }); const getModalStyle = () => ({ opacity: isVisible() ? 1 : 0, transform: isVisible() ? "scale(1)" : "scale(0.95)", transition: `opacity ${ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1), transform ${ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`, }); const getContainerClass = () => { const centered = resolve(props.centerOnScreen); return CONTAINER_BASE + " " + (centered ? CONTAINER_CENTER : CONTAINER_TOP); }; const getModalClass = () => { const size = resolve(props.size) || MODAL_DEFAULT; return MODAL_BASE + " " + MODAL_SIZES[size]; }; const handleClose = () => { const fn = props.onClose; if (typeof fn === "function") { fn(); } }; return (
{resolve(props.children)}
); } interface ModalProviderProps { children?: JSXElement; } export function ModalProvider(props: ModalProviderProps) { const [isOpen, setIsOpen] = createSignal(false); const [content, setContent] = createSignal(null); const [options, setOptions] = createSignal({}); const openModal = (modalContent: JSXElement, modalOptions: ModalOptions = {}) => { setContent(() => modalContent); setOptions(modalOptions); setIsOpen(true); }; const closeModal = () => { setIsOpen(false); setContent(null); setOptions({}); }; useModalEscape(isOpen, closeModal); const value: ModalContextValue = { openModal, closeModal, isOpen, }; return ( {props.children} ); } interface ModalContentProps { header?: JSXElement; footer?: JSXElement; onClose?: () => void; children?: JSXElement; } export function ModalContent(props: ModalContentProps) { const { closeModal } = useModal(); const handleClose = () => { const fn = props.onClose; if (typeof fn === "function") { fn(); } else { closeModal(); } }; const header = () => props.header; const footer = () => props.footer; return [
,
{header()}
,
{props.children}
,
,
{footer()}
, ]; } interface ModalProps { isOpen: Reactive; onClose: () => void; size?: ModalSize; centerOnScreen?: boolean; header?: JSXElement; footer?: JSXElement; children?: JSXElement; } export function Modal(props: ModalProps) { const isOpen = () => { const val = props.isOpen; return typeof val === "function" ? (val as () => boolean)() : val; }; const handleClose = () => { const fn = props.onClose; if (typeof fn === "function") { fn(); } }; const header = () => props.header; const footer = () => props.footer; const size = () => props.size || MODAL_DEFAULT; const centerOnScreen = () => props.centerOnScreen; useModalEscape(isOpen, handleClose); // Render through a Portal (to document.body) so a Modal nested inside // another Modal's body isn't clipped by the parent panel's overflow or // trapped by its `transform` (which would make it the containing block for // our `position: fixed` container). WizardModal/ModalProvider do the same. return (
{header()}
{props.children}
{footer()}
); } interface ConfirmModalProps { isOpen: Reactive; onClose: () => void; onConfirm: () => void; title?: string; message: string; confirmText?: string; cancelText?: string; confirmStyle?: "danger" | "primary"; } export function ConfirmModal(props: ConfirmModalProps) { const isOpen = () => { const val = props.isOpen; return typeof val === "function" ? (val as () => boolean)() : val; }; const handleClose = () => { const fn = props.onClose; if (typeof fn === "function") { fn(); } }; const title = () => props.title ?? "Confirm"; const message = () => props.message; const confirmText = () => props.confirmText ?? "Confirm"; const cancelText = () => props.cancelText ?? "Cancel"; const confirmStyle = (): "danger" | "primary" => props.confirmStyle ?? "danger"; const handleConfirm = () => { const fn = props.onConfirm; if (typeof fn === "function") { fn(); } handleClose(); }; return ( }> {message()} ); } export interface WizardStepContext { setCanContinue: (complete: boolean) => void; nextStep: () => void; prevStep: () => void; } export interface WizardStep { title: string; content: (stepContext: WizardStepContext) => JSXElement; } interface WizardModalProps { isOpen: Reactive; onClose: () => void; onComplete: () => void; steps: WizardStep[]; size?: ModalSize; centerOnScreen?: boolean; title?: string; finishText?: string; error?: Reactive; } export function WizardModal(props: WizardModalProps) { const isOpen = () => { const val = props.isOpen; return typeof val === "function" ? (val as () => boolean)() : val; }; const handleClose = () => { const fn = props.onClose; if (typeof fn === "function") { fn(); } }; const steps = () => props.steps || []; const size = () => props.size || MODAL_LARGE; const centerOnScreen = () => props.centerOnScreen; const finishText = () => props.finishText ?? "Finish"; const [currentStep, setCurrentStep] = createSignal(0); const [stepContinueFlags, setStepContinueFlags] = createSignal([]); const [openVersion, setOpenVersion] = createSignal(0); const totalSteps = () => steps().length; const isFirstStep = () => currentStep() === 0; const isLastStep = () => currentStep() === totalSteps() - 1; const title = () => props.title ?? steps()[currentStep()]?.title ?? ""; const canContinue = () => !!stepContinueFlags()[currentStep()]; const makeSetCanContinue = (stepIndex: number) => (value: boolean) => { setStepContinueFlags((prev) => { const next = [...prev]; next[stepIndex] = value; return next; }); }; createEffect(() => { if (isOpen()) { setCurrentStep(0); setStepContinueFlags([]); setOpenVersion(v => v + 1); } }); const nextStep = () => { if (!isLastStep()) { setCurrentStep((s) => s + 1); } }; const prevStep = () => { if (!isFirstStep()) { setCurrentStep((s) => s - 1); } }; const handleNext = () => { if (isLastStep()) { const fn = props.onComplete; if (typeof fn === "function") { fn(); } } else { nextStep(); } }; useModalEscape(isOpen, handleClose); const renderedSteps = createMemo(() => { openVersion(); return steps().map((step, index) => { const stepContext: WizardStepContext = { setCanContinue: makeSetCanContinue(index), nextStep, prevStep, }; return step.content(stepContext); }); }); const currentStepTitle = () => steps()[currentStep()]?.title ?? ""; const progressPercent = () => totalSteps() <= 1 ? 100 : (currentStep() / (totalSteps() - 1)) * 100; const stepIndicatorClass = (i: number, cur: number): string => { let c = STEP_INDICATOR_BASE + " "; if (i === cur) c += STEP_INDICATOR_ACTIVE; else if (i < cur) c += STEP_INDICATOR_COMPLETED; else c += STEP_INDICATOR_PENDING; return c; }; const header = () => (
{title()}
{currentStepTitle()}
{(_step, index) => (
{index() < currentStep() ? "✓" : index() + 1}
)}
); const nextBtnClass = () => WIZARD_BTN_BASE + " " + (isLastStep() ? WIZARD_BTN_FINISH : WIZARD_BTN_NEXT); const footer = () => (
); return (
{header()}
{(content, index) => (
{content}
)}
{ const e = props.error; return typeof e === "function" ? (e as () => JSXElement)() : e; })()}>
{(() => { const e = props.error; return typeof e === "function" ? (e as () => JSXElement)() : e; })()}
{footer()}
); }