feat: add loading state to FilledButton (#7650)

This commit is contained in:
David Luzar 2024-02-03 14:53:31 +01:00 committed by GitHub
parent d67eaa8710
commit a289c42830
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 119 additions and 31 deletions

View file

@ -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();

View file

@ -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);
}); });

View file

@ -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 {

View file

@ -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>
); );
}, },

View file

@ -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;

View file

@ -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>

View file

@ -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>

View file

@ -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;