(null);
+ return (
+
+
+ Email address
+
+ Used for account notifications.
+
+ {error}
+
+
+ setError(current => (current === null ? errorMessages[0] : null))}
+ >
+ Toggle short error
+
+ setError(current => (current === null ? errorMessages[1] : null))}
+ >
+ Toggle long error
+
+
+ );
+}
+
+export function WithFeedback() {
+ const [feedback, setFeedback] = React.useState<'error' | 'success' | null>(null);
+ return (
+
+
+ Current password
+
+
+
+ {feedback === 'error'
+ ? 'The password you entered is incorrect. Check for typos and try again, or reset your password if you have forgotten it.'
+ : null}
+
+ {feedback === 'success' ? 'Password verified.' : null}
+
+
+ setFeedback('error')}
+ >
+ Show error
+
+ setFeedback('success')}
+ >
+ Show success
+
+ setFeedback(null)}
+ >
+ Clear
+
+
+ );
+}
diff --git a/packages/swingset/src/stories/otp.component.mdx b/packages/swingset/src/stories/otp.component.mdx
index 050bbc0b451..b4777ef644c 100644
--- a/packages/swingset/src/stories/otp.component.mdx
+++ b/packages/swingset/src/stories/otp.component.mdx
@@ -63,7 +63,10 @@ Inside a `Field.Root`, the field's `disabled`, `required`, and `invalid` flow in
>
Verification code
- {error ? {error} : Didn’t receive a code? Resend }
+ Didn’t receive a code? Resend
+
+ {error}
+
```
diff --git a/packages/ui/src/mosaic/blocks/destructive/destructive.tsx b/packages/ui/src/mosaic/blocks/destructive/destructive.tsx
index fa0804dc8f5..16b74876a8d 100644
--- a/packages/ui/src/mosaic/blocks/destructive/destructive.tsx
+++ b/packages/ui/src/mosaic/blocks/destructive/destructive.tsx
@@ -127,7 +127,9 @@ export function Destructive({
disabled={isDeleting}
onChange={event => setTypedValue(event.target.value)}
/>
- {errorMessage ? {errorMessage} : null}
+
+ {errorMessage}
+
diff --git a/packages/ui/src/mosaic/components/field/field.context.tsx b/packages/ui/src/mosaic/components/field/field.context.tsx
index 1651e2097a8..a38ad447302 100644
--- a/packages/ui/src/mosaic/components/field/field.context.tsx
+++ b/packages/ui/src/mosaic/components/field/field.context.tsx
@@ -93,6 +93,29 @@ export function useRegisterFieldPartId(
}, [id, setIds]);
}
+interface FieldMessageContextValue {
+ register: (key: symbol, element: HTMLElement | null) => void;
+}
+
+const FieldMessageContext = React.createContext(null);
+
+export function FieldMessageProvider({ register, children }: React.PropsWithChildren) {
+ const context = React.useMemo(() => ({ register }), [register]);
+ return {children} ;
+}
+
+export function useRegisterFieldMessage(open: boolean): React.RefCallback {
+ const register = React.useContext(FieldMessageContext)?.register;
+ const key = React.useRef(Symbol('field-message'));
+
+ return React.useCallback(
+ (element: HTMLElement | null) => {
+ register?.(key.current, open ? element : null);
+ },
+ [register, open],
+ );
+}
+
interface FieldControlProps {
id?: string;
disabled?: boolean;
diff --git a/packages/ui/src/mosaic/components/field/field.styles.ts b/packages/ui/src/mosaic/components/field/field.styles.ts
index ce762ce4eb3..50b75c9e063 100644
--- a/packages/ui/src/mosaic/components/field/field.styles.ts
+++ b/packages/ui/src/mosaic/components/field/field.styles.ts
@@ -1,10 +1,12 @@
import * as stylex from '@stylexjs/stylex';
-import { colorVars, fontWeightVars, space } from '../../tokens.stylex';
+import { colorVars, durationVars, easingVars, fontWeightVars, space } from '../../tokens.stylex';
+
+const ROOT_GAP = space['2'];
export const styles = stylex.create({
root: {
- gap: space['2'],
+ gap: ROOT_GAP,
display: 'flex',
flexDirection: 'column',
},
@@ -18,15 +20,50 @@ export const styles = stylex.create({
description: {
color: colorVars['--cl-color-neutral-faded'],
},
- error: {
+ messageRoot: {
+ overflow: 'clip',
+ alignContent: 'start',
+ display: 'grid',
+ position: 'relative',
+ transitionDuration: durationVars['--cl-duration-slow'],
+ transitionProperty: {
+ default: 'height, margin-top',
+ '@media (prefers-reduced-motion: reduce)': 'none',
+ },
+ transitionTimingFunction: easingVars['--cl-ease-enter'],
+ height: {
+ default: 'var(--_cl-field-message-height)',
+ ':where(:not([data-open]), [data-starting-style])': 0,
+ },
+ marginTop: {
+ default: 0,
+ ':where(:not([data-open]), [data-starting-style])': `calc(-1 * ${ROOT_GAP})`,
+ },
+ },
+ feedback: {
+ inset: { default: null, ':where([data-ending-style])': '0 0 auto' },
gap: space['1'],
alignItems: 'flex-start',
- color: colorVars['--cl-color-negative'],
display: 'flex',
+ opacity: { default: 1, ':where([data-starting-style], [data-ending-style])': 0 },
+ position: { default: null, ':where([data-ending-style])': 'absolute' },
textWrap: 'pretty',
+ transitionDuration: durationVars['--cl-duration-base'],
+ transitionProperty: 'opacity',
+ transitionTimingFunction: 'linear',
+ },
+ error: {
+ color: colorVars['--cl-color-negative'],
+ },
+ success: {
+ color: colorVars['--cl-color-positive'],
},
- errorIcon: {
+ feedbackIcon: {
flexShrink: 0,
height: '1lh',
},
});
+
+export const dynamic = stylex.create({
+ messageHeight: (height: number) => ({ '--_cl-field-message-height': `${height}px` }),
+});
diff --git a/packages/ui/src/mosaic/components/field/field.test.tsx b/packages/ui/src/mosaic/components/field/field.test.tsx
index b7a3c26d484..8d3c65aa0a4 100644
--- a/packages/ui/src/mosaic/components/field/field.test.tsx
+++ b/packages/ui/src/mosaic/components/field/field.test.tsx
@@ -3,7 +3,7 @@ import { act, render, screen } from '@testing-library/react';
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import { renderToString } from 'react-dom/server';
-import { describe, expect, it, vi } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { Input } from '../input';
import { Field } from './field';
@@ -18,7 +18,25 @@ const overrides = stylex.create({
const atoms = (style: stylex.StyleXStyles) =>
(stylex.props(style).className ?? '').split(' ').filter(name => /^x[a-z0-9]+$/.test(name));
+const restores: Array<() => void> = [];
+
+function stubPrototype(target: object, name: string, descriptor: PropertyDescriptor) {
+ const original = Object.getOwnPropertyDescriptor(target, name);
+ Object.defineProperty(target, name, { configurable: true, ...descriptor });
+ restores.push(() => {
+ if (original) {
+ Object.defineProperty(target, name, original);
+ } else {
+ Reflect.deleteProperty(target, name);
+ }
+ });
+}
+
describe('Mosaic Field', () => {
+ afterEach(() => {
+ restores.splice(0).forEach(restore => restore());
+ });
+
it('generates native label and message relationships', () => {
render(
@@ -363,6 +381,208 @@ describe('Mosaic Field', () => {
expect(error?.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
});
+ it('mounts through the headless enter transition and animates the message height', () => {
+ const probe = stylex.create({
+ message: {
+ transitionProperty: {
+ default: 'height, margin-top',
+ '@media (prefers-reduced-motion: reduce)': 'none',
+ },
+ height: {
+ default: 'var(--_cl-field-message-height)',
+ ':where(:not([data-open]), [data-starting-style])': 0,
+ },
+ },
+ feedback: {
+ opacity: { default: 1, ':where([data-starting-style], [data-ending-style])': 0 },
+ position: { default: null, ':where([data-ending-style])': 'absolute' },
+ },
+ });
+
+ const { container } = render(
+
+
+ Enter a valid email.
+
+ ,
+ );
+
+ const message = container.querySelector('.cl-field-message');
+ const error = screen.getByText('Enter a valid email.').closest('p');
+ expect(atoms(probe.message)).toHaveLength(4);
+ expect(message).toHaveClass(...atoms(probe.message));
+ expect(message).toHaveAttribute('data-open');
+ expect(message).toHaveAttribute('data-starting-style');
+ expect(atoms(probe.feedback)).toHaveLength(3);
+ expect(error).toHaveClass(...atoms(probe.feedback));
+ expect(error).toHaveAttribute('data-open');
+ expect(error).toHaveAttribute('data-starting-style');
+ });
+
+ it('renders nothing without a message', () => {
+ const { container } = render(
+
+ {null}
+ {''}
+ {false}
+ ,
+ );
+
+ expect(container.querySelector('.cl-field-error')).toBeNull();
+ });
+
+ it('crossfades an error into a success message inside one Field.Message', async () => {
+ stubPrototype(Element.prototype, 'getAnimations', {
+ value: () => [{ finished: new Promise(() => undefined) }],
+ });
+
+ const { container, rerender } = render(
+
+
+
+ Password is incorrect.
+ {null}
+
+ ,
+ );
+ await act(async () => {
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ });
+ expect(container.querySelector('.cl-field-success')).toBeNull();
+
+ rerender(
+
+
+
+ {null}
+ Password verified.
+
+ ,
+ );
+
+ const error = container.querySelector('.cl-field-error');
+ const success = container.querySelector('.cl-field-success');
+ expect(container.querySelector('.cl-field-message')).toHaveAttribute('data-open');
+ expect(container.querySelector('.cl-field-message')).not.toHaveAttribute('data-ending-style');
+ expect(error).toHaveTextContent('Password is incorrect.');
+ expect(error).toHaveAttribute('data-ending-style');
+ expect(error).toHaveAttribute('aria-hidden', 'true');
+ expect(success).toHaveTextContent('Password verified.');
+ expect(success).toHaveAttribute('data-starting-style');
+ expect(success).not.toHaveAttribute('aria-hidden');
+ expect(screen.getByRole('textbox')).toHaveAttribute('aria-describedby', success?.id);
+ });
+
+ it('re-measures when an open message swaps its rendered element', () => {
+ stubPrototype(HTMLElement.prototype, 'offsetHeight', {
+ get(this: HTMLElement) {
+ return this.tagName === 'DIV' ? 40 : 24;
+ },
+ });
+
+ const { container, rerender } = render(
+
+
+
+ Enter a valid email.
+
+ ,
+ );
+ const message = container.querySelector('.cl-field-message');
+ expect(message).toHaveAttribute('data-open');
+ expect(message?.getAttribute('style')).toContain('24px');
+
+ rerender(
+
+
+
+ }>Enter a valid email.
+
+ ,
+ );
+
+ expect(screen.getByText('Enter a valid email.').closest('div')).toHaveClass('cl-field-error');
+ expect(message).toHaveAttribute('data-open');
+ expect(message).not.toHaveAttribute('data-ending-style');
+ expect(message?.getAttribute('style')).toContain('40px');
+ });
+
+ it('announces messages from a persistent polite live region that callers can escalate', () => {
+ const { container, rerender } = render(
+
+
+
+ {null}
+
+ ,
+ );
+
+ const message = container.querySelector('.cl-field-message');
+ expect(message).toHaveAttribute('role', 'status');
+ expect(message).toBeEmptyDOMElement();
+
+ rerender(
+
+
+
+ Required.
+
+ ,
+ );
+
+ expect(container.querySelector('.cl-field-message')).toBe(message);
+ expect(message).toHaveAttribute('role', 'alert');
+ expect(message).toHaveTextContent('Required.');
+ });
+
+ it('holds the last message through the exit transition, then unmounts', async () => {
+ let finish = () => undefined as void;
+ const finished = new Promise(resolve => {
+ finish = resolve;
+ });
+ const getAnimations = vi.fn(() => [{ finished }]);
+ stubPrototype(Element.prototype, 'getAnimations', { value: getAnimations });
+
+ const { container, rerender } = render(
+
+
+
+ Enter a valid email.
+
+ ,
+ );
+ const error = screen.getByText('Enter a valid email.').closest('p');
+ await act(async () => {
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ });
+ expect(error).not.toHaveAttribute('data-starting-style');
+ expect(screen.getByRole('textbox')).toHaveAttribute('aria-describedby', error?.id);
+
+ rerender(
+
+
+
+ {null}
+
+ ,
+ );
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 0));
+ });
+
+ expect(screen.getByText('Enter a valid email.').closest('p')).toHaveAttribute('data-ending-style');
+ expect(container.querySelector('.cl-field-message')).toHaveAttribute('data-ending-style');
+ expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby');
+
+ getAnimations.mockReturnValue([]);
+ await act(async () => {
+ finish();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ });
+
+ expect(screen.queryByText('Enter a valid email.')).toBeNull();
+ });
+
it('warns when Field.Label does not render a native label', () => {
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
diff --git a/packages/ui/src/mosaic/components/field/field.tsx b/packages/ui/src/mosaic/components/field/field.tsx
index 83d59ab7d2b..8699b41c9b0 100644
--- a/packages/ui/src/mosaic/components/field/field.tsx
+++ b/packages/ui/src/mosaic/components/field/field.tsx
@@ -1,15 +1,24 @@
+import { useTransition } from '@clerk/headless/hooks';
import { useRender } from '@clerk/headless/utils';
+import { useSafeLayoutEffect } from '@clerk/shared/react';
import * as stylex from '@stylexjs/stylex';
import React from 'react';
+import type { IconName } from '../../icons/registry';
import type { MosaicComponentProps } from '../../props';
import { mergeStyleProps, themeProps } from '../../props';
import { reset } from '../../utils/reset.styles';
import { sizes as typographySizes, styles as typographyStyles } from '../../utils/typography.styles';
import { visuallyHidden } from '../../utils/visually-hidden.styles';
import { Icon } from '../icon';
-import { FieldProvider, useOptionalFieldContext, useRegisterFieldPartId } from './field.context';
-import { styles } from './field.styles';
+import {
+ FieldMessageProvider,
+ FieldProvider,
+ useOptionalFieldContext,
+ useRegisterFieldMessage,
+ useRegisterFieldPartId,
+} from './field.context';
+import { dynamic, styles } from './field.styles';
function useNativeLabelWarning(label: HTMLLabelElement | null) {
React.useEffect(() => {
@@ -116,42 +125,160 @@ const Description = React.forwardRef;
+/** Props for the container that animates field feedback height and crossfades its messages. */
+export type FieldMessageProps = MosaicComponentProps<'div'>;
+
+function useMessageHeight(active: HTMLElement | null) {
+ const [height, setHeight] = React.useState(0);
+
+ useSafeLayoutEffect(() => {
+ if (!active) {
+ return undefined;
+ }
+
+ const measure = () => setHeight(active.offsetHeight);
+ measure();
+
+ if (typeof ResizeObserver === 'undefined') {
+ return undefined;
+ }
+
+ const observer = new ResizeObserver(measure);
+ observer.observe(active);
+ return () => observer.disconnect();
+ }, [active]);
+
+ return height;
+}
+
+const Message = React.forwardRef(function MosaicFieldMessage(
+ { render, xstyle, children, ...rest },
+ ref,
+) {
+ const entries = React.useRef(new Map());
+ const [active, setActive] = React.useState(null);
+ const register = React.useCallback((key: symbol, element: HTMLElement | null) => {
+ if (element) {
+ entries.current.set(key, element);
+ } else {
+ entries.current.delete(key);
+ }
+ setActive(entries.current.values().next().value ?? null);
+ }, []);
+ const height = useMessageHeight(active);
+ const element = React.useRef(null);
+ const { transitionProps } = useTransition({ open: active !== null, ref: element });
+ const rendered = useRender({
+ defaultTagName: 'div',
+ render,
+ ref: [ref, element],
+ props: {
+ ...mergeStyleProps(
+ themeProps('field-message'),
+ stylex.props(reset.base, styles.messageRoot, dynamic.messageHeight(height), xstyle),
+ { role: 'status', ...transitionProps },
+ rest,
+ ),
+ children,
+ },
+ });
+
+ return {rendered} ;
+});
-const FieldError = React.forwardRef(function MosaicFieldError(
- { render, xstyle, id: idProp, children, ...rest },
+type FieldFeedbackKind = 'error' | 'success';
+
+const FEEDBACK: Record = {
+ error: { slot: 'field-error', icon: 'alert-circle', color: styles.error },
+ success: { slot: 'field-success', icon: 'check', color: styles.success },
+};
+
+interface FieldFeedbackProps extends MosaicComponentProps<'p'> {
+ kind: FieldFeedbackKind;
+}
+
+function hasMessage(children: React.ReactNode) {
+ return React.Children.toArray(children).some(child => child !== '');
+}
+
+const FieldFeedback = React.forwardRef(function MosaicFieldFeedback(
+ { render, xstyle, id: idProp, children, kind, ...rest },
ref,
) {
+ const { slot, icon, color } = FEEDBACK[kind];
const context = useOptionalFieldContext();
const generatedId = React.useId();
- const id = idProp ?? (context ? `cl-field-${generatedId}-error` : undefined);
- useRegisterFieldPartId(id, context?.setMessageIds);
+ const id = idProp ?? (context ? `cl-field-${generatedId}-${kind}` : undefined);
+ const open = hasMessage(children);
+ const element = React.useRef(null);
+ const { mounted, transitionProps } = useTransition({ open, ref: element });
+ const lastMessage = React.useRef(children);
+ if (open) {
+ lastMessage.current = children;
+ }
+ useRegisterFieldPartId(open ? id : undefined, context?.setMessageIds);
+ const registerMessage = useRegisterFieldMessage(open);
return useRender({
defaultTagName: 'p',
render,
- ref,
+ enabled: mounted,
+ ref: [ref, element, registerMessage],
props: {
...mergeStyleProps(
- themeProps('field-error'),
- stylex.props(reset.base, typographyStyles.base, typographySizes.xs, styles.message, styles.error, xstyle),
+ themeProps(slot),
+ stylex.props(
+ reset.base,
+ typographyStyles.base,
+ typographySizes.xs,
+ styles.message,
+ styles.feedback,
+ color,
+ xstyle,
+ ),
+ { 'aria-hidden': open ? undefined : true, ...transitionProps },
rest,
),
id,
children: (
<>
- {children}
+ {lastMessage.current}
>
),
},
});
});
+/** Props for field validation text. */
+export type FieldErrorProps = MosaicComponentProps<'p'>;
+
+const FieldError = React.forwardRef(function MosaicFieldError(props, ref) {
+ return (
+
+ );
+});
+
+/** Props for field success text. */
+export type FieldSuccessProps = MosaicComponentProps<'p'>;
+
+const FieldSuccess = React.forwardRef(function MosaicFieldSuccess(props, ref) {
+ return (
+
+ );
+});
+
/** Styled parts for composing an automatically associated single-control field. */
-export const Field = { Root, Label, Description, Error: FieldError };
+export const Field = { Root, Label, Description, Message, Error: FieldError, Success: FieldSuccess };
diff --git a/packages/ui/src/mosaic/components/field/index.ts b/packages/ui/src/mosaic/components/field/index.ts
index 786c9820509..95609821d13 100644
--- a/packages/ui/src/mosaic/components/field/index.ts
+++ b/packages/ui/src/mosaic/components/field/index.ts
@@ -1,2 +1,9 @@
export { Field } from './field';
-export type { FieldDescriptionProps, FieldErrorProps, FieldLabelProps, FieldRootProps } from './field';
+export type {
+ FieldDescriptionProps,
+ FieldErrorProps,
+ FieldLabelProps,
+ FieldMessageProps,
+ FieldRootProps,
+ FieldSuccessProps,
+} from './field';
diff --git a/packages/ui/src/mosaic/features/reverification/steps/reverification-backup-code.tsx b/packages/ui/src/mosaic/features/reverification/steps/reverification-backup-code.tsx
index 2b0206cd32e..bdd0ff96d77 100644
--- a/packages/ui/src/mosaic/features/reverification/steps/reverification-backup-code.tsx
+++ b/packages/ui/src/mosaic/features/reverification/steps/reverification-backup-code.tsx
@@ -71,7 +71,9 @@ export function ReverificationBackupCode({
value={value}
onChange={event => onValueChange(event.target.value)}
/>
- {errorMessage ? {errorMessage} : null}
+
+ {errorMessage}
+
diff --git a/packages/ui/src/mosaic/features/reverification/steps/reverification-otp.tsx b/packages/ui/src/mosaic/features/reverification/steps/reverification-otp.tsx
index a07242e9100..bf34033f959 100644
--- a/packages/ui/src/mosaic/features/reverification/steps/reverification-otp.tsx
+++ b/packages/ui/src/mosaic/features/reverification/steps/reverification-otp.tsx
@@ -85,7 +85,9 @@ export function ReverificationOTP({
}
}}
/>
- {errorMessage ? {errorMessage} : null}
+
+ {errorMessage}
+
{resend ? (
onValueChange(event.target.value)}
/>
- {errorMessage ? {errorMessage} : null}
+
+ {errorMessage}
+
diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx
index f0aa54db900..738c9ec7aa4 100644
--- a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx
+++ b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx
@@ -89,7 +89,9 @@ export function UserProfileAddPhoneDialog(props: UserProfileAddPhoneDialogProps)
value={current.phoneNumber}
onValueChange={current.onPhoneNumberChange}
/>
- {current.errorMessage ? {current.errorMessage} : null}
+
+ {current.errorMessage}
+
@@ -133,7 +135,9 @@ export function UserProfileAddPhoneDialog(props: UserProfileAddPhoneDialogProps)
onValueChange={current.onCodeChange}
onComplete={current.onSubmit}
/>
- {current.errorMessage ? {current.errorMessage} : null}
+
+ {current.errorMessage}
+
onFirstNameChange(event.target.value)}
/>
- {error?.fields?.firstName ? {error.fields.firstName} : null}
+
+ {error?.fields?.firstName}
+
) : null}
{showLastName ? (
@@ -130,7 +132,9 @@ export function UserProfileEditNameDialog({
value={lastName}
onChange={event => onLastNameChange(event.target.value)}
/>
- {error?.fields?.lastName ? {error.fields.lastName} : null}
+
+ {error?.fields?.lastName}
+
) : null}
diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx
index 36e8e5dede3..507072edd58 100644
--- a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx
+++ b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx
@@ -90,7 +90,9 @@ export function UserProfileEditUsernameDialog({
value={username}
onChange={event => onUsernameChange(event.target.value)}
/>
- {error?.fields?.username ? {error.fields.username} : null}
+
+ {error?.fields?.username}
+