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/avatar-button-block.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
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 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/settings-group-block.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/user-profile-account-section.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
4 changes: 4 additions & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ViewSource } from './ViewSource';
const docModules: Record<string, Record<string, React.ComponentType>> = {
user: {
'user-button': dynamic(() => import('../stories/user-button.mdx')),
'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')),
},
organization: {
'organization-profile': dynamic(() => import('../stories/organization-profile.mdx')),
Expand All @@ -28,7 +29,9 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
'organization-profile-delete-section': dynamic(() => import('../stories/organization-profile-delete-section.mdx')),
},
blocks: {
'avatar-button': dynamic(() => import('../stories/avatar-button.mdx')),
destructive: dynamic(() => import('../stories/destructive.mdx')),
'settings-group': dynamic(() => import('../stories/settings-group.mdx')),
},
components: {
avatar: dynamic(() => import('../stories/avatar.mdx')),
Expand All @@ -44,6 +47,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
41 changes: 41 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import {
Shapes as AvatarShapes,
Sizes as AvatarSizes,
} from '../stories/avatar.stories';
import {
Default as AvatarButtonDefault,
Fallback as AvatarButtonFallback,
meta as avatarButtonMeta,
} from '../stories/avatar-button.stories';
import {
Colors as BadgeColors,
meta as badgeMeta,
Expand All @@ -25,6 +30,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 @@ -105,6 +111,12 @@ import {
ThemedScrollbar as ScrollAreaThemedScrollbar,
} from '../stories/scroll-area.stories';
import { meta as selectMeta } from '../stories/select.stories';
import {
ConnectedAccounts as SettingsGroupConnectedAccounts,
Default as SettingsGroupDefault,
Destructive as SettingsGroupDestructive,
meta as settingsGroupMeta,
} from '../stories/settings-group.stories';
import { Default as TabsComponentDefault, meta as tabsComponentMeta } from '../stories/tabs.component.stories';
import { meta as tabsMeta } from '../stories/tabs.stories';
import {
Expand All @@ -121,10 +133,25 @@ import {
Organizations as UserButtonOrganizations,
User as UserButtonUser,
} from '../stories/user-button.stories';
import {
Default as UserProfileAccountSectionDefault,
meta as userProfileAccountSectionMeta,
} from '../stories/user-profile-account-section.stories';
import { toSlug } from './slug';
import type { StoryModule } from './types';

const destructiveModule: StoryModule = { meta: destructiveMeta, Default: DestructiveDefault };
const avatarButtonModule: StoryModule = {
meta: avatarButtonMeta,
Default: AvatarButtonDefault,
Fallback: AvatarButtonFallback,
};
const settingsGroupModule: StoryModule = {
meta: settingsGroupMeta,
Default: SettingsGroupDefault,
ConnectedAccounts: SettingsGroupConnectedAccounts,
Destructive: SettingsGroupDestructive,
};
const organizationProfileLeaveSectionModule: StoryModule = {
meta: organizationProfileLeaveSectionMeta,
Default: OrganizationProfileLeaveSectionDefault,
Expand Down Expand Up @@ -213,6 +240,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 @@ -249,9 +281,15 @@ const scrollAreaModule: StoryModule = {

const useDataTableModule: StoryModule = { meta: useDataTableMeta };

const userProfileAccountSectionModule: StoryModule = {
meta: userProfileAccountSectionMeta,
Default: UserProfileAccountSectionDefault,
};

export const registry: StoryModule[] = [
// User
userButtonModule,
userProfileAccountSectionModule,
// Organization
organizationProfileModule,
organizationProfileGeneralPanelModule,
Expand All @@ -262,7 +300,9 @@ export const registry: StoryModule[] = [
organizationProfileLeaveSectionModule,
organizationProfileDeleteSectionModule,
// Blocks
avatarButtonModule,
destructiveModule,
settingsGroupModule,
// Components
avatarModule,
badgeModule,
Expand All @@ -277,6 +317,7 @@ export const registry: StoryModule[] = [
popoverComponentModule,
tabsComponentModule,
textModule,
fieldModule,
// Primitives — alphabetical within the group.
accordionModule,
autocompleteModule,
Expand Down
11 changes: 11 additions & 0 deletions packages/swingset/src/stories/avatar-button.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as AvatarButtonStories from './avatar-button.stories';

# AvatarButton

An interactive profile avatar with a stable edit affordance, accessible button semantics, and initials fallback.

<Story name='Default' storyModule={AvatarButtonStories} composition={[{ name: 'Avatar', href: '/components/avatar', layer: 'Components' }, { name: 'Button', href: '/components/button', layer: 'Components' }]} />

## Fallback

<Story name='Fallback' storyModule={AvatarButtonStories} />
32 changes: 32 additions & 0 deletions packages/swingset/src/stories/avatar-button.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/** @jsxImportSource @emotion/react */
import { AvatarButton } from '@clerk/ui/mosaic/block/avatar-button';

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

export { default as __source } from './avatar-button.stories?raw';

export const meta: StoryMeta = {
group: 'Blocks',
title: 'AvatarButton',
source: 'packages/ui/src/mosaic/block/avatar-button.tsx',
styleEngine: 'stylex',
};

export function Default() {
return (
<AvatarButton
imageUrl='https://avatars.githubusercontent.com/u/51144033?v=4'
name='Preston Booth'
onClick={() => undefined}
/>
);
}

export function Fallback() {
return (
<AvatarButton
name='Preston Booth'
onClick={() => undefined}
/>
);
}
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';
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>
);
}
7 changes: 6 additions & 1 deletion packages/swingset/src/stories/icon.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const meta: StoryMeta = {
styleEngine: 'stylex',
styles: {
_variants: {
size: { sm: {}, md: {}, lg: {} },
size: { xs: {}, sm: {}, md: {}, lg: {} },
},
_defaultVariants: {
size: 'md',
Expand All @@ -44,6 +44,11 @@ export function Default(props: Record<string, unknown>) {
export function Sizes(props: Record<string, unknown>) {
return (
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<Icon
{...knobsAsProps(props)}
name='chevron-right'
size='xs'
/>
<Icon
{...knobsAsProps(props)}
name='chevron-right'
Expand Down
66 changes: 66 additions & 0 deletions packages/swingset/src/stories/settings-group.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import * as SettingsGroupStories from './settings-group.stories';

# SettingsGroup

A settings block that carries the canonical section, surface, separator, and label/control layout. Use it for product settings rather than assembling those decisions from generic cards and item groups.

## Usage

```tsx
import { SettingsGroup } from '@clerk/ui/mosaic/block/settings-group';

<SettingsGroup.Root>
<SettingsGroup.Title>Account</SettingsGroup.Title>
<SettingsGroup.List>
<SettingsGroup.Row>
<SettingsGroup.Media>{icon}</SettingsGroup.Media>
<SettingsGroup.Label description='Shown throughout the application.'>
Name
</SettingsGroup.Label>
<SettingsGroup.Control>{control}</SettingsGroup.Control>
</SettingsGroup.Row>
</SettingsGroup.List>
</SettingsGroup.Root>
```

`SettingsGroup.List` owns the outlined surface and separation between direct `SettingsGroup.Row` children. `SettingsGroup.Media` reserves a consistent slot for icons, avatars, and provider marks. Each row places its label opposite its control. Use `SettingsGroup.Control` for inputs, switches, menus, and buttons—not only actions.

When a row contains one form control, add `field` to associate its label automatically:

```tsx
<SettingsGroup.Row field>
<SettingsGroup.Label>Name</SettingsGroup.Label>
<SettingsGroup.Control>
<Input />
</SettingsGroup.Control>
</SettingsGroup.Row>
```

## Examples

<Story
name='Default'
storyModule={SettingsGroupStories}
composition={[
{ name: 'Avatar', href: '/components/avatar', layer: 'Components' },
{ name: 'Input', href: '/components/input', layer: 'Components' },
]}
/>

### Connected accounts

<Story
name='ConnectedAccounts'
storyModule={SettingsGroupStories}
composition={[
{ name: 'Button', href: '/components/button', layer: 'Components' },
{ name: 'Icon', href: '/components/icon', layer: 'Components' },
]}
/>

### Destructive setting

<Story
name='Destructive'
storyModule={SettingsGroupStories}
/>
Loading
Loading