diff --git a/.changeset/quiet-fields-compose.md b/.changeset/quiet-fields-compose.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/quiet-fields-compose.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 9a19812fbf1..4a700973eef 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -41,6 +41,7 @@ const docModules: Record> = { popover: dynamic(() => import('../stories/popover.component.mdx')), tabs: dynamic(() => import('../stories/tabs.component.mdx')), text: dynamic(() => import('../stories/text.mdx')), + field: dynamic(() => import('../stories/field.component.mdx')), }, primitives: { // Headless primitives — alphabetical. diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 93db371c0e5..420f857cfe0 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -25,6 +25,7 @@ import { Default as DestructiveDefault, meta as destructiveMeta } from '../stori import { Default as DialogDefault, meta as dialogComponentMeta } from '../stories/dialog.component.stories'; import { meta as dialogMeta } from '../stories/dialog.stories'; import { meta as drawerMeta } from '../stories/drawer.stories'; +import { Default as FieldDefault, meta as fieldMeta } from '../stories/field.component.stories'; import { meta as fileUploadMeta } from '../stories/file-upload.stories'; import { Colors as HeadingColors, @@ -200,6 +201,11 @@ const tabsComponentModule: StoryModule = { meta: tabsComponentMeta, Default: Tab const textModule: StoryModule = { meta: textMeta, Default: TextDefault, Sizes: TextSizes, Colors: TextColors }; +const fieldModule: StoryModule = { + meta: fieldMeta, + Default: FieldDefault, +}; + const iconModule: StoryModule = { meta: iconMeta, Default: IconDefault, @@ -262,6 +268,7 @@ export const registry: StoryModule[] = [ popoverComponentModule, tabsComponentModule, textModule, + fieldModule, // Primitives — alphabetical within the group. accordionModule, autocompleteModule, diff --git a/packages/swingset/src/stories/field.component.mdx b/packages/swingset/src/stories/field.component.mdx new file mode 100644 index 00000000000..755ee661a2b --- /dev/null +++ b/packages/swingset/src/stories/field.component.mdx @@ -0,0 +1,61 @@ +import * as FieldStories from './field.component.stories'; + +# Field + +The Mosaic `Field` provides StyleX-themed parts for composing labels, supporting text, and validation errors around a form control. Its context automatically connects Mosaic controls to the rendered label and messages. + +## Example + + + +## Usage + +Compose one Mosaic control inside each `Field.Root` to generate its ID, the label's `htmlFor`, and the message relationships. Rendering multiple controls logs a development warning. The caller owns validation and decides when to render an error. Use a separate `Field.Root` for each control; grouped controls should use native `
` and `` semantics until dedicated Mosaic `Fieldset` and `Field.Item` components are available. + +```tsx +import { Field } from '@clerk/ui/mosaic/components/field'; +import { Input } from '@clerk/ui/mosaic/components/input'; + + + Email address + + {error ? {error} : Used for account notifications.} +; +``` + +Explicit `id`, `htmlFor`, `aria-labelledby`, and `aria-describedby` values remain supported. Field preserves explicit IDs after hydration and merges external ARIA references with its generated relationships. During server rendering, Field emits its generated control ID and native label relationship; explicit control IDs and generated label and message ARIA references finalize during hydration. `name` still identifies the submitted form value and is typically what form libraries use for registration. + +Field does not validate controls, propagate semantic state, or render errors automatically. Its parts may also be used independently without `Field.Root`. + +## Parts + +| Part | Stable slot class | Description | +| ------------------- | ----------------------- | ------------------------------------------------- | +| `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.Error` | `.cl-field-error` | Associated error `p` with an alert icon. | + +## Styling + +The Mosaic field is themed with **StyleX**. Each styled part carries the stable public slot class shown above alongside the generated StyleX atoms. Consumers never target the hashed atomic classes—override a `.cl-field-*` class from a CSS layer that wins over `@clerk/ui/styles.css`: + +```css +@import '@clerk/ui/styles.css' layer(components); + +@layer overrides { + .cl-field-label { + font-weight: 600; + } +} +``` + +`Field.Root` ships no layout. Higher-level blocks own how its parts are arranged; for example, a settings row can provide the grid and alignment for a field. Customize an `Input` through its exposed tokens, `.cl-input`, `className`, and `style`; Field does not add control-specific styling. diff --git a/packages/swingset/src/stories/field.component.stories.tsx b/packages/swingset/src/stories/field.component.stories.tsx new file mode 100644 index 00000000000..1a7ca15cb8a --- /dev/null +++ b/packages/swingset/src/stories/field.component.stories.tsx @@ -0,0 +1,35 @@ +import { Field } from '@clerk/ui/mosaic/components/field'; +import { Input } from '@clerk/ui/mosaic/components/input'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './field.component.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + title: 'Field', + source: 'packages/ui/src/mosaic/components/field/field.tsx', + styleEngine: 'stylex', +}; + +const stackStyles = { + display: 'grid', + gap: 8, + maxWidth: 384, +} as const; + +export function Default() { + return ( + + Email address + + Used for account notifications. + + ); +} diff --git a/packages/ui/src/mosaic/components/field/field.context.tsx b/packages/ui/src/mosaic/components/field/field.context.tsx new file mode 100644 index 00000000000..1f72ce9e08d --- /dev/null +++ b/packages/ui/src/mosaic/components/field/field.context.tsx @@ -0,0 +1,106 @@ +import { useSafeLayoutEffect } from '@clerk/shared/react'; +import React from 'react'; + +interface FieldContextValue { + controlId: string; + labelIds: string[]; + messageIds: string[]; + registerControlId: (source: symbol, id: string | null | undefined) => void; + setLabelIds: React.Dispatch>; + setMessageIds: React.Dispatch>; +} + +const FieldContext = React.createContext(null); + +function mergeIds(...values: Array): string | undefined { + const ids = Array.from(new Set(values.flatMap(value => value?.split(/\s+/).filter(Boolean) ?? []))); + return ids.length > 0 ? ids.join(' ') : undefined; +} + +export function FieldProvider({ children }: React.PropsWithChildren) { + const generatedId = React.useId(); + const defaultControlId = `cl-field-${generatedId}`; + const [controlId, setControlId] = React.useState(defaultControlId); + const [labelIds, setLabelIds] = React.useState([]); + const [messageIds, setMessageIds] = React.useState([]); + const controlIds = React.useRef(new Map()); + const warnedAboutMultipleControls = React.useRef(false); + const registerControlId = React.useCallback( + (source: symbol, id: string | null | undefined) => { + if (id === undefined) { + controlIds.current.delete(source); + } else { + controlIds.current.set(source, id); + } + + if ( + process.env.NODE_ENV !== 'production' && + controlIds.current.size > 1 && + !warnedAboutMultipleControls.current + ) { + warnedAboutMultipleControls.current = true; + console.warn( + '[clerk] supports a single form control. Use a separate for each control or native
semantics for grouped controls.', + ); + } + + setControlId(controlIds.current.values().next().value ?? defaultControlId); + }, + [defaultControlId], + ); + const context = React.useMemo( + () => ({ controlId, labelIds, messageIds, registerControlId, setLabelIds, setMessageIds }), + [controlId, labelIds, messageIds, registerControlId], + ); + + return {children}; +} + +export function useOptionalFieldContext() { + return React.useContext(FieldContext); +} + +export function useRegisterFieldPartId( + id: string | undefined, + setIds: React.Dispatch> | undefined, +) { + useSafeLayoutEffect(() => { + if (!id || !setIds) { + return undefined; + } + + setIds(ids => (ids.includes(id) ? ids : [...ids, id])); + return () => setIds(ids => ids.filter(value => value !== id)); + }, [id, setIds]); +} + +interface FieldControlProps { + id?: string; + ariaLabelledBy?: string; + ariaDescribedBy?: string; +} + +export function useOptionalFieldControlProps({ id, ariaLabelledBy, ariaDescribedBy }: FieldControlProps) { + const context = useOptionalFieldContext(); + const registerControlId = context?.registerControlId; + const source = React.useRef(Symbol('field-control')); + + useSafeLayoutEffect(() => { + if (!registerControlId) { + return undefined; + } + + registerControlId(source.current, id ?? null); + return () => registerControlId(source.current, undefined); + }, [registerControlId, id]); + + if (!context) { + return null; + } + + return { + id: context.controlId, + 'aria-labelledby': mergeIds(ariaLabelledBy, ...context.labelIds), + 'aria-describedby': mergeIds(ariaDescribedBy, ...context.messageIds), + }; +} diff --git a/packages/ui/src/mosaic/components/field/field.ssr.test.tsx b/packages/ui/src/mosaic/components/field/field.ssr.test.tsx new file mode 100644 index 00000000000..ad07f4494f7 --- /dev/null +++ b/packages/ui/src/mosaic/components/field/field.ssr.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment node + +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import { Input } from '../input'; +import { Field } from './field'; + +describe('Mosaic Field SSR', () => { + it('emits render-time relationships and defers registered relationships until hydration', () => { + const html = renderToString( + + Email + + Description + Error + , + ); + + const labelControlId = html.match(/for="([^"]+)"/)?.[1]; + const inputControlId = html.match(/]*\sid="([^"]+)"/)?.[1]; + const input = html.match(/]*>/)?.[0]; + const descriptionId = html.match(/id="([^"]+-description)"/)?.[1]; + const errorId = html.match(/id="([^"]+-error)"/)?.[1]; + expect(labelControlId).toBeDefined(); + expect(labelControlId).toBe(inputControlId); + expect(descriptionId).toBeDefined(); + expect(errorId).toBeDefined(); + expect(html).toContain('name="email"'); + expect(input).toContain('aria-describedby="external-description"'); + expect(input).not.toContain('aria-labelledby'); + expect(input).not.toContain(descriptionId); + expect(input).not.toContain(errorId); + expect(html).toContain('aria-invalid="true"'); + expect(html).toMatch(/id="cl-field-[^"]+-label"/); + expect(html).toMatch(/id="cl-field-[^"]+-description"/); + expect(html).toMatch(/id="cl-field-[^"]+-error"/); + expect(html).toContain('required=""'); + expect(html).not.toContain('cl-field-control'); + }); + + it('defers an explicit control ID until hydration', () => { + const html = renderToString( + + Email + + , + ); + + const labelControlId = html.match(/for="([^"]+)"/)?.[1]; + const inputControlId = html.match(/]*\sid="([^"]+)"/)?.[1]; + expect(inputControlId).toBeDefined(); + expect(inputControlId).not.toBe('custom-control'); + expect(labelControlId).toBe(inputControlId); + expect(html).not.toContain('id="custom-control"'); + }); +}); diff --git a/packages/ui/src/mosaic/components/field/field.styles.ts b/packages/ui/src/mosaic/components/field/field.styles.ts new file mode 100644 index 00000000000..58e40d03234 --- /dev/null +++ b/packages/ui/src/mosaic/components/field/field.styles.ts @@ -0,0 +1,26 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontWeightVars, space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + label: { + color: colorVars['--cl-color-primary'], + fontWeight: fontWeightVars['--cl-font-medium'], + }, + message: { + margin: 0, + }, + description: { + color: colorVars['--cl-color-neutral-faded'], + }, + error: { + gap: space['1'], + alignItems: 'flex-start', + color: colorVars['--cl-color-negative'], + display: 'flex', + }, + errorIcon: { + flexShrink: 0, + height: '1lh', + }, +}); diff --git a/packages/ui/src/mosaic/components/field/field.test.tsx b/packages/ui/src/mosaic/components/field/field.test.tsx new file mode 100644 index 00000000000..9d20710e8cd --- /dev/null +++ b/packages/ui/src/mosaic/components/field/field.test.tsx @@ -0,0 +1,327 @@ +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 { Input } from '../input'; +import { Field } from './field'; + +describe('Mosaic Field', () => { + it('generates native label and message relationships', () => { + render( + + Email + + Used for account notifications. + Enter a valid email. + , + ); + + const control = screen.getByRole('textbox', { name: 'Email' }); + const label = screen.getByText('Email'); + const description = screen.getByText('Used for account notifications.'); + const error = screen.getByText('Enter a valid email.').closest('p'); + expect(control.id).not.toBe(''); + expect(label.id).not.toBe(''); + expect(label).toHaveAttribute('for', control.id); + expect(control).toHaveAttribute('aria-labelledby', label.id); + expect(description.id).not.toBe(''); + expect(error?.id).not.toBe(''); + expect(control).toHaveAttribute('aria-describedby', `${description.id} ${error?.id}`); + expect(control).toHaveAttribute('aria-invalid', 'true'); + }); + + it('preserves caller-provided IDs and merges ARIA relationships', () => { + render( + + + Account + + + + Description + + + Error + + , + ); + + expect(screen.getByLabelText('Account details')).toHaveClass('cl-field-root'); + expect(screen.getByText('Account')).toHaveAttribute('id', 'custom-label'); + expect(screen.getByText('Account')).toHaveAttribute('for', 'custom-control'); + expect(screen.getByText('Account')).toHaveAttribute('aria-hidden', 'false'); + expect(screen.getByRole('textbox')).toHaveAttribute('id', 'custom-control'); + expect(screen.getByRole('textbox')).toHaveAttribute('aria-labelledby', 'external-label custom-label'); + expect(screen.getByRole('textbox')).toHaveAttribute( + 'aria-describedby', + 'external-description custom-description custom-error', + ); + expect(screen.getByText('Description')).toHaveAttribute('id', 'custom-description'); + expect(screen.getByText('Description')).toHaveAttribute('aria-live', 'polite'); + expect(screen.getByRole('alert')).toHaveAttribute('id', 'custom-error'); + }); + + it('allows every part to render independently of Root', () => { + render( + <> + Standalone label + Standalone description + Standalone error + , + ); + + expect(screen.getByText('Standalone label')).toHaveClass('cl-field-label'); + expect(screen.getByText('Standalone description')).toHaveClass('cl-field-description'); + expect(screen.getByText('Standalone error').closest('p')).toHaveClass('cl-field-error'); + }); + + it('keeps Input native and ARIA behavior identical inside and outside Field', () => { + const props = { + id: 'account-email', + name: 'email', + type: 'email', + required: true, + readOnly: true, + 'aria-label': 'Account email', + 'aria-labelledby': 'external-label', + 'aria-describedby': 'external-description external-error', + 'aria-invalid': 'grammar' as const, + 'aria-disabled': 'false' as const, + 'aria-required': 'true' as const, + }; + + render( + <> + + + + + , + ); + + const outside = screen.getByTestId('outside'); + const inside = screen.getByTestId('inside'); + const attributes = [ + 'id', + 'name', + 'type', + 'required', + 'readonly', + 'aria-label', + 'aria-labelledby', + 'aria-describedby', + 'aria-invalid', + 'aria-disabled', + 'aria-required', + 'class', + 'data-size', + ]; + for (const attribute of attributes) { + expect(inside.getAttribute(attribute)).toBe(outside.getAttribute(attribute)); + } + expect(inside).toHaveClass('cl-input'); + expect(inside).not.toHaveClass('cl-field-control'); + }); + + it('finalizes explicit IDs and generated relationships during hydration', async () => { + const field = ( + + Email + + Description + + ); + const container = document.createElement('div'); + container.innerHTML = renderToString(field); + expect(container.querySelector('input')).not.toHaveAttribute('id', 'custom-control'); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + let root: ReturnType | undefined; + await act(() => { + root = hydrateRoot(container, field); + }); + + const control = container.querySelector('input'); + const label = container.querySelector('label'); + const description = container.querySelector('.cl-field-description'); + expect(consoleError).not.toHaveBeenCalled(); + expect(control).toHaveAttribute('id', 'custom-control'); + expect(label).toHaveAttribute('for', control?.id); + expect(control).toHaveAttribute('aria-labelledby', label?.id); + expect(control).toHaveAttribute('aria-describedby', description?.id); + + await act(() => root?.unmount()); + consoleError.mockRestore(); + }); + + it('warns when Root contains more than one form control', () => { + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + render( + + + + , + ); + + expect(consoleWarn).toHaveBeenCalledTimes(1); + expect(consoleWarn).toHaveBeenCalledWith( + '[clerk] supports a single form control. Use a separate for each control or native
semantics for grouped controls.', + ); + consoleWarn.mockRestore(); + }); + + it('updates registered control and message IDs as parts change', () => { + const { rerender } = render( + + Email + + Description + , + ); + const descriptionId = screen.getByText('Description').id; + + expect(screen.getByRole('textbox', { name: 'Email' })).toHaveAttribute('aria-describedby', descriptionId); + + rerender( + + Email + + , + ); + + const control = screen.getByRole('textbox', { name: 'Email' }); + expect(control).toHaveAttribute('id', 'billing-email'); + expect(control).not.toHaveAttribute('aria-describedby'); + expect(screen.getByText('Email')).toHaveAttribute('for', 'billing-email'); + }); + + it('forwards refs and native props from every part', () => { + const rootRef = React.createRef(); + const labelRef = React.createRef(); + const descriptionRef = React.createRef(); + const errorRef = React.createRef(); + + render( + + + Name + + + Description + + + Error + + , + ); + + expect(rootRef.current).toHaveAttribute('data-root', 'field'); + expect(labelRef.current).toHaveAttribute('for', 'name'); + expect(descriptionRef.current).toHaveAttribute('title', 'Help'); + expect(errorRef.current).toHaveAttribute('role', 'status'); + }); + + it('lets caller styling win without prescribing layout', () => { + render( + + + Email + + + Description + + + Error + + , + ); + + expect(screen.getByTestId('root')).toHaveClass('cl-field-root', 'root'); + expect(screen.getByTestId('root')).toHaveStyle({ display: 'grid' }); + expect(screen.getByText('Email')).toHaveClass('cl-field-label', 'label'); + expect(screen.getByText('Email')).toHaveStyle({ fontWeight: 700 }); + expect(screen.getByText('Description')).toHaveClass('cl-field-description', 'description'); + expect(screen.getByText('Description')).toHaveStyle({ opacity: 0.8 }); + expect(screen.getByText('Error').closest('p')).toHaveClass('cl-field-error', 'error'); + expect(screen.getByText('Error').closest('p')).toHaveStyle({ fontWeight: 600 }); + }); + + it('supports render escape hatches on every part', () => { + render( +
}> + +
}>Description +
}>Error + , + ); + + expect(screen.getByText('Biography').closest('section')).not.toBeNull(); + expect(screen.getByText('Description').tagName).toBe('DIV'); + expect(screen.getByText('Error').closest('div')).toHaveClass('cl-field-error'); + }); + + it('preserves the opt-in error presentation and icon', () => { + render(Email is invalid.); + + const error = screen.getByText('Email is invalid.').closest('p'); + expect(error).not.toHaveAttribute('role'); + expect(error).not.toHaveAttribute('aria-live'); + expect(error?.querySelector('svg')).toHaveAttribute('aria-hidden', 'true'); + }); + + it('warns when Field.Label does not render a native label', () => { + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + render(}>Email); + + expect(consoleWarn).toHaveBeenCalledWith('[clerk] must render a native `