Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/grumpy-dancers-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@clerk/elements": minor
---

Improve `<FieldState>` and re-organize some data attributes related to validity states. These changes might be breaking changes for you.

Overview of changes:

- `<form>` no longer has `data-valid` and `data-invalid` attributes. If there are global errors (same heuristics as `<GlobalError>`) then a `data-global-error` attribute will be present.
- Fixed a bug where `<Field>` could contain `data-valid` and `data-invalid` at the same time.
- The field state (accessible through e.g. `<FieldState>`) now also incorporates the field's [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) into its output. If the `ValidityState` is invalid, the field state will be an `error`. You can access this information in three places:
1. `<FieldState>`
2. `data-state` attribute on `<Input>`
3. `<Field>{(state) => <p>Field's state is {state}</p>}</Field>`
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export default function SignInPage() {
<Clerk.Label className='sr-only'>Email</Clerk.Label>
<Clerk.Input
className={`w-full rounded border border-[rgb(37,37,37)] bg-[rgb(12,12,12)] px-4 py-2 placeholder-[rgb(100,100,100)] ${
fieldState === 'invalid' && 'border-red-500'
fieldState === 'error' && 'border-red-500'
}`}
placeholder='Enter your email address'
/>
Expand Down Expand Up @@ -190,9 +190,10 @@ export default function SignInPage() {
<Clerk.Label className='sr-only'>Email</Clerk.Label>
<Clerk.Input
className={`w-full rounded border border-[rgb(37,37,37)] bg-[rgb(12,12,12)] px-4 py-2 placeholder-[rgb(100,100,100)] ${
fieldState === 'invalid' && 'border-red-500'
fieldState === 'error' && 'border-red-500'
}`}
placeholder='Enter your email address'
type='email'
/>
<Clerk.FieldError className='block w-full font-mono text-red-400' />
</>
Expand Down
77 changes: 52 additions & 25 deletions packages/elements/src/react/common/form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
FormMessage as RadixFormMessage,
Label as RadixLabel,
Submit as RadixSubmit,
ValidityState as RadixValidityState,
} from '@radix-ui/react-form';
import { Slot } from '@radix-ui/react-slot';
import * as React from 'react';
Expand All @@ -43,7 +44,7 @@ import { isReactFragment } from '~/react/utils/is-react-fragment';

import type { OTPInputProps } from './otp';
import { OTP_LENGTH_DEFAULT, OTPInput } from './otp';
import { type ClerkFieldId, FIELD_STATES, FIELD_VALIDITY, type FieldStates } from './types';
import { type ClerkFieldId, FIELD_STATES, type FieldStates } from './types';

/* -------------------------------------------------------------------------------------------------
* Context
Expand All @@ -52,26 +53,13 @@ import { type ClerkFieldId, FIELD_STATES, FIELD_VALIDITY, type FieldStates } fro
const FieldContext = React.createContext<Pick<FieldDetails, 'name'> | null>(null);
const useFieldContext = () => React.useContext(FieldContext);

const ValidityStateContext = React.createContext<ValidityState | undefined>(undefined);
const useValidityStateContext = () => React.useContext(ValidityStateContext);

/* -------------------------------------------------------------------------------------------------
* Hooks
* Utils
* -----------------------------------------------------------------------------------------------*/

const useGlobalErrors = () => {
const errors = useFormSelector(globalErrorsSelector);

return {
errors,
};
};

const useFieldFeedback = ({ name }: Partial<Pick<FieldDetails, 'name'>>) => {
const feedback = useFormSelector(fieldFeedbackSelector(name));

return {
feedback,
};
};

const determineInputTypeFromName = (name: FormFieldProps['name']) => {
if (name === 'password' || name === 'confirmPassword' || name === 'currentPassword' || name === 'newPassword') {
return 'password' as const;
Expand All @@ -89,6 +77,36 @@ const determineInputTypeFromName = (name: FormFieldProps['name']) => {
return 'text' as const;
};

/**
* Radix can return the ValidityState object, which contains the validity of the field. We need to merge this with our existing fieldState.
* When the ValidityState is valid: false, the fieldState should be overriden. Otherwise, it shouldn't change at all.
* @see https://www.radix-ui.com/primitives/docs/components/form#validitystate
* @see https://developer.mozilla.org/en-US/docs/Web/API/ValidityState
*/
const enrichFieldState = (validity: ValidityState | undefined, fieldState: FieldStates) => {
return validity?.valid === false ? FIELD_STATES.error : fieldState;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

valid

A boolean value that is true if the element meets all its validation constraints, and is therefore considered to be valid, or false if it fails any constraint.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also thought about doing this instead:

return fieldState === FIELD_STATES.idle ? validity?.valid : fieldState

I guess it's a question of what should take precedence. The validity or our own fieldState

};

/* -------------------------------------------------------------------------------------------------
* Hooks
* -----------------------------------------------------------------------------------------------*/

const useGlobalErrors = () => {
const errors = useFormSelector(globalErrorsSelector);

return {
errors,
};
};

const useFieldFeedback = ({ name }: Partial<Pick<FieldDetails, 'name'>>) => {
const feedback = useFormSelector(fieldFeedbackSelector(name));

return {
feedback,
};
};

/**
* Given a field name, determine the current state of the field
*/
Expand Down Expand Up @@ -133,7 +151,6 @@ const useFieldState = ({ name }: Partial<Pick<FieldDetails, 'name'>>) => {
*/
const useForm = ({ flowActor }: { flowActor?: BaseActorRef<{ type: 'SUBMIT' }> }) => {
const { errors } = useGlobalErrors();
const validity = errors.length > 0 ? FIELD_VALIDITY.invalid : FIELD_VALIDITY.valid;

// Register the onSubmit handler for form submission
// TODO: merge user-provided submit handler
Expand All @@ -149,7 +166,7 @@ const useForm = ({ flowActor }: { flowActor?: BaseActorRef<{ type: 'SUBMIT' }> }

return {
props: {
[`data-${validity}`]: true,
...(errors.length > 0 ? { 'data-global-error': true } : {}),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It didn't make much sense to me to have data-valid/data-invalid on the <form> if that's already placed further below in the DOM tree. And in those instances we refer to the <FieldError> state, not <GlobalError>.

So renaming this to make it more clear that this is about global errors.

onSubmit,
},
};
Expand All @@ -161,12 +178,10 @@ const useField = ({ name }: Partial<Pick<FieldDetails, 'name'>>) => {

const shouldBeHidden = false; // TODO: Implement clerk-js utils
const hasError = feedback ? feedback.type === 'error' : false;
const validity = hasError ? FIELD_VALIDITY.invalid : FIELD_VALIDITY.valid;

return {
hasValue,
props: {
[`data-${validity}`]: true,

@LekoArts LekoArts Jun 19, 2024

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Radix already adds data-valid/data-invalid correctly to the field, so this is removed to not end up in a situation where both data-valid/data-invalid can exist at the same time.

'data-hidden': shouldBeHidden ? true : undefined,
serverInvalid: hasError,
},
Expand All @@ -186,6 +201,7 @@ const useInput = ({
const fieldContext = useFieldContext();
const name = inputName || fieldContext?.name;
const { state: fieldState } = useFieldState({ name });
const validity = useValidityStateContext();

if (!name) {
throw new Error('Clerk: <Input /> must be wrapped in a <Field> component or have a name prop.');
Expand Down Expand Up @@ -342,7 +358,7 @@ const useInput = ({
onFocus,
'data-hidden': shouldBeHidden ? true : undefined,
'data-has-value': hasValue ? true : undefined,
'data-state': fieldState,
'data-state': enrichFieldState(validity, fieldState),
...props,
...rest,
},
Expand Down Expand Up @@ -444,7 +460,17 @@ const FieldInner = React.forwardRef<FormFieldElement, FormFieldProps>((props, fo
{...rest}
ref={forwardedRef}
>
{typeof children === 'function' ? children(fieldState) : children}
<RadixValidityState>
{validity => {
const enrichedFieldState = enrichFieldState(validity, fieldState);

return (
<ValidityStateContext.Provider value={validity}>
{typeof children === 'function' ? children(enrichedFieldState) : children}
</ValidityStateContext.Provider>
);
}}
</RadixValidityState>
Comment on lines +463 to +473

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Main gist of this PR. Using Context here to not have to pass around values through props 👍

</RadixField>
);
});
Expand Down Expand Up @@ -493,11 +519,12 @@ function FieldState({ children }: FieldStateRenderFn) {
const field = useFieldContext();
const { feedback } = useFieldFeedback({ name: field?.name });
const { state } = useFieldState({ name: field?.name });
const validity = useValidityStateContext();

const message = feedback?.message instanceof ClerkElementsFieldError ? feedback.message.message : feedback?.message;
const codes = feedback?.codes;

const fieldState = { state, message, codes };
const fieldState = { state: enrichFieldState(validity, state), message, codes };

return children(fieldState);
}
Expand Down