Skip to content
Open
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 on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Add an @clerk/ui release entry.

This PR adds the public Field API, but this changeset has no package entry. Add an @clerk/ui minor bump and describe the new Field compound component API.

Proposed fix
 ---
+ '`@clerk/ui`': minor
 ---
+
+Add Mosaic Field compound components.

Based on learnings, an empty changeset is only acceptable for documentation-only or non-published changes.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
---
---
---
'`@clerk/ui`': minor
---
Add Mosaic Field compound components.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/quiet-fields-compose.md around lines 1 - 2, Replace the empty
changeset frontmatter in quiet-fields-compose.md with an `@clerk/ui` minor release
entry, and add a concise description of the new public Field compound component
API.

Source: Learnings

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.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 @@ -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
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, propagate semantic state, or render errors automatically. Its parts may also be used independently without `Field.Root`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the Field state contract.

Field does propagate invalid, disabled, and required state to composed Mosaic inputs. Keep the statement that Field does not validate controls or render errors automatically, but remove the claim that it does not propagate semantic state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/swingset/src/stories/field.component.mdx` at line 36, Update the
Field description near the Field.Root usage note to remove the claim that it
does not propagate semantic state, while preserving the statements that it does
not validate controls or render errors automatically and that its parts can be
used independently.

Source: Coding guidelines


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add the required Emotion JSX pragma.

This styled Mosaic story must begin with /** @jsxImportSource @emotion/react */.

Proposed fix
+/** `@jsxImportSource` `@emotion/react` */
 import { Field } from '`@clerk/ui/mosaic/components/field`';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { Field } from '@clerk/ui/mosaic/components/field';
/** `@jsxImportSource` `@emotion/react` */
import { Field } from '`@clerk/ui/mosaic/components/field`';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/swingset/src/stories/field.component.stories.tsx` at line 1, Add the
Emotion JSX import-source pragma at the beginning of the field component story,
before the existing Field import, so the styled Mosaic story uses Emotion's JSX
transform.

Source: Coding guidelines

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>
);
}
106 changes: 106 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,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<React.SetStateAction<string[]>>;
setMessageIds: React.Dispatch<React.SetStateAction<string[]>>;
Comment on lines +4 to +10

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Propagate shared field state to the control.

FieldContextValue contains only association IDs. Field.Root accepts only div props. Input reads only association props. Therefore, disabled, required, and invalid state cannot propagate from Field.Root to the composed control.

  • packages/ui/src/mosaic/components/field/field.context.tsx#L4-L10: add typed disabled, required, and invalid state to the context value.
  • packages/ui/src/mosaic/components/field/field.tsx#L20-L37: add these states to FieldRootProps and provide them through FieldProvider.
  • packages/ui/src/mosaic/components/input/input.tsx#L15-L39: resolve explicit control props before context defaults, and apply the resulting disabled, required, and aria-invalid values.
