diff --git a/.changeset/grumpy-dancers-thank.md b/.changeset/grumpy-dancers-thank.md new file mode 100644 index 0000000000..70c8e3d4b0 --- /dev/null +++ b/.changeset/grumpy-dancers-thank.md @@ -0,0 +1,14 @@ +--- +"@clerk/elements": minor +--- + +Improve `` and re-organize some data attributes related to validity states. These changes might be breaking changes for you. + +Overview of changes: + +- `
` no longer has `data-valid` and `data-invalid` attributes. If there are global errors (same heuristics as ``) then a `data-global-error` attribute will be present. +- Fixed a bug where `` could contain `data-valid` and `data-invalid` at the same time. +- The field state (accessible through e.g. ``) 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. `` + 2. `data-state` attribute on `` + 3. `{(state) =>

Field's state is {state}

}
` diff --git a/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx b/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx index 69dcdd32a7..a5c0d888ad 100644 --- a/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx +++ b/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx @@ -130,7 +130,7 @@ export default function SignInPage() { Email @@ -190,9 +190,10 @@ export default function SignInPage() { Email diff --git a/packages/elements/src/react/common/form/index.tsx b/packages/elements/src/react/common/form/index.tsx index 796d2bd5db..4975352567 100644 --- a/packages/elements/src/react/common/form/index.tsx +++ b/packages/elements/src/react/common/form/index.tsx @@ -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'; @@ -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 @@ -52,26 +53,13 @@ import { type ClerkFieldId, FIELD_STATES, FIELD_VALIDITY, type FieldStates } fro const FieldContext = React.createContext | null>(null); const useFieldContext = () => React.useContext(FieldContext); +const ValidityStateContext = React.createContext(undefined); +const useValidityStateContext = () => React.useContext(ValidityStateContext); + /* ------------------------------------------------------------------------------------------------- - * Hooks + * Utils * -----------------------------------------------------------------------------------------------*/ -const useGlobalErrors = () => { - const errors = useFormSelector(globalErrorsSelector); - - return { - errors, - }; -}; - -const useFieldFeedback = ({ name }: Partial>) => { - 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; @@ -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; +}; + +/* ------------------------------------------------------------------------------------------------- + * Hooks + * -----------------------------------------------------------------------------------------------*/ + +const useGlobalErrors = () => { + const errors = useFormSelector(globalErrorsSelector); + + return { + errors, + }; +}; + +const useFieldFeedback = ({ name }: Partial>) => { + const feedback = useFormSelector(fieldFeedbackSelector(name)); + + return { + feedback, + }; +}; + /** * Given a field name, determine the current state of the field */ @@ -133,7 +151,6 @@ const useFieldState = ({ name }: Partial>) => { */ 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 @@ -149,7 +166,7 @@ const useForm = ({ flowActor }: { flowActor?: BaseActorRef<{ type: 'SUBMIT' }> } return { props: { - [`data-${validity}`]: true, + ...(errors.length > 0 ? { 'data-global-error': true } : {}), onSubmit, }, }; @@ -161,12 +178,10 @@ const useField = ({ name }: Partial>) => { 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, 'data-hidden': shouldBeHidden ? true : undefined, serverInvalid: hasError, }, @@ -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: must be wrapped in a component or have a name prop.'); @@ -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, }, @@ -444,7 +460,17 @@ const FieldInner = React.forwardRef((props, fo {...rest} ref={forwardedRef} > - {typeof children === 'function' ? children(fieldState) : children} + + {validity => { + const enrichedFieldState = enrichFieldState(validity, fieldState); + + return ( + + {typeof children === 'function' ? children(enrichedFieldState) : children} + + ); + }} + ); }); @@ -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); }