Files
kjol/go/jsruntime/uikit/Modal.tsx

581 lines
20 KiB
TypeScript

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 | (() => T);
interface ModalOptions {
size?: ModalSize;
centerOnScreen?: boolean;
}
interface ModalContextValue {
openModal: (content: JSXElement, options?: ModalOptions) => void;
closeModal: () => void;
isOpen: () => boolean;
}
const ModalContext = createContext<ModalContextValue | null>(null);
const resolve = <T,>(val: Reactive<T>): 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-surface shadow-md text-sm w-full rounded-default max-h-[calc(100dvh_-_5rem)] flex flex-col overflow-hidden";
const MODAL_SIZES: Record<ModalSize, string> = {
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-line text-lg font-semibold text-ink";
const HEADER_CLOSE_ONLY = "flex items-center justify-end p-4 pb-1";
const CLOSE_BTN = "cursor-pointer text-ink-muted bg-transparent border-0 p-0 leading-none hover:text-ink";
const BODY = "px-7 py-6 overflow-y-auto flex-1 min-h-0 [background:linear-gradient(var(--color-surface),var(--color-surface))_bottom_/_100%_3rem_no-repeat_local,linear-gradient(to_bottom,transparent,var(--color-surface))_bottom_/_100%_3rem_no-repeat_scroll,var(--color-surface)]";
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-line bg-surface-muted 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 dark:text-red-400 bg-red-50 dark:bg-red-950/40 border-t border-red-200 dark:border-red-900";
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-line-strong rounded-default bg-transparent cursor-pointer hover:bg-surface-muted";
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-ink-soft 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-surface-strong -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-line-strong text-ink-faint bg-surface";
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-line-strong text-ink enabled:hover:bg-surface-muted";
const WIZARD_BTN_NEXT = "bg-fill-neutral text-on-fill-neutral enabled:hover:bg-fill-neutral-hover";
const WIZARD_BTN_FINISH = "bg-primary text-white enabled:hover:bg-red-700";
interface ModalDisplayProps {
size?: Reactive<ModalSize | undefined>;
centerOnScreen?: Reactive<boolean | undefined>;
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 (
<dialog open class={getContainerClass()}>
<div class={BACKDROP} style={getBackdropStyle()} onclick={handleClose}></div>
<div class={getModalClass()} style={getModalStyle()}>
{resolve(props.children)}
</div>
</dialog>
);
}
interface ModalProviderProps {
children?: JSXElement;
}
export function ModalProvider(props: ModalProviderProps) {
const [isOpen, setIsOpen] = createSignal(false);
const [content, setContent] = createSignal<JSXElement>(null);
const [options, setOptions] = createSignal<ModalOptions>({});
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 (
<ModalContext.Provider value={value}>
{props.children}
<Portal>
<Show when={isOpen()}>
<ModalDisplay size={options().size} centerOnScreen={options().centerOnScreen} onClose={closeModal} children={content()}/>
</Show>
</Portal>
</ModalContext.Provider>
);
}
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 [
<Show when={header() === undefined}>
<div class={HEADER_CLOSE_ONLY}>
<button onclick={handleClose} class={CLOSE_BTN}>
<Icon icon="xmark" size={24}/>
</button>
</div>
</Show>,
<Show when={header() !== undefined && header() !== null}>
<div class={HEADER}>
{header()}
<button onclick={handleClose} class={CLOSE_BTN}>
<Icon icon="xmark" size={24}/>
</button>
</div>
</Show>,
<div class={BODY}>{props.children}</div>,
<Show when={footer() === undefined}>
<div class={FOOTER_SPACER}></div>
</Show>,
<Show when={footer() !== undefined && footer() !== null}>
<div class={FOOTER}>{footer()}</div>
</Show>,
];
}
interface ModalProps {
isOpen: Reactive<boolean>;
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 (
<Portal>
<Show when={isOpen()}>
<ModalDisplay size={size()} centerOnScreen={centerOnScreen()} onClose={handleClose}>
<Show when={header() === undefined}>
<div class={HEADER_CLOSE_ONLY}>
<button onclick={handleClose} class={CLOSE_BTN}>
<Icon icon="xmark" size={24}/>
</button>
</div>
</Show>
<Show when={header() !== undefined && header() !== null}>
<div class={HEADER}>
{header()}
<button onclick={handleClose} class={CLOSE_BTN}>
<Icon icon="xmark" size={24}/>
</button>
</div>
</Show>
<div class={BODY}>{props.children}</div>
<Show when={footer() === undefined}>
<div class={FOOTER_SPACER}></div>
</Show>
<Show when={footer() !== undefined && footer() !== null}>
<div class={FOOTER}>{footer()}</div>
</Show>
</ModalDisplay>
</Show>
</Portal>
);
}
interface ConfirmModalProps {
isOpen: Reactive<boolean>;
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 (
<Modal isOpen={isOpen()} onClose={handleClose} size={MODAL_SMALL} centerOnScreen={true} header={title()} footer={
<div class={CONFIRM_WRAP}>
<button onclick={handleClose} class={CONFIRM_CANCEL}>
{cancelText()}
</button>
<button onclick={handleConfirm} class={CONFIRM_OK_BASE + " " + CONFIRM_OK_VARIANTS[confirmStyle()]}>
{confirmText()}
</button>
</div>
}>
{message()}
</Modal>
);
}
export interface WizardStepContext {
setCanContinue: (complete: boolean) => void;
nextStep: () => void;
prevStep: () => void;
}
export interface WizardStep {
title: string;
content: (stepContext: WizardStepContext) => JSXElement;
}
interface WizardModalProps {
isOpen: Reactive<boolean>;
onClose: () => void;
onComplete: () => void;
steps: WizardStep[];
size?: ModalSize;
centerOnScreen?: boolean;
title?: string;
finishText?: string;
error?: Reactive<string | null | undefined>;
}
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<boolean[]>([]);
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 = () => (
<div class={WIZARD_HEADER}>
<div class={WIZARD_TITLE_ROW}>
<span class={WIZARD_TITLE}>{title()}</span>
<button onclick={handleClose} class={CLOSE_BTN}>
<Icon icon="xmark" size={24}/>
</button>
</div>
<div class={WIZARD_STEP_NAME}>{currentStepTitle()}</div>
<div class={WIZARD_STEPS}>
<div class={WIZARD_TRACK}>
<div class={WIZARD_TRACK_FILL} style={`width:${progressPercent()}%`}/>
</div>
<For each={steps()}>{(_step, index) => (
<div class={WIZARD_STEP_WRAP}>
<div class={stepIndicatorClass(index(), currentStep())}>{index() < currentStep() ? "✓" : index() + 1}</div>
</div>
)}</For>
</div>
</div>
);
const nextBtnClass = () => WIZARD_BTN_BASE + " " + (isLastStep() ? WIZARD_BTN_FINISH : WIZARD_BTN_NEXT);
const footer = () => (
<div class={WIZARD_FOOTER}>
<button onclick={prevStep} class={WIZARD_BTN_BASE + " " + WIZARD_BTN_BACK} disabled={isFirstStep()}>Back</button>
<button onclick={handleNext} class={nextBtnClass()} disabled={!canContinue()}>{isLastStep() ? finishText() : "Next"}</button>
</div>
);
return (
<Portal>
<Show when={isOpen()}>
<ModalDisplay size={size()} centerOnScreen={centerOnScreen()} onClose={handleClose}>
<div class={HEADER}>
{header()}
</div>
<div class={BODY}>
<For each={renderedSteps()}>{(content, index) => (
<div style={index() === currentStep()
? ""
: "display:none"
}>{content}</div>
)}</For>
</div>
<Show when={(() => { const e = props.error; return typeof e === "function" ? (e as () => JSXElement)() : e; })()}>
<div class={WIZARD_ERROR}>
<Icon icon="circle-exclamation" size={16} class={WIZARD_ERROR_ICON}/>
<span>{(() => { const e = props.error; return typeof e === "function" ? (e as () => JSXElement)() : e; })()}</span>
</div>
</Show>
<div class={FOOTER}>
{footer()}
</div>
</ModalDisplay>
</Show>
</Portal>
);
}