mirror of
https://github.com/ente-io/ente.git
synced 2025-06-04 00:01:58 +00:00
390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
import { FocusVisibleButton } from "@/base/components/mui/FocusVisibleButton";
|
|
import { LoadingButton } from "@/base/components/mui/LoadingButton";
|
|
import type { ButtonProps, ModalProps } from "@mui/material";
|
|
import {
|
|
Box,
|
|
Dialog,
|
|
DialogContent,
|
|
DialogTitle,
|
|
Stack,
|
|
Typography,
|
|
type DialogProps,
|
|
} from "@mui/material";
|
|
import { t } from "i18next";
|
|
import React, { useState } from "react";
|
|
import log from "../log";
|
|
import { InlineErrorIndicator } from "./ErrorIndicator";
|
|
|
|
/**
|
|
* Customize the contents of an {@link AttributedMiniDialog}.
|
|
*/
|
|
export interface MiniDialogAttributes {
|
|
/**
|
|
* The dialog's title.
|
|
*
|
|
* This will be usually be a string, but the prop accepts any React node to
|
|
* allow passing a i18next <Trans /> component.
|
|
*/
|
|
title?: React.ReactNode;
|
|
/**
|
|
* An optional component shown next to the title.
|
|
*/
|
|
icon?: React.ReactNode;
|
|
/**
|
|
* The dialog's message.
|
|
*
|
|
* This will be usually be a string, but the prop accepts any React node to
|
|
* allow passing a i18next <Trans /> component.
|
|
*/
|
|
message?: React.ReactNode;
|
|
/**
|
|
* If `true`, then the dialog cannot be closed (e.g. with the ESC key, or
|
|
* clicking on the backdrop) except through one of the explicitly provided
|
|
* actions.
|
|
*/
|
|
nonClosable?: boolean;
|
|
/**
|
|
* Customize the primary action button shown in the dialog.
|
|
*
|
|
* This is provided by boxes which serve as some sort of confirmation. If
|
|
* not provided, only the {@link cancel} button is shown, unless that too is
|
|
* explicitly disabled.
|
|
*/
|
|
continue?: {
|
|
/**
|
|
* The string to use as the label for the primary action button.
|
|
*
|
|
* Default is `t("ok")`.
|
|
*/
|
|
text?: string;
|
|
/**
|
|
* The color of the button.
|
|
*
|
|
* Default is "accent".
|
|
*/
|
|
color?: ButtonProps["color"];
|
|
/**
|
|
* If `true`, the primary action button is auto focused when the dialog
|
|
* is opened, allowing the user to confirm just by pressing ENTER.
|
|
*/
|
|
autoFocus?: ButtonProps["autoFocus"];
|
|
/**
|
|
* The function to call when the user activates the button.
|
|
*
|
|
* If this function returns a promise, then an activity indicator will
|
|
* be shown on the button until the promise settles.
|
|
*
|
|
* If this function is not provided, or if the function completes /
|
|
* fullfills, then then the dialog is automatically closed.
|
|
*
|
|
* Otherwise (that is, if the provided function throws), the dialog
|
|
* remains open, showing a generic error.
|
|
*
|
|
* That's quite a mouthful, here's a flowchart:
|
|
*
|
|
* - Not provided: Close
|
|
* - Provided sync:
|
|
* - Success: Close
|
|
* - Failure: Remain open, showing generic error
|
|
* - Provided async:
|
|
* - Success: Close
|
|
* - Failure: Remain open, showing generic error
|
|
*/
|
|
action?: () => void | Promise<void>;
|
|
};
|
|
/**
|
|
* Customize the secondary action button shown in the dialog.
|
|
*
|
|
* This is rarely needed. When provided, these attributes behave similar to
|
|
* the {@link continue} attributes, except this button is shown below the
|
|
* primary button.
|
|
*
|
|
* This is not supported when button direction is "row".
|
|
*/
|
|
secondary?: {
|
|
/**
|
|
* The string to use as the label for the secondary action button.
|
|
*
|
|
* Must be provided.
|
|
*/
|
|
text: string;
|
|
/**
|
|
* The color of the button.
|
|
*
|
|
* Default is "primary".
|
|
*/
|
|
color?: ButtonProps["color"];
|
|
/**
|
|
* The function to call when the user activates the button.
|
|
*
|
|
* The behaviour of this function is exactly the same as that of the
|
|
* primary {@link action} provided via the {@link continue} attributes.
|
|
*/
|
|
action?: () => void | Promise<void>;
|
|
};
|
|
/**
|
|
* The string to use as the label for the cancel button.
|
|
*
|
|
* Default is `t("cancel")`.
|
|
*
|
|
* Set this to `false` to omit the cancel button altogether.
|
|
*
|
|
* The object form allows providing both the button title and the action
|
|
* handler (synchronous). The dialog is always closed on clicks.
|
|
*/
|
|
cancel?:
|
|
| string
|
|
| false
|
|
| {
|
|
text: string;
|
|
action: () => void;
|
|
};
|
|
/** The direction in which the buttons are stacked. Default is "column". */
|
|
buttonDirection?: "row" | "column";
|
|
}
|
|
|
|
type MiniDialogProps = Omit<DialogProps, "onClose"> & {
|
|
onClose: () => void;
|
|
attributes?: MiniDialogAttributes;
|
|
};
|
|
|
|
/**
|
|
* A small, mostly predefined, MUI {@link Dialog} that can be used to notify the
|
|
* user, or ask for confirmation before actions.
|
|
*
|
|
* The rendered dialog can be customized by modifying the {@link attributes}
|
|
* prop. If you find yourself wanting to customize it further, consider either
|
|
* using a {@link TitledMiniDialog} or {@link Dialog}.
|
|
*/
|
|
export const AttributedMiniDialog: React.FC<
|
|
React.PropsWithChildren<MiniDialogProps>
|
|
> = ({ open, onClose, attributes, children, ...props }) => {
|
|
const [phase, setPhase] = useState<
|
|
"loading" | "secondary-loading" | "failed" | undefined
|
|
>();
|
|
|
|
if (!attributes) {
|
|
return <></>;
|
|
}
|
|
|
|
const resetPhaseAndClose = () => {
|
|
setPhase(undefined);
|
|
onClose();
|
|
};
|
|
|
|
const handleClose: ModalProps["onClose"] = (_, reason) => {
|
|
if (attributes.nonClosable) return;
|
|
// Ignore backdrop clicks when we're processing the user request.
|
|
if (
|
|
reason == "backdropClick" &&
|
|
(phase == "loading" || phase == "secondary-loading")
|
|
) {
|
|
return;
|
|
}
|
|
resetPhaseAndClose();
|
|
};
|
|
|
|
const [cancelTitle, handleCancel] = ((
|
|
c: MiniDialogAttributes["cancel"],
|
|
) => {
|
|
if (c === false) return [undefined, undefined];
|
|
if (c === undefined) return [t("cancel"), resetPhaseAndClose];
|
|
if (typeof c == "string") return [c, resetPhaseAndClose];
|
|
return [
|
|
c.text,
|
|
() => {
|
|
resetPhaseAndClose();
|
|
c.action();
|
|
},
|
|
];
|
|
})(attributes.cancel);
|
|
|
|
const { PaperProps, ...rest } = props;
|
|
|
|
const loadingButton = attributes.continue && (
|
|
<LoadingButton
|
|
loading={phase == "loading"}
|
|
disabled={phase == "secondary-loading"}
|
|
fullWidth
|
|
color={attributes.continue.color ?? "accent"}
|
|
autoFocus={attributes.continue.autoFocus}
|
|
onClick={async () => {
|
|
setPhase("loading");
|
|
try {
|
|
await attributes.continue?.action?.();
|
|
resetPhaseAndClose();
|
|
} catch (e) {
|
|
log.error(e);
|
|
setPhase("failed");
|
|
}
|
|
}}
|
|
>
|
|
{attributes.continue.text ?? t("ok")}
|
|
</LoadingButton>
|
|
);
|
|
|
|
const secondaryLoadingButton = attributes.secondary?.text && (
|
|
<LoadingButton
|
|
disabled={phase == "loading"}
|
|
loading={phase == "secondary-loading"}
|
|
fullWidth
|
|
color={attributes.secondary.color ?? "primary"}
|
|
onClick={async () => {
|
|
setPhase("secondary-loading");
|
|
try {
|
|
await attributes.secondary?.action?.();
|
|
resetPhaseAndClose();
|
|
} catch (e) {
|
|
log.error(e);
|
|
setPhase("failed");
|
|
}
|
|
}}
|
|
>
|
|
{attributes.secondary.text}
|
|
</LoadingButton>
|
|
);
|
|
|
|
if (secondaryLoadingButton && attributes.buttonDirection == "row")
|
|
throw new Error("Unsupported combination");
|
|
|
|
const cancelButton = cancelTitle && (
|
|
<FocusVisibleButton
|
|
fullWidth
|
|
color="secondary"
|
|
disabled={phase == "loading"}
|
|
onClick={handleCancel}
|
|
>
|
|
{cancelTitle}
|
|
</FocusVisibleButton>
|
|
);
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
fullWidth
|
|
PaperProps={{
|
|
...PaperProps,
|
|
sx: {
|
|
maxWidth: "360px",
|
|
...PaperProps?.sx,
|
|
},
|
|
}}
|
|
onClose={handleClose}
|
|
{...rest}
|
|
>
|
|
{(attributes.icon ?? attributes.title) ? (
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
"& > svg": {
|
|
fontSize: "32px",
|
|
color: (theme) => theme.colors.stroke.faint,
|
|
},
|
|
padding:
|
|
attributes.icon && attributes.title
|
|
? "20px 16px 0px 16px"
|
|
: "24px 16px 4px 16px",
|
|
}}
|
|
>
|
|
{attributes.title && (
|
|
<DialogTitle
|
|
sx={{
|
|
"&&&": { padding: 0 },
|
|
// Wrap the title to the next line if there
|
|
// isn't sufficient space to make it fit in one.
|
|
flexShrink: 1,
|
|
}}
|
|
>
|
|
{attributes.title}
|
|
</DialogTitle>
|
|
)}
|
|
{attributes.icon}
|
|
</Box>
|
|
) : (
|
|
<Box sx={{ height: "8px" }} /> /* Spacer */
|
|
)}
|
|
<DialogContent>
|
|
{attributes.message && (
|
|
<Typography
|
|
component={
|
|
typeof attributes.message == "string" ? "p" : "div"
|
|
}
|
|
color="text.muted"
|
|
>
|
|
{attributes.message}
|
|
</Typography>
|
|
)}
|
|
{children}
|
|
<Stack
|
|
sx={{ paddingBlockStart: "24px", gap: "8px" }}
|
|
direction={attributes.buttonDirection ?? "column"}
|
|
>
|
|
{phase == "failed" && <InlineErrorIndicator />}
|
|
{attributes.buttonDirection == "row" ? (
|
|
<>
|
|
{cancelButton}
|
|
{loadingButton}
|
|
</>
|
|
) : (
|
|
<>
|
|
{loadingButton}
|
|
{secondaryLoadingButton}
|
|
{cancelButton}
|
|
</>
|
|
)}
|
|
</Stack>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
type TitledMiniDialogProps = Omit<DialogProps, "onClose"> & {
|
|
onClose: () => void;
|
|
/**
|
|
* The dialog's title.
|
|
*/
|
|
title?: React.ReactNode;
|
|
};
|
|
|
|
/**
|
|
* MiniDialog in a "shell" form.
|
|
*
|
|
* This is a {@link Dialog} for use at places which need more customization than
|
|
* what {@link AttributedMiniDialog} provides, but wish to retain a similar look
|
|
* and feel without duplicating code.
|
|
*
|
|
* It does three things:
|
|
*
|
|
* - Sets a fixed size and padding similar to {@link AttributedMiniDialog}.
|
|
* - Takes the title as a prop, and wraps it in a {@link DialogTitle}.
|
|
* - Wraps children in a scrollable {@link DialogContent}.
|
|
*/
|
|
export const TitledMiniDialog: React.FC<
|
|
React.PropsWithChildren<TitledMiniDialogProps>
|
|
> = ({ open, onClose, title, children, ...props }) => {
|
|
const { PaperProps, ...rest } = props;
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onClose={onClose}
|
|
fullWidth
|
|
PaperProps={{
|
|
...PaperProps,
|
|
sx: {
|
|
maxWidth: "360px",
|
|
...PaperProps?.sx,
|
|
},
|
|
}}
|
|
{...rest}
|
|
>
|
|
<DialogTitle sx={{ "&&&": { paddingBlock: "24px 16px" } }}>
|
|
{title}
|
|
</DialogTitle>
|
|
<DialogContent>{children}</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|