📍 Affects 3 files
  • packages/ui/src/mosaic/components/field/field.context.tsx#L4-L10 (this comment)
  • packages/ui/src/mosaic/components/field/field.tsx#L20-L37
  • packages/ui/src/mosaic/components/input/input.tsx#L15-L39
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/mosaic/components/field/field.context.tsx` around lines 4 -
10, Propagate disabled, required, and invalid state from Field.Root to composed
controls: in packages/ui/src/mosaic/components/field/field.context.tsx lines
4-10, add typed state fields to FieldContextValue; in
packages/ui/src/mosaic/components/field/field.tsx lines 20-37, add the states to
FieldRootProps and pass them through FieldProvider; in
packages/ui/src/mosaic/components/input/input.tsx lines 15-39, resolve explicit
control props before context values and apply the resulting disabled, required,
and aria-invalid attributes.

}

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;
}

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<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, labelIds, messageIds, registerControlId, setLabelIds, setMessageIds }),
[controlId, 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 on lines +67 to +74

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate files ---'
fd -i 'field|input|mosaic|ssr|server' packages/ui/src packages/ui/test packages/ui/tests 2>/dev/null | head -200 || true

printf '%s\n' '--- relevant symbols ---'
rg -n -C 5 \
  'useSafeLayoutEffect|messageIds|aria-describedby|aria-labelledby|Field\.Description|Field\.Error|renderToString' \
  packages/ui/src packages/ui/test packages/ui/tests 2>/dev/null | head -500 || true

printf '%s\n' '--- field context outline ---'
if [ -f packages/ui/src/mosaic/components/field/field.context.tsx ]; then
  ast-grep outline packages/ui/src/mosaic/components/field/field.context.tsx
  cat -n packages/ui/src/mosaic/components/field/field.context.tsx | sed -n '1,180p'
fi

printf '%s\n' '--- input outline ---'
if [ -f packages/ui/src/mosaic/components/input/input.tsx ]; then
  ast-grep outline packages/ui/src/mosaic/components/input/input.tsx
  cat -n packages/ui/src/mosaic/components/input/input.tsx | sed -n '1,140p'
fi

Repository: clerk/javascript

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- field context ---'
cat -n packages/ui/src/mosaic/components/field/field.context.tsx | sed -n '1,140p'

printf '%s\n' '--- field implementation ---'
cat -n packages/ui/src/mosaic/components/field/field.tsx | sed -n '1,280p'

printf '%s\n' '--- complete SSR test ---'
cat -n packages/ui/src/mosaic/components/field/field.ssr.test.tsx | sed -n '1,180p'

printf '%s\n' '--- relevant hydration and input tests ---'
cat -n packages/ui/src/mosaic/components/field/field.test.tsx | sed -n '1,230p'
cat -n packages/ui/src/mosaic/components/input/input.test.tsx | sed -n '1,220p'

printf '%s\n' '--- package scripts and diff summary ---'
if [ -f packages/ui/package.json ]; then
  sed -n '1,180p' packages/ui/package.json
fi
git diff --stat
git status --short

Repository: clerk/javascript

Length of output: 31861


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from dataclasses import dataclass, field

`@dataclass`
class FieldContext:
    message_ids: list[str] = field(default_factory=list)

    def register_after_render(self, value: str) -> None:
        if value and value not in self.message_ids:
            self.message_ids.append(value)

def merge_ids(*values: str | None) -> str | None:
    ids: list[str] = []
    for value in values:
        if value:
            for item in value.split():
                if item and item not in ids:
                    ids.append(item)
    return " ".join(ids) if ids else None

# Model the relevant React SSR order:
# children render first, Input reads the provider's initial state, and effects do not run.
ctx = FieldContext()
description_id = "cl-field-description"
error_id = "cl-field-error"
server_aria = merge_ids("external-description", *ctx.message_ids)

assert server_aria == "external-description"
assert description_id not in server_aria
assert error_id not in server_aria

# Model the client effect phase after the same tree commits.
ctx.register_after_render(description_id)
ctx.register_after_render(error_id)
client_aria = merge_ids("external-description", *ctx.message_ids)

assert client_aria == "external-description cl-field-description cl-field-error"
print("SSR initial render omits generated message IDs; post-effect render includes both IDs.")
PY

Repository: clerk/javascript

Length of output: 296


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from dataclasses import dataclass, field
from typing import Optional

`@dataclass`
class FieldContext:
    message_ids: list = field(default_factory=list)

    def register_after_render(self, value: str) -> None:
        if value and value not in self.message_ids:
            self.message_ids.append(value)

def merge_ids(*values: Optional[str]) -> Optional[str]:
    ids = []
    for value in values:
        if value:
            for item in value.split():
                if item and item not in ids:
                    ids.append(item)
    return " ".join(ids) if ids else None

# Initial server render: Input reads the provider state before effects run.
ctx = FieldContext()
description_id = "cl-field-description"
error_id = "cl-field-error"
server_aria = merge_ids("external-description", *ctx.message_ids)

assert server_aria == "external-description"
assert description_id not in server_aria
assert error_id not in server_aria

# Post-commit effect phase: message parts register their IDs.
ctx.register_after_render(description_id)
ctx.register_after_render(error_id)
client_aria = merge_ids("external-description", *ctx.message_ids)

assert client_aria == "external-description cl-field-description cl-field-error"
print("SSR initial render omits generated message IDs; post-effect render includes both IDs.")
PY

Repository: clerk/javascript

Length of output: 240


Generate automatic ARIA relationships during server rendering.

messageIds is populated only in useSafeLayoutEffect, so SSR omits generated aria-describedby values for Field.Description and Field.Error. Derive these relationships during render and add an SSR assertion.

📍 Affects 2 files
  • packages/ui/src/mosaic/components/field/field.context.tsx#L67-L74 (this comment)
  • packages/ui/src/mosaic/components/input/input.tsx#L37-L39
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/mosaic/components/field/field.context.tsx` around lines 67 -
74, Update the field context around the useSafeLayoutEffect registration and the
input relationship handling in
packages/ui/src/mosaic/components/field/field.context.tsx:67-74 and
packages/ui/src/mosaic/components/input/input.tsx:37-39 so generated Description
and Error IDs are derived during render rather than only populated by the
effect, while preserving client-side registration and cleanup. Add an SSR
assertion covering the generated aria-describedby relationships.

}

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),
};
}
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 on lines +48 to +62

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Preserve explicit control IDs in server markup.

The tests require Input id='custom-control' to be absent until hydration. This breaks external labels, ARIA references, URL fragments, and DOM integrations before client JavaScript runs. Keep the caller-provided ID in SSR output and make the associated Field label use that same ID.

  • packages/ui/src/mosaic/components/field/field.ssr.test.tsx#L48-L62: expect custom-control in the server HTML and on the label for attribute.
  • packages/ui/src/mosaic/components/field/field.test.tsx#L147-L176: verify hydration preserves the already-rendered explicit ID instead of adding it after hydration.
📍 Affects 2 files
  • packages/ui/src/mosaic/components/field/field.ssr.test.tsx#L48-L62 (this comment)
  • packages/ui/src/mosaic/components/field/field.test.tsx#L147-L176
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/mosaic/components/field/field.ssr.test.tsx` around lines 48 -
62, Update Field.Root and its associated label/control ID handling so an
explicit Input id such as custom-control is preserved in SSR markup and used for
the label for attribute. Update
packages/ui/src/mosaic/components/field/field.ssr.test.tsx lines 48-62 to expect
custom-control in both locations, and update
packages/ui/src/mosaic/components/field/field.test.tsx lines 147-176 to verify
hydration preserves the server-rendered ID without adding it afterward.

});
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