Skip to content

Commit d8407c9

Browse files
feat(ui): add Mosaic Badge component (#9221)
1 parent e2dd4e2 commit d8407c9

17 files changed

Lines changed: 432 additions & 49 deletions

File tree

.changeset/mosaic-badge.md

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

packages/swingset/next.config.mjs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,23 @@ const nextConfig = {
6161

6262
// Swingset consumes Mosaic from source, so StyleX (`defineVars`/`create`/`props`) must be
6363
// compiled here — otherwise the calls hit the runtime and throw. The unplugin transforms the
64-
// StyleX *JS only* (calls → static atom references), keeping SWC intact so `next/font` and the
65-
// Emotion transform keep working. The CSS is emitted separately by `@stylexjs/postcss-plugin`
66-
// (`@stylex` in `globals.css`), so this runs in extraction mode (no `runtimeInjection`); both
67-
// share the same StyleX babel version/options so the atom hashes match, and the plugin's dev
68-
// "no CSS asset" warning is expected and harmless. `useCSSLayers: true` matches the published
69-
// build so atoms carry StyleX's `@layer priorityN` precedence.
64+
// StyleX *JS only*, keeping SWC intact so `next/font` and the Emotion transform keep working.
65+
//
66+
// The `@stylexjs/postcss-plugin` (see `postcss.config.mjs`) is what extracts the CSS — the
67+
// token `:root { --cl-* }` defaults and the atoms — in both dev and prod. This unplugin only
68+
// transforms the StyleX *calls* in the JS. `runtimeInjection` forks by env:
69+
// - Prod: `false`. Atoms are static class refs resolved against the extracted sheet.
70+
// - Dev: `true`. On top of the extracted sheet, StyleX also injects each atom at runtime under
71+
// its content hash, so editing a `.styles.ts` file hot-reloads a fresh atom (the extracted
72+
// sheet goes stale because Next won't re-run the `globals.css` PostCSS pass on Mosaic-source
73+
// edits). The `:root` token defaults come from the extraction and never change mid-session,
74+
// so they stay correct — `runtimeInjection` can't emit them (`defineVars` is compile-only).
75+
// Both passes share the same babel version/options so atom hashes match.
76+
const isDev = process.env.NODE_ENV !== 'production';
7077
config.plugins.push(
7178
stylexPlugin({
72-
dev: process.env.NODE_ENV !== 'production',
79+
dev: isDev,
80+
runtimeInjection: isDev,
7381
unstable_moduleResolution: { type: 'commonJS', rootDir: resolve(__dirname, '../ui') },
7482
useCSSLayers: true,
7583
}),

packages/swingset/postcss.config.mjs

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,47 @@ import { fileURLToPath } from 'url';
55
const require = createRequire(import.meta.url);
66
const __dirname = dirname(fileURLToPath(import.meta.url));
77

8-
// StyleX CSS extraction. The `@stylexjs/postcss-plugin` scans the Mosaic source, runs the
9-
// StyleX babel transform itself, and replaces the `@stylex;` directive in `globals.css` with
10-
// the generated CSS (token `:root` defaults + atoms). This is the CSS half of the setup; the
11-
// JS half is the unplugin in `next.config.mjs`. Both must use the SAME StyleX babel version
12-
// and options (`dev`, `rootDir`) so the atom class hashes line up.
138
const uiRoot = resolve(__dirname, '../ui');
9+
const isDev = process.env.NODE_ENV !== 'production';
1410

15-
export default {
16-
plugins: {
17-
'@stylexjs/postcss-plugin': {
18-
useCSSLayers: true,
19-
babelConfig: {
20-
babelrc: false,
21-
configFile: false,
22-
presets: [require('@babel/preset-typescript')],
23-
plugins: [
24-
require('@babel/plugin-syntax-jsx'),
25-
[
26-
require('@stylexjs/babel-plugin'),
27-
{
28-
dev: process.env.NODE_ENV !== 'production',
29-
runtimeInjection: false,
30-
unstable_moduleResolution: { type: 'commonJS', rootDir: uiRoot },
31-
},
32-
],
11+
// StyleX CSS. `@stylexjs/postcss-plugin` scans the Mosaic source, runs the StyleX babel
12+
// transform, and replaces the `@stylex;` directive in `globals.css` with the generated CSS:
13+
// the token `:root { --cl-* }` defaults *and* the atoms. This runs in BOTH dev and prod
14+
// because it is the only thing that emits the `:root` token defaults — StyleX's `defineVars`
15+
// is compile-time-only (its runtime export throws), so `runtimeInjection` alone leaves every
16+
// `var(--cl-*)` unresolved (unstyled). Its babel `dev`/`rootDir` must match the unplugin in
17+
// `next.config.mjs` so atom hashes line up.
18+
//
19+
// In dev this sheet goes stale on `.styles.ts` edits (Next won't re-run the `globals.css`
20+
// PostCSS pass for files outside the CSS import graph), but that's fine: the unplugin's
21+
// `runtimeInjection` (see `next.config.mjs`) injects the *fresh* atom at runtime under a new
22+
// content hash, which HMR tracks. The stale extracted atom is dead CSS; the `:root` token
23+
// defaults never change mid-session, so they stay correct.
24+
const stylexExtraction = {
25+
'@stylexjs/postcss-plugin': {
26+
useCSSLayers: true,
27+
babelConfig: {
28+
babelrc: false,
29+
configFile: false,
30+
presets: [require('@babel/preset-typescript')],
31+
plugins: [
32+
require('@babel/plugin-syntax-jsx'),
33+
[
34+
require('@stylexjs/babel-plugin'),
35+
{
36+
dev: isDev,
37+
runtimeInjection: false,
38+
unstable_moduleResolution: { type: 'commonJS', rootDir: uiRoot },
39+
},
3340
],
34-
},
41+
],
3542
},
43+
},
44+
};
45+
46+
export default {
47+
plugins: {
48+
...stylexExtraction,
3649
'@tailwindcss/postcss': {},
3750
},
3851
};

packages/swingset/src/components/DocsViewer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
2828
destructive: dynamic(() => import('../stories/destructive.mdx')),
2929
},
3030
components: {
31+
badge: dynamic(() => import('../stories/badge.mdx')),
3132
button: dynamic(() => import('../stories/button.mdx')),
3233
card: dynamic(() => import('../stories/card.component.mdx')),
3334
input: dynamic(() => import('../stories/input.mdx')),

packages/swingset/src/components/PropTable.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ interface ExtraProp {
1515
interface PropTableProps {
1616
meta: StoryMeta;
1717
extra?: ExtraProp[];
18+
/** Append the `sx` row. StyleX components (e.g. Badge) don't take `sx`, so pass `false`. */
19+
sx?: boolean;
1820
}
1921

2022
const SX_ROW: ExtraProp = { name: 'sx', type: 'StyleRule | (theme) => StyleRule' };
2123

22-
export function PropTable({ meta, extra = [] }: PropTableProps) {
24+
export function PropTable({ meta, extra = [], sx = true }: PropTableProps) {
2325
const playground = usePlayground();
2426
const variants = meta.styles?._variants ?? {};
2527
const defaults = meta.styles?._defaultVariants ?? {};
@@ -35,7 +37,7 @@ export function PropTable({ meta, extra = [] }: PropTableProps) {
3537
return { name, type, default: defDisplay };
3638
}),
3739
...extra,
38-
SX_ROW,
40+
...(sx ? [SX_ROW] : []),
3941
];
4042

4143
return (

packages/swingset/src/lib/registry.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
// Import stories explicitly to control order and avoid type casting through unknown.
22
import { meta as accordionMeta } from '../stories/accordion.stories';
33
import { meta as autocompleteMeta } from '../stories/autocomplete.stories';
4+
import {
5+
Colors as BadgeColors,
6+
meta as badgeMeta,
7+
Primary as BadgePrimary,
8+
WithIcon as BadgeWithIcon,
9+
} from '../stories/badge.stories';
410
import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories';
511
import {
612
Centered as CardCentered,
@@ -115,6 +121,13 @@ const organizationProfileMembersPanelModule: StoryModule = {
115121

116122
const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: CardDefault, Centered: CardCentered };
117123

124+
const badgeModule: StoryModule = {
125+
meta: badgeMeta,
126+
Primary: BadgePrimary,
127+
Colors: BadgeColors,
128+
WithIcon: BadgeWithIcon,
129+
};
130+
118131
const buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled };
119132

120133
const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes, Disabled: InputDisabled, Invalid };
@@ -171,6 +184,7 @@ export const registry: StoryModule[] = [
171184
// Blocks
172185
destructiveModule,
173186
// Components
187+
badgeModule,
174188
buttonModule,
175189
cardComponentModule,
176190
inputModule,
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import * as BadgeStories from './badge.stories';
2+
3+
# Badge
4+
5+
Badge labels the status or category of the thing next to it. It renders a `span` by default and forwards a ref to the underlying element; use the `render` prop when the badge belongs in a different element, such as a link. Its `color` carries meaning, so keep the label itself self-describing for anyone who can't see it.
6+
7+
## Playground
8+
9+
<Preview
10+
name='Primary'
11+
storyModule={BadgeStories}
12+
/>
13+
14+
## Props
15+
16+
<PropTable
17+
meta={BadgeStories.meta}
18+
extra={[{ name: 'render', type: '(props) => ReactNode' }]}
19+
sx={false}
20+
/>
21+
22+
## Usage
23+
24+
<Usage
25+
component='Badge'
26+
module='@clerk/ui/mosaic/components/badge'
27+
>
28+
Badge Label
29+
</Usage>
30+
31+
---
32+
33+
## Examples
34+
35+
### Colors
36+
37+
<Story
38+
name='Colors'
39+
storyModule={BadgeStories}
40+
/>
41+
42+
### With an icon
43+
44+
<Story
45+
name='WithIcon'
46+
storyModule={BadgeStories}
47+
/>
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import type { BadgeProps } from '@clerk/ui/mosaic/components/badge';
2+
import { Badge } from '@clerk/ui/mosaic/components/badge';
3+
4+
import type { StoryMeta } from '@/lib/types';
5+
6+
// Exposes this file's own source (via the `?raw` webpack rule) so each `<Story>` example
7+
// renders a code footer with its function's source. See `StoryModule.__source`.
8+
export { default as __source } from './badge.stories?raw';
9+
10+
// StyleX has no runtime recipe to derive knobs from, so the variant surface is described
11+
// here to drive the playground + prop table. Keys mirror `BadgeProps`.
12+
export const meta: StoryMeta = {
13+
group: 'Components',
14+
title: 'Badge',
15+
source: 'packages/ui/src/mosaic/components/badge/badge.tsx',
16+
styles: {
17+
_variants: {
18+
color: { primary: {}, neutral: {}, warning: {}, negative: {}, positive: {} },
19+
},
20+
_defaultVariants: {
21+
color: 'primary',
22+
},
23+
},
24+
};
25+
26+
// Story functions accept Record<string,unknown> (knob values) and cast to BadgeProps.
27+
// The cast is unavoidable: knobs are dynamically typed; Badge has a strict prop interface.
28+
function knobsAsProps(props: Record<string, unknown>) {
29+
return props as unknown as BadgeProps;
30+
}
31+
32+
export function Primary(props: Record<string, unknown>) {
33+
return <Badge {...knobsAsProps(props)}>Badge Label</Badge>;
34+
}
35+
36+
export function Colors(props: Record<string, unknown>) {
37+
return (
38+
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
39+
<Badge
40+
{...knobsAsProps(props)}
41+
color='primary'
42+
>
43+
Primary
44+
</Badge>
45+
<Badge
46+
{...knobsAsProps(props)}
47+
color='neutral'
48+
>
49+
Neutral
50+
</Badge>
51+
<Badge
52+
{...knobsAsProps(props)}
53+
color='warning'
54+
>
55+
Warning
56+
</Badge>
57+
<Badge
58+
{...knobsAsProps(props)}
59+
color='negative'
60+
>
61+
Negative
62+
</Badge>
63+
<Badge
64+
{...knobsAsProps(props)}
65+
color='positive'
66+
>
67+
Positive
68+
</Badge>
69+
</div>
70+
);
71+
}
72+
73+
export function WithIcon(props: Record<string, unknown>) {
74+
return (
75+
<Badge
76+
{...knobsAsProps(props)}
77+
color='positive'
78+
>
79+
<svg
80+
width='10'
81+
height='10'
82+
viewBox='0 0 24 24'
83+
fill='none'
84+
stroke='currentColor'
85+
strokeWidth='3'
86+
strokeLinecap='round'
87+
strokeLinejoin='round'
88+
style={{ flexShrink: 0 }}
89+
>
90+
<path d='M20 6 9 17l-5-5' />
91+
</svg>
92+
Verified
93+
</Badge>
94+
);
95+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as stylex from '@stylexjs/stylex';
2+
3+
import { colorVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex';
4+
5+
// warning/negative/positive tint a faded fill and use the saturated token as text;
6+
// primary/neutral fill with the solid token and use its `-foreground` for text.
7+
export const styles = stylex.create({
8+
base: {
9+
borderRadius: radiusVars['--cl-radius-full'],
10+
gap: space['1'],
11+
paddingInline: space['2'],
12+
alignItems: 'center',
13+
boxSizing: 'border-box',
14+
display: 'inline-flex',
15+
fontFamily: 'inherit',
16+
fontSize: typeScaleVars['--cl-text-label-sm-size'],
17+
fontWeight: typeScaleVars['--cl-text-label-sm-weight'],
18+
justifyContent: 'center',
19+
lineHeight: typeScaleVars['--cl-text-label-sm-leading'],
20+
whiteSpace: 'nowrap',
21+
height: space['5'],
22+
},
23+
});
24+
25+
export const colors = stylex.create({
26+
primary: {
27+
backgroundColor: colorVars['--cl-color-primary'],
28+
color: colorVars['--cl-color-primary-foreground'],
29+
},
30+
neutral: {
31+
backgroundColor: colorVars['--cl-color-neutral'],
32+
color: colorVars['--cl-color-neutral-foreground'],
33+
},
34+
warning: {
35+
backgroundColor: colorVars['--cl-color-warning-faded'],
36+
color: colorVars['--cl-color-warning'],
37+
},
38+
negative: {
39+
backgroundColor: colorVars['--cl-color-negative-faded'],
40+
color: colorVars['--cl-color-negative'],
41+
},
42+
positive: {
43+
backgroundColor: colorVars['--cl-color-positive-faded'],
44+
color: colorVars['--cl-color-positive'],
45+
},
46+
});

0 commit comments

Comments
 (0)