From e5fb0a1a52a38c813a0f2c1fe6b7614b907cee4e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 22 Jul 2026 16:35:40 -0400 Subject: [PATCH] feat(ui): add Mosaic Badge component --- .changeset/mosaic-badge.md | 2 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 14 +++ packages/swingset/src/stories/badge.mdx | 43 +++++++++ .../swingset/src/stories/badge.stories.tsx | 95 +++++++++++++++++++ .../mosaic/components/badge/badge.styles.ts | 53 +++++++++++ .../mosaic/components/badge/badge.test.tsx | 55 +++++++++++ .../ui/src/mosaic/components/badge/badge.tsx | 24 +++++ .../ui/src/mosaic/components/badge/index.ts | 2 + packages/ui/src/mosaic/styles/index.ts | 2 + packages/ui/src/mosaic/tokens.stylex.ts | 4 + 11 files changed, 295 insertions(+) create mode 100644 .changeset/mosaic-badge.md create mode 100644 packages/swingset/src/stories/badge.mdx create mode 100644 packages/swingset/src/stories/badge.stories.tsx create mode 100644 packages/ui/src/mosaic/components/badge/badge.styles.ts create mode 100644 packages/ui/src/mosaic/components/badge/badge.test.tsx create mode 100644 packages/ui/src/mosaic/components/badge/badge.tsx create mode 100644 packages/ui/src/mosaic/components/badge/index.ts diff --git a/.changeset/mosaic-badge.md b/.changeset/mosaic-badge.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-badge.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 35bdb3e9da1..8ee60a0d29e 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -28,6 +28,7 @@ const docModules: Record> = { destructive: dynamic(() => import('../stories/destructive.mdx')), }, components: { + badge: dynamic(() => import('../stories/badge.mdx')), button: dynamic(() => import('../stories/button.mdx')), card: dynamic(() => import('../stories/card.component.mdx')), input: dynamic(() => import('../stories/input.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index dee445b26f0..15217dd123f 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -1,6 +1,12 @@ // Import stories explicitly to control order and avoid type casting through unknown. import { meta as accordionMeta } from '../stories/accordion.stories'; import { meta as autocompleteMeta } from '../stories/autocomplete.stories'; +import { + Intents as BadgeIntents, + meta as badgeMeta, + Primary as BadgePrimary, + WithIcon as BadgeWithIcon, +} from '../stories/badge.stories'; import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories'; import { Centered as CardCentered, @@ -115,6 +121,13 @@ const organizationProfileMembersPanelModule: StoryModule = { const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: CardDefault, Centered: CardCentered }; +const badgeModule: StoryModule = { + meta: badgeMeta, + Primary: BadgePrimary, + Intents: BadgeIntents, + WithIcon: BadgeWithIcon, +}; + const buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled }; const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes, Disabled: InputDisabled, Invalid }; @@ -171,6 +184,7 @@ export const registry: StoryModule[] = [ // Blocks destructiveModule, // Components + badgeModule, buttonModule, cardComponentModule, inputModule, diff --git a/packages/swingset/src/stories/badge.mdx b/packages/swingset/src/stories/badge.mdx new file mode 100644 index 00000000000..6c11cbe694f --- /dev/null +++ b/packages/swingset/src/stories/badge.mdx @@ -0,0 +1,43 @@ +import * as BadgeStories from './badge.stories'; + +# Badge + +Badge labels the status or category of the thing next to it. It is presentational only — it renders a `span`, has no interactive behavior, and its `intent` carries meaning through color, so keep the label itself self-describing for anyone who can't see it. + +## Playground + + + +## Props + + + +## Usage + + + Badge Label + + +--- + +## Examples + +### Intents + + + +### With an icon + + diff --git a/packages/swingset/src/stories/badge.stories.tsx b/packages/swingset/src/stories/badge.stories.tsx new file mode 100644 index 00000000000..5ecd991b601 --- /dev/null +++ b/packages/swingset/src/stories/badge.stories.tsx @@ -0,0 +1,95 @@ +import type { BadgeProps } from '@clerk/ui/mosaic/components/badge'; +import { Badge } from '@clerk/ui/mosaic/components/badge'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './badge.stories?raw'; + +// StyleX has no runtime recipe to derive knobs from, so the variant surface is described +// here to drive the playground + prop table. Keys mirror `BadgeProps`. +export const meta: StoryMeta = { + group: 'Components', + title: 'Badge', + source: 'packages/ui/src/mosaic/components/badge/badge.tsx', + styles: { + _variants: { + intent: { primary: {}, secondary: {}, warning: {}, destructive: {}, success: {} }, + }, + _defaultVariants: { + intent: 'primary', + }, + }, +}; + +// Story functions accept Record (knob values) and cast to BadgeProps. +// The cast is unavoidable: knobs are dynamically typed; Badge has a strict prop interface. +function knobsAsProps(props: Record) { + return props as unknown as BadgeProps; +} + +export function Primary(props: Record) { + return Badge Label; +} + +export function Intents(props: Record) { + return ( +
+ + Primary + + + Secondary + + + Warning + + + Destructive + + + Success + +
+ ); +} + +export function WithIcon(props: Record) { + return ( + + + + + Verified + + ); +} diff --git a/packages/ui/src/mosaic/components/badge/badge.styles.ts b/packages/ui/src/mosaic/components/badge/badge.styles.ts new file mode 100644 index 00000000000..34905829b55 --- /dev/null +++ b/packages/ui/src/mosaic/components/badge/badge.styles.ts @@ -0,0 +1,53 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; + +// Every intent is the same recipe applied to one token: the token itself is the +// text color, a 12% mix is the fill and a 24% mix the border. Mixing toward +// `transparent` rather than a surface token keeps the badge legible on any +// background it's dropped onto. +export const styles = stylex.create({ + base: { + borderRadius: radiusVars['--cl-radius-inner'], + borderStyle: 'solid', + borderWidth: '1px', + gap: space['1'], + paddingInline: space['1.5'], + alignItems: 'center', + boxSizing: 'border-box', + display: 'inline-flex', + fontFamily: 'inherit', + fontSize: typeScaleVars['--cl-text-label-sm-size'], + fontWeight: typeScaleVars['--cl-text-label-sm-weight'], + justifyContent: 'center', + lineHeight: typeScaleVars['--cl-text-label-sm-leading'], + whiteSpace: 'nowrap', + height: space['5'], + }, + + primary: { + borderColor: `color-mix(in oklab, ${colorVars['--cl-color-primary']} 24%, transparent)`, + backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-primary']} 12%, transparent)`, + color: colorVars['--cl-color-primary'], + }, + secondary: { + borderColor: `color-mix(in oklab, ${colorVars['--cl-color-muted-foreground']} 24%, transparent)`, + backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-muted-foreground']} 12%, transparent)`, + color: colorVars['--cl-color-muted-foreground'], + }, + warning: { + borderColor: `color-mix(in oklab, ${colorVars['--cl-color-warning']} 24%, transparent)`, + backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-warning']} 12%, transparent)`, + color: colorVars['--cl-color-warning'], + }, + destructive: { + borderColor: `color-mix(in oklab, ${colorVars['--cl-color-destructive']} 24%, transparent)`, + backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-destructive']} 12%, transparent)`, + color: colorVars['--cl-color-destructive'], + }, + success: { + borderColor: `color-mix(in oklab, ${colorVars['--cl-color-success']} 24%, transparent)`, + backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-success']} 12%, transparent)`, + color: colorVars['--cl-color-success'], + }, +}); diff --git a/packages/ui/src/mosaic/components/badge/badge.test.tsx b/packages/ui/src/mosaic/components/badge/badge.test.tsx new file mode 100644 index 00000000000..5930e6bd895 --- /dev/null +++ b/packages/ui/src/mosaic/components/badge/badge.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { Badge } from './badge'; + +describe('Mosaic Badge', () => { + it('renders its children', () => { + render(Active); + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + + it('applies the default intent when none is passed', () => { + render(Active); + const badge = screen.getByText('Active'); + expect(badge).toHaveClass('cl-badge'); + expect(badge).toHaveAttribute('data-intent', 'primary'); + }); + + it.each(['primary', 'secondary', 'warning', 'destructive', 'success'] as const)('reflects the %s intent', intent => { + render(Active); + expect(screen.getByText('Active')).toHaveAttribute('data-intent', intent); + }); + + it('lets the consumer className and style win', () => { + render( + + Active + , + ); + const badge = screen.getByText('Active'); + expect(badge).toHaveClass('cl-badge', 'my-badge'); + expect(badge).toHaveStyle({ marginTop: '8px' }); + }); + + it('forwards arbitrary span props and the ref', () => { + const ref = React.createRef(); + render( + + Active + , + ); + const badge = screen.getByText('Active'); + expect(ref.current).toBe(badge); + expect(badge).toHaveAttribute('id', 'status'); + expect(badge).toHaveAttribute('aria-label', 'Status'); + }); +}); diff --git a/packages/ui/src/mosaic/components/badge/badge.tsx b/packages/ui/src/mosaic/components/badge/badge.tsx new file mode 100644 index 00000000000..d8334e2d8e0 --- /dev/null +++ b/packages/ui/src/mosaic/components/badge/badge.tsx @@ -0,0 +1,24 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import { mergeProps, themeProps } from '../../props'; +import { styles } from './badge.styles'; + +export interface BadgeProps extends React.ComponentPropsWithRef<'span'> { + intent?: 'primary' | 'secondary' | 'warning' | 'destructive' | 'success'; +} + +export const Badge = React.forwardRef(function MosaicBadge( + { intent = 'primary', className, style, children, ...rest }, + ref, +) { + return ( + + {children} + + ); +}); diff --git a/packages/ui/src/mosaic/components/badge/index.ts b/packages/ui/src/mosaic/components/badge/index.ts new file mode 100644 index 00000000000..b8e71bd1b09 --- /dev/null +++ b/packages/ui/src/mosaic/components/badge/index.ts @@ -0,0 +1,2 @@ +export { Badge } from './badge'; +export type { BadgeProps } from './badge'; diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 787900e54bf..dc751508751 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -4,6 +4,8 @@ // static `styles.css`. Keep it isolated from Emotion/un-migrated code — grow it // as components migrate. +export { Badge } from '../components/badge'; +export type { BadgeProps } from '../components/badge'; export { Button } from '../components/button'; export type { ButtonProps } from '../components/button'; diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 859d98ae9ac..b3d0c5c98de 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -28,6 +28,10 @@ const colorDefaults = { '--cl-color-primary-foreground': 'light-dark(oklch(0.985 0 0), oklch(0.205 0 0))', '--cl-color-destructive': 'light-dark(oklch(0.577 0.245 27.325), oklch(0.637 0.237 25.331))', '--cl-color-destructive-foreground': 'oklch(0.985 0 0)', + '--cl-color-success': 'light-dark(oklch(0.548 0.153 152.535), oklch(0.696 0.17 162.48))', + '--cl-color-success-foreground': 'oklch(0.985 0 0)', + '--cl-color-warning': 'light-dark(oklch(0.646 0.222 41.116), oklch(0.75 0.183 55.934))', + '--cl-color-warning-foreground': 'oklch(0.985 0 0)', '--cl-color-muted': 'light-dark(oklch(0.97 0 0), oklch(0.269 0 0))', '--cl-color-muted-foreground': 'light-dark(oklch(0.556 0 0), oklch(0.708 0 0))', '--cl-color-card': 'light-dark(oklch(1 0 0), oklch(0.205 0 0))',