Skip to content

Commit 6beca4d

Browse files
atrakhConvex, Inc.
authored andcommitted
design-system: add "loading" prop to Button (#36847)
- Updates the design of Spinner to be a simple circle with a rotate and dasharray animation - Adds the `loading` prop to Button that automatically displays a spinner inside of the button, maintaining the size of the button while loading. - Removes the "register" page for oauth applications. this is now on the convex website (noticed this could be removed while updating all of the buttons) - Replaces existing spinners in buttons with the loading prop in dashboard - fixes a couple style issues with spacing / button declarations GitOrigin-RevId: c1cc0d3fa0e7dfebb35a0c5299b096e83b600021
1 parent e6df20f commit 6beca4d

34 files changed

+162
-453
lines changed

npm-packages/@convex-dev/design-system/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@convex-dev/design-system",
3-
"version": "0.1.7",
3+
"version": "0.1.8",
44
"type": "module",
55
"sideEffects": false,
66
"files": [

npm-packages/@convex-dev/design-system/src/Button.tsx

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { tv } from "tailwind-variants";
33
import { Tooltip, TooltipSide } from "@ui/Tooltip";
44
import { UrlObject } from "url";
55
import { UIContext } from "@ui/UIContext";
6+
import { Spinner } from "@ui/Spinner";
7+
import classNames from "classnames";
68

79
export type ButtonProps = {
810
children?: React.ReactNode;
@@ -15,6 +17,7 @@ export type ButtonProps = {
1517
disabled?: boolean;
1618
tip?: React.ReactNode;
1719
tipSide?: TooltipSide;
20+
loading?: boolean;
1821
} & Pick<
1922
React.HTMLProps<HTMLElement>,
2023
| "tabIndex"
@@ -39,6 +42,7 @@ export type ButtonProps = {
3942
// In most cases you shouldn’t use this. This is only useful when you
4043
// need the event sent before the native link behavior is handled.
4144
onClickOfAnchorLink?: React.AnchorHTMLAttributes<HTMLAnchorElement>["onClick"];
45+
download?: boolean;
4246
type?: never;
4347
target?: React.AnchorHTMLAttributes<HTMLAnchorElement>["target"];
4448
}
@@ -56,6 +60,7 @@ export const Button = forwardRef<HTMLElement, ButtonProps>(function Button(
5660
icon,
5761
tip,
5862
tipSide,
63+
loading = false,
5964
...props
6065
},
6166
ref,
@@ -76,6 +81,7 @@ export const Button = forwardRef<HTMLElement, ButtonProps>(function Button(
7681
focused,
7782
className,
7883
size,
84+
loading,
7985
});
8086
if (href !== undefined && !disabled) {
8187
return (
@@ -108,7 +114,7 @@ export const Button = forwardRef<HTMLElement, ButtonProps>(function Button(
108114
tabIndex={0}
109115
onClick={onClick}
110116
className={buttonClassName}
111-
disabled={disabled}
117+
disabled={disabled || loading}
112118
// There is something weird here with `forwardRef`, I’d expect this to work without `any`
113119
ref={ref as any}
114120
{...htmlProps}
@@ -122,13 +128,31 @@ export const Button = forwardRef<HTMLElement, ButtonProps>(function Button(
122128
*/}
123129
{icon && <div>{icon}</div>}
124130
{children}
131+
{loading && (
132+
<div
133+
className={classNames(
134+
"transition-none absolute left-1/2 -translate-x-1/2",
135+
)}
136+
>
137+
<span className="sr-only">(Loading...)</span>
138+
<Spinner
139+
className={
140+
variant === "primary"
141+
? "text-white"
142+
: variant === "danger"
143+
? "text-content-error"
144+
: undefined
145+
}
146+
/>
147+
</div>
148+
)}
125149
</button>
126150
</Tooltip>
127151
);
128152
});
129153

130154
const button = tv({
131-
base: "inline-flex animate-fadeInFromLoading select-none items-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:border focus-visible:border-border-selected focus-visible:outline-none",
155+
base: "relative inline-flex animate-fadeInFromLoading select-none items-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:border focus-visible:border-border-selected focus-visible:outline-none",
132156
variants: {
133157
variant: {
134158
primary: "border-white/30 bg-util-accent text-white",
@@ -147,6 +171,10 @@ const button = tv({
147171
true: "cursor-not-allowed opacity-50",
148172
false: "cursor-pointer",
149173
},
174+
loading: {
175+
true: "cursor-not-allowed text-transparent",
176+
false: "",
177+
},
150178
focused: {
151179
true: "",
152180
false: "",
@@ -163,6 +191,7 @@ const button = tv({
163191
variant: "primary",
164192
accent: "inline",
165193
class: "bg-transparent text-content-accent hover:bg-background-tertiary",
194+
loading: false,
166195
},
167196
{
168197
variant: "primary",
@@ -190,6 +219,7 @@ const button = tv({
190219
variant: "neutral",
191220
class: "hover:bg-background-tertiary",
192221
disabled: false,
222+
loading: false,
193223
},
194224
{
195225
variant: "neutral",
@@ -219,21 +249,25 @@ const button = tv({
219249
disabled: false,
220250
accent: "none",
221251
class: "hover:bg-util-accent/80",
252+
loading: false,
222253
},
223254
{
224255
variant: "danger",
225256
disabled: false,
226257
class: "hover:bg-background-errorSecondary",
258+
loading: false,
227259
},
228260
{
229261
variant: "neutral",
230262
disabled: false,
231263
class: "hover:bg-background-primary",
264+
loading: false,
232265
},
233266
{
234267
variant: "danger",
235268
disabled: false,
236269
class: "hover:bg-background-errorSecondary",
270+
loading: false,
237271
},
238272
],
239273
defaultVariants: {
@@ -254,9 +288,17 @@ export function buttonClasses({
254288
focused,
255289
className,
256290
size,
291+
loading,
257292
}: Pick<
258293
ButtonProps,
259-
"variant" | "disabled" | "focused" | "className" | "size" | "inline" | "icon"
294+
| "variant"
295+
| "disabled"
296+
| "focused"
297+
| "className"
298+
| "size"
299+
| "inline"
300+
| "icon"
301+
| "loading"
260302
>) {
261303
return variant === "unstyled"
262304
? className
@@ -268,5 +310,6 @@ export function buttonClasses({
268310
focused,
269311
className,
270312
size,
313+
loading,
271314
});
272315
}

npm-packages/@convex-dev/design-system/src/Combobox.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ export function Combobox<T>({
166166
data-testid={`combobox-button-${label}`}
167167
className={cn(
168168
"flex gap-1 w-full items-center group",
169-
"truncate relative text-left text-content-primary rounded-md disabled:bg-background-tertiary disabled:text-content-secondary disabled:cursor-not-allowed",
169+
"truncate relative text-left text-content-primary rounded-md disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-background-secondary",
170170
"border focus-visible:z-10 focus-visible:border-border-selected focus-visible:outline-none bg-background-secondary text-sm",
171171
"hover:bg-background-tertiary",
172172
"cursor-pointer",

npm-packages/@convex-dev/design-system/src/ConfirmationDialog.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import React, { ReactNode, useState } from "react";
22
import { TextInput } from "@ui/TextInput";
33
import { Button } from "@ui/Button";
4-
import { Spinner } from "@ui/Spinner";
54
import { Modal } from "@ui/Modal";
65

76
export function ConfirmationDialog({
@@ -63,6 +62,7 @@ export function ConfirmationDialog({
6362
</div>
6463
<TextInput
6564
id="validation"
65+
aria-label={`Enter ${validationText} to continue`}
6666
labelHidden
6767
onKeyDown={(e) =>
6868
e.key === "Enter" && !disabled && handleConfirm()
@@ -86,7 +86,7 @@ export function ConfirmationDialog({
8686
data-testid="confirm-button"
8787
disabled={disabled || isConfirming}
8888
onClick={handleConfirm}
89-
icon={isConfirming ? <Spinner /> : undefined}
89+
loading={isConfirming}
9090
variant={variant}
9191
>
9292
{confirmText}

npm-packages/@convex-dev/design-system/src/MultiSelectCombobox.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ export function MultiSelectCombobox({
149149
<Combobox.Button
150150
className={classNames(
151151
"flex gap-2 w-full justify-between",
152-
"truncate relative rounded-md py-1.5 px-1.5 text-left text-sm text-content-primary disabled:bg-background-tertiary disabled:text-content-secondary disabled:cursor-not-allowed",
152+
"truncate relative rounded-md py-1.5 px-1.5 text-left text-sm text-content-primary disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-background-secondary",
153153
"border",
154154
"focus:border-border-selected focus:outline-none bg-background-secondary hover:bg-background-tertiary",
155155
open && "border-border-selected",

npm-packages/@convex-dev/design-system/src/Spinner.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,20 @@ export function Spinner({ className }: { className?: string }) {
55
<svg
66
role="status"
77
className={cn(
8-
"ml-auto h-4 w-4 animate-spin fill-content-link text-neutral-4",
8+
"ml-auto h-4 w-4 text-content-primary/70 animate-rotate",
99
className,
1010
)}
11-
viewBox="0 0 100 101"
11+
viewBox="0 0 100 100"
1212
fill="none"
1313
xmlns="http://www.w3.org/2000/svg"
1414
>
1515
<path
16-
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
17-
fill="currentColor"
18-
/>
19-
<path
20-
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
21-
fill="currentFill"
16+
d="M 50,50 m 0,-38 a 38,38 0 1,1 0,76 a 38,38 0 1,1 0,-76"
17+
pathLength="100"
18+
stroke="currentColor"
19+
strokeWidth="18"
20+
fill="none"
21+
className="animate-dashLength"
2222
/>
2323
</svg>
2424
);

npm-packages/@convex-dev/design-system/src/tailwind.config.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,13 +187,19 @@ const config: Config = {
187187
fadeInFromLoading: "fadeIn 0.3s",
188188
vhs: "vhs 0.5s linear 0.25s 1 normal forwards",
189189
blinkFill: "blinkFill 1.2s ease-in-out infinite",
190-
rotate: "fadeIn 1.2s, rotate 1.2s ease-in-out infinite",
190+
rotate: "fadeIn 0.8s, rotate 0.75s linear infinite",
191+
dashLength: "dashLength 1.5s ease-in-out infinite",
191192
},
192193
keyframes: {
193194
rotate: {
194195
"0%": { transform: "rotate(0deg)" },
195196
"100%": { transform: "rotate(360deg)" },
196197
},
198+
dashLength: {
199+
"0%": { strokeDasharray: "1 99" },
200+
"50%": { strokeDasharray: "35 65" },
201+
"100%": { strokeDasharray: "1 99" },
202+
},
197203
blinkFill: {
198204
"0%": {
199205
fillOpacity: "1",

npm-packages/dashboard-common/src/features/data/components/ShowSchema.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export function ShowSchema({
8686
)}
8787

8888
{inProgressSchema && (
89-
<div className="flex items-center gap-1 text-sm text-content-secondary">
89+
<div className="mt-4 flex items-center gap-1 text-sm text-content-secondary">
9090
<div>
9191
<Spinner />
9292
</div>{" "}

npm-packages/dashboard-common/src/features/data/components/Table/EditDocumentPanel/JavascriptDocumentsForm.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import omitBy from "lodash/omitBy";
77
import { UNDEFINED_PLACEHOLDER } from "system-udfs/convex/_system/frontend/patchDocumentsFields";
88
import { ObjectEditor } from "@common/elements/ObjectEditor/ObjectEditor";
99
import { Button } from "@ui/Button";
10-
import { Spinner } from "@ui/Spinner";
1110

1211
function isDocument(
1312
value: Value | undefined,
@@ -137,11 +136,7 @@ export function JavascriptDocumentsForm({
137136
{validationMessage}
138137
</p>
139138
)}
140-
<Button
141-
disabled={disabled}
142-
onClick={save}
143-
icon={isSaving ? <Spinner /> : null}
144-
>
139+
<Button disabled={disabled} onClick={save} loading={isSaving}>
145140
{mode === "patchDocuments" ? "Apply" : "Save"}
146141
</Button>
147142
</div>

npm-packages/dashboard-common/src/features/files/components/FileActions.tsx

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { DownloadIcon, TrashIcon } from "@radix-ui/react-icons";
22
import { useContext, useState } from "react";
3-
import Link from "next/link";
43
import { FileMetadata } from "system-udfs/convex/_system/frontend/fileStorageV2";
5-
import { Button, buttonClasses } from "@ui/Button";
4+
import { Button } from "@ui/Button";
65
import { Tooltip } from "@ui/Tooltip";
76
import { DeploymentInfoContext } from "@common/lib/deploymentContext";
87
import { useNents } from "@common/lib/useNents";
@@ -36,20 +35,15 @@ export function FileActions({ file }: { file: FileMetadata }) {
3635
file.size < 10_000_000 && <PreviewImage url={file.url} />
3736
}
3837
>
39-
<Link
38+
<Button
4039
href={file.url}
41-
passHref
42-
className={buttonClasses({
43-
variant: "primary",
44-
size: "sm",
45-
inline: true,
46-
})}
4740
aria-label="Download File"
4841
download
42+
inline
4943
target="_blank"
5044
>
5145
<DownloadIcon aria-label="Download" />
52-
</Link>
46+
</Button>
5347
</Tooltip>
5448
<Button
5549
aria-label="Delete File"

npm-packages/dashboard-common/src/features/files/components/FileStorageListHeader.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
} from "@radix-ui/react-icons";
88
import { Button } from "@ui/Button";
99
import { Tooltip } from "@ui/Tooltip";
10-
import { Spinner } from "@ui/Spinner";
1110
import { Checkbox } from "@ui/Checkbox";
1211
import classNames from "classnames";
1312
import { FileFilters } from "./FileStorageHeader";
@@ -101,14 +100,8 @@ export function FileStorageListHeader({
101100
)}
102101
{isPaused && (
103102
<Button
104-
icon={
105-
isLoadingPausedData ? (
106-
<Spinner className="opacity-50" />
107-
) : (
108-
<ReloadIcon />
109-
)
110-
}
111-
disabled={isLoadingPausedData}
103+
icon={<ReloadIcon />}
104+
loading={isLoadingPausedData}
112105
variant="neutral"
113106
className="animate-fadeInFromLoading text-xs"
114107
size="xs"

npm-packages/dashboard-common/src/features/functionRunner/components/FunctionEditor.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import { Loading } from "@ui/Loading";
1313
import { stringifyValue } from "@common/lib/stringifyValue";
1414
import { SchemaJson, displaySchema } from "@common/lib/format";
1515
import { useRunTestFunction } from "@common/features/functionRunner/lib/client";
16-
import { Spinner } from "@ui/Spinner";
1716
import { ComponentId } from "@common/lib/useNents";
1817
import { Result } from "@common/features/functionRunner/components/Result";
1918
import {
@@ -422,8 +421,8 @@ export function useFunctionEditor(
422421
onClick={onSave}
423422
size="sm"
424423
className={classNames("items-center justify-center", "w-full")}
425-
disabled={isInFlight}
426-
icon={isInFlight ? <Spinner /> : <PlayIcon />}
424+
loading={isInFlight}
425+
icon={<PlayIcon />}
427426
>
428427
Run Custom Query
429428
</Button>

npm-packages/dashboard-common/src/features/functionRunner/components/FunctionResult.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { Button } from "@ui/Button";
88
import { DeploymentInfoContext } from "@common/lib/deploymentContext";
99
import { toast } from "@common/lib/utils";
1010
import { RequestFilter } from "@common/lib/appMetrics";
11-
import { Spinner } from "@ui/Spinner";
1211
import { ComponentId } from "@common/lib/useNents";
1312
import { Result } from "@common/features/functionRunner/components/Result";
1413
import {
@@ -173,13 +172,11 @@ export function useFunctionResult({
173172
size="sm"
174173
className="w-full max-w-[48rem] items-center justify-center"
175174
disabled={
176-
isInFlight ||
177-
disabled ||
178-
(isProd && !prodEditsEnabled) ||
179-
!canRunFunction
175+
disabled || (isProd && !prodEditsEnabled) || !canRunFunction
180176
}
177+
loading={isInFlight}
181178
onClick={runFunction}
182-
icon={isInFlight ? <Spinner /> : <PlayIcon />}
179+
icon={<PlayIcon />}
183180
>
184181
Run {udfType.toLowerCase()}
185182
</Button>

0 commit comments

Comments
 (0)