Skip to content

Commit

Permalink
Implement feedback behavior of button
Browse files Browse the repository at this point in the history
  • Loading branch information
jamesricky committed Dec 30, 2024
1 parent 0d58521 commit 39bfac2
Show file tree
Hide file tree
Showing 3 changed files with 243 additions and 42 deletions.
150 changes: 118 additions & 32 deletions packages/admin/admin/src/common/buttons/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,32 @@
import { ThreeDotSaving } from "@comet/admin-icons";
import {
Breakpoint,
Button as MuiButton,
ButtonProps as MuiButtonProps,
ComponentsOverrides,
css,
Theme,
Tooltip,
useTheme,
useThemeProps,
} from "@mui/material";
import { ReactNode } from "react";
import { ReactNode, useState } from "react";
import { FormattedMessage } from "react-intl";

import { createComponentSlot } from "../../helpers/createComponentSlot";
import { ThemedComponentBaseProps } from "../../helpers/ThemedComponentBaseProps";
import { useWindowSize } from "../../helpers/useWindowSize";
import { Tooltip } from "../Tooltip";

type StateClassKey = "usingResponsiveBehavior";
type SlotClassKey = "root" | "mobileTooltip";
type StateClassKey = "usingResponsiveBehavior" | "feedbackStateLoading" | "feedbackStateSuccess" | "feedbackStateError";
type SlotClassKey = "root" | "mobileTooltip" | "successFeedback" | "errorFeedback";

export type ButtonClassKey = StateClassKey | SlotClassKey;

type ButtonThemeProps = ThemedComponentBaseProps<{
root: typeof MuiButton;
mobileTooltip: typeof Tooltip;
successFeedback: typeof Tooltip;
errorFeedback: typeof Tooltip;
}>;

type ResponsiveBehaviorSettings = {
Expand All @@ -35,39 +39,31 @@ type ResponsiveBehaviorOptions = Omit<Partial<ResponsiveBehaviorSettings>, "mobi
};

type FeedbackBehaviorOptions = {
state?: ButtonFeedbackState | null;
successMessage?: ReactNode;
errorMessage?: ReactNode;
loading?: boolean;
hasError?: boolean;
tooltipDuration?: {
success?: number;
error?: number;
};
successDuration?: number;
errorDuration?: number;
};

export type ButtonFeedbackState = "none" | "loading" | "success" | "error";

type OwnerState = {
usingResponsiveBehavior: boolean;
feedbackState: ButtonFeedbackState;
};

/**
* TODO:
* - Restrice imports from MuiButton
* - Consider if we should also use this for IconButtons (and restrict imports from MuiIconButton)
*/

type CustomButtonProps = {
responsiveBehavior?: boolean | ResponsiveBehaviorOptions;

// TODO: Implement feedback on click behavior
// TODO: Figure out if we only need the controlled or uncontrolled version
feedbackBehavior?: boolean | FeedbackBehaviorOptions;

iconMapping?: {
loading?: ReactNode;
};
};

export type ButtonProps = CustomButtonProps & ButtonThemeProps & MuiButtonProps;
type OnClick = ((event: React.MouseEvent<HTMLElement>) => void) | ((event: React.MouseEvent<HTMLElement>) => Promise<void>);

export type ButtonProps = CustomButtonProps & ButtonThemeProps & MuiButtonProps & { onClick?: OnClick };

