Skip to content

Commit

Permalink
feat(ui): Button busy states (#3502)
Browse files Browse the repository at this point in the history
Co-authored-by: Alex Carpenter <[email protected]>
  • Loading branch information
joe-bell and alexcarpenter authored Jun 3, 2024
1 parent 5b4902b commit 7070989
Show file tree
Hide file tree
Showing 10 changed files with 278 additions and 51 deletions.
2 changes: 2 additions & 0 deletions .changeset/brave-bees-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 24 additions & 4 deletions packages/ui/src/components/sign-in/sign-in.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as Common from '@clerk/elements/common';
import * as SignIn from '@clerk/elements/sign-in';
import * as React from 'react';

import { PROVIDERS } from '~/constants';
import { Button } from '~/primitives/button';
Expand All @@ -9,7 +10,14 @@ import * as Field from '~/primitives/field';
import * as Icon from '~/primitives/icon';
import { Seperator } from '~/primitives/seperator';

type ConnectionName = (typeof PROVIDERS)[number]['name'];

export function SignInComponent() {
const [busyConnectionName, setBusyConnectionName] = React.useState<ConnectionName | null>(null);
const [isContinuing, setIsContinuing] = React.useState(false);

const hasBusyConnection = busyConnectionName !== null;

return (
<SignIn.Root exampleMode>
<SignIn.Step name='start'>
Expand All @@ -29,8 +37,13 @@ export function SignInComponent() {
name={provider.id}
asChild
>
<Connection.Button>
<ConnectionIcon className='text-base' />
<Connection.Button
busy={busyConnectionName === provider.name}
disabled={isContinuing || (hasBusyConnection && busyConnectionName !== provider.name)}
icon={<ConnectionIcon className='text-base' />}
onClick={() => setBusyConnectionName(provider.name)}
key={provider.name}
>
{provider.name}
</Connection.Button>
</Common.Connection>
Expand All @@ -47,7 +60,7 @@ export function SignInComponent() {
<Field.Label>Email address</Field.Label>
</Common.Label>
<Common.Input asChild>
<Field.Input />
<Field.Input disabled={isContinuing || hasBusyConnection} />
</Common.Input>
<Common.FieldError>
{({ message }) => {
Expand All @@ -61,7 +74,14 @@ export function SignInComponent() {
submit
asChild
>
<Button>Continue</Button>
<Button
icon={<Icon.CaretRight />}
busy={isContinuing}
disabled={hasBusyConnection}
onClick={() => setIsContinuing(true)}
>
Continue
</Button>
</SignIn.Action>
</Card.Body>
</Card.Content>
Expand Down
28 changes: 24 additions & 4 deletions packages/ui/src/components/sign-up/sign-up.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as Common from '@clerk/elements/common';
import * as SignUp from '@clerk/elements/sign-up';
import * as React from 'react';

import { PROVIDERS } from '~/constants';
import { Button } from '~/primitives/button';
Expand All @@ -9,7 +10,14 @@ import * as Field from '~/primitives/field';
import * as Icon from '~/primitives/icon';
import { Seperator } from '~/primitives/seperator';

type ConnectionName = (typeof PROVIDERS)[number]['name'];

export function SignUpComponent() {
const [busyConnectionName, setBusyConnectionName] = React.useState<ConnectionName | null>(null);
const [isContinuing, setIsContinuing] = React.useState(false);

const hasBusyConnection = busyConnectionName !== null;

return (
<SignUp.Root exampleMode>
<SignUp.Step name='start'>
Expand All @@ -28,8 +36,13 @@ export function SignUpComponent() {
key={provider.id}
name={provider.id}
>
<Connection.Button>
<ConnectionIcon className='text-base' />
<Connection.Button
busy={busyConnectionName === provider.name}
disabled={isContinuing || (hasBusyConnection && busyConnectionName !== provider.name)}
icon={<ConnectionIcon className='text-base' />}
onClick={() => setBusyConnectionName(provider.name)}
key={provider.name}
>
{provider.name}
</Connection.Button>
</Common.Connection>
Expand All @@ -46,7 +59,7 @@ export function SignUpComponent() {
<Field.Label>Email address</Field.Label>
</Common.Label>
<Common.Input asChild>
<Field.Input />
<Field.Input disabled={isContinuing || hasBusyConnection} />
</Common.Input>
<Common.FieldError>
{({ message }) => {
Expand All @@ -60,7 +73,14 @@ export function SignUpComponent() {
submit
asChild
>
<Button>Continue</Button>
<Button
icon={<Icon.CaretRight />}
busy={isContinuing}
disabled={hasBusyConnection}
onClick={() => setIsContinuing(true)}
>
Continue
</Button>
</SignUp.Action>
</Card.Body>
</Card.Content>
Expand Down
11 changes: 11 additions & 0 deletions packages/ui/src/global.css
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
@keyframes cl-spin {
to {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

:where(:root) {
--cl-font-family: inherit;
--cl-color-destructive: 0 84% 60%;
--cl-radius: 0.375rem;
--cl-spacing-unit: 1rem;
}

*,
::before,
::after {
Expand All @@ -12,6 +22,7 @@
border-style: solid;
border-color: #e5e7eb;
}

::before,
::after {
--cl-content: '';
Expand Down
57 changes: 37 additions & 20 deletions packages/ui/src/primitives/button.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,41 @@
import cn from 'clsx';
import * as React from 'react';

import * as Icon from './icon';
import { Spinner } from './spinner';

export const Button = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
function Button({ children, className, ...props }, forwardedRef) {
return (
// eslint-disable-next-line react/button-has-type
<button
ref={forwardedRef}
{...props}
className={cn(
'text-accent-contrast bg-accent-9 border-accent-9 hover:bg-accent-10 focus:ring-gray-a3 relative isolate inline-flex w-full select-none appearance-none items-center justify-center rounded-md border px-space-2 py-space-1.5 text-[0.8125rem]/[1.125rem] font-medium shadow-[0_1px_1px_0_theme(colors.white/.07)_inset] outline-none ring-[0.1875rem] ring-transparent before:absolute before:inset-0 before:rounded-[calc(theme(borderRadius.md)-1px)] before:shadow-[0_1px_1px_0_theme(colors.white/.07)_inset] after:pointer-events-none after:absolute after:inset-0 after:-z-10 after:rounded-[calc(theme(borderRadius.md)-1px)] after:bg-gradient-to-b after:from-white/10 after:to-transparent hover:after:opacity-0 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
>
{children}

<Icon.CaretRight className='text-[0.625rem] ml-2 opacity-60 shrink-0' />
</button>
);
},
);
export const Button = React.forwardRef(function Button(
{
busy,
children,
className,
disabled,
icon,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { busy?: boolean; icon?: React.ReactNode },
forwardedRef: React.ForwardedRef<HTMLButtonElement>,
) {
return (
// eslint-disable-next-line react/button-has-type
<button
ref={forwardedRef}
{...props}
className={cn(
'text-accent-contrast bg-accent-9 border-accent-9 focus:ring-gray-a3 relative isolate inline-flex w-full select-none appearance-none items-center justify-center rounded-md border px-2 py-1.5 text-[0.8125rem]/[1.125rem] font-medium shadow-[0_1px_1px_0_theme(colors.white/.07)_inset] outline-none ring-[0.1875rem] ring-transparent before:absolute before:inset-0 before:rounded-[calc(theme(borderRadius.md)-1px)] before:shadow-[0_1px_1px_0_theme(colors.white/.07)_inset] after:pointer-events-none after:absolute after:inset-0 after:-z-10 after:rounded-[calc(theme(borderRadius.md)-1px)] after:bg-gradient-to-b after:from-white/10 after:to-transparent disabled:pointer-events-none disabled:cursor-not-allowed',
// note: only reduce opacity of `disabled` so `busy` is more prominent
disabled && 'disabled:opacity-50',
!busy && !disabled && 'hover:bg-accent-10 hover:after:opacity-0',
className,
)}
disabled={busy || disabled}
>
{busy ? (
<Spinner className='shrink-0 text-[1.125rem]'>Loading…</Spinner>
) : (
<>
{children}
{icon && <span className='text-[0.625rem] ml-2 opacity-60 shrink-0'>{icon}</span>}
</>
)}
</button>
);
});
49 changes: 32 additions & 17 deletions packages/ui/src/primitives/connection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import cn from 'clsx';
import * as React from 'react';

import { Spinner } from './spinner';

export const Root = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(function Root(
{ children, className, ...props },
forwardedRef,
Expand All @@ -16,20 +18,33 @@ export const Root = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDi
);
});

export const Button = React.forwardRef<HTMLButtonElement, Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'type'>>(
function Button({ children, className, ...props }, forwardedRef) {
return (
<button
ref={forwardedRef}
{...props}
type='button'
className={cn(
'flex items-center justify-center gap-2 w-full bg-transparent text-gray-12 font-medium rounded-md bg-clip-padding border border-gray-a6 shadow-sm shadow-gray-a3 py-1.5 px-2.5 outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-gray-a3 focus-visible:border-gray-a8 disabled:opacity-50 disabled:cursor-not-allowed text-[0.8125rem]/[1.125rem] hover:bg-gray-a2',
className,
)}
>
{children}
</button>
);
},
);
export const Button = React.forwardRef(function Button(
{
busy,
children,
className,
disabled,
icon,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { icon?: React.ReactNode; busy?: boolean },
forwardedRef: React.ForwardedRef<HTMLButtonElement>,
) {
return (
// eslint-disable-next-line react/button-has-type
<button
ref={forwardedRef}
{...props}
className={cn(
'flex items-center justify-center gap-2 w-full bg-transparent text-gray-12 font-medium rounded-md bg-clip-padding border border-gray-a6 shadow-sm shadow-gray-a3 py-1.5 px-2.5 outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-gray-a3 focus-visible:border-gray-a8 disabled:cursor-not-allowed text-[0.8125rem]/[1.125rem]',
// note: only reduce opacity of `disabled` so `busy` is more prominent
disabled && 'disabled:opacity-40',
!busy && !disabled && 'hover:bg-gray-a2',
className,
)}
disabled={busy || disabled}
>
{icon ? <span className='text-base'>{busy ? <Spinner>Loading…</Spinner> : <span>{icon}</span>}</span> : null}
{children}
</button>
);
});
8 changes: 6 additions & 2 deletions packages/ui/src/primitives/field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const Root = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDi
<div
ref={forwardedRef}
{...props}
className={cn('space-y-2', className)}
className={cn('has-[[data-field-input][disabled]]:[--cl-field-label-opacity:0.5]', 'space-y-2', className)}
>
{children}
</div>
Expand All @@ -24,7 +24,10 @@ export const Label = React.forwardRef<HTMLLabelElement, React.HTMLAttributes<HTM
<label
ref={forwardedRef}
{...props}
className={cn('text-[0.8125rem]/[1.125rem] font-medium flex items-center text-gray-12 gap-x-1', className)}
className={cn(
'text-[0.8125rem]/[1.125rem] font-medium flex items-center text-gray-12 gap-x-1 opacity-[--cl-field-label-opacity,1]',
className,
)}
>
{children}
</label>
Expand All @@ -37,6 +40,7 @@ export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttribute
) {
return (
<input
data-field-input=''
ref={forwardedRef}
{...props}
className={cn(
Expand Down
Loading

0 comments on commit 7070989

Please sign in to comment.