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..d61175f14b1 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.mdx')), }, primitives: { // Headless primitives — alphabetical. diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 93db371c0e5..9971fee489d 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.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.mdx b/packages/swingset/src/stories/field.mdx new file mode 100644 index 00000000000..3daafabdbac --- /dev/null +++ b/packages/swingset/src/stories/field.mdx @@ -0,0 +1,73 @@ +import * as FieldStories from './field.stories'; + +# Field + +The Mosaic `Field` is a compound component themed with StyleX. It coordinates a label, supporting text, validation errors, and shared semantic state with an integrated Mosaic form control while leaving form data and layout to the surrounding composition. + +## Example + + + +## Usage + +Compose the parts around a Mosaic control. `Field.Root` owns `invalid`, `disabled`, and `required`; the control continues to own its native form props such as `name`, `value`, and event handlers. + +```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. + )} +; +``` + +`Field.Root` generates and owns the control ID used by its label and supporting messages. An `id` passed to a Mosaic control inside the field does not replace the generated ID; use `name` for form submission and form-library registration. + +## Parts + +| Part | Slot | Description | +| ------------------- | ------------------- | -------------------------------------------------------------------------- | +| `Field.Root` | `field-root` | State provider; renders a `div` and shares semantic state with every part. | +| `Field.Label` | `field-label` | Native `label` associated with the integrated control. | +| Mosaic control | `field-control` | Receives the generated ID, semantic state, and accessible relationships. | +| `Field.Description` | `field-description` | Supporting `p` included in the control's accessible description. | +| `Field.Error` | `field-error` | Validation `p` included in the description while the field is invalid. | + +## Styling + +The Mosaic field is themed with **StyleX**. Each styled part carries a stable `.cl-` class from the table above alongside the StyleX atoms. Consumers never target the hashed atomic classes—override a stable 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; + } +} +``` + +Semantic state is available for CSS targeting on every field part and the integrated control: + +| Attribute | Description | +| --------------- | --------------------------------------- | +| `data-invalid` | Present while `Field.Root` is invalid. | +| `data-disabled` | Present while `Field.Root` is disabled. | +| `data-required` | Present while `Field.Root` is required. | + +`Field` ships no layout. Apply layout through a surrounding block or through caller-owned `className` and `style` props. diff --git a/packages/swingset/src/stories/field.stories.tsx b/packages/swingset/src/stories/field.stories.tsx new file mode 100644 index 00000000000..d19a5ef2307 --- /dev/null +++ b/packages/swingset/src/stories/field.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.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.ssr.test.tsx b/packages/ui/src/mosaic/components/field/field.ssr.test.tsx new file mode 100644 index 00000000000..47abd966f79 --- /dev/null +++ b/packages/ui/src/mosaic/components/field/field.ssr.test.tsx @@ -0,0 +1,40 @@ +// @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 the field-owned control ID and stable part IDs', () => { + const html = renderToString( + + Email + + Description + Error + , + ); + + const labelControlId = html.match(/for="([^"]+)"/)?.[1]; + const inputControlId = html.match(/]*\sid="([^"]+)"/)?.[1]; + expect(labelControlId).toBeDefined(); + expect(labelControlId).toBe(inputControlId); + expect(inputControlId).not.toBe('account-email'); + expect(html).toMatch(/id="cl-field-[^"]+-label"/); + expect(html).toMatch(/id="cl-field-[^"]+-description"/); + expect(html).toMatch(/id="cl-field-[^"]+-error"/); + expect(html).toContain('aria-describedby="external"'); + expect(html).not.toContain('aria-labelledby'); + expect(html).toContain('aria-invalid="true"'); + expect(html).toContain('required=""'); + }); +}); 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..f14fe657df4 --- /dev/null +++ b/packages/ui/src/mosaic/components/field/field.styles.ts @@ -0,0 +1,30 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontWeightVars, space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + label: { + color: colorVars['--cl-color-card-foreground'], + 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, + position: 'relative', + top: 1, + }, + disabledText: { + opacity: 0.5, + }, +}); 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..81a213e865d --- /dev/null +++ b/packages/ui/src/mosaic/components/field/field.test.tsx @@ -0,0 +1,323 @@ +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('associates generated label and description IDs with the control', () => { + render( + + Email + + Used for account notifications. + , + ); + + const control = screen.getByRole('textbox', { name: 'Email' }); + const label = screen.getByText('Email'); + const description = screen.getByText('Used for account notifications.'); + + expect(control.id).not.toBe(''); + expect(label).toHaveAttribute('for', control.id); + expect(label.id).not.toBe(''); + expect(control).toHaveAttribute('aria-labelledby', label.id); + expect(description.id).not.toBe(''); + expect(control).toHaveAttribute('aria-describedby', description.id); + }); + + it('preserves deduplicated consumer descriptions alongside generated message IDs', () => { + render( + + Email + + Description + Error + , + ); + + const control = screen.getByRole('textbox', { name: 'Email' }); + const description = screen.getByText('Description'); + const error = screen.getByText('Error').closest('p'); + expect(control).toHaveAttribute('aria-describedby', `external ${description.id} ${error?.id}`); + }); + + it('only references label and message parts that are rendered', () => { + render( + + + Email is invalid. + , + ); + + const control = screen.getByRole('textbox', { name: 'Email' }); + const error = screen.getByText('Email is invalid.').closest('p'); + expect(control).not.toHaveAttribute('aria-labelledby'); + expect(control).toHaveAttribute('aria-describedby', error?.id); + }); + + it('registers multiple descriptions without duplicate IDs', () => { + render( + + Email + + Primary description + Secondary description + , + ); + + const control = screen.getByRole('textbox', { name: 'Email' }); + const primary = screen.getByText('Primary description'); + const secondary = screen.getByText('Secondary description'); + expect(primary.id).not.toBe(secondary.id); + expect(control).toHaveAttribute('aria-describedby', `${primary.id} ${secondary.id}`); + }); + + it('uses the field-owned control ID when Input provides its own ID', () => { + render( + + Email + + , + ); + + const control = screen.getByRole('textbox', { name: 'Email' }); + expect(control).not.toHaveAttribute('id', 'account-email'); + expect(screen.getByText('Email')).toHaveAttribute('for', control.id); + }); + + it('keeps the field-owned control ID stable when the Input ID changes', () => { + const { rerender } = render( + + Email + + , + ); + const controlId = screen.getByRole('textbox', { name: 'Email' }).id; + + rerender( + + Email + + , + ); + + expect(screen.getByText('Email')).toHaveAttribute('for', controlId); + expect(screen.getByRole('textbox', { name: 'Email' })).toHaveAttribute('id', controlId); + }); + + it('propagates semantic state while the rendered control owns its size', () => { + render( + + Website + + Description + Error + , + ); + + const root = screen.getByTestId('root'); + const control = screen.getByRole('textbox', { name: 'Website' }); + const label = screen.getByText('Website'); + const description = screen.getByText('Description'); + const error = screen.getByText('Error').closest('p'); + expect(root).toHaveAttribute('data-invalid', ''); + expect(root).toHaveAttribute('data-disabled', ''); + expect(root).toHaveAttribute('data-required', ''); + expect(root).not.toHaveAttribute('data-size'); + expect(control).toHaveAttribute('data-size', 'lg'); + expect(control).toHaveClass('cl-input', 'cl-field-control'); + expect(control).toHaveAttribute('aria-labelledby', label.id); + expect(control).toHaveAttribute('aria-describedby', `${description.id} ${error?.id}`); + expect(control).toHaveAttribute('aria-invalid', 'true'); + expect(control).toHaveAttribute('aria-disabled', 'true'); + expect(control).toHaveAttribute('aria-required', 'true'); + expect(control).toBeDisabled(); + expect(control).toBeRequired(); + expect(screen.getByText('Website')).not.toHaveAttribute('data-size'); + }); + + it('adds the error relationship only while the root is invalid', () => { + const { rerender } = render( + + Name + + Description + Error + , + ); + + const control = screen.getByRole('textbox', { name: 'Name' }); + const description = screen.getByText('Description'); + const error = screen.getByText('Error').closest('p'); + expect(control).toHaveAttribute('aria-describedby', description.id); + expect(control).not.toHaveAttribute('aria-invalid'); + + rerender( + + Name + + Description + Error + , + ); + + expect(control).toHaveAttribute('aria-describedby', `${description.id} ${error?.id}`); + expect(control).toHaveAttribute('aria-invalid', 'true'); + }); + + it('hydrates the field-owned control ID and generated relationships without warnings', async () => { + const field = ( + + Email + + Description + Error + + ); + const container = document.createElement('div'); + container.innerHTML = renderToString(field); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + let root: ReturnType | undefined; + await act(() => { + root = hydrateRoot(container, field); + }); + + expect(consoleError).not.toHaveBeenCalled(); + const control = container.querySelector('input'); + const description = container.querySelector('.cl-field-description'); + const error = container.querySelector('.cl-field-error'); + expect(control).not.toHaveAttribute('id', 'account-email'); + expect(container.querySelector('label')).toHaveAttribute('for', control?.id); + expect(control?.getAttribute('aria-describedby')).toBe(`${description?.id} ${error?.id}`); + + await act(() => root?.unmount()); + consoleError.mockRestore(); + }); + + it('keeps error announcements opt-in', () => { + render( + + Email + + 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('forwards refs from every part', () => { + const rootRef = React.createRef(); + const labelRef = React.createRef(); + const controlRef = React.createRef(); + const descriptionRef = React.createRef(); + const errorRef = React.createRef(); + + render( + + Email + + Description + Error + , + ); + + expect(rootRef.current).toHaveClass('cl-field-root'); + expect(labelRef.current).toBe(screen.getByText('Email')); + expect(controlRef.current).toBe(screen.getByRole('textbox', { name: 'Email' })); + expect(descriptionRef.current).toBe(screen.getByText('Description')); + expect(errorRef.current).toBe(screen.getByText('Error').closest('p')); + }); + + 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', gridTemplateColumns: '1fr 2fr' }); + expect(screen.getByText('Email')).toHaveClass('cl-field-label', 'label'); + expect(screen.getByText('Email')).toHaveStyle({ fontWeight: 700 }); + expect(screen.getByRole('textbox', { name: 'Email' })).toHaveClass('cl-field-control', 'control'); + expect(screen.getByRole('textbox', { name: 'Email' })).toHaveStyle({ paddingInline: 16 }); + 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 structural parts', () => { + render( +
}> + Biography + +
}>Description +
}>Error + , + ); + + expect(screen.getByText('Description').tagName).toBe('DIV'); + expect(screen.getByText('Error').closest('div')).toHaveClass('cl-field-error'); + expect(screen.getByText('Biography').closest('section')).not.toBeNull(); + }); + + 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 `