From 949c8006789b04e6711bdec56a3b2fa23c3c469f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 26 Aug 2026 13:38:07 -0400 Subject: [PATCH 1/6] feat(ui): add the Mosaic Banner component --- .changeset/mosaic-banner-component.md | 7 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 16 +++ packages/swingset/src/stories/banner.mdx | 86 ++++++++++++ .../swingset/src/stories/banner.stories.tsx | 82 ++++++++++++ .../mosaic/components/banner/banner.styles.ts | 77 +++++++++++ .../mosaic/components/banner/banner.test.tsx | 118 ++++++++++++++++ .../src/mosaic/components/banner/banner.tsx | 126 ++++++++++++++++++ .../ui/src/mosaic/components/banner/index.ts | 2 + packages/ui/src/mosaic/icons/registry.tsx | 16 +++ packages/ui/src/mosaic/styles/index.ts | 2 + 11 files changed, 533 insertions(+) create mode 100644 .changeset/mosaic-banner-component.md create mode 100644 packages/swingset/src/stories/banner.mdx create mode 100644 packages/swingset/src/stories/banner.stories.tsx create mode 100644 packages/ui/src/mosaic/components/banner/banner.styles.ts create mode 100644 packages/ui/src/mosaic/components/banner/banner.test.tsx create mode 100644 packages/ui/src/mosaic/components/banner/banner.tsx create mode 100644 packages/ui/src/mosaic/components/banner/index.ts diff --git a/.changeset/mosaic-banner-component.md b/.changeset/mosaic-banner-component.md new file mode 100644 index 00000000000..cb56ec171ad --- /dev/null +++ b/.changeset/mosaic-banner-component.md @@ -0,0 +1,7 @@ +--- +'@clerk/ui': minor +--- + +Add the Mosaic `Banner` component: a tinted surface that annotates the content around it with a status message. Compose it from `Banner.Root`, `Banner.Label`, and `Banner.Description`. `Banner.Root` takes a `color` of `neutral`, `warning`, or `negative`, and renders the icon for that color itself. It sets no ARIA role, so pass `role='status'` (or `role='alert'`) when the banner appears in response to something the user did. + +Also adds an `info-circle` glyph to the Mosaic icon set. diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 3a52fbae376..b7f276f0fe5 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -40,6 +40,7 @@ const docModules: Record> = { components: { avatar: dynamic(() => import('../stories/avatar.mdx')), badge: dynamic(() => import('../stories/badge.mdx')), + banner: dynamic(() => import('../stories/banner.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 dbf534ce294..3d159590b97 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -20,6 +20,13 @@ import { Primary as BadgePrimary, WithIcon as BadgeWithIcon, } from '../stories/badge.stories'; +import { + Announced as BannerAnnounced, + Colors as BannerColors, + Default as BannerDefault, + LabelOnly as BannerLabelOnly, + meta as bannerMeta, +} from '../stories/banner.stories'; import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories'; import { Centered as CardCentered, @@ -204,6 +211,14 @@ const badgeModule: StoryModule = { WithIcon: BadgeWithIcon, }; +const bannerModule: StoryModule = { + meta: bannerMeta, + Default: BannerDefault, + Colors: BannerColors, + LabelOnly: BannerLabelOnly, + Announced: BannerAnnounced, +}; + const buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled }; const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes, Disabled: InputDisabled, Invalid }; @@ -379,6 +394,7 @@ export const registry: StoryModule[] = [ // Components avatarModule, badgeModule, + bannerModule, buttonModule, cardComponentModule, inputModule, diff --git a/packages/swingset/src/stories/banner.mdx b/packages/swingset/src/stories/banner.mdx new file mode 100644 index 00000000000..ef6f52c74b0 --- /dev/null +++ b/packages/swingset/src/stories/banner.mdx @@ -0,0 +1,86 @@ +import * as BannerStories from './banner.stories'; + +# Banner + +Banner annotates the surface it sits on with a status message. It is a compound component: `Banner.Root` sets the semantic `color` and renders the matching icon itself, and `Banner.Label` and `Banner.Description` carry the copy. It ships no ARIA role, so a banner that appears in response to something the user did needs `role='status'` (or `role='alert'` for an error) to be announced. + +## Playground + + + +## Props + + + +## Usage + +```tsx +import { Banner } from '@clerk/ui/mosaic/components/banner'; + + + Error banner + Renew now to avoid service interruption. +; +``` + +`color` lives on `Banner.Root` only; `Banner.Label` and `Banner.Description` read it from context, so they never need it passed again. The description is optional — a `Banner.Root` with just a label renders as a single line. + +## Parts + +| Part | Stable slot class | Description | +| -------------------- | ------------------------ | --------------------------------------------------------------- | +| `Banner.Root` | `.cl-banner-root` | Tinted `div` surface. Owns `color` and renders the icon for it. | +| `Banner.Label` | `.cl-banner-label` | The headline `span`, in the root's color at medium weight. | +| `Banner.Description` | `.cl-banner-description` | Supporting `p` beneath the label. | + +`Banner.Root` also wraps its children in a `.cl-banner-content` column so the copy aligns past the icon. Every part accepts `render` for polymorphism and forwards its ref. + +## Styling + +Each part carries its stable slot class alongside the generated StyleX atoms and reflects the active color as `data-color`. Override a `.cl-banner-*` class from a CSS layer that wins over `@clerk/ui/styles.css`: + +```css +@import '@clerk/ui/styles.css' layer(components); + +@layer overrides { + .cl-banner-root[data-color='negative'] { + border-radius: 0; + } +} +``` + +| Part | `data-color` | +| -------------------- | ------------------------------------ | +| `Banner.Root` | `neutral` \| `warning` \| `negative` | +| `Banner.Label` | `neutral` \| `warning` \| `negative` | +| `Banner.Description` | `neutral` \| `warning` \| `negative` | + +The fill is a 4% mix of the color's token rather than its `-faded` surface, so a banner tints whatever it sits on instead of painting over it, and it inverts with the token in dark mode. Retheme a color by overriding the token it reads — `--cl-color-negative`, `--cl-color-warning`, or `--cl-color-neutral` (plus `--cl-color-border`, which draws the neutral hairline) — and the fill, border, icon, and copy all move together. + +--- + +## Examples + +### Colors + + + +### Label only + + + +### Announced + + diff --git a/packages/swingset/src/stories/banner.stories.tsx b/packages/swingset/src/stories/banner.stories.tsx new file mode 100644 index 00000000000..15de0495353 --- /dev/null +++ b/packages/swingset/src/stories/banner.stories.tsx @@ -0,0 +1,82 @@ +import type { BannerRootProps } from '@clerk/ui/mosaic/components/banner'; +import { Banner } from '@clerk/ui/mosaic/components/banner'; + +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 './banner.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 `BannerRootProps`. +export const meta: StoryMeta = { + group: 'Components', + title: 'Banner', + source: 'packages/ui/src/mosaic/components/banner/banner.tsx', + styles: { + _variants: { + color: { neutral: {}, warning: {}, negative: {} }, + }, + _defaultVariants: { + color: 'neutral', + }, + }, +}; + +// Story functions accept Record (knob values) and cast to BannerRootProps. +// The cast is unavoidable: knobs are dynamically typed; Banner.Root has a strict prop interface. +function knobsAsProps(props: Record) { + return props as unknown as BannerRootProps; +} + +export function Default(props: Record) { + return ( + + Info banner + Here is a tip for how this should work + + ); +} + +export function Colors() { + return ( +
+ + Error banner + + Renew now to avoid service interruption or upgrade to a paid plan to continue using the service. + + + + Warning banner + + Your payment could not be processed. Please check your payment method and try again. + + + + Info banner + Here is a tip for how this should work + +
+ ); +} + +export function LabelOnly() { + return ( + + Your trial ends in 3 days + + ); +} + +export function Announced() { + return ( + + Payment failed + We could not charge your card. Update your payment method to continue. + + ); +} diff --git a/packages/ui/src/mosaic/components/banner/banner.styles.ts b/packages/ui/src/mosaic/components/banner/banner.styles.ts new file mode 100644 index 00000000000..d52efa4ba60 --- /dev/null +++ b/packages/ui/src/mosaic/components/banner/banner.styles.ts @@ -0,0 +1,77 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontFamilyVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; + +// The fill is a 4% tint of the semantic token rather than that token's `-faded` surface, so a +// banner composites over whatever it sits on and inverts with the token in dark mode. Each mix +// must be its own local binding — StyleX inlines the literal, and neither an imported constant +// nor a local helper call passes `valid-styles`. +const neutralFill = `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`; +const warningFill = `color-mix(in oklab, ${colorVars['--cl-color-warning']} 4%, transparent)`; +const negativeFill = `color-mix(in oklab, ${colorVars['--cl-color-negative']} 4%, transparent)`; +const warningBorder = `color-mix(in oklab, ${colorVars['--cl-color-warning']} 20%, transparent)`; +const negativeBorder = `color-mix(in oklab, ${colorVars['--cl-color-negative']} 20%, transparent)`; + +export const styles = stylex.create({ + root: { + borderRadius: radiusVars['--cl-radius-lg'], + borderStyle: 'solid', + borderWidth: '1px', + gap: space['1.5'], + paddingBlock: space['1.5'], + paddingInline: space['3'], + alignItems: 'flex-start', + display: 'flex', + fontFamily: fontFamilyVars['--cl-font-family-sans'], + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + }, + // `1lh` resolves against the root's line height, which centres the glyph on the label's first + // line rather than on the whole stack. + icon: { + flexShrink: 0, + height: '1lh', + }, + content: { + display: 'flex', + flexDirection: 'column', + minWidth: 0, + }, + label: { + fontWeight: fontWeightVars['--cl-font-medium'], + }, + description: { + margin: 0, + textWrap: 'pretty', + }, +}); + +// Each colour names its own fill, border, and label colour; the label inherits the root's `color`. +// Neutral borrows `--cl-color-border` because that token exists for exactly this hairline — a tint +// of `--cl-color-neutral` strong enough to read as a border is far darker than the rest of Mosaic's +// dividers. +export const rootColors = stylex.create({ + neutral: { + borderColor: colorVars['--cl-color-border'], + backgroundColor: neutralFill, + color: colorVars['--cl-color-neutral-foreground'], + }, + warning: { + borderColor: warningBorder, + backgroundColor: warningFill, + color: colorVars['--cl-color-warning'], + }, + negative: { + borderColor: negativeBorder, + backgroundColor: negativeFill, + color: colorVars['--cl-color-negative'], + }, +}); + +// Neutral drops to the faded gray so the copy sits under its label; the saturated colours already +// read as supporting text at regular weight, so they stay on the label's colour. +export const descriptionColors = stylex.create({ + neutral: { color: colorVars['--cl-color-neutral-faded'] }, + warning: { color: colorVars['--cl-color-warning'] }, + negative: { color: colorVars['--cl-color-negative'] }, +}); diff --git a/packages/ui/src/mosaic/components/banner/banner.test.tsx b/packages/ui/src/mosaic/components/banner/banner.test.tsx new file mode 100644 index 00000000000..fd11a615420 --- /dev/null +++ b/packages/ui/src/mosaic/components/banner/banner.test.tsx @@ -0,0 +1,118 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { Banner } from './banner'; + +const COLORS = ['neutral', 'warning', 'negative'] as const; + +describe('Mosaic Banner', () => { + it('renders its label and description', () => { + render( + + Info banner + Here is a tip for how this should work + , + ); + expect(screen.getByText('Info banner')).toBeInTheDocument(); + expect(screen.getByText('Here is a tip for how this should work')).toBeInTheDocument(); + }); + + it('applies the default color when none is passed', () => { + render( + + Info banner + , + ); + expect(screen.getByText('Info banner').closest('.cl-banner-root')).toHaveAttribute('data-color', 'neutral'); + }); + + it.each(COLORS)('reflects the %s color on every part', color => { + const { container } = render( + + Label + Description + , + ); + expect(container.querySelector('.cl-banner-root')).toHaveAttribute('data-color', color); + expect(screen.getByText('Label')).toHaveClass('cl-banner-label'); + expect(screen.getByText('Label')).toHaveAttribute('data-color', color); + expect(screen.getByText('Description')).toHaveClass('cl-banner-description'); + expect(screen.getByText('Description')).toHaveAttribute('data-color', color); + }); + + it('renders a decorative icon keyed to the color', () => { + const { container, rerender } = render( + + Label + , + ); + const icon = container.querySelector('.cl-banner-root > .cl-icon'); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveAttribute('aria-hidden', 'true'); + + rerender( + + Label + , + ); + expect(container.querySelector('.cl-banner-root > .cl-icon')).toBeInTheDocument(); + }); + + it('lets the consumer className and style win on every part', () => { + const { container } = render( + + Label + Description + , + ); + const root = container.querySelector('.cl-banner-root'); + expect(root).toHaveClass('cl-banner-root', 'my-banner'); + expect(root).toHaveStyle({ marginTop: '8px' }); + expect(screen.getByText('Label')).toHaveClass('cl-banner-label', 'my-label'); + expect(screen.getByText('Description')).toHaveClass('cl-banner-description', 'my-description'); + }); + + it('forwards arbitrary props and refs', () => { + const rootRef = React.createRef(); + const labelRef = React.createRef(); + const descriptionRef = React.createRef(); + const { container } = render( + + Label + Description + , + ); + expect(rootRef.current).toBe(container.querySelector('.cl-banner-root')); + expect(rootRef.current).toHaveAttribute('id', 'renewal'); + expect(rootRef.current).toHaveAttribute('role', 'status'); + expect(labelRef.current).toBe(screen.getByText('Label')); + expect(descriptionRef.current).toBe(screen.getByText('Description')); + }); + + it('renders custom elements via render, keeping the styling contract', () => { + render( + } + > + }>Warning banner + }>Your payment could not be processed. + , + ); + const label = screen.getByRole('heading', { name: 'Warning banner' }); + expect(label.tagName).toBe('H2'); + expect(label).toHaveClass('cl-banner-label'); + expect(label).toHaveAttribute('data-color', 'warning'); + const description = screen.getByText('Your payment could not be processed.'); + expect(description.tagName).toBe('DIV'); + expect(description).toHaveClass('cl-banner-description'); + }); +}); diff --git a/packages/ui/src/mosaic/components/banner/banner.tsx b/packages/ui/src/mosaic/components/banner/banner.tsx new file mode 100644 index 00000000000..534e76362ea --- /dev/null +++ b/packages/ui/src/mosaic/components/banner/banner.tsx @@ -0,0 +1,126 @@ +import { useRender } from '@clerk/headless/utils'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { IconName } from '../../icons/registry'; +import type { MosaicComponentProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../utils/reset.styles'; +import { Icon } from '../icon'; +import { descriptionColors, rootColors, styles } from './banner.styles'; + +type BannerColor = 'neutral' | 'warning' | 'negative'; + +const DEFAULT_COLOR: BannerColor = 'neutral'; + +const ICONS: Record = { + neutral: 'info-circle', + warning: 'alert-circle', + negative: 'alert-circle', +}; + +const BannerColorContext = React.createContext(DEFAULT_COLOR); + +/** Props for the banner surface, including native `div` props and the Mosaic `render` escape hatch. */ +export interface BannerRootProps extends MosaicComponentProps<'div'> { + /** Semantic colour of the fill, border, icon, and copy. @default 'neutral' */ + color?: BannerColor; +} + +const Root = React.forwardRef(function MosaicBannerRoot( + { color = DEFAULT_COLOR, render, className, style, children, ...rest }, + ref, +) { + const element = useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('banner-root', { color }), + stylex.props(reset.base, styles.root, rootColors[color]), + className, + style, + ), + ...rest, + children: ( + <> +