Skip to content

Commit d968300

Browse files
fix(ui): Add IconFrame (#9480)
Co-authored-by: Alex Carpenter <alex.carpenter@clerk.dev>
1 parent de51154 commit d968300

15 files changed

Lines changed: 474 additions & 59 deletions

.changeset/tidy-icons-frame.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
@@ -58,6 +58,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
5858
'alert-dialog': dynamic(() => import('../stories/alert-dialog.component.mdx')),
5959
heading: dynamic(() => import('../stories/heading.mdx')),
6060
icon: dynamic(() => import('../stories/icon.mdx')),
61+
'icon-frame': dynamic(() => import('../stories/icon-frame.mdx')),
6162
menu: dynamic(() => import('../stories/menu.component.mdx')),
6263
otp: dynamic(() => import('../stories/otp.component.mdx')),
6364
popover: dynamic(() => import('../stories/popover.component.mdx')),

packages/swingset/src/lib/registry.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ import {
5454
Override as IconOverride,
5555
Sizes as IconSizes,
5656
} from '../stories/icon.stories';
57+
import {
58+
BrandIcons as IconFrameBrandIcons,
59+
CustomSurface as IconFrameCustomSurface,
60+
Default as IconFrameDefault,
61+
meta as iconFrameMeta,
62+
Sizes as IconFrameSizes,
63+
Treatments as IconFrameTreatments,
64+
} from '../stories/icon-frame.stories';
5765
import {
5866
Default,
5967
Disabled as InputDisabled,
@@ -123,6 +131,7 @@ import {
123131
ConnectedAccounts as SectionConnectedAccounts,
124132
Default as SectionDefault,
125133
Destructive as SectionDestructive,
134+
IconFrameMedia as SectionIconFrameMedia,
126135
meta as sectionMeta,
127136
MultipleEmailAndPhoneNumbers as SectionMultipleEmailAndPhoneNumbers,
128137
} from '../stories/section.stories';
@@ -218,6 +227,7 @@ const sectionModule: StoryModule = {
218227
Default: SectionDefault,
219228
MultipleEmailAndPhoneNumbers: SectionMultipleEmailAndPhoneNumbers,
220229
ConnectedAccounts: SectionConnectedAccounts,
230+
IconFrameMedia: SectionIconFrameMedia,
221231
Destructive: SectionDestructive,
222232
};
223233
const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault };
@@ -312,6 +322,15 @@ const iconModule: StoryModule = {
312322
Override: IconOverride,
313323
};
314324

325+
const iconFrameModule: StoryModule = {
326+
meta: iconFrameMeta,
327+
Default: IconFrameDefault,
328+
Sizes: IconFrameSizes,
329+
Treatments: IconFrameTreatments,
330+
CustomSurface: IconFrameCustomSurface,
331+
BrandIcons: IconFrameBrandIcons,
332+
};
333+
315334
// Headless primitives carry just `meta` (no story functions). Like every component
316335
// they're documented as a single overview page; their live demos come from `<Story>` /
317336
// `<Preview>` embeds in the MDX, which import the stories module directly.
@@ -487,6 +506,7 @@ export const registry: StoryModule[] = [
487506
alertDialogComponentModule,
488507
headingModule,
489508
iconModule,
509+
iconFrameModule,
490510
menuComponentModule,
491511
otpComponentModule,
492512
popoverComponentModule,
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import * as IconFrameStories from './icon-frame.stories';
2+
3+
# IconFrame
4+
5+
IconFrame centers an icon or other visual in a square Mosaic surface. `bordered` and `filled` are independent treatments, while `size` controls the frame dimensions. The default is a bordered 40px frame; the filled treatment uses `oklch(0.9702 0 0)` in light mode and `oklch(0.2393 0 0)` in dark mode.
6+
7+
## Playground
8+
9+
<Preview
10+
name='Default'
11+
storyModule={IconFrameStories}
12+
/>
13+
14+
## Props
15+
16+
<PropTable
17+
meta={IconFrameStories.meta}
18+
extra={[{ name: 'render', type: '(props) => ReactNode' }]}
19+
/>
20+
21+
## Usage
22+
23+
```tsx
24+
import { Icon, IconFrame } from '@clerk/ui/mosaic/components/icon';
25+
26+
<IconFrame
27+
bordered={false}
28+
filled
29+
size='lg'
30+
>
31+
<Icon name='check' />
32+
</IconFrame>
33+
```
34+
35+
---
36+
37+
## Examples
38+
39+
### Sizes
40+
41+
<Story
42+
name='Sizes'
43+
storyModule={IconFrameStories}
44+
composition={[{ name: 'Icon', href: '/components/icon', layer: 'Components' }]}
45+
/>
46+
47+
`sm`, `md`, `lg`, and `xl` map to 28px, 32px, 36px, and 40px respectively.
48+
49+
### Treatments
50+
51+
<Story
52+
name='Treatments'
53+
storyModule={IconFrameStories}
54+
/>
55+
56+
`bordered` and `filled` can be used separately, together, or both disabled for a centered bare icon.
57+
58+
### Custom surface
59+
60+
<Story
61+
name='CustomSurface'
62+
storyModule={IconFrameStories}
63+
/>
64+
65+
### Brand icons
66+
67+
<Story
68+
name='BrandIcons'
69+
storyModule={IconFrameStories}
70+
/>
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import type { IconFrameProps } from '@clerk/ui/mosaic/components/icon';
2+
import { Icon, IconFrame } from '@clerk/ui/mosaic/components/icon';
3+
import { colorVars, space } from '@clerk/ui/mosaic/styles';
4+
5+
import type { StoryMeta } from '@/lib/types';
6+
7+
export { default as __source } from './icon-frame.stories?raw';
8+
9+
const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`;
10+
11+
function ProviderLogo({ provider }: { provider: string }) {
12+
return (
13+
<img
14+
alt=''
15+
src={providerIconUrl(provider)}
16+
style={{ display: 'block', height: space['5'], objectFit: 'contain', width: space['5'] }}
17+
/>
18+
);
19+
}
20+
21+
export const meta: StoryMeta = {
22+
group: 'Components',
23+
title: 'IconFrame',
24+
source: 'packages/ui/src/mosaic/components/icon/icon-frame.tsx',
25+
styles: {
26+
_variants: {
27+
bordered: { true: {}, false: {} },
28+
filled: { true: {}, false: {} },
29+
size: { sm: {}, md: {}, lg: {}, xl: {} },
30+
},
31+
_defaultVariants: {
32+
bordered: true,
33+
filled: false,
34+
size: 'xl',
35+
},
36+
},
37+
};
38+
39+
function knobsAsProps(props: Record<string, unknown>) {
40+
return props as unknown as IconFrameProps;
41+
}
42+
43+
export function Default(props: Record<string, unknown>) {
44+
return (
45+
<IconFrame {...knobsAsProps(props)}>
46+
<Icon name='check' />
47+
</IconFrame>
48+
);
49+
}
50+
51+
export function Sizes() {
52+
return (
53+
<div style={{ alignItems: 'center', display: 'flex', gap: 12 }}>
54+
<IconFrame size='sm'>
55+
<Icon
56+
name='check'
57+
size='sm'
58+
/>
59+
</IconFrame>
60+
<IconFrame size='md'>
61+
<Icon
62+
name='check'
63+
size='md'
64+
/>
65+
</IconFrame>
66+
<IconFrame size='lg'>
67+
<Icon
68+
name='check'
69+
size='md'
70+
/>
71+
</IconFrame>
72+
<IconFrame size='xl'>
73+
<Icon
74+
name='check'
75+
size='lg'
76+
/>
77+
</IconFrame>
78+
</div>
79+
);
80+
}
81+
82+
export function Treatments() {
83+
return (
84+
<div style={{ alignItems: 'center', display: 'flex', flexWrap: 'wrap', gap: 12 }}>
85+
<IconFrame
86+
aria-label='Unframed'
87+
bordered={false}
88+
>
89+
<Icon name='check' />
90+
</IconFrame>
91+
<IconFrame aria-label='Bordered'>
92+
<Icon name='check' />
93+
</IconFrame>
94+
<IconFrame
95+
aria-label='Filled'
96+
bordered={false}
97+
filled
98+
>
99+
<Icon name='check' />
100+
</IconFrame>
101+
<IconFrame
102+
aria-label='Bordered and filled'
103+
filled
104+
>
105+
<Icon name='check' />
106+
</IconFrame>
107+
</div>
108+
);
109+
}
110+
111+
export function CustomSurface() {
112+
return (
113+
<IconFrame
114+
style={{
115+
backgroundColor: colorVars['--cl-color-primary'],
116+
color: colorVars['--cl-color-primary-foreground'],
117+
}}
118+
>
119+
<Icon
120+
name='check'
121+
size='lg'
122+
/>
123+
</IconFrame>
124+
);
125+
}
126+
127+
export function BrandIcons() {
128+
return (
129+
<div style={{ alignItems: 'center', display: 'flex', gap: 12 }}>
130+
<IconFrame aria-label='Google'>
131+
<ProviderLogo provider='google' />
132+
</IconFrame>
133+
<IconFrame aria-label='MetaMask'>
134+
<ProviderLogo provider='metamask' />
135+
</IconFrame>
136+
</div>
137+
);
138+
}

packages/swingset/src/stories/section.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,17 @@ Use `Section.Items` for a nested value list beneath a row's header item. The row
6464
]}
6565
/>
6666

67+
### Framed icon media
68+
69+
<Story
70+
name='IconFrameMedia'
71+
storyModule={SectionStories}
72+
composition={[
73+
{ name: 'IconFrame', href: '/components/icon-frame', layer: 'Components' },
74+
{ name: 'Icon', href: '/components/icon', layer: 'Components' },
75+
]}
76+
/>
77+
6778
### Destructive section
6879

6980
<Story

packages/swingset/src/stories/section.stories.tsx

Lines changed: 39 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,26 @@
11
import { Avatar } from '@clerk/ui/mosaic/components/avatar';
22
import { Badge } from '@clerk/ui/mosaic/components/badge';
33
import { Button } from '@clerk/ui/mosaic/components/button';
4-
import { Icon } from '@clerk/ui/mosaic/components/icon';
4+
import { Icon, IconFrame } from '@clerk/ui/mosaic/components/icon';
55
import { Section } from '@clerk/ui/mosaic/components/section';
6-
import * as stylex from '@stylexjs/stylex';
6+
import { space } from '@clerk/ui/mosaic/styles';
77

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

1010
export { default as __source } from './section.stories?raw';
1111

1212
const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`;
1313

14-
const styles = stylex.create({
15-
providerMedia: {
16-
backgroundColor: 'var(--cl-color-background)',
17-
borderColor: 'light-dark(var(--cl-color-border-faded), var(--cl-color-background))',
18-
borderRadius: 'var(--cl-radius-lg)',
19-
borderStyle: 'solid',
20-
borderWidth: '1px',
21-
},
22-
providerIcon: {
23-
display: 'block',
24-
height: '20px',
25-
width: '20px',
26-
},
27-
});
28-
29-
function ProviderIcon({ provider }: { provider: string }) {
14+
function ProviderMedia({ provider }: { provider: string }) {
3015
return (
31-
<Section.Media
32-
size='lg'
33-
{...stylex.props(styles.providerMedia)}
34-
>
35-
<img
36-
alt=''
37-
src={providerIconUrl(provider)}
38-
{...stylex.props(styles.providerIcon)}
39-
/>
16+
<Section.Media size='lg'>
17+
<IconFrame>
18+
<img
19+
alt=''
20+
src={providerIconUrl(provider)}
21+
style={{ display: 'block', height: space['5'], width: space['5'] }}
22+
/>
23+
</IconFrame>
4024
</Section.Media>
4125
);
4226
}
@@ -308,7 +292,7 @@ export function ConnectedAccounts() {
308292
<Section.Group>
309293
<Section.Row>
310294
<Section.Item>
311-
<ProviderIcon provider='google' />
295+
<ProviderMedia provider='google' />
312296
<Section.Content>
313297
<Section.Label>Google</Section.Label>
314298
<Section.Description>test@google.com</Section.Description>
@@ -328,7 +312,7 @@ export function ConnectedAccounts() {
328312
</Section.Row>
329313
<Section.Row>
330314
<Section.Item>
331-
<ProviderIcon provider='apple' />
315+
<ProviderMedia provider='apple' />
332316
<Section.Content>
333317
<Section.Label>Apple</Section.Label>
334318
</Section.Content>
@@ -353,6 +337,32 @@ export function ConnectedAccounts() {
353337
);
354338
}
355339

340+
export function IconFrameMedia() {
341+
return (
342+
<Section.Root style={{ maxWidth: 560 }}>
343+
<Section.Title>Team</Section.Title>
344+
<Section.Group>
345+
<Section.Row>
346+
<Section.Item>
347+
<Section.Media size='lg'>
348+
<IconFrame>
349+
<Icon
350+
name='users'
351+
size='lg'
352+
/>
353+
</IconFrame>
354+
</Section.Media>
355+
<Section.Content>
356+
<Section.Label>Engineering</Section.Label>
357+
<Section.Description>12 members</Section.Description>
358+
</Section.Content>
359+
</Section.Item>
360+
</Section.Row>
361+
</Section.Group>
362+
</Section.Root>
363+
);
364+
}
365+
356366
export function Destructive() {
357367
return (
358368
<Section.Root>

0 commit comments

Comments
 (0)