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')),
'text-field': dynamic(() => import('../stories/text-field.mdx')),
},
primitives: {
// Headless primitives — alphabetical.
Expand Down
16 changes: 16 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ import {
meta as textMeta,
Sizes as TextSizes,
} from '../stories/text.stories';
import {
Default as TextFieldDefault,
Horizontal as TextFieldHorizontal,
Invalid as TextFieldInvalid,
meta as textFieldMeta,
Sizes as TextFieldSizes,
} from '../stories/text-field.stories';
import { meta as tooltipMeta } from '../stories/tooltip.stories';
import { meta as useDataTableMeta } from '../stories/use-data-table.stories';
import { toSlug } from './slug';
Expand Down Expand Up @@ -200,6 +207,14 @@ const tabsComponentModule: StoryModule = { meta: tabsComponentMeta, Default: Tab

const textModule: StoryModule = { meta: textMeta, Default: TextDefault, Sizes: TextSizes, Colors: TextColors };

const textFieldModule: StoryModule = {
meta: textFieldMeta,
Default: TextFieldDefault,
Horizontal: TextFieldHorizontal,
Invalid: TextFieldInvalid,
Sizes: TextFieldSizes,
};

const iconModule: StoryModule = {
meta: iconMeta,
Default: IconDefault,
Expand Down Expand Up @@ -262,6 +277,7 @@ export const registry: StoryModule[] = [
popoverComponentModule,
tabsComponentModule,
textModule,
textFieldModule,
// Primitives — alphabetical within the group.
accordionModule,
autocompleteModule,
Expand Down
58 changes: 58 additions & 0 deletions packages/swingset/src/stories/text-field.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import * as TextFieldStories from './text-field.stories';

# Text Field

`TextField` composes a label, Mosaic `Input`, description, and error message with automatic accessible relationships. It supports stacked authentication forms and horizontal settings rows without managing form values or validation.

## Playground

<Preview
name='Default'
storyModule={TextFieldStories}
/>

## Props

<PropTable meta={TextFieldStories.meta} />

## Usage

```tsx
import { TextField } from '@clerk/ui/mosaic/components/text-field';

<TextField.Root required>
<TextField.Label>Email address</TextField.Label>
<TextField.Content>
<TextField.Input
name='email'
type='email'
/>
<TextField.Description>Used for account notifications.</TextField.Description>
</TextField.Content>
</TextField.Root>;
```

---

## Examples

### Horizontal layout

<Story
name='Horizontal'
storyModule={TextFieldStories}
/>

### Invalid

<Story
name='Invalid'
storyModule={TextFieldStories}
/>

### Sizes

<Story
name='Sizes'
storyModule={TextFieldStories}
/>
120 changes: 120 additions & 0 deletions packages/swingset/src/stories/text-field.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import type { TextFieldRootProps } from '@clerk/ui/mosaic/components/text-field';
import { TextField } from '@clerk/ui/mosaic/components/text-field';

import type { StoryMeta } from '@/lib/types';

export { default as __source } from './text-field.stories?raw';

export const meta: StoryMeta = {
group: 'Components',
title: 'Text Field',
source: 'packages/ui/src/mosaic/components/text-field/text-field.tsx',
styleEngine: 'stylex',
styles: {
_variants: {
layout: { stacked: {}, horizontal: {} },
size: { sm: {}, md: {}, lg: {} },
invalid: { true: {}, false: {} },
disabled: { true: {}, false: {} },
required: { true: {}, false: {} },
},
_defaultVariants: {
layout: 'stacked',
size: 'md',
invalid: false,
disabled: false,
required: false,
},
},
};

function knobsAsProps(props: Record<string, unknown>) {
return props as unknown as TextFieldRootProps;
}

export function Default(props: Record<string, unknown>) {
const textFieldProps = knobsAsProps(props);

return (
<TextField.Root
{...textFieldProps}
ids={{ control: 'text-field-playground' }}
style={{ maxWidth: textFieldProps.layout === 'horizontal' ? 960 : 384 }}
>
<TextField.Label>Email address</TextField.Label>
<TextField.Content>
<TextField.Input
name='email'
type='email'
placeholder='you@example.com'
/>
{textFieldProps.invalid ? (
<TextField.Error>Enter a valid email address.</TextField.Error>
) : (
<TextField.Description>Used for account notifications.</TextField.Description>
)}
</TextField.Content>
</TextField.Root>
);
}

export function Horizontal() {
return (
<div style={{ width: '100%', maxWidth: 960 }}>
<TextField.Root
layout='horizontal'
ids={{ control: 'text-field-horizontal' }}
>
<TextField.Label>Username</TextField.Label>
<TextField.Content>
<TextField.Input
name='username'
defaultValue='prestonxyz'
/>
<TextField.Description>This is how other members will identify you.</TextField.Description>
</TextField.Content>
</TextField.Root>
</div>
);
}

export function Invalid() {
return (
<TextField.Root
invalid
required
ids={{ control: 'text-field-invalid' }}
style={{ maxWidth: 384 }}
>
<TextField.Label>Email address</TextField.Label>
<TextField.Content>
<TextField.Input
name='email'
type='email'
defaultValue='not-an-email'
/>
<TextField.Error>Enter a valid email address.</TextField.Error>
</TextField.Content>
</TextField.Root>
);
}

export function Sizes() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, width: 384 }}>
{(['sm', 'md', 'lg'] as const).map(size => (
<TextField.Root
key={size}
size={size}
ids={{ control: `text-field-${size}` }}
>
<TextField.Label>{size.toUpperCase()} field</TextField.Label>
<TextField.Content>
<TextField.Input placeholder={`${size} input`} />
<TextField.Description>Supporting text follows the field size.</TextField.Description>
</TextField.Content>
</TextField.Root>
))}
</div>
);
}
12 changes: 12 additions & 0 deletions packages/ui/src/mosaic/components/text-field/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export { TextField } from './text-field';
export type {
TextFieldContentProps,
TextFieldDescriptionProps,
TextFieldErrorProps,
TextFieldIds,
TextFieldInputProps,
TextFieldLabelProps,
TextFieldLayout,
TextFieldRootProps,
TextFieldSize,
} from './text-field';
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// @vitest-environment node

