581 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|