Skip to content

Commit ef0bb0e

Browse files
feat(ui): Mosaic <Card /> component scaffolding (#8893)
1 parent 97e7a63 commit ef0bb0e

12 files changed

Lines changed: 288 additions & 15 deletions

File tree

.changeset/some-towns-study.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/swingset/src/components/DocsViewer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
2525
},
2626
components: {
2727
button: dynamic(() => import('../stories/button.mdx')),
28+
card: dynamic(() => import('../stories/card.component.mdx')),
2829
input: dynamic(() => import('../stories/input.mdx')),
2930
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
3031
heading: dynamic(() => import('../stories/heading.mdx')),

packages/swingset/src/lib/registry.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
import { meta as accordionMeta } from '../stories/accordion.stories';
33
import { meta as autocompleteMeta } from '../stories/autocomplete.stories';
44
import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories';
5+
import {
6+
Centered as CardCentered,
7+
Default as CardDefault,
8+
meta as cardComponentMeta,
9+
} from '../stories/card.component.stories';
510
import { meta as collapsibleMeta } from '../stories/collapsible.stories';
611
import {
712
Default as DeleteOrganizationDefault,
@@ -67,6 +72,8 @@ const organizationProfileGeneralModule: StoryModule = {
6772
Default: OrganizationProfileGeneralDefault,
6873
};
6974

75+
const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: CardDefault, Centered: CardCentered };
76+
7077
const buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled };
7178

