Browse Source

feat: add loading state to FilledButton (#7650)

David Luzar 1 year ago
parent
commit
a289c42830

+ 4 - 4
excalidraw-app/collab/RoomDialog.tsx

@@ -120,7 +120,7 @@ export const RoomModal = ({
               size="large"
               variant="icon"
               label="Share"
-              startIcon={getShareIcon()}
+              icon={getShareIcon()}
               className="RoomDialog__active__share"
               onClick={shareRoomLink}
             />
@@ -130,7 +130,7 @@ export const RoomModal = ({
               <FilledButton
                 size="large"
                 label="Copy link"
-                startIcon={copyIcon}
+                icon={copyIcon}
                 onClick={copyRoomLink}
               />
             </Popover.Trigger>
@@ -166,7 +166,7 @@ export const RoomModal = ({
             variant="outlined"
             color="danger"
             label={t("roomDialog.button_stopSession")}
-            startIcon={playerStopFilledIcon}
+            icon={playerStopFilledIcon}
             onClick={() => {
               trackEvent("share", "room closed");
               onRoomDestroy();
@@ -195,7 +195,7 @@ export const RoomModal = ({
         <FilledButton
           size="large"
           label={t("roomDialog.button_startSession")}
-          startIcon={playerPlayIcon}
+          icon={playerPlayIcon}
           onClick={() => {
             trackEvent("share", "room creation", `ui (${getFrame()})`);
             onRoomCreate();

+ 2 - 1
packages/excalidraw/actions/manager.tsx

@@ -10,6 +10,7 @@ import {
 import { ExcalidrawElement } from "../element/types";
 import { AppClassProperties, AppState } from "../types";
 import { trackEvent } from "../analytics";
+import { isPromiseLike } from "../utils";
 
 const trackAction = (
   action: Action,
@@ -55,7 +56,7 @@ export class ActionManager {
     app: AppClassProperties,
   ) {
     this.updater = (actionResult) => {
-      if (actionResult && "then" in actionResult) {
+      if (isPromiseLike(actionResult)) {
         actionResult.then((actionResult) => {
           return updater(actionResult);
         });

+ 66 - 10
packages/excalidraw/components/FilledButton.scss

@@ -10,11 +10,39 @@
     background-color: var(--back-color);
     border-color: var(--border-color);
 
+    .Spinner {
+      --spinner-color: var(--color-surface-lowest);
+      position: absolute;
+      visibility: visible;
+    }
+
+    &[disabled] {
+      pointer-events: none;
+
+      .ExcButton__contents {
+        visibility: hidden;
+      }
+    }
+
+    &__contents {
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      flex-shrink: 0;
+      flex-wrap: nowrap;
+      // needed because of .Spinner
+      position: relative;
+    }
+
     &--color-primary {
       &.ExcButton--variant-filled {
         --text-color: var(--color-surface-lowest);
         --back-color: var(--color-primary);
 
+        .Spinner {
+          --spinner-color: var(--text-color);
+        }
+
         &:hover {
           --back-color: var(--color-brand-hover);
         }
@@ -27,9 +55,13 @@
       &.ExcButton--variant-outlined,
       &.ExcButton--variant-icon {
         --text-color: var(--color-primary);
-        --border-color: var(--color-border-outline);
+        --border-color: var(--color-primary);
         --back-color: transparent;
 
+        .Spinner {
+          --spinner-color: var(--text-color);
+        }
+
         &:hover {
           --text-color: var(--color-brand-hover);
           --border-color: var(--color-brand-hover);
@@ -47,6 +79,10 @@
         --text-color: var(--color-danger-text);
         --back-color: var(--color-danger-dark);
 
+        .Spinner {
+          --spinner-color: var(--text-color);
+        }
+
         &:hover {
           --back-color: var(--color-danger-darker);
         }
@@ -62,6 +98,10 @@
         --border-color: var(--color-danger);
         --back-color: transparent;
 
+        .Spinner {
+          --spinner-color: var(--text-color);
+        }
+
         &:hover {
           --text-color: var(--color-danger-darkest);
           --border-color: var(--color-danger-darkest);
@@ -79,6 +119,10 @@
         --text-color: var(--island-bg-color);
         --back-color: var(--color-gray-50);
 
+        .Spinner {
+          --spinner-color: var(--text-color);
+        }
+
         &:hover {
           --back-color: var(--color-gray-60);
         }
@@ -94,6 +138,10 @@
         --border-color: var(--color-muted);
         --back-color: var(--island-bg-color);
 
+        .Spinner {
+          --spinner-color: var(--text-color);
+        }
+
         &:hover {
           --text-color: var(--color-muted-background-darker);
           --border-color: var(--color-muted-darker);
@@ -111,6 +159,10 @@
         --text-color: black;
         --back-color: var(--color-warning-dark);
 
+        .Spinner {
+          --spinner-color: var(--text-color);
+        }
+
         &:hover {
           --back-color: var(--color-warning-darker);
         }
@@ -126,6 +178,10 @@
         --border-color: var(--color-warning-dark);
         --back-color: var(--input-bg-color);
 
+        .Spinner {
+          --spinner-color: var(--text-color);
+        }
+
         &:hover {
           --text-color: var(--color-warning-darker);
           --border-color: var(--color-warning-darker);
@@ -138,17 +194,11 @@
       }
     }
 
-    display: flex;
-    justify-content: center;
-    align-items: center;
-    flex-shrink: 0;
-    flex-wrap: nowrap;
-
     border-radius: 0.5rem;
     border-width: 1px;
     border-style: solid;
 
-    font-family: "Assistant";
+    font-family: var(--font-family);
 
     user-select: none;
 
@@ -159,9 +209,12 @@
       font-size: 0.875rem;
       min-height: 3rem;
       padding: 0.5rem 1.5rem;
-      gap: 0.75rem;
 
       letter-spacing: 0.4px;
+
+      .ExcButton__contents {
+        gap: 0.75rem;
+      }
     }
 
     &--size-medium {
@@ -169,9 +222,12 @@
       font-size: 0.75rem;
       min-height: 2.5rem;
       padding: 0.5rem 1rem;
-      gap: 0.5rem;
 
       letter-spacing: normal;
+
+      .ExcButton__contents {
+        gap: 0.5rem;
+      }
     }
 
     &--variant-icon {

+ 39 - 11
packages/excalidraw/components/FilledButton.tsx

@@ -1,7 +1,10 @@
-import React, { forwardRef } from "react";
+import React, { forwardRef, useState } from "react";
 import clsx from "clsx";
 
 import "./FilledButton.scss";
+import { AbortError } from "../errors";
+import Spinner from "./Spinner";
+import { isPromiseLike } from "../utils";
 
 export type ButtonVariant = "filled" | "outlined" | "icon";
 export type ButtonColor = "primary" | "danger" | "warning" | "muted";
@@ -11,7 +14,7 @@ export type FilledButtonProps = {
   label: string;
 
   children?: React.ReactNode;
-  onClick?: () => void;
+  onClick?: (event: React.MouseEvent) => void;
 
   variant?: ButtonVariant;
   color?: ButtonColor;
@@ -19,14 +22,14 @@ export type FilledButtonProps = {
   className?: string;
   fullWidth?: boolean;
 
-  startIcon?: React.ReactNode;
+  icon?: React.ReactNode;
 };
 
 export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
   (
     {
       children,
-      startIcon,
+      icon,
       onClick,
       label,
       variant = "filled",
@@ -37,6 +40,27 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
     },
     ref,
   ) => {
+    const [isLoading, setIsLoading] = useState(false);
+
+    const _onClick = async (event: React.MouseEvent) => {
+      const ret = onClick?.(event);
+
+      if (isPromiseLike(ret)) {
+        try {
+          setIsLoading(true);
+          await ret;
+        } catch (error: any) {
+          if (!(error instanceof AbortError)) {
+            throw error;
+          } else {
+            console.warn(error);
+          }
+        } finally {
+          setIsLoading(false);
+        }
+      }
+    };
+
     return (
       <button
         className={clsx(
@@ -47,17 +71,21 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
           { "ExcButton--fullWidth": fullWidth },
           className,
         )}
-        onClick={onClick}
+        onClick={_onClick}
         type="button"
         aria-label={label}
         ref={ref}
+        disabled={isLoading}
       >
-        {startIcon && (
-          <div className="ExcButton__icon" aria-hidden>
-            {startIcon}
-          </div>
-        )}
-        {variant !== "icon" && (children ?? label)}
+        <div className="ExcButton__contents">
+          {isLoading && <Spinner />}
+          {icon && (
+            <div className="ExcButton__icon" aria-hidden>
+              {icon}
+            </div>
+          )}
+          {variant !== "icon" && (children ?? label)}
+        </div>
       </button>
     );
   },

+ 2 - 0
packages/excalidraw/components/ImageExportDialog.scss

@@ -12,6 +12,8 @@
     flex-direction: row;
     justify-content: space-between;
 
+    user-select: none;
+
     & h3 {
       font-family: "Assistant";
       font-style: normal;

+ 3 - 3
packages/excalidraw/components/ImageExportDialog.tsx

@@ -271,7 +271,7 @@ const ImageExportModal = ({
                 exportingFrame,
               })
             }
-            startIcon={downloadIcon}
+            icon={downloadIcon}
           >
             {t("imageExportDialog.button.exportToPng")}
           </FilledButton>
@@ -283,7 +283,7 @@ const ImageExportModal = ({
                 exportingFrame,
               })
             }
-            startIcon={downloadIcon}
+            icon={downloadIcon}
           >
             {t("imageExportDialog.button.exportToSvg")}
           </FilledButton>
@@ -296,7 +296,7 @@ const ImageExportModal = ({
                   exportingFrame,
                 })
               }
-              startIcon={copyIcon}
+              icon={copyIcon}
             >
               {t("imageExportDialog.button.copyPngToClipboard")}
             </FilledButton>

+ 1 - 1
packages/excalidraw/components/ShareableLinkDialog.tsx

@@ -66,7 +66,7 @@ export const ShareableLinkDialog = ({
               <FilledButton
                 size="large"
                 label="Copy link"
-                startIcon={copyIcon}
+                icon={copyIcon}
                 onClick={copyRoomLink}
               />
             </Popover.Trigger>

+ 2 - 1
packages/excalidraw/components/ToolButton.tsx

@@ -6,6 +6,7 @@ import { useExcalidrawContainer } from "./App";
 import { AbortError } from "../errors";
 import Spinner from "./Spinner";
 import { PointerType } from "../element/types";
+import { isPromiseLike } from "../utils";
 
 export type ToolButtonSize = "small" | "medium";
 
@@ -65,7 +66,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
   const onClick = async (event: React.MouseEvent) => {
     const ret = "onClick" in props && props.onClick?.(event);
 
-    if (ret && "then" in ret) {
+    if (isPromiseLike(ret)) {
       try {
         setIsLoading(true);
         await ret;