From 2f58a4dfd65434378043d28d81c7aa6636f75ce6 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 14 Sep 2026 15:21:10 -0400 Subject: [PATCH 1/9] feat(ui): animate mosaic field messages Co-Authored-By: Claude --- .../mosaic-field-error-enter-animation.md | 2 + .../swingset/src/stories/field.component.mdx | 25 ++- .../src/stories/field.component.stories.tsx | 86 ++++++++++ .../swingset/src/stories/otp.component.mdx | 5 +- .../mosaic/blocks/destructive/destructive.tsx | 4 +- .../mosaic/components/field/field.context.tsx | 25 +++ .../mosaic/components/field/field.styles.ts | 47 +++++- .../mosaic/components/field/field.test.tsx | 140 ++++++++++++++++ .../ui/src/mosaic/components/field/field.tsx | 157 ++++++++++++++++-- .../ui/src/mosaic/components/field/index.ts | 9 +- .../steps/reverification-backup-code.tsx | 4 +- .../steps/reverification-otp.tsx | 4 +- .../steps/reverification-password.tsx | 4 +- .../user-profile-edit-name.dialog.tsx | 8 +- .../user-profile-edit-username.dialog.tsx | 4 +- 15 files changed, 494 insertions(+), 30 deletions(-) create mode 100644 .changeset/mosaic-field-error-enter-animation.md diff --git a/.changeset/mosaic-field-error-enter-animation.md b/.changeset/mosaic-field-error-enter-animation.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-field-error-enter-animation.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/stories/field.component.mdx b/packages/swingset/src/stories/field.component.mdx index 29ee1850bf8..84fdccf3b29 100644 --- a/packages/swingset/src/stories/field.component.mdx +++ b/packages/swingset/src/stories/field.component.mdx @@ -25,7 +25,10 @@ import { Input } from '@clerk/ui/mosaic/components/input'; required aria-invalid={Boolean(error)} /> - {error ? {error} : Used for account notifications.} + Used for account notifications. + + {error} + ; ``` @@ -42,6 +45,24 @@ Field does not validate controls or render errors automatically. Its parts may a Pass `visuallyHidden` to `Field.Label` when the surrounding UI already makes the label redundant. The label is still rendered and still associated with the control, so the control keeps its accessible name — unlike dropping the label and relying on a `placeholder`, which is not an accessible name. It applies the same style as the [VisuallyHidden](/components/visually-hidden) component. +### Error + + + +Wrap `Field.Error` in `Field.Message` and pass the message as its children. `Field.Message` measures whichever message is showing and animates its own height to match, so it grows when a message arrives, follows the text when the message changes, and collapses after it clears. The caller never conditionally mounts either part. Under `prefers-reduced-motion` the height snaps and only the message fades. + +### Success + + + +`Field.Success` is the positive counterpart. Render both inside one `Field.Message` and pass each its own message; when one replaces the other, the outgoing message fades out over the incoming one while the container height moves once from the old height to the new. + ## Parts | Part | Stable slot class | Description | @@ -49,7 +70,9 @@ Pass `visuallyHidden` to `Field.Label` when the surrounding UI already makes the | `Field.Root` | `.cl-field-root` | Unstyled `div` and field context provider. | | `Field.Label` | `.cl-field-label` | Native `label` associated with the field control. | | `Field.Description` | `.cl-field-description` | Supporting `p` associated with the field control. | +| `Field.Message` | `.cl-field-message` | Height-animated container for the messages below. | | `Field.Error` | `.cl-field-error` | Associated error `p` with an alert icon. | +| `Field.Success` | `.cl-field-success` | Associated success `p` with a check icon. | ## Styling diff --git a/packages/swingset/src/stories/field.component.stories.tsx b/packages/swingset/src/stories/field.component.stories.tsx index 069d9a5284d..11782448007 100644 --- a/packages/swingset/src/stories/field.component.stories.tsx +++ b/packages/swingset/src/stories/field.component.stories.tsx @@ -1,6 +1,8 @@ +import { Button } from '@clerk/ui/mosaic/components/button'; import { Field } from '@clerk/ui/mosaic/components/field'; import { Input } from '@clerk/ui/mosaic/components/input'; import * as stylex from '@stylexjs/stylex'; +import React from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -21,6 +23,12 @@ const styles = stylex.create({ gap: 8, maxWidth: 384, }, + errorStack: { + display: 'grid', + gap: 8, + maxWidth: 384, + width: '100%', + }, }); export function Default() { @@ -49,3 +57,81 @@ export function VisuallyHiddenLabel() { ); } + +const errorMessages = [ + 'Enter a valid email address.', + 'This email address is already in use. Sign in instead, or use a different address to create a new account.', +]; + +export function WithError() { + const [error, setError] = React.useState(null); + return ( +
+ + Email address + + Used for account notifications. + + {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} + + + + + +
+ ); +} 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..bbd5c144282 100644 --- a/packages/ui/src/mosaic/components/field/field.context.tsx +++ b/packages/ui/src/mosaic/components/field/field.context.tsx @@ -93,6 +93,31 @@ 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, element: React.RefObject) { + const register = React.useContext(FieldMessageContext)?.register; + const key = React.useRef(Symbol('field-message')); + + useSafeLayoutEffect(() => { + if (!register) { + return undefined; + } + + register(key.current, open ? element.current : null); + return () => register(key.current, null); + }, [register, open, element]); +} + 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..326d3720a0f 100644 --- a/packages/ui/src/mosaic/components/field/field.test.tsx +++ b/packages/ui/src/mosaic/components/field/field.test.tsx @@ -363,6 +363,146 @@ 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 () => { + const getAnimations = vi.fn(() => [{ finished: new Promise(() => undefined) }] as unknown as Animation[]); + Object.defineProperty(Element.prototype, 'getAnimations', { configurable: true, value: getAnimations }); + + 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(success).toHaveTextContent('Password verified.'); + expect(success).toHaveAttribute('data-starting-style'); + expect(screen.getByRole('textbox')).toHaveAttribute('aria-describedby', success?.id); + + Reflect.deleteProperty(Element.prototype, 'getAnimations'); + }); + + 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 }] as unknown as Animation[]); + Object.defineProperty(Element.prototype, 'getAnimations', { configurable: true, 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(); + Reflect.deleteProperty(Element.prototype, 'getAnimations'); + }); + 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..8fdf3638443 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), + { ...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); + useRegisterFieldMessage(open, element); return useRender({ defaultTagName: 'p', render, - ref, + enabled: mounted, + ref: [ref, element], 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, + ), + { ...transitionProps }, rest, ), id, children: ( <>