Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/quiet-fields-compose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
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.
Expand Down
7 changes: 7 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -262,6 +268,7 @@ export const registry: StoryModule[] = [
popoverComponentModule,
tabsComponentModule,
textModule,
fieldModule,
// Primitives — alphabetical within the group.
accordionModule,
autocompleteModule,
Expand Down
73 changes: 73 additions & 0 deletions packages/swingset/src/stories/field.mdx
Original file line number Diff line number Diff line change
@@ -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

<Story
name='Default'
storyModule={FieldStories}
/>

## 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';

<Field.Root
invalid={Boolean(error)}
required
>
<Field.Label>Email address</Field.Label>
<Input
name='email'
type='email'
/>
{error ? (
<Field.Error>{error}</Field.Error>
) : (
<Field.Description>Used for account notifications.</Field.Description>
)}
</Field.Root>;
```

`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-<slot>` 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.
35 changes: 35 additions & 0 deletions packages/swingset/src/stories/field.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 `<Story>` 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 (
<Field.Root style={stackStyles}>
<Field.Label>Email address</Field.Label>
<Input
name='email'
type='email'
placeholder='you@example.com'
/>
<Field.Description>Used for account notifications.</Field.Description>
</Field.Root>
);
}
40 changes: 40 additions & 0 deletions packages/ui/src/mosaic/components/field/field.ssr.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Field.Root
invalid
required
>
<Field.Label>Email</Field.Label>
<Input
id='account-email'
aria-describedby='external'
/>
<Field.Description>Description</Field.Description>
<Field.Error>Error</Field.Error>
</Field.Root>,
);

const labelControlId = html.match(/for="([^"]+)"/)?.[1];
const inputControlId = html.match(/<input[^>]*\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=""');
});
});
30 changes: 30 additions & 0 deletions packages/ui/src/mosaic/components/field/field.styles.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
Loading
Loading