diff --git a/.changeset/migrate-mosaic-input-to-stylex.md b/.changeset/migrate-mosaic-input-to-stylex.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/migrate-mosaic-input-to-stylex.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/mosaic-user-profile-profile-panel.md b/.changeset/mosaic-user-profile-profile-panel.md new file mode 100644 index 00000000000..f3a615f6b40 --- /dev/null +++ b/.changeset/mosaic-user-profile-profile-panel.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Add an outlined Card elevation for bordered, non-elevated surfaces, make Item groups layout-only, and expose Web3 wallets in the Mosaic user profile panel. 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..b1527362df6 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -10,6 +10,9 @@ import { ViewSource } from './ViewSource'; // MDX docs keyed by `group` slug → `component` slug. Group-aware so identically-named // entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct. const docModules: Record> = { + user: { + 'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')), + }, organization: { 'organization-profile': dynamic(() => import('../stories/organization-profile.mdx')), 'organization-profile-general-panel': dynamic(() => import('../stories/organization-profile-general-panel.mdx')), @@ -41,6 +44,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.component.mdx')), }, primitives: { // Headless primitives — alphabetical. diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 93db371c0e5..959a0e95455 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.component.stories'; import { meta as fileUploadMeta } from '../stories/file-upload.stories'; import { Colors as HeadingColors, @@ -115,6 +116,10 @@ import { } from '../stories/text.stories'; import { meta as tooltipMeta } from '../stories/tooltip.stories'; import { meta as useDataTableMeta } from '../stories/use-data-table.stories'; +import { + Default as UserProfileProfilePanelDefault, + meta as userProfileProfilePanelMeta, +} from '../stories/user-profile-profile-panel.stories'; import { toSlug } from './slug'; import type { StoryModule } from './types'; @@ -200,6 +205,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, @@ -236,7 +246,14 @@ const scrollAreaModule: StoryModule = { const useDataTableModule: StoryModule = { meta: useDataTableMeta }; +const userProfileProfilePanelModule: StoryModule = { + meta: userProfileProfilePanelMeta, + Default: UserProfileProfilePanelDefault, +}; + export const registry: StoryModule[] = [ + // User + userProfileProfilePanelModule, // Organization organizationProfileModule, organizationProfileGeneralPanelModule, @@ -262,6 +279,7 @@ export const registry: StoryModule[] = [ popoverComponentModule, tabsComponentModule, textModule, + fieldModule, // Primitives — alphabetical within the group. accordionModule, autocompleteModule, diff --git a/packages/swingset/src/stories/card.component.stories.tsx b/packages/swingset/src/stories/card.component.stories.tsx index 201fd081626..cd7d2170a38 100644 --- a/packages/swingset/src/stories/card.component.stories.tsx +++ b/packages/swingset/src/stories/card.component.stories.tsx @@ -19,7 +19,7 @@ export const meta: StoryMeta = { styles: { _variants: { alignment: { start: {}, center: {} }, - elevation: { card: {}, flush: {}, overlay: {} }, + elevation: { card: {}, flush: {}, outlined: {}, overlay: {} }, }, _defaultVariants: { alignment: 'start', diff --git a/packages/swingset/src/stories/field.component.mdx b/packages/swingset/src/stories/field.component.mdx new file mode 100644 index 00000000000..755ee661a2b --- /dev/null +++ b/packages/swingset/src/stories/field.component.mdx @@ -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 + + + +## 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 `
` and `` 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'; + + + Email address + + {error ? {error} : Used for account notifications.} +; +``` + +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`. + +## 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. diff --git a/packages/swingset/src/stories/field.component.stories.tsx b/packages/swingset/src/stories/field.component.stories.tsx new file mode 100644 index 00000000000..1a7ca15cb8a --- /dev/null +++ b/packages/swingset/src/stories/field.component.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.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 ( + + Email address + + Used for account notifications. + + ); +} diff --git a/packages/swingset/src/stories/icon.stories.tsx b/packages/swingset/src/stories/icon.stories.tsx index d3bc7eed11c..0bc34de25ff 100644 --- a/packages/swingset/src/stories/icon.stories.tsx +++ b/packages/swingset/src/stories/icon.stories.tsx @@ -17,7 +17,7 @@ export const meta: StoryMeta = { styleEngine: 'stylex', styles: { _variants: { - size: { sm: {}, md: {}, lg: {} }, + size: { xs: {}, sm: {}, md: {}, lg: {} }, }, _defaultVariants: { size: 'md', @@ -44,6 +44,11 @@ export function Default(props: Record) { export function Sizes(props: Record) { return (
+ ) { @@ -41,6 +48,11 @@ export function Sizes(props: Record) { size='md' placeholder='Medium' /> +
); } diff --git a/packages/swingset/src/stories/user-profile-profile-panel.mdx b/packages/swingset/src/stories/user-profile-profile-panel.mdx new file mode 100644 index 00000000000..6d87845c1dd --- /dev/null +++ b/packages/swingset/src/stories/user-profile-profile-panel.mdx @@ -0,0 +1,62 @@ +import * as UserProfileProfilePanelStories from './user-profile-profile-panel.stories'; + +# UserProfileProfilePanel + +The Profile panel from UserProfile, composed without the surrounding navigation shell. The +presentational view renders account details, connected accounts, Web3 wallets, and the danger zone from props +while its callbacks provide the seam for connected Clerk resources. + +## Example + + + +## Usage + +```tsx +import { UserProfileProfilePanelView } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view'; + + ({ + id: email.id, + value: email.emailAddress, + isDefault: email.id === user.primaryEmailAddressId, + isVerified: email.verification.status === 'verified', + }))} + phones={user.phoneNumbers.map(phone => ({ + id: phone.id, + value: phone.phoneNumber, + isDefault: phone.id === user.primaryPhoneNumberId, + isVerified: phone.verification.status === 'verified', + }))} + connectedAccounts={user.externalAccounts.map(account => ({ + id: account.id, + provider: account.provider, + identifier: account.emailAddress, + connected: true, + }))} + web3Wallets={user.web3Wallets.map(wallet => ({ + id: wallet.id, + address: wallet.web3Wallet, + isPrimary: wallet.id === user.primaryWeb3WalletId, + isVerified: wallet.verification.status === 'verified', + }))} + onNameChange={setName} + onUsernameChange={setUsername} + onVerifyEmail={verifyEmail} + onSetPrimaryEmail={setPrimaryEmail} + onRemoveEmail={removeEmail} + onVerifyPhone={verifyPhone} + onSetPrimaryPhone={setPrimaryPhone} + onRemovePhone={removePhone} + onRemoveConnectedAccount={removeConnectedAccount} + onAddWeb3Wallet={addWeb3Wallet} + onSetPrimaryWeb3Wallet={setPrimaryWeb3Wallet} + onRemoveWeb3Wallet={removeWeb3Wallet} +/> +``` diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx new file mode 100644 index 00000000000..6565f4a1a83 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -0,0 +1,71 @@ +/** @jsxImportSource @emotion/react */ +import { UserProfileProfilePanelView } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`; +const profileImageUrl = 'https://avatars.githubusercontent.com/u/51144033?v=4'; + +export { default as __source } from './user-profile-profile-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'User', + title: 'UserProfileProfilePanel', + source: 'packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx', +}; + +export function Default(_args: Record) { + const [name, setName] = useState('Preston Booth'); + const [username, setUsername] = useState('prestonxyz'); + + return ( + undefined} + onAddPhone={() => undefined} + onConnectAccount={() => undefined} + onDeleteAccount={() => undefined} + onEditProfilePicture={() => undefined} + onRemoveConnectedAccount={() => undefined} + onRemoveEmail={() => undefined} + onRemovePhone={() => undefined} + onAddWeb3Wallet={() => undefined} + onRemoveWeb3Wallet={() => undefined} + onSetPrimaryWeb3Wallet={() => undefined} + onSetPrimaryEmail={() => undefined} + onSetPrimaryPhone={() => undefined} + onVerifyEmail={() => undefined} + onVerifyPhone={() => undefined} + onNameChange={setName} + onUsernameChange={setUsername} + /> + ); +} diff --git a/packages/ui/src/mosaic/block/destructive.tsx b/packages/ui/src/mosaic/block/destructive.tsx index cac00ae1fb5..066d430041a 100644 --- a/packages/ui/src/mosaic/block/destructive.tsx +++ b/packages/ui/src/mosaic/block/destructive.tsx @@ -90,9 +90,7 @@ export function Destructive({ value={confirmValue} onChange={e => setConfirmValue(e.target.value)} disabled={isDeleting} - sx={t => ({ - marginBlockStart: t.spacing(1), - })} + style={{ marginBlockStart: 'var(--cl-spacing)' }} /> { expect(screen.getByTestId('footer')).toHaveAttribute('data-elevation', 'overlay'); }); + it('reflects outlined elevation on the root and footer', () => { + render( + + + , + ); + + expect(screen.getByTestId('root')).toHaveAttribute('data-elevation', 'outlined'); + expect(screen.getByTestId('footer')).toHaveAttribute('data-elevation', 'outlined'); + }); + it('provides the neutral text color to header copy', () => { render( diff --git a/packages/ui/src/mosaic/components/card/card.tsx b/packages/ui/src/mosaic/components/card/card.tsx index 506e3f43db9..5f32ac321b8 100644 --- a/packages/ui/src/mosaic/components/card/card.tsx +++ b/packages/ui/src/mosaic/components/card/card.tsx @@ -9,7 +9,7 @@ import { TextContext } from '../text'; import { elevations, headerAlignments, styles } from './card.styles'; type CardAlignment = 'start' | 'center'; -type CardElevation = 'card' | 'flush' | 'overlay'; +type CardElevation = 'card' | 'flush' | 'outlined' | 'overlay'; const DEFAULT_ALIGNMENT: CardAlignment = 'start'; const DEFAULT_ELEVATION: CardElevation = 'card'; diff --git a/packages/ui/src/mosaic/components/field/field.context.tsx b/packages/ui/src/mosaic/components/field/field.context.tsx new file mode 100644 index 00000000000..1f72ce9e08d --- /dev/null +++ b/packages/ui/src/mosaic/components/field/field.context.tsx @@ -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>; + setMessageIds: React.Dispatch>; +} + +const FieldContext = React.createContext(null); + +function mergeIds(...values: Array): 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([]); + const [messageIds, setMessageIds] = React.useState([]); + const controlIds = React.useRef(new Map()); + 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] supports a single form control. Use a separate for each control or native
semantics for grouped controls.', + ); + } + + setControlId(controlIds.current.values().next().value ?? defaultControlId); + }, + [defaultControlId], + ); + const context = React.useMemo( + () => ({ controlId, labelIds, messageIds, registerControlId, setLabelIds, setMessageIds }), + [controlId, labelIds, messageIds, registerControlId], + ); + + return {children}; +} + +export function useOptionalFieldContext() { + return React.useContext(FieldContext); +} + +export function useRegisterFieldPartId( + id: string | undefined, + setIds: React.Dispatch> | 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]); +} + +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), + }; +} 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..ad07f4494f7 --- /dev/null +++ b/packages/ui/src/mosaic/components/field/field.ssr.test.tsx @@ -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( + + Email + + Description + Error + , + ); + + const labelControlId = html.match(/for="([^"]+)"/)?.[1]; + const inputControlId = html.match(/]*\sid="([^"]+)"/)?.[1]; + const input = html.match(/]*>/)?.[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( + + Email + + , + ); + + const labelControlId = html.match(/for="([^"]+)"/)?.[1]; + const inputControlId = html.match(/]*\sid="([^"]+)"/)?.[1]; + expect(inputControlId).toBeDefined(); + expect(inputControlId).not.toBe('custom-control'); + expect(labelControlId).toBe(inputControlId); + expect(html).not.toContain('id="custom-control"'); + }); +}); 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..58e40d03234 --- /dev/null +++ b/packages/ui/src/mosaic/components/field/field.styles.ts @@ -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', + }, +}); 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..9d20710e8cd --- /dev/null +++ b/packages/ui/src/mosaic/components/field/field.test.tsx @@ -0,0 +1,327 @@ +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('generates native label and message relationships', () => { + render( + + Email + + Used for account notifications. + Enter a valid email. + , + ); + + const control = screen.getByRole('textbox', { name: 'Email' }); + const label = screen.getByText('Email'); + const description = screen.getByText('Used for account notifications.'); + const error = screen.getByText('Enter a valid email.').closest('p'); + expect(control.id).not.toBe(''); + expect(label.id).not.toBe(''); + expect(label).toHaveAttribute('for', control.id); + expect(control).toHaveAttribute('aria-labelledby', label.id); + expect(description.id).not.toBe(''); + expect(error?.id).not.toBe(''); + expect(control).toHaveAttribute('aria-describedby', `${description.id} ${error?.id}`); + expect(control).toHaveAttribute('aria-invalid', 'true'); + }); + + it('preserves caller-provided IDs and merges ARIA relationships', () => { + render( + + + Account + + + + Description + + + Error + + , + ); + + expect(screen.getByLabelText('Account details')).toHaveClass('cl-field-root'); + expect(screen.getByText('Account')).toHaveAttribute('id', 'custom-label'); + expect(screen.getByText('Account')).toHaveAttribute('for', 'custom-control'); + expect(screen.getByText('Account')).toHaveAttribute('aria-hidden', 'false'); + expect(screen.getByRole('textbox')).toHaveAttribute('id', 'custom-control'); + expect(screen.getByRole('textbox')).toHaveAttribute('aria-labelledby', 'external-label custom-label'); + expect(screen.getByRole('textbox')).toHaveAttribute( + 'aria-describedby', + 'external-description custom-description custom-error', + ); + expect(screen.getByText('Description')).toHaveAttribute('id', 'custom-description'); + expect(screen.getByText('Description')).toHaveAttribute('aria-live', 'polite'); + expect(screen.getByRole('alert')).toHaveAttribute('id', 'custom-error'); + }); + + it('allows every part to render independently of Root', () => { + render( + <> + Standalone label + Standalone description + Standalone error + , + ); + + expect(screen.getByText('Standalone label')).toHaveClass('cl-field-label'); + expect(screen.getByText('Standalone description')).toHaveClass('cl-field-description'); + expect(screen.getByText('Standalone error').closest('p')).toHaveClass('cl-field-error'); + }); + + it('keeps Input native and ARIA behavior identical inside and outside Field', () => { + const props = { + id: 'account-email', + name: 'email', + type: 'email', + required: true, + readOnly: true, + 'aria-label': 'Account email', + 'aria-labelledby': 'external-label', + 'aria-describedby': 'external-description external-error', + 'aria-invalid': 'grammar' as const, + 'aria-disabled': 'false' as const, + 'aria-required': 'true' as const, + }; + + render( + <> + + + + + , + ); + + const outside = screen.getByTestId('outside'); + const inside = screen.getByTestId('inside'); + const attributes = [ + 'id', + 'name', + 'type', + 'required', + 'readonly', + 'aria-label', + 'aria-labelledby', + 'aria-describedby', + 'aria-invalid', + 'aria-disabled', + 'aria-required', + 'class', + 'data-size', + ]; + for (const attribute of attributes) { + expect(inside.getAttribute(attribute)).toBe(outside.getAttribute(attribute)); + } + expect(inside).toHaveClass('cl-input'); + expect(inside).not.toHaveClass('cl-field-control'); + }); + + it('finalizes explicit IDs and generated relationships during hydration', async () => { + const field = ( + + Email + + Description + + ); + const container = document.createElement('div'); + container.innerHTML = renderToString(field); + expect(container.querySelector('input')).not.toHaveAttribute('id', 'custom-control'); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + let root: ReturnType | undefined; + await act(() => { + root = hydrateRoot(container, field); + }); + + const control = container.querySelector('input'); + const label = container.querySelector('label'); + const description = container.querySelector('.cl-field-description'); + expect(consoleError).not.toHaveBeenCalled(); + expect(control).toHaveAttribute('id', 'custom-control'); + expect(label).toHaveAttribute('for', control?.id); + expect(control).toHaveAttribute('aria-labelledby', label?.id); + expect(control).toHaveAttribute('aria-describedby', description?.id); + + await act(() => root?.unmount()); + consoleError.mockRestore(); + }); + + it('warns when Root contains more than one form control', () => { + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + render( + + + + , + ); + + expect(consoleWarn).toHaveBeenCalledTimes(1); + expect(consoleWarn).toHaveBeenCalledWith( + '[clerk] supports a single form control. Use a separate for each control or native
semantics for grouped controls.', + ); + consoleWarn.mockRestore(); + }); + + it('updates registered control and message IDs as parts change', () => { + const { rerender } = render( + + Email + + Description + , + ); + const descriptionId = screen.getByText('Description').id; + + expect(screen.getByRole('textbox', { name: 'Email' })).toHaveAttribute('aria-describedby', descriptionId); + + rerender( + + Email + + , + ); + + const control = screen.getByRole('textbox', { name: 'Email' }); + expect(control).toHaveAttribute('id', 'billing-email'); + expect(control).not.toHaveAttribute('aria-describedby'); + expect(screen.getByText('Email')).toHaveAttribute('for', 'billing-email'); + }); + + it('forwards refs and native props from every part', () => { + const rootRef = React.createRef(); + const labelRef = React.createRef(); + const descriptionRef = React.createRef(); + const errorRef = React.createRef(); + + render( + + + Name + + + Description + + + Error + + , + ); + + expect(rootRef.current).toHaveAttribute('data-root', 'field'); + expect(labelRef.current).toHaveAttribute('for', 'name'); + expect(descriptionRef.current).toHaveAttribute('title', 'Help'); + expect(errorRef.current).toHaveAttribute('role', 'status'); + }); + + 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' }); + expect(screen.getByText('Email')).toHaveClass('cl-field-label', 'label'); + expect(screen.getByText('Email')).toHaveStyle({ fontWeight: 700 }); + 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 every part', () => { + render( +
}> + +
}>Description +
}>Error + , + ); + + expect(screen.getByText('Biography').closest('section')).not.toBeNull(); + expect(screen.getByText('Description').tagName).toBe('DIV'); + expect(screen.getByText('Error').closest('div')).toHaveClass('cl-field-error'); + }); + + it('preserves the opt-in error presentation and icon', () => { + render(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('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 `