7279
const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes, Disabled: InputDisabled, Invalid };
@@ -119,6 +126,7 @@ export const registry: StoryModule[] = [
119126
destructiveModule,
120127
// Components
121128
buttonModule,
129+
cardComponentModule,
122130
inputModule,
123131
dialogComponentModule,
124132
headingModule,
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import * as CardStories from './card.component.stories';
2+
3+
# Card
4+
5+
A styled surface container that groups related content into Header, Content, and Footer zones. Use it to visually separate distinct sections of a page or flow.
6+
7+
## Playground
8+
9+
<Preview
10+
name='Default'
11+
storyModule={CardStories}
12+
/>
13+
14+
## Props
15+
16+
<PropTable meta={CardStories.meta} />
17+
18+
## Usage
19+
20+
```tsx
21+
import { Card } from '@clerk/ui/mosaic/components/card';
22+
23+
<Card>
24+
<Card.Header>Heading</Card.Header>
25+
<Card.Content>Body content.</Card.Content>
26+
<Card.Footer>Footer actions</Card.Footer>
27+
</Card>
28+
```
29+
30+
---
31+
32+
## Examples
33+
34+
### Centered
35+
36+
<Story
37+
name='Centered'
38+
storyModule={CardStories}
39+
/>
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/** @jsxImportSource @emotion/react */
2+
import { Button } from '@clerk/ui/mosaic/components/button';
3+
import type { CardProps } from '@clerk/ui/mosaic/components/card';
4+
import { Card, cardRecipe } from '@clerk/ui/mosaic/components/card';
5+
import { Heading } from '@clerk/ui/mosaic/components/heading';
6+
import { Text } from '@clerk/ui/mosaic/components/text';
7+
8+
import type { StoryMeta } from '@/lib/types';
9+
10+
// Exposes this file's own source (via the `?raw` webpack rule) so each `<Story>` example
11+
// renders a code footer with its function's source. See `StoryModule.__source`.
12+
export { default as __source } from './card.component.stories?raw';
13+
14+
export const meta: StoryMeta = {
15+
group: 'Components',
16+
title: 'Card',
17+
source: 'packages/ui/src/mosaic/components/card.tsx',
18+
styles: cardRecipe,
19+
};
20+
21+
function knobsAsProps(props: Record<string, unknown>) {
22+
return props as unknown as CardProps;
23+
}
24+
25+
export function Default(props: Record<string, unknown>) {
26+
return (
27+
<Card
28+
{...knobsAsProps(props)}
29+
style={{ maxWidth: 400 }}
30+
>
31+
<Card.Header>
32+
<Heading>Login to your account</Heading>
33+
<Text>Enter your email below to login to your account</Text>
34+
</Card.Header>
35+
<Card.Content>Card body content goes here.</Card.Content>
36+
<Card.Footer>
37+
<Button fullWidth>Continue</Button>
38+
</Card.Footer>
39+
</Card>
40+
);
41+
}
42+
43+
export function Centered() {
44+
return (
45+
<Card
46+
alignment='center'
47+
style={{ maxWidth: 400 }}
48+
>
49+
<Card.Header>
50+
<Heading>Verify your email</Heading>
51+
<Text>We sent a verification code to your email address</Text>
52+
</Card.Header>
53+
<Card.Content>Enter the code below to continue.</Card.Content>
54+
<Card.Footer>
55+
<Button fullWidth>Verify</Button>
56+
</Card.Footer>
57+
</Card>
58+
);
59+
}

packages/ui/src/mosaic/components/button.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ export const buttonRecipe = defineSlotRecipe(theme => ({
5353
borderRadius: theme.rounded.full,
5454
},
5555
},
56+
fullWidth: {
57+
true: {
58+
width: '100%',
59+
},
60+
false: {},
61+
},
5662
},
5763
compoundVariants: [
5864
{
@@ -126,7 +132,7 @@ export const buttonRecipe = defineSlotRecipe(theme => ({
126132
{ shape: 'circle', size: 'sm', css: { width: theme.spacing(6), height: theme.spacing(6) } },
127133
{ shape: 'circle', size: 'md', css: { width: theme.spacing(9), height: theme.spacing(9) } },
128134
],
129-
defaultVariants: { intent: 'primary', size: 'md', variant: 'filled', shape: 'default' },
135+
defaultVariants: { intent: 'primary', size: 'md', variant: 'filled', shape: 'default', fullWidth: false },
130136
}));
131137

132138
declare module '../registry' {
@@ -138,9 +144,9 @@ declare module '../registry' {
138144
export type ButtonProps = React.ComponentPropsWithRef<'button'> & RecipeVariantProps<typeof buttonRecipe>;
139145

140146
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function MosaicButton(props, ref) {
141-
const { intent, variant, size, shape, disabled, sx, children, ...rest } = props;
147+
const { intent, variant, size, shape, fullWidth, disabled, sx, children, ...rest } = props;
142148
const { root } = useRecipe(buttonRecipe, {
143-
variants: { intent, variant, size, shape },
149+
variants: { intent, variant, size, shape, fullWidth },
144150
state: { disabled: !!disabled },
145151
sx,
146152
});
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import React from 'react';
2+
3+
import type { RecipeVariantProps } from '../slot-recipe';
4+
import { defineSlotRecipe, useRecipe } from '../slot-recipe';
5+
import { TextContext } from './text';
6+
7+
export const cardRecipe = defineSlotRecipe(theme => ({
8+
slots: {
9+
root: { slot: 'card-root' },
10+
header: { slot: 'card-header' },
11+
content: { slot: 'card-content' },
12+
footer: { slot: 'card-footer' },
13+
},
14+
base: {
15+
root: {
16+
backgroundColor: theme.color.card,
17+
color: theme.color.cardForeground,
18+
borderRadius: theme.rounded.lg,
19+
display: 'flex',
20+
flexDirection: 'column',
21+
rowGap: theme.spacing(5),
22+
width: '100%',
23+
overflow: 'hidden',
24+
border: `1px solid ${theme.color.border}`,
25+
},
26+
header: {
27+
display: 'flex',
28+
flexDirection: 'column',
29+
paddingBlockStart: theme.spacing(5),
30+
paddingInline: theme.spacing(4),
31+
},
32+
content: {
33+
paddingInline: theme.spacing(4),
34+
flex: '1 1 auto',
35+
},
36+
footer: {
37+
paddingBlockEnd: theme.spacing(5),
38+
paddingInline: theme.spacing(4),
39+
},
40+
},
41+
variants: {
42+
alignment: {
43+
start: {
44+
header: { alignItems: 'flex-start', textAlign: 'start' },
45+
},
46+
center: {
47+
header: { alignItems: 'center', textAlign: 'center' },
48+
},
49+
},
50+
},
51+
defaultVariants: {
52+
alignment: 'start',
53+
},
54+
}));
55+
56+
type CardVariantProps = RecipeVariantProps<typeof cardRecipe>;
57+
58+
const CardVariantContext = React.createContext<CardVariantProps>({});
59+
60+
declare module '../registry' {
61+
interface MosaicSlotRegistry {
62+
'card-root': true;
63+
'card-header': true;
64+
'card-content': true;
65+
'card-footer': true;
66+
}
67+
}
68+
69+
const Root = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(function CardRoot(props, ref) {
70+
const variantProps = React.useContext(CardVariantContext);
71+
const { root } = useRecipe(cardRecipe, { variants: variantProps });
72+
return (
73+
<div
74+
ref={ref}
75+
{...root}
76+
{...props}
77+
/>
78+
);
79+
});
80+
81+
const Header = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(function CardHeader(props, ref) {
82+
const variantProps = React.useContext(CardVariantContext);
83+
const { header } = useRecipe(cardRecipe, { variants: variantProps });
84+
return (
85+
<TextContext.Provider value={{ intent: 'mutedForeground' }}>
86+
<div
87+
ref={ref}
88+
{...header}
89+
{...props}
90+
/>
91+
</TextContext.Provider>
92+
);
93+
});
94+
95+
const Content = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
96+
function CardContent(props, ref) {
97+
const { content } = useRecipe(cardRecipe);
98+
return (
99+
<div
100+
ref={ref}
101+
{...content}
102+
{...props}
103+
/>
104+
);
105+
},
106+
);
107+
108+
const Footer = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(function CardFooter(props, ref) {
109+
const { footer } = useRecipe(cardRecipe);
110+
return (
111+
<div
112+
ref={ref}
113+
{...footer}
114+
{...props}
115+
/>
116+
);
117+
});
118+
119+
export interface CardProps extends React.ComponentPropsWithoutRef<'div'> {
120+
alignment?: CardVariantProps['alignment'];
121+
}
122+
123+
export function Card({ alignment, children, ...props }: CardProps) {
124+
return (
125+
<CardVariantContext.Provider value={{ alignment }}>
126+
<Root {...props}>{children}</Root>
127+
</CardVariantContext.Provider>
128+
);
129+
}
130+
131+
Card.Root = Root;
132+
Card.Header = Header;
133+
Card.Content = Content;
134+
Card.Footer = Footer;

packages/ui/src/mosaic/components/dialog.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
import React from 'react';
2-
import type { ReactNode } from 'react';
3-
4-
import { useDialogContext } from '@clerk/headless/dialog';
51
import type { DialogProps as HeadlessDialogProps } from '@clerk/headless/dialog';
2+
import { useDialogContext } from '@clerk/headless/dialog';
3+
import type { ReactNode } from 'react';
4+
import React from 'react';
65

76
import { Dialog as Primitive } from '../primitives/dialog';
8-
import { defineSlotRecipe, useRecipe } from '../slot-recipe';
97
import type { RecipeVariantProps } from '../slot-recipe';
8+
import { defineSlotRecipe, useRecipe } from '../slot-recipe';
109

1110
/**
1211
* One multi-slot recipe owns every dialog part: slot identity (`data-cl-slot`),
@@ -130,7 +129,9 @@ interface DialogProps extends Pick<HeadlessDialogProps, 'open' | 'defaultOpen' |
130129

131130
function DialogContent({ children }: { children: DialogProps['children'] }) {
132131
const { setOpen } = useDialogContext();
133-
if (typeof children !== 'function') return <>{children}</>;
132+
if (typeof children !== 'function') {
133+
return <>{children}</>;
134+
}
134135
return <>{children({ close: () => setOpen(false) })}</>;
135136
}
136137

packages/ui/src/mosaic/components/heading.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { renderElement } from '@clerk/headless/utils';
55

66
import type { RecipeVariantProps } from '../slot-recipe';
77
import { defineSlotRecipe, useRecipe } from '../slot-recipe';
8+
import { useContextProps } from '../utils/context';
89

910
/**
1011
* Mosaic heading slot recipe.
@@ -46,13 +47,15 @@ declare module '../registry' {
4647
/** Props for the Heading component combining h2 attributes with recipe variants. */
4748
export type HeadingProps = ComponentProps<'h2'> & RecipeVariantProps<typeof headingRecipe>;
4849

50+
export const HeadingContext = React.createContext<Partial<HeadingProps> | null>(null);
51+
4952
/**
5053
* Themeable heading component.
5154
* Renders as an h2 element by default, forwards refs, and supports
5255
* size and intent variants plus Mosaic styling (sx prop and render callback).
5356
*/
54-
export const Heading = React.forwardRef<HTMLHeadingElement, HeadingProps>(function MosaicHeading(props, ref) {
55-
const { size, intent, sx, render, ...rest } = props;
57+
export const Heading = React.forwardRef<HTMLHeadingElement, HeadingProps>(function MosaicHeading(rawProps, ref) {
58+
const { size, intent, sx, render, ...rest } = useContextProps(rawProps, HeadingContext);
5659
const { root } = useRecipe(headingRecipe, { variants: { size, intent }, sx });
5760
if (render) {
5861
// renderElement handles the cast; Emotion processes `css` inside the callback's own JSX.

packages/ui/src/mosaic/components/text.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { renderElement } from '@clerk/headless/utils';
55

66
import type { RecipeVariantProps } from '../slot-recipe';
77
import { defineSlotRecipe, useRecipe } from '../slot-recipe';
8+
import { useContextProps } from '../utils/context';
89

910
/**
1011
* Mosaic text slot recipe.
@@ -44,13 +45,15 @@ declare module '../registry' {
4445
/** Props for the Text component combining p element attributes with recipe variants. */
4546
export type TextProps = ComponentProps<'p'> & RecipeVariantProps<typeof textRecipe>;
4647

48+
export const TextContext = React.createContext<Partial<TextProps> | null>(null);
49+
4750
/**
4851
* Themeable text component.
4952
* Renders as a p element by default, forwards refs, and supports
5053
* size and intent variants plus Mosaic styling (sx prop and render callback).
5154
*/
52-
export const Text = React.forwardRef<HTMLParagraphElement, TextProps>(function MosaicText(props, ref) {
53-
const { size, intent, sx, render, ...rest } = props;
55+
export const Text = React.forwardRef<HTMLParagraphElement, TextProps>(function MosaicText(rawProps, ref) {
56+
const { size, intent, sx, render, ...rest } = useContextProps(rawProps, TextContext);
5457
const { root } = useRecipe(textRecipe, { variants: { size, intent }, sx });
5558
if (render) {
5659
return renderElement({ defaultTagName: 'p', render, props: { ref, ...root, ...rest } as Record<string, unknown> });

0 commit comments

Comments
 (0)