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..09eea8dc2b0 --- /dev/null +++ b/packages/ui/src/mosaic/components/badge/badge.styles.ts @@ -0,0 +1,46 @@ +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-full'], + gap: space['1'], + paddingInline: space['2'], + 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: { + backgroundColor: colorVars['--cl-color-primary'], + color: colorVars['--cl-color-primary-foreground'], + }, + secondary: { + backgroundColor: colorVars['--cl-color-muted'], + color: colorVars['--cl-color-muted-foreground'], + }, + warning: { + backgroundColor: colorVars['--cl-color-warning-faded'], + color: colorVars['--cl-color-warning'], + }, + destructive: { + backgroundColor: colorVars['--cl-color-destructive-faded'], + color: colorVars['--cl-color-destructive'], + }, + success: { + backgroundColor: colorVars['--cl-color-success-faded'], + 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..6f540b01fd8 --- /dev/null +++ b/packages/ui/src/mosaic/components/badge/badge.test.tsx @@ -0,0 +1,72 @@ +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'); + }); + + it('renders a custom element via render, keeping the styling contract', () => { + render( + } + > + Active + , + ); + const badge = screen.getByRole('link', { name: 'Active' }); + expect(badge.tagName).toBe('A'); + expect(badge).toHaveAttribute('href', '/status'); + expect(badge).toHaveClass('cl-badge'); + expect(badge).toHaveAttribute('data-intent', 'success'); + }); +}); 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..23be5cdff6d --- /dev/null +++ b/packages/ui/src/mosaic/components/badge/badge.tsx @@ -0,0 +1,26 @@ +import { type ComponentProps, type RenderProp, useRender } from '@clerk/headless/utils'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import { mergeStyleProps, themeProps } from '../../props'; +import { styles } from './badge.styles'; + +export type BadgeProps = Omit, 'render'> & { + intent?: 'primary' | 'secondary' | 'warning' | 'destructive' | 'success'; + render?: RenderProp> | React.ReactElement; +}; + +export const Badge = React.forwardRef(function MosaicBadge( + { intent = 'primary', render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'span', + render, + ref, + props: { + ...mergeStyleProps(themeProps('badge', { intent }), stylex.props(styles.base, styles[intent]), className, style), + ...rest, + }, + }); +}); 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 f1716fae927..22839ba4829 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..e0328756f35 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -26,10 +26,23 @@ import * as stylex from '@stylexjs/stylex'; const colorDefaults = { '--cl-color-primary': 'light-dark(oklch(0.205 0 0), oklch(0.922 0 0))', '--cl-color-primary-foreground': 'light-dark(oklch(0.985 0 0), oklch(0.205 0 0))', + '--cl-color-primary-faded': 'light-dark(oklch(0.9583 0.0214 291.74), oklch(0.3097 0.1008 285.05))', + '--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-destructive-faded': 'light-dark(oklch(0.9757 0.0118 17.36), oklch(0.255 0.0604 22.31))', + + '--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-success-faded': 'light-dark(oklch(0.9859 0.0164 156.92), oklch(0.3297 0.052 152.31))', + + '--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-warning-faded': 'light-dark(oklch(0.9799 0.0147 70.89), oklch(0.2725 0.0547 55.7))', + '--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))', '--cl-color-card-foreground': 'light-dark(oklch(0.145 0 0), oklch(0.985 0 0))', '--cl-color-border': 'light-dark(oklch(0.922 0 0), oklch(1 0 0 / 10%))', diff --git a/packages/ui/tsdown.mosaic.config.mts b/packages/ui/tsdown.mosaic.config.mts index 6241344067b..3bc6a1bca22 100644 --- a/packages/ui/tsdown.mosaic.config.mts +++ b/packages/ui/tsdown.mosaic.config.mts @@ -37,7 +37,7 @@ export default defineConfig({ minify: false, // Use the standard React JSX runtime, not Emotion's — the Mosaic build must be Emotion-free. tsconfig: './tsconfig.mosaic.json', - external: ['react', 'react-dom', '@stylexjs/stylex'], + external: ['react', 'react-dom', '@stylexjs/stylex', '@clerk/headless', '@clerk/headless/utils'], plugins: [ stylexPlugin({ fileName: 'styles.css',