const getResponsiveBehaviorSettings = (propValue: ButtonProps["responsiveBehavior"], startIcon: ReactNode, endIcon: ReactNode) => {
let settings: ResponsiveBehaviorSettings = {
Expand All @@ -93,56 +89,136 @@ const getResponsiveBehaviorSettings = (propValue: ButtonProps["responsiveBehavio
return settings;
};

const defaultFeedbackBehaviorSettings = {
successMessage: <FormattedMessage id="comet.feedbackButton.tooltipSuccessMessage" defaultMessage="Success" />,
errorMessage: <FormattedMessage id="comet.feedbackButton.tooltipErrorMessage" defaultMessage="Error" />,
successDuration: 2000,
errorDuration: 5000,
state: null,
};

export const Button = (props: ButtonProps) => {
const { slotProps, responsiveBehavior, feedbackBehavior, children, startIcon, endIcon, ...restProps } = useThemeProps({
const {
iconMapping = {},
slotProps,
responsiveBehavior,
feedbackBehavior,
children,
onClick,
startIcon,
endIcon,
...restProps
} = useThemeProps({
props,
name: "CometAdminButton",
});

const windowSize = useWindowSize();
const theme = useTheme();

const { loading: loadingIcon = <ThreeDotSaving /> } = iconMapping;

const responsiveBehaviorSettings = getResponsiveBehaviorSettings(responsiveBehavior, startIcon, endIcon);
const usingResponsiveBehavior = Boolean(responsiveBehavior) && windowSize.width < theme.breakpoints.values[responsiveBehaviorSettings.breakpoint];

const [uncontrolledFeedbackState, setUncontrolledFeedbackState] = useState<ButtonFeedbackState>("none");
const feedbackBehaviorSettings: Required<FeedbackBehaviorOptions> =
typeof feedbackBehavior === "object" ? { ...defaultFeedbackBehaviorSettings, ...feedbackBehavior } : defaultFeedbackBehaviorSettings;
const feedbackStateIsControlledByProp = feedbackBehaviorSettings.state !== null;
const feedbackState = feedbackBehaviorSettings.state ?? uncontrolledFeedbackState;

const handleClick: OnClick = async (event) => {
if (feedbackStateIsControlledByProp) {
onClick?.(event);
return;
}

try {
setUncontrolledFeedbackState("loading");
await onClick?.(event);
setUncontrolledFeedbackState("success");
setTimeout(() => {
setUncontrolledFeedbackState("none");
}, feedbackBehaviorSettings.successDuration);
} catch (error) {
setUncontrolledFeedbackState("error");
setTimeout(() => {
setUncontrolledFeedbackState("none");
}, feedbackBehaviorSettings.errorDuration);
}
};

const loadingFeedbackStateProps =
feedbackState === "loading"
? {
disabled: true,
startIcon: loadingIcon,
}
: {};

const ownerState: OwnerState = {
usingResponsiveBehavior,
feedbackState,
};

const buttonNode = (
<Root
startIcon={usingResponsiveBehavior ? undefined : startIcon}
endIcon={usingResponsiveBehavior ? undefined : endIcon}
onClick={handleClick}
ownerState={ownerState}
{...loadingFeedbackStateProps}
{...restProps}
{...slotProps?.root}
>
{usingResponsiveBehavior ? responsiveBehaviorSettings.mobileIcon : children}
</Root>
);

if (usingResponsiveBehavior) {
return (
<MobileTooltip title={children} {...slotProps?.mobileTooltip}>
{buttonNode}
</MobileTooltip>
);
}
const buttonNodeWithResponsiveBehavior = usingResponsiveBehavior ? (
<MobileTooltip title={children} {...slotProps?.mobileTooltip}>
{buttonNode}
</MobileTooltip>
) : (
buttonNode
);

return buttonNode;
return (
<SuccessFeedback
open={feedbackState === "success"}
variant="success"
placement="top-start"
title={feedbackBehaviorSettings.successMessage}
{...slotProps?.successFeedback}
>
<ErrorFeedback
open={feedbackState === "error"}
variant="error"
placement="top-start"
title={feedbackBehaviorSettings.errorMessage}
{...slotProps?.errorFeedback}
>
<span>{buttonNodeWithResponsiveBehavior}</span>
</ErrorFeedback>
</SuccessFeedback>
);
};

const Root = createComponentSlot(MuiButton)<ButtonClassKey, OwnerState>({
componentName: "Button",
slotName: "root",
classesResolver(ownerState) {
return [ownerState.usingResponsiveBehavior && "usingResponsiveBehavior"];
return [
ownerState.usingResponsiveBehavior && "usingResponsiveBehavior",
ownerState.feedbackState === "loading" && "feedbackStateLoading",
ownerState.feedbackState === "success" && "feedbackStateSuccess",
ownerState.feedbackState === "error" && "feedbackStateError",
];
},
})(
({ ownerState }) => css`
${ownerState.usingResponsiveBehavior &&
css`
// TODO: Should the size be smaller than in the standard button design? Like the 'ToolbarActionButton'
min-width: 0;
`}
`,
Expand All @@ -153,6 +229,16 @@ const MobileTooltip = createComponentSlot(Tooltip)<ButtonClassKey>({
slotName: "mobileTooltip",
})();

const SuccessFeedback = createComponentSlot(Tooltip)<ButtonClassKey>({
componentName: "Button",
slotName: "successFeedback",
})();

const ErrorFeedback = createComponentSlot(Tooltip)<ButtonClassKey>({
componentName: "Button",
slotName: "errorFeedback",
})();

declare module "@mui/material/styles" {
interface ComponentNameToClassKey {
CometAdminButton: ButtonClassKey;
Expand Down
2 changes: 1 addition & 1 deletion packages/admin/admin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export { AppHeaderMenuButton, AppHeaderMenuButtonClassKey, AppHeaderMenuButtonPr
export { buildCreateRestMutation, buildDeleteRestMutation, buildUpdateRestMutation } from "./buildRestMutation";
export { readClipboardText } from "./clipboard/readClipboardText";
export { writeClipboardText } from "./clipboard/writeClipboardText";
export { Button, ButtonClassKey, ButtonProps } from "./common/buttons/Button";
export { Button, ButtonClassKey, ButtonFeedbackState, ButtonProps } from "./common/buttons/Button";
export { CancelButton, CancelButtonClassKey, CancelButtonProps } from "./common/buttons/cancel/CancelButton";
export { ClearInputButton, ClearInputButtonClassKey, ClearInputButtonProps } from "./common/buttons/clearinput/ClearInputButton";
export { CopyToClipboardButton, CopyToClipboardButtonClassKey, CopyToClipboardButtonProps } from "./common/buttons/CopyToClipboardButton";
Expand Down
Loading

0 comments on commit 39bfac2

Please sign in to comment.