import React from 'react';
import { renderToString } from 'react-dom/server';
import { describe, expect, it } from 'vitest';

import { TextField } from './text-field';

describe('Mosaic TextField SSR', () => {
it('emits complete label and message relationships on the first render', () => {
const html = renderToString(
<TextField.Root
invalid
required
ids={{
control: 'email',
label: 'email-label',
description: 'email-description',
error: 'email-error',
}}
>
<TextField.Label>Email</TextField.Label>
<TextField.Content>
<TextField.Input aria-describedby='external' />
<TextField.Description>Description</TextField.Description>
<TextField.Error>Error</TextField.Error>
</TextField.Content>
</TextField.Root>,
);

expect(html).toContain('for="email"');
expect(html).toContain('id="email-label"');
expect(html).toContain('id="email-description"');
expect(html).toContain('id="email-error"');
expect(html).toContain('aria-describedby="external email-description email-error"');
expect(html).toContain('aria-invalid="true"');
expect(html).toContain('required=""');
});
});
90 changes: 90 additions & 0 deletions packages/ui/src/mosaic/components/text-field/text-field.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import * as stylex from '@stylexjs/stylex';

import { colorVars, fontWeightVars, space, typeScaleVars } from '../../tokens.stylex';

export const styles = stylex.create({
root: {
color: colorVars['--cl-color-card-foreground'],
display: 'grid',
minWidth: 0,
width: '100%',
},
label: {
alignItems: 'center',
alignSelf: 'start',
display: 'inline-flex',
fontWeight: fontWeightVars['--cl-font-medium'],
minWidth: 0,
},
content: {
display: 'flex',
flexDirection: 'column',
rowGap: space['1.5'],
minWidth: 0,
},
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,
},
disabledText: {
opacity: 0.5,
},
});

export const layouts = stylex.create({
stacked: {
gridTemplateColumns: 'minmax(0, 1fr)',
rowGap: space['2'],
},
horizontal: {
columnGap: space['6'],
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 24rem)',
},
});

export const labelSizes = stylex.create({
sm: {
fontSize: typeScaleVars['--cl-text-xs-size'],
lineHeight: typeScaleVars['--cl-text-xs-leading'],
},
md: {
fontSize: typeScaleVars['--cl-text-sm-size'],
lineHeight: typeScaleVars['--cl-text-sm-leading'],
},
lg: {
fontSize: typeScaleVars['--cl-text-base-size'],
lineHeight: 1.375,
},
});

export const horizontalLabelSizes = stylex.create({
sm: { minHeight: space['7'] },
md: { minHeight: space['8'] },
lg: { minHeight: space['9'] },
});

export const messageSizes = stylex.create({
sm: {
fontSize: typeScaleVars['--cl-text-xs-size'],
lineHeight: typeScaleVars['--cl-text-xs-leading'],
},
md: {
fontSize: typeScaleVars['--cl-text-xs-size'],
lineHeight: typeScaleVars['--cl-text-xs-leading'],
},
lg: {
fontSize: typeScaleVars['--cl-text-sm-size'],
lineHeight: typeScaleVars['--cl-text-sm-leading'],
},
});
Loading
Loading