diff options
Diffstat (limited to 'FrontEnd/src/components/dialog')
-rw-r--r-- | FrontEnd/src/components/dialog/ConfirmDialog.tsx | 54 | ||||
-rw-r--r-- | FrontEnd/src/components/dialog/Dialog.css | 39 | ||||
-rw-r--r-- | FrontEnd/src/components/dialog/Dialog.tsx | 55 | ||||
-rw-r--r-- | FrontEnd/src/components/dialog/DialogContainer.css | 20 | ||||
-rw-r--r-- | FrontEnd/src/components/dialog/DialogContainer.tsx | 95 | ||||
-rw-r--r-- | FrontEnd/src/components/dialog/DialogProvider.tsx | 95 | ||||
-rw-r--r-- | FrontEnd/src/components/dialog/FullPageDialog.css | 30 | ||||
-rw-r--r-- | FrontEnd/src/components/dialog/FullPageDialog.tsx | 52 | ||||
-rw-r--r-- | FrontEnd/src/components/dialog/OperationDialog.css | 4 | ||||
-rw-r--r-- | FrontEnd/src/components/dialog/OperationDialog.tsx | 221 | ||||
-rw-r--r-- | FrontEnd/src/components/dialog/index.tsx | 12 |
11 files changed, 677 insertions, 0 deletions
diff --git a/FrontEnd/src/components/dialog/ConfirmDialog.tsx b/FrontEnd/src/components/dialog/ConfirmDialog.tsx new file mode 100644 index 00000000..8b0a4219 --- /dev/null +++ b/FrontEnd/src/components/dialog/ConfirmDialog.tsx @@ -0,0 +1,54 @@ +import { useC, Text, ThemeColor } from "../common"; + +import Dialog from "./Dialog"; +import DialogContainer from "./DialogContainer"; +import { useCloseDialog } from "./DialogProvider"; + +export default function ConfirmDialog({ + onConfirm, + title, + body, + color, +}: { + onConfirm: () => void; + title: Text; + body: Text; + color?: ThemeColor; + bodyColor?: ThemeColor; +}) { + const c = useC(); + + const closeDialog = useCloseDialog(); + + return ( + <Dialog color={color ?? "danger"}> + <DialogContainer + title={title} + titleColor={color ?? "danger"} + buttonsV2={[ + { + key: "cancel", + type: "normal", + action: "minor", + + text: "operationDialog.cancel", + onClick: closeDialog, + }, + { + key: "confirm", + type: "normal", + action: "major", + text: "operationDialog.confirm", + color: "danger", + onClick: () => { + onConfirm(); + closeDialog(); + }, + }, + ]} + > + <div>{c(body)}</div> + </DialogContainer> + </Dialog> + ); +} diff --git a/FrontEnd/src/components/dialog/Dialog.css b/FrontEnd/src/components/dialog/Dialog.css new file mode 100644 index 00000000..23b663db --- /dev/null +++ b/FrontEnd/src/components/dialog/Dialog.css @@ -0,0 +1,39 @@ +.cru-dialog-overlay {
+ position: fixed;
+ z-index: 1040;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ overflow: auto;
+ padding: 20vh 1em;
+}
+
+.cru-dialog-background {
+ position: absolute;
+ z-index: -1;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ background-color: var(--cru-dialog-overlay-color);
+ opacity: 0.8;
+}
+
+.cru-dialog-container {
+ max-width: 100%;
+ min-width: 30vw;
+
+ margin: 2em auto;
+
+ border: var(--cru-theme-color) 2px solid;
+ border-radius: 5px;
+ padding: 1.5em;
+ background-color: var(--cru-dialog-container-background-color);
+}
+
+@media (min-width: 576px) {
+ .cru-dialog-container {
+ max-width: 800px;
+ }
+}
diff --git a/FrontEnd/src/components/dialog/Dialog.tsx b/FrontEnd/src/components/dialog/Dialog.tsx new file mode 100644 index 00000000..043a8eec --- /dev/null +++ b/FrontEnd/src/components/dialog/Dialog.tsx @@ -0,0 +1,55 @@ +import { ReactNode, useRef } from "react"; +import ReactDOM from "react-dom"; +import classNames from "classnames"; + +import { ThemeColor, UiLogicError } from "../common"; + +import { useCloseDialog } from "./DialogProvider"; + +import "./Dialog.css"; + +const optionalPortalElement = document.getElementById("portal"); +if (optionalPortalElement == null) { + throw new UiLogicError(); +} +const portalElement = optionalPortalElement; + +interface DialogProps { + color?: ThemeColor; + children?: ReactNode; + disableCloseOnClickOnOverlay?: boolean; +} + +export default function Dialog({ + color, + children, + disableCloseOnClickOnOverlay, +}: DialogProps) { + const closeDialog = useCloseDialog(); + + const lastPointerDownIdRef = useRef<number | null>(null); + + return ReactDOM.createPortal( + <div + className={classNames( + `cru-theme-${color ?? "primary"}`, + "cru-dialog-overlay", + )} + > + <div + className="cru-dialog-background" + onPointerDown={(e) => { + lastPointerDownIdRef.current = e.pointerId; + }} + onPointerUp={(e) => { + if (lastPointerDownIdRef.current === e.pointerId) { + if (!disableCloseOnClickOnOverlay) closeDialog(); + } + lastPointerDownIdRef.current = null; + }} + /> + <div className="cru-dialog-container">{children}</div> + </div>, + portalElement, + ); +} diff --git a/FrontEnd/src/components/dialog/DialogContainer.css b/FrontEnd/src/components/dialog/DialogContainer.css new file mode 100644 index 00000000..f0d27a66 --- /dev/null +++ b/FrontEnd/src/components/dialog/DialogContainer.css @@ -0,0 +1,20 @@ +.cru-dialog-container-title { + font-size: 1.2em; + font-weight: bold; + color: var(--cru-theme-color); + margin-bottom: 0.5em; +} + +.cru-dialog-container-hr { + margin: 1em 0; + border-color: var(--cru-text-minor-color); +} + +.cru-dialog-container-button-row { + display: flex; + justify-content: flex-end; +} + +.cru-dialog-container-button { + margin-left: 1em; +} diff --git a/FrontEnd/src/components/dialog/DialogContainer.tsx b/FrontEnd/src/components/dialog/DialogContainer.tsx new file mode 100644 index 00000000..6ee4e134 --- /dev/null +++ b/FrontEnd/src/components/dialog/DialogContainer.tsx @@ -0,0 +1,95 @@ +import { ComponentProps, Ref, ReactNode } from "react"; +import classNames from "classnames"; + +import { ThemeColor, Text, useC } from "../common"; +import { ButtonRow, ButtonRowV2 } from "../button"; + +import "./DialogContainer.css"; + +interface DialogContainerBaseProps { + className?: string; + title: Text; + titleColor?: ThemeColor; + titleClassName?: string; + titleRef?: Ref<HTMLDivElement>; + bodyContainerClassName?: string; + bodyContainerRef?: Ref<HTMLDivElement>; + buttonsClassName?: string; + buttonsContainerRef?: ComponentProps<typeof ButtonRow>["containerRef"]; + children: ReactNode; +} + +interface DialogContainerWithButtonsProps extends DialogContainerBaseProps { + buttons: ComponentProps<typeof ButtonRow>["buttons"]; +} + +interface DialogContainerWithButtonsV2Props extends DialogContainerBaseProps { + buttonsV2: ComponentProps<typeof ButtonRowV2>["buttons"]; +} + +type DialogContainerProps = + | DialogContainerWithButtonsProps + | DialogContainerWithButtonsV2Props; + +export default function DialogContainer(props: DialogContainerProps) { + const { + className, + title, + titleColor, + titleClassName, + titleRef, + bodyContainerClassName, + bodyContainerRef, + buttonsClassName, + buttonsContainerRef, + children, + } = props; + + const c = useC(); + + return ( + <div className={classNames(className)}> + <div + ref={titleRef} + className={classNames( + `cru-dialog-container-title cru-theme-${titleColor ?? "primary"}`, + titleClassName, + )} + > + {c(title)} + </div> + <hr className="cru-dialog-container-hr" /> + <div + ref={bodyContainerRef} + className={classNames( + "cru-dialog-container-body", + bodyContainerClassName, + )} + > + {children} + </div> + <hr className="cru-dialog-container-hr" /> + {"buttons" in props ? ( + <ButtonRow + containerRef={buttonsContainerRef} + className={classNames( + "cru-dialog-container-button-row", + buttonsClassName, + )} + buttons={props.buttons} + buttonsClassName="cru-dialog-container-button" + /> + ) : ( + <ButtonRowV2 + containerRef={buttonsContainerRef} + className={classNames( + "cru-dialog-container-button-row", + buttonsClassName, + )} + buttons={props.buttonsV2} + buttonsClassName="cru-dialog-container-button" + /> + )} + </div> + ); +} diff --git a/FrontEnd/src/components/dialog/DialogProvider.tsx b/FrontEnd/src/components/dialog/DialogProvider.tsx new file mode 100644 index 00000000..bb85e4cf --- /dev/null +++ b/FrontEnd/src/components/dialog/DialogProvider.tsx @@ -0,0 +1,95 @@ +import { useState, useContext, createContext, ReactNode } from "react"; + +import { UiLogicError } from "../common"; + +type DialogMap<D extends string> = { + [K in D]: ReactNode; +}; + +interface DialogController<D extends string> { + currentDialog: D | null; + currentDialogReactNode: ReactNode; + canSwitchDialog: boolean; + switchDialog: (newDialog: D | null) => void; + setCanSwitchDialog: (enable: boolean) => void; + closeDialog: () => void; + forceSwitchDialog: (newDialog: D | null) => void; + forceCloseDialog: () => void; +} + +export function useDialog<D extends string>( + dialogs: DialogMap<D>, + options?: { + initDialog?: D | null; + onClose?: { + [K in D]?: () => void; + }; + }, +): { + controller: DialogController<D>; + switchDialog: (newDialog: D | null) => void; + forceSwitchDialog: (newDialog: D | null) => void; + createDialogSwitch: (newDialog: D | null) => () => void; +} { + const [canSwitchDialog, setCanSwitchDialog] = useState<boolean>(true); + const [dialog, setDialog] = useState<D | null>(options?.initDialog ?? null); + + const forceSwitchDialog = (newDialog: D | null) => { + if (dialog != null) { + options?.onClose?.[dialog]?.(); + } + setDialog(newDialog); + setCanSwitchDialog(true); + }; + + const switchDialog = (newDialog: D | null) => { + if (canSwitchDialog) { + forceSwitchDialog(newDialog); + } + }; + + const controller: DialogController<D> = { + currentDialog: dialog, + currentDialogReactNode: dialog == null ? null : dialogs[dialog], + canSwitchDialog, + switchDialog, + setCanSwitchDialog, + closeDialog: () => switchDialog(null), + forceSwitchDialog, + forceCloseDialog: () => forceSwitchDialog(null), + }; + + return { + controller, + switchDialog, + forceSwitchDialog, + createDialogSwitch: (newDialog: D | null) => () => switchDialog(newDialog), + }; +} + +const DialogControllerContext = createContext<DialogController<string> | null>( + null, +); + +export function useDialogController(): DialogController<string> { + const controller = useContext(DialogControllerContext); + if (controller == null) throw new UiLogicError("not in dialog provider"); + return controller; +} + +export function useCloseDialog(): () => void { + const controller = useDialogController(); + return controller.closeDialog; +} + +export function DialogProvider<D extends string>({ + controller, +}: { + controller: DialogController<D>; +}) { + return ( + <DialogControllerContext.Provider value={controller as never}> + {controller.currentDialogReactNode} + </DialogControllerContext.Provider> + ); +} diff --git a/FrontEnd/src/components/dialog/FullPageDialog.css b/FrontEnd/src/components/dialog/FullPageDialog.css new file mode 100644 index 00000000..ce07c6ac --- /dev/null +++ b/FrontEnd/src/components/dialog/FullPageDialog.css @@ -0,0 +1,30 @@ +.cru-dialog-full-page {
+ position: fixed;
+ z-index: 1030;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ background-color: var(--cru-background-color);
+ padding-top: 56px;
+}
+
+.cru-dialog-full-page-top-bar {
+ height: 56px;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 1;
+ background-color: var(--cru-theme-color);
+ display: flex;
+ align-items: center;
+}
+
+.cru-dialog-full-page-content-container {
+ overflow: scroll;
+}
+
+.cru-dialog-full-page-back-button {
+ margin-left: 0.5em;
+}
diff --git a/FrontEnd/src/components/dialog/FullPageDialog.tsx b/FrontEnd/src/components/dialog/FullPageDialog.tsx new file mode 100644 index 00000000..575abf7f --- /dev/null +++ b/FrontEnd/src/components/dialog/FullPageDialog.tsx @@ -0,0 +1,52 @@ +import { ReactNode } from "react"; +import { createPortal } from "react-dom"; +import classNames from "classnames"; + +import { ThemeColor, UiLogicError } from "../common"; +import { IconButton } from "../button"; + +import { useCloseDialog } from "./DialogProvider"; + +import "./FullPageDialog.css"; + +const optionalPortalElement = document.getElementById("portal"); +if (optionalPortalElement == null) { + throw new UiLogicError(); +} +const portalElement = optionalPortalElement; + +interface FullPageDialogProps { + color?: ThemeColor; + contentContainerClassName?: string; + children: ReactNode; +} + +export default function FullPageDialog({ + color, + children, + contentContainerClassName, +}: FullPageDialogProps) { + const closeDialog = useCloseDialog(); + + return createPortal( + <div className={`cru-dialog-full-page cru-theme-${color ?? "primary"}`}> + <div className="cru-dialog-full-page-top-bar"> + <IconButton + icon="arrow-left" + color="light" + className="cru-dialog-full-page-back-button" + onClick={closeDialog} + /> + </div> + <div + className={classNames( + "cru-dialog-full-page-content-container", + contentContainerClassName, + )} + > + {children} + </div> + </div>, + portalElement, + ); +} diff --git a/FrontEnd/src/components/dialog/OperationDialog.css b/FrontEnd/src/components/dialog/OperationDialog.css new file mode 100644 index 00000000..28f73c9d --- /dev/null +++ b/FrontEnd/src/components/dialog/OperationDialog.css @@ -0,0 +1,4 @@ +.cru-operation-dialog-input-group {
+ display: block;
+ margin: 0.5em 0;
+}
diff --git a/FrontEnd/src/components/dialog/OperationDialog.tsx b/FrontEnd/src/components/dialog/OperationDialog.tsx new file mode 100644 index 00000000..6ca4d0a0 --- /dev/null +++ b/FrontEnd/src/components/dialog/OperationDialog.tsx @@ -0,0 +1,221 @@ +import { useState, ReactNode, ComponentProps } from "react"; +import classNames from "classnames"; + +import { useC, Text, ThemeColor } from "../common"; +import { + useInputs, + InputGroup, + Initializer as InputInitializer, + InputConfirmValueDict, +} from "../input"; +import { ButtonRowV2 } from "../button"; +import Dialog from "./Dialog"; +import DialogContainer from "./DialogContainer"; +import { useDialogController } from "./DialogProvider"; + +import "./OperationDialog.css"; + +interface OperationDialogPromptProps { + message?: Text; + customMessage?: Text; + customMessageNode?: ReactNode; + className?: string; +} + +function OperationDialogPrompt(props: OperationDialogPromptProps) { + const { message, customMessage, customMessageNode, className } = props; + + const c = useC(); + + return ( + <div className={classNames(className, "cru-operation-dialog-prompt")}> + {message && <p>{c(message)}</p>} + {customMessageNode ?? (customMessage != null ? c(customMessage) : null)} + </div> + ); +} + +export interface OperationDialogProps<TData> { + color?: ThemeColor; + inputColor?: ThemeColor; + title: Text; + inputPrompt?: Text; + inputPromptNode?: ReactNode; + successPrompt?: (data: TData) => Text; + successPromptNode?: (data: TData) => ReactNode; + failurePrompt?: (error: unknown) => Text; + failurePromptNode?: (error: unknown) => ReactNode; + + inputs: InputInitializer; + + onProcess: (inputs: InputConfirmValueDict) => Promise<TData>; + onSuccessAndClose?: (data: TData) => void; +} + +function OperationDialog<TData>(props: OperationDialogProps<TData>) { + const { + color, + inputColor, + title, + inputPrompt, + inputPromptNode, + successPrompt, + successPromptNode, + failurePrompt, + failurePromptNode, + inputs, + onProcess, + onSuccessAndClose, + } = props; + + if (process.env.NODE_ENV === "development") { + if (inputPrompt && inputPromptNode) { + console.log("InputPrompt and inputPromptNode are both set."); + } + if (successPrompt && successPromptNode) { + console.log("SuccessPrompt and successPromptNode are both set."); + } + if (failurePrompt && failurePromptNode) { + console.log("FailurePrompt and failurePromptNode are both set."); + } + } + + type Step = + | { type: "input" } + | { type: "process" } + | { + type: "success"; + data: TData; + } + | { + type: "failure"; + data: unknown; + }; + + const dialogController = useDialogController(); + + const [step, setStep] = useState<Step>({ type: "input" }); + + const { inputGroupProps, hasErrorAndDirty, setAllDisabled, confirm } = + useInputs({ + init: inputs, + }); + + function close() { + if (step.type !== "process") { + dialogController.closeDialog(); + if (step.type === "success" && onSuccessAndClose) { + onSuccessAndClose?.(step.data); + } + } else { + console.log("Attempt to close modal dialog when processing."); + } + } + + function onConfirm() { + const result = confirm(); + if (result.type === "ok") { + setStep({ type: "process" }); + dialogController.setCanSwitchDialog(false); + setAllDisabled(true); + onProcess(result.values) + .then( + (d) => { + setStep({ + type: "success", + data: d, + }); + }, + (e: unknown) => { + setStep({ + type: "failure", + data: e, + }); + }, + ) + .finally(() => { + dialogController.setCanSwitchDialog(true); + }); + } + } + + let body: ReactNode; + let buttons: ComponentProps<typeof ButtonRowV2>["buttons"]; + + if (step.type === "input" || step.type === "process") { + const isProcessing = step.type === "process"; + + body = ( + <div> + <OperationDialogPrompt + customMessage={inputPrompt} + customMessageNode={inputPromptNode} + /> + <InputGroup + containerClassName="cru-operation-dialog-input-group" + color={inputColor ?? "primary"} + {...inputGroupProps} + /> + </div> + ); + buttons = [ + { + key: "cancel", + text: "operationDialog.cancel", + onClick: close, + disabled: isProcessing, + }, + { + key: "confirm", + type: "loading", + action: "major", + text: "operationDialog.confirm", + color, + loading: isProcessing, + disabled: hasErrorAndDirty, + onClick: onConfirm, + }, + ]; + } else { + const result = step; + + const promptProps: OperationDialogPromptProps = + result.type === "success" + ? { + message: "operationDialog.success", + customMessage: successPrompt?.(result.data), + customMessageNode: successPromptNode?.(result.data), + } + : { + message: "operationDialog.error", + customMessage: failurePrompt?.(result.data), + customMessageNode: failurePromptNode?.(result.data), + }; + body = ( + <div> + <OperationDialogPrompt {...promptProps} /> + </div> + ); + + buttons = [ + { + key: "ok", + type: "normal", + action: "major", + color: "create", + text: "operationDialog.ok", + onClick: close, + }, + ]; + } + + return ( + <Dialog color={color}> + <DialogContainer title={title} titleColor={color} buttonsV2={buttons}> + {body} + </DialogContainer> + </Dialog> + ); +} + +export default OperationDialog; diff --git a/FrontEnd/src/components/dialog/index.tsx b/FrontEnd/src/components/dialog/index.tsx new file mode 100644 index 00000000..9ca06de2 --- /dev/null +++ b/FrontEnd/src/components/dialog/index.tsx @@ -0,0 +1,12 @@ +export { default as Dialog } from "./Dialog"; +export { default as FullPageDialog } from "./FullPageDialog"; +export { default as OperationDialog } from "./OperationDialog"; +export { default as ConfirmDialog } from "./ConfirmDialog"; +export { default as DialogContainer } from "./DialogContainer"; + +export { + useDialog, + useDialogController, + useCloseDialog, + DialogProvider, +} from "./DialogProvider"; |