Skip to content
Merged
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 @@
---
---
Comment thread
austincalvelage marked this conversation as resolved.
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,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.component.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.component.stories';
import { meta as fileUploadMeta } from '../stories/file-upload.stories';
import {
Colors as HeadingColors,
Expand Down Expand Up @@ -213,6 +214,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 @@ -277,6 +283,7 @@ export const registry: StoryModule[] = [
popoverComponentModule,
tabsComponentModule,
textModule,
fieldModule,
// Primitives — alphabetical within the group.
accordionModule,
autocompleteModule,
Expand Down
61 changes: 61 additions & 0 deletions packages/swingset/src/stories/field.component.mdx
Original file line number Diff line number Diff line change
@@ -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

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

## 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 `<fieldset>` and `<legend>` 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';

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

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 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.
35 changes: 35 additions & 0 deletions packages/swingset/src/stories/field.component.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Field } from '@clerk/ui/mosaic/components/field';
Comment thread
austincalvelage marked this conversation as resolved.
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.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 (
<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>
);
}
138 changes: 138 additions & 0 deletions packages/ui/src/mosaic/components/field/field.context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { useSafeLayoutEffect } from '@clerk/shared/react';
import React from 'react';

interface FieldContextValue {
controlId: string;
disabled: boolean;
required: boolean;
invalid: boolean;
labelIds: string[];
messageIds: string[];
registerControlId: (source: symbol, id: string | null | undefined) => void;
setLabelIds: React.Dispatch<React.SetStateAction<string[]>>;
setMessageIds: React.Dispatch<React.SetStateAction<string[]>>;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

const FieldContext = React.createContext<FieldContextValue | null>(null);

function mergeIds(...values: Array<string | undefined>): string | undefined {
const ids = Array.from(new Set(values.flatMap(value => value?.split(/\s+/).filter(Boolean) ?? [])));
return ids.length > 0 ? ids.join(' ') : undefined;
}

interface FieldProviderProps extends React.PropsWithChildren {
disabled: boolean;
required: boolean;
invalid: boolean;
}

export function FieldProvider({ children, disabled, required, invalid }: FieldProviderProps) {
const generatedId = React.useId();
const defaultControlId = `cl-field-${generatedId}`;
const [controlId, setControlId] = React.useState(defaultControlId);
const [labelIds, setLabelIds] = React.useState<string[]>([]);
const [messageIds, setMessageIds] = React.useState<string[]>([]);
const controlIds = React.useRef(new Map<symbol, string | null>());
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] <Field.Root> supports a single form control. Use a separate <Field.Root> for each control or native <fieldset> semantics for grouped controls.',
);
}

setControlId(controlIds.current.values().next().value ?? defaultControlId);
},
[defaultControlId],
);
const context = React.useMemo<FieldContextValue>(
() => ({
controlId,
disabled,
required,
invalid,
labelIds,
messageIds,
registerControlId,
setLabelIds,
setMessageIds,
}),
[controlId, disabled, required, invalid, labelIds, messageIds, registerControlId],
);

return <FieldContext.Provider value={context}>{children}</FieldContext.Provider>;
}

export function useOptionalFieldContext() {
return React.useContext(FieldContext);
}

export function useRegisterFieldPartId(
id: string | undefined,
setIds: React.Dispatch<React.SetStateAction<string[]>> | 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]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

interface FieldControlProps {
id?: string;
disabled?: boolean;
required?: boolean;
ariaInvalid?: React.AriaAttributes['aria-invalid'];
ariaLabelledBy?: string;
ariaDescribedBy?: string;
}

export function useOptionalFieldControlProps({
id,
disabled,
required,
ariaInvalid,
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,
disabled: disabled ?? context.disabled,
required: required ?? context.required,
'aria-invalid': ariaInvalid ?? (context.invalid ? true : undefined),
'aria-labelledby': mergeIds(ariaLabelledBy, ...context.labelIds),
'aria-describedby': mergeIds(ariaDescribedBy, ...context.messageIds),
};
}
63 changes: 63 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,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(
<Field.Root>
<Field.Label>Email</Field.Label>
<Input
name='email'
required
aria-describedby='external-description'
aria-invalid='true'
/>
<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];
const input = html.match(/<input[^>]*>/)?.[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(
<Field.Root>
<Field.Label>Email</Field.Label>
<Input id='custom-control' />
</Field.Root>,
);

const labelControlId = html.match(/for="([^"]+)"/)?.[1];
const inputControlId = html.match(/<input[^>]*\sid="([^"]+)"/)?.[1];
expect(inputControlId).toBeDefined();
expect(inputControlId).not.toBe('custom-control');
expect(labelControlId).toBe(inputControlId);
expect(html).not.toContain('id="custom-control"');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
26 changes: 26 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,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',
},
});
Loading
Loading