-
Notifications
You must be signed in to change notification settings - Fork 464
feat(ui): Mosaic field component #9322
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9b63308
e08bc6c
dc8bbb6
f6ebe45
f4ca6c8
d1d5489
991c8c9
c0b90dd
5876dbd
30fc7ba
e014d4e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| --- | ||
| --- | ||
| 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`. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Correct the Field state contract.
🤖 Prompt for AI AgentsSource: 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. | ||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,35 @@ | ||||||||
| import { Field } from '@clerk/ui/mosaic/components/field'; | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Proposed fix+/** `@jsxImportSource` `@emotion/react` */
import { Field } from '`@clerk/ui/mosaic/components/field`';📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: 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> | ||||||||
| ); | ||||||||
| } | ||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Propagate shared field state to the control.
📍 Affects 3 files
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'
fiRepository: 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 --shortRepository: 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.")
PYRepository: 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.")
PYRepository: clerk/javascript Length of output: 240 Generate automatic ARIA relationships during server rendering.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| 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), | ||
| }; | ||
| } | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| }); | ||
| 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', | ||
| }, | ||
| }); |
There was a problem hiding this comment.
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/uirelease entry.This PR adds the public
FieldAPI, but this changeset has no package entry. Add an@clerk/uiminor bump and describe the new Field compound component API.Proposed fix
Based on learnings, an empty changeset is only acceptable for documentation-only or non-published changes.
📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Learnings