mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-04-14 16:40:58 -04:00
feat: add loading state to FilledButton (#7650)
This commit is contained in:
parent
d67eaa8710
commit
a289c42830
8 changed files with 119 additions and 31 deletions
|
@ -120,7 +120,7 @@ export const RoomModal = ({
|
||||||
size="large"
|
size="large"
|
||||||
variant="icon"
|
variant="icon"
|
||||||
label="Share"
|
label="Share"
|
||||||
startIcon={getShareIcon()}
|
icon={getShareIcon()}
|
||||||
className="RoomDialog__active__share"
|
className="RoomDialog__active__share"
|
||||||
onClick={shareRoomLink}
|
onClick={shareRoomLink}
|
||||||
/>
|
/>
|
||||||
|
@ -130,7 +130,7 @@ export const RoomModal = ({
|
||||||
<FilledButton
|
<FilledButton
|
||||||
size="large"
|
size="large"
|
||||||
label="Copy link"
|
label="Copy link"
|
||||||
startIcon={copyIcon}
|
icon={copyIcon}
|
||||||
onClick={copyRoomLink}
|
onClick={copyRoomLink}
|
||||||
/>
|
/>
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
|
@ -166,7 +166,7 @@ export const RoomModal = ({
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="danger"
|
color="danger"
|
||||||
label={t("roomDialog.button_stopSession")}
|
label={t("roomDialog.button_stopSession")}
|
||||||
startIcon={playerStopFilledIcon}
|
icon={playerStopFilledIcon}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackEvent("share", "room closed");
|
trackEvent("share", "room closed");
|
||||||
onRoomDestroy();
|
onRoomDestroy();
|
||||||
|
@ -195,7 +195,7 @@ export const RoomModal = ({
|
||||||
<FilledButton
|
<FilledButton
|
||||||
size="large"
|
size="large"
|
||||||
label={t("roomDialog.button_startSession")}
|
label={t("roomDialog.button_startSession")}
|
||||||
startIcon={playerPlayIcon}
|
icon={playerPlayIcon}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
||||||
onRoomCreate();
|
onRoomCreate();
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppClassProperties, AppState } from "../types";
|
import { AppClassProperties, AppState } from "../types";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
|
import { isPromiseLike } from "../utils";
|
||||||
|
|
||||||
const trackAction = (
|
const trackAction = (
|
||||||
action: Action,
|
action: Action,
|
||||||
|
@ -55,7 +56,7 @@ export class ActionManager {
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
) {
|
) {
|
||||||
this.updater = (actionResult) => {
|
this.updater = (actionResult) => {
|
||||||
if (actionResult && "then" in actionResult) {
|
if (isPromiseLike(actionResult)) {
|
||||||
actionResult.then((actionResult) => {
|
actionResult.then((actionResult) => {
|
||||||
return updater(actionResult);
|
return updater(actionResult);
|
||||||
});
|
});
|
||||||
|
|
|
@ -10,11 +10,39 @@
|
||||||
background-color: var(--back-color);
|
background-color: var(--back-color);
|
||||||
border-color: var(--border-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 {
|
&--color-primary {
|
||||||
&.ExcButton--variant-filled {
|
&.ExcButton--variant-filled {
|
||||||
--text-color: var(--color-surface-lowest);
|
--text-color: var(--color-surface-lowest);
|
||||||
--back-color: var(--color-primary);
|
--back-color: var(--color-primary);
|
||||||
|
|
||||||
|
.Spinner {
|
||||||
|
--spinner-color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
--back-color: var(--color-brand-hover);
|
--back-color: var(--color-brand-hover);
|
||||||
}
|
}
|
||||||
|
@ -27,9 +55,13 @@
|
||||||
&.ExcButton--variant-outlined,
|
&.ExcButton--variant-outlined,
|
||||||
&.ExcButton--variant-icon {
|
&.ExcButton--variant-icon {
|
||||||
--text-color: var(--color-primary);
|
--text-color: var(--color-primary);
|
||||||
--border-color: var(--color-border-outline);
|
--border-color: var(--color-primary);
|
||||||
--back-color: transparent;
|
--back-color: transparent;
|
||||||
|
|
||||||
|
.Spinner {
|
||||||
|
--spinner-color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
--text-color: var(--color-brand-hover);
|
--text-color: var(--color-brand-hover);
|
||||||
--border-color: var(--color-brand-hover);
|
--border-color: var(--color-brand-hover);
|
||||||
|
@ -47,6 +79,10 @@
|
||||||
--text-color: var(--color-danger-text);
|
--text-color: var(--color-danger-text);
|
||||||
--back-color: var(--color-danger-dark);
|
--back-color: var(--color-danger-dark);
|
||||||
|
|
||||||
|
.Spinner {
|
||||||
|
--spinner-color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
--back-color: var(--color-danger-darker);
|
--back-color: var(--color-danger-darker);
|
||||||
}
|
}
|
||||||
|
@ -62,6 +98,10 @@
|
||||||
--border-color: var(--color-danger);
|
--border-color: var(--color-danger);
|
||||||
--back-color: transparent;
|
--back-color: transparent;
|
||||||
|
|
||||||
|
.Spinner {
|
||||||
|
--spinner-color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
--text-color: var(--color-danger-darkest);
|
--text-color: var(--color-danger-darkest);
|
||||||
--border-color: var(--color-danger-darkest);
|
--border-color: var(--color-danger-darkest);
|
||||||
|
@ -79,6 +119,10 @@
|
||||||
--text-color: var(--island-bg-color);
|
--text-color: var(--island-bg-color);
|
||||||
--back-color: var(--color-gray-50);
|
--back-color: var(--color-gray-50);
|
||||||
|
|
||||||
|
.Spinner {
|
||||||
|
--spinner-color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
--back-color: var(--color-gray-60);
|
--back-color: var(--color-gray-60);
|
||||||
}
|
}
|
||||||
|
@ -94,6 +138,10 @@
|
||||||
--border-color: var(--color-muted);
|
--border-color: var(--color-muted);
|
||||||
--back-color: var(--island-bg-color);
|
--back-color: var(--island-bg-color);
|
||||||
|
|
||||||
|
.Spinner {
|
||||||
|
--spinner-color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
--text-color: var(--color-muted-background-darker);
|
--text-color: var(--color-muted-background-darker);
|
||||||
--border-color: var(--color-muted-darker);
|
--border-color: var(--color-muted-darker);
|
||||||
|
@ -111,6 +159,10 @@
|
||||||
--text-color: black;
|
--text-color: black;
|
||||||
--back-color: var(--color-warning-dark);
|
--back-color: var(--color-warning-dark);
|
||||||
|
|
||||||
|
.Spinner {
|
||||||
|
--spinner-color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
--back-color: var(--color-warning-darker);
|
--back-color: var(--color-warning-darker);
|
||||||
}
|
}
|
||||||
|
@ -126,6 +178,10 @@
|
||||||
--border-color: var(--color-warning-dark);
|
--border-color: var(--color-warning-dark);
|
||||||
--back-color: var(--input-bg-color);
|
--back-color: var(--input-bg-color);
|
||||||
|
|
||||||
|
.Spinner {
|
||||||
|
--spinner-color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
--text-color: var(--color-warning-darker);
|
--text-color: var(--color-warning-darker);
|
||||||
--border-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-radius: 0.5rem;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
|
|
||||||
font-family: "Assistant";
|
font-family: var(--font-family);
|
||||||
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
|
@ -159,9 +209,12 @@
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
min-height: 3rem;
|
min-height: 3rem;
|
||||||
padding: 0.5rem 1.5rem;
|
padding: 0.5rem 1.5rem;
|
||||||
gap: 0.75rem;
|
|
||||||
|
|
||||||
letter-spacing: 0.4px;
|
letter-spacing: 0.4px;
|
||||||
|
|
||||||
|
.ExcButton__contents {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--size-medium {
|
&--size-medium {
|
||||||
|
@ -169,9 +222,12 @@
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
min-height: 2.5rem;
|
min-height: 2.5rem;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
gap: 0.5rem;
|
|
||||||
|
|
||||||
letter-spacing: normal;
|
letter-spacing: normal;
|
||||||
|
|
||||||
|
.ExcButton__contents {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--variant-icon {
|
&--variant-icon {
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
import React, { forwardRef } from "react";
|
import React, { forwardRef, useState } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import "./FilledButton.scss";
|
import "./FilledButton.scss";
|
||||||
|
import { AbortError } from "../errors";
|
||||||
|
import Spinner from "./Spinner";
|
||||||
|
import { isPromiseLike } from "../utils";
|
||||||
|
|
||||||
export type ButtonVariant = "filled" | "outlined" | "icon";
|
export type ButtonVariant = "filled" | "outlined" | "icon";
|
||||||
export type ButtonColor = "primary" | "danger" | "warning" | "muted";
|
export type ButtonColor = "primary" | "danger" | "warning" | "muted";
|
||||||
|
@ -11,7 +14,7 @@ export type FilledButtonProps = {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
onClick?: () => void;
|
onClick?: (event: React.MouseEvent) => void;
|
||||||
|
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
color?: ButtonColor;
|
color?: ButtonColor;
|
||||||
|
@ -19,14 +22,14 @@ export type FilledButtonProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
|
|
||||||
startIcon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
children,
|
children,
|
||||||
startIcon,
|
icon,
|
||||||
onClick,
|
onClick,
|
||||||
label,
|
label,
|
||||||
variant = "filled",
|
variant = "filled",
|
||||||
|
@ -37,6 +40,27 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
||||||
},
|
},
|
||||||
ref,
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
@ -47,17 +71,21 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
||||||
{ "ExcButton--fullWidth": fullWidth },
|
{ "ExcButton--fullWidth": fullWidth },
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={_onClick}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{startIcon && (
|
<div className="ExcButton__contents">
|
||||||
|
{isLoading && <Spinner />}
|
||||||
|
{icon && (
|
||||||
<div className="ExcButton__icon" aria-hidden>
|
<div className="ExcButton__icon" aria-hidden>
|
||||||
{startIcon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{variant !== "icon" && (children ?? label)}
|
{variant !== "icon" && (children ?? label)}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
@ -12,6 +12,8 @@
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
& h3 {
|
& h3 {
|
||||||
font-family: "Assistant";
|
font-family: "Assistant";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
|
|
@ -271,7 +271,7 @@ const ImageExportModal = ({
|
||||||
exportingFrame,
|
exportingFrame,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
startIcon={downloadIcon}
|
icon={downloadIcon}
|
||||||
>
|
>
|
||||||
{t("imageExportDialog.button.exportToPng")}
|
{t("imageExportDialog.button.exportToPng")}
|
||||||
</FilledButton>
|
</FilledButton>
|
||||||
|
@ -283,7 +283,7 @@ const ImageExportModal = ({
|
||||||
exportingFrame,
|
exportingFrame,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
startIcon={downloadIcon}
|
icon={downloadIcon}
|
||||||
>
|
>
|
||||||
{t("imageExportDialog.button.exportToSvg")}
|
{t("imageExportDialog.button.exportToSvg")}
|
||||||
</FilledButton>
|
</FilledButton>
|
||||||
|
@ -296,7 +296,7 @@ const ImageExportModal = ({
|
||||||
exportingFrame,
|
exportingFrame,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
startIcon={copyIcon}
|
icon={copyIcon}
|
||||||
>
|
>
|
||||||
{t("imageExportDialog.button.copyPngToClipboard")}
|
{t("imageExportDialog.button.copyPngToClipboard")}
|
||||||
</FilledButton>
|
</FilledButton>
|
||||||
|
|
|
@ -66,7 +66,7 @@ export const ShareableLinkDialog = ({
|
||||||
<FilledButton
|
<FilledButton
|
||||||
size="large"
|
size="large"
|
||||||
label="Copy link"
|
label="Copy link"
|
||||||
startIcon={copyIcon}
|
icon={copyIcon}
|
||||||
onClick={copyRoomLink}
|
onClick={copyRoomLink}
|
||||||
/>
|
/>
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { useExcalidrawContainer } from "./App";
|
||||||
import { AbortError } from "../errors";
|
import { AbortError } from "../errors";
|
||||||
import Spinner from "./Spinner";
|
import Spinner from "./Spinner";
|
||||||
import { PointerType } from "../element/types";
|
import { PointerType } from "../element/types";
|
||||||
|
import { isPromiseLike } from "../utils";
|
||||||
|
|
||||||
export type ToolButtonSize = "small" | "medium";
|
export type ToolButtonSize = "small" | "medium";
|
||||||
|
|
||||||
|
@ -65,7 +66,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||||
const onClick = async (event: React.MouseEvent) => {
|
const onClick = async (event: React.MouseEvent) => {
|
||||||
const ret = "onClick" in props && props.onClick?.(event);
|
const ret = "onClick" in props && props.onClick?.(event);
|
||||||
|
|
||||||
if (ret && "then" in ret) {
|
if (isPromiseLike(ret)) {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await ret;
|
await ret;
|
||||||
|
|
Loading…
Add table
Reference in a new issue