-
Notifications
You must be signed in to change notification settings - Fork 283
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(elements): Loading states #2669
Changes from 3 commits
74a9976
1cd5919
070b52e
f7f276f
2028534
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,10 +2,11 @@ | |
|
||
import { GlobalError, Submit } from '@clerk/elements'; | ||
import { | ||
SignIn, | ||
Root as SignIn, | ||
SignInContinue, | ||
SignInFactorOne, | ||
SignInFactorTwo, | ||
SignInLoading, | ||
SignInSocialProvider, | ||
SignInStart, | ||
SignInStrategy, | ||
|
@@ -23,9 +24,7 @@ export default function SignInPage() { | |
<SignInStart> | ||
<div className='flex flex-col items-center justify-center gap-12'> | ||
<H1>START</H1> | ||
|
||
<GlobalError className='block text-red-400 font-mono' /> | ||
|
||
<div className='flex flex-col items-stretch justify-center gap-2'> | ||
<SignInSocialProvider | ||
name='github' | ||
|
@@ -51,23 +50,16 @@ export default function SignInPage() { | |
Sign In with Metamask | ||
</SignInSocialProvider> | ||
</div> | ||
|
||
<Hr /> | ||
|
||
<div className='flex gap-6 flex-col'> | ||
<CustomField | ||
label='Email' | ||
name='identifier' | ||
/> | ||
|
||
{/* <Hr /> | ||
|
||
<CustomField | ||
label='Phone' | ||
name='identifier' | ||
/> */} | ||
|
||
<CustomSubmit>Sign In</CustomSubmit> | ||
<CustomSubmit> | ||
<SignInLoading fallback={<>Submitting...</>}>Sign In</SignInLoading> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Example usage for rendering based on the global loading state. For further focusing we could do something like:
Ideally we can make this a non-flow-specific component, though it's hard if we rely on state node tags. |
||
</CustomSubmit> | ||
</div> | ||
</div> | ||
</SignInStart> | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -58,7 +58,8 @@ export type SignInMachineEvents = | |
| { type: 'AUTHENTICATE.WEB3'; strategy: Web3Strategy } | ||
| { type: 'FAILURE'; error: Error } | ||
| { type: 'OAUTH.CALLBACK' } | ||
| { type: 'SUBMIT' }; | ||
| { type: 'SUBMIT' } | ||
| { type: 'SET_LOADING_CONTEXT'; name: string }; | ||
|
||
export interface SignInMachineTypes { | ||
context: SignInMachineContext; | ||
|
@@ -141,6 +142,14 @@ export const SignInMachine = setup({ | |
}; | ||
}, | ||
), | ||
setLoadingContext: assign(({ event }) => { | ||
if (event.type === 'SET_LOADING_CONTEXT') { | ||
return { | ||
loadingContext: event.name, | ||
}; | ||
} | ||
return {}; | ||
}), | ||
Comment on lines
+145
to
+152
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's likely a more elegant way we can store an identifier for whatever action / element triggered the loading state, this was my first stab at it. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This feels best handled when |
||
}, | ||
guards: { | ||
isCurrentFactorPassword: ({ context }) => context.currentFactor?.strategy === 'password', | ||
|
@@ -188,6 +197,7 @@ export const SignInMachine = setup({ | |
on: { | ||
'CLERKJS.NAVIGATE.*': '.HavingTrouble', | ||
FAILURE: '.HavingTrouble', | ||
SET_LOADING_CONTEXT: { actions: ['setLoadingContext'] }, | ||
}, | ||
states: { | ||
Init: { | ||
|
@@ -263,6 +273,11 @@ export const SignInMachine = setup({ | |
guard: 'isSignInComplete', | ||
target: 'Complete', | ||
}, | ||
{ | ||
guard: 'needsIdentifier', | ||
target: 'Start', | ||
reenter: true, | ||
}, | ||
{ | ||
guard: 'needsFirstFactor', | ||
target: 'FirstFactor', | ||
|
@@ -283,6 +298,7 @@ export const SignInMachine = setup({ | |
}, | ||
}, | ||
Attempting: { | ||
tags: ['loading'], | ||
invoke: { | ||
id: 'createSignIn', | ||
src: 'createSignIn', | ||
|
@@ -307,6 +323,7 @@ export const SignInMachine = setup({ | |
}, | ||
FirstFactor: { | ||
initial: 'DeterminingState', | ||
// entry: ['assignStartingFirstFactor'], | ||
BRKalow marked this conversation as resolved.
Show resolved
Hide resolved
|
||
entry: [{ type: 'navigateTo', params: { path: '/continue' } }, 'assignStartingFirstFactor'], | ||
onDone: [ | ||
{ | ||
|
@@ -323,7 +340,7 @@ export const SignInMachine = setup({ | |
always: [ | ||
{ | ||
description: 'If the current factor is not password, prepare the factor', | ||
guard: not('isCurrentFactorPassword'), | ||
guard: and([not('isCurrentFactorPassword'), { type: 'isCurrentPath', params: { path: '/continue' } }]), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ensures prepare only fires once we're on the right path |
||
target: 'Preparing', | ||
}, | ||
{ | ||
|
@@ -333,6 +350,7 @@ export const SignInMachine = setup({ | |
], | ||
}, | ||
Preparing: { | ||
tags: ['loading'], | ||
invoke: { | ||
id: 'prepareFirstFactor', | ||
src: 'prepareFirstFactor', | ||
|
@@ -367,6 +385,7 @@ export const SignInMachine = setup({ | |
}, | ||
}, | ||
Attempting: { | ||
tags: ['loading'], | ||
invoke: { | ||
id: 'attemptFirstFactor', | ||
src: 'attemptFirstFactor', | ||
|
@@ -406,7 +425,7 @@ export const SignInMachine = setup({ | |
always: [ | ||
{ | ||
description: 'If the current factor is not TOTP, prepare the factor', | ||
guard: not('isCurrentFactorTOTP'), | ||
guard: and([not('isCurrentFactorTOTP'), { type: 'isCurrentPath', params: { path: '/continue' } }]), | ||
target: 'Preparing', | ||
reenter: true, | ||
}, | ||
|
@@ -418,6 +437,7 @@ export const SignInMachine = setup({ | |
], | ||
}, | ||
Preparing: { | ||
tags: ['loading'], | ||
invoke: { | ||
id: 'prepareSecondFactor', | ||
src: 'prepareSecondFactor', | ||
|
@@ -445,6 +465,7 @@ export const SignInMachine = setup({ | |
}, | ||
}, | ||
Attempting: { | ||
tags: ['loading'], | ||
invoke: { | ||
id: 'attemptSecondFactor', | ||
src: 'attemptSecondFactor', | ||
|
@@ -475,6 +496,7 @@ export const SignInMachine = setup({ | |
}, | ||
}, | ||
AuthenticatingWithRedirect: { | ||
tags: ['loading'], | ||
invoke: { | ||
id: 'authenticateWithSignInRedirect', | ||
src: 'authenticateWithSignInRedirect', | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,8 +13,8 @@ import { | |
Label as RadixLabel, | ||
Submit, | ||
} from '@radix-ui/react-form'; | ||
import type { ComponentProps, ReactNode } from 'react'; | ||
import React, { createContext, useCallback, useContext, useEffect } from 'react'; | ||
import type { ChangeEvent, ComponentProps, FormEvent, ReactNode } from 'react'; | ||
import { createContext, useCallback, useContext, useEffect, useTransition } from 'react'; | ||
import type { BaseActorRef } from 'xstate'; | ||
|
||
import type { ClerkElementsError } from '~/internals/errors/error'; | ||
|
@@ -40,22 +40,24 @@ const useFieldContext = () => useContext(FieldContext); | |
*/ | ||
const useForm = ({ flowActor }: { flowActor?: BaseActorRef<{ type: 'SUBMIT' }> }) => { | ||
const error = useFormSelector(globalErrorsSelector); | ||
const [isPending, startTransition] = useTransition(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Testing using React's native transitions. Not sure if it provides value beyond the loading state tracking within the state machine. |
||
const validity = error ? 'invalid' : 'valid'; | ||
|
||
// Register the onSubmit handler for form submission | ||
// TODO: merge user-provided submit handler | ||
const onSubmit = useCallback( | ||
(event: React.FormEvent<Element>) => { | ||
(event: FormEvent<Element>) => { | ||
event.preventDefault(); | ||
if (flowActor) { | ||
flowActor.send({ type: 'SUBMIT' }); | ||
startTransition(() => flowActor.send({ type: 'SUBMIT' })); | ||
} | ||
}, | ||
[flowActor], | ||
); | ||
|
||
return { | ||
props: { | ||
[`data-submitting`]: isPending, | ||
[`data-${validity}`]: true, | ||
onSubmit, | ||
}, | ||
|
@@ -130,7 +132,7 @@ const useInput = ({ name: inputName, value: initialValue, type: inputType, ...pa | |
|
||
// Register the onChange handler for field updates to persist to the machine context | ||
const onChange = useCallback( | ||
(event: React.ChangeEvent<HTMLInputElement>) => { | ||
(event: ChangeEvent<HTMLInputElement>) => { | ||
onChangeProp?.(event); | ||
if (!name) return; | ||
ref.send({ type: 'FIELD.UPDATE', field: { name, value: event.target.value } }); | ||
|
@@ -156,7 +158,7 @@ const useInput = ({ name: inputName, value: initialValue, type: inputType, ...pa | |
inputMode: 'numeric', | ||
pattern: '[0-9]*', | ||
maxLength: 6, | ||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => { | ||
onChange: (event: ChangeEvent<HTMLInputElement>) => { | ||
// Only accept numbers | ||
event.currentTarget.value = event.currentTarget.value.replace(/\D+/g, ''); | ||
onChange(event); | ||
|
@@ -241,11 +243,11 @@ type FormErrorRenderProps = Pick<ClerkElementsError, 'code' | 'message'>; | |
type FormErrorProps<T> = Omit<T, 'asChild' | 'children'> & | ||
( | ||
| { | ||
children?: (error: FormErrorRenderProps) => React.ReactNode; | ||
children?: (error: FormErrorRenderProps) => ReactNode; | ||
code?: string; | ||
} | ||
| { | ||
children: React.ReactNode; | ||
children: ReactNode; | ||
code: string; | ||
} | ||
); | ||
|
@@ -303,9 +305,9 @@ export { Field, FieldError, FieldState, Form, GlobalError, Input, Label, Submit | |
export type { | ||
FormControlProps, | ||
FormErrorProps, | ||
FormGlobalErrorProps, | ||
FormErrorRenderProps, | ||
FormFieldErrorProps, | ||
FormFieldProps, | ||
FormGlobalErrorProps, | ||
FormProps, | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,8 +1,7 @@ | ||
import type { FormControlProps } from '@radix-ui/react-form'; | ||
import { Control as RadixControl } from '@radix-ui/react-form'; | ||
import { Slot } from '@radix-ui/react-slot'; | ||
import type { CSSProperties, ReactNode, RefObject } from 'react'; | ||
import { forwardRef, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react'; | ||
import { forwardRef, Fragment, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react'; | ||
|
||
export type OTPInputProps = Exclude< | ||
FormControlProps, | ||
|
@@ -101,7 +100,7 @@ const OTPInputSegmented = forwardRef<HTMLInputElement, Required<Pick<OTPInputPro | |
}} | ||
> | ||
{Array.from({ length: maxLength }).map((_, i) => ( | ||
<Slot key={i}> | ||
<Fragment key={`${String(props.value)[i]}-i`}> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixes key error |
||
{render({ | ||
value: String(props.value)[i] || '', | ||
status: | ||
|
@@ -112,7 +111,7 @@ const OTPInputSegmented = forwardRef<HTMLInputElement, Required<Pick<OTPInputPro | |
: 'none', | ||
index: i, | ||
})} | ||
</Slot> | ||
</Fragment> | ||
))} | ||
</div> | ||
</div> | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Align exports with our current docs