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 87a34d116c0..a336f3e4aa2 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -43,6 +43,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/components/PropTable.tsx b/packages/swingset/src/components/PropTable.tsx index 022d7b80142..22d2fd228b2 100644 --- a/packages/swingset/src/components/PropTable.tsx +++ b/packages/swingset/src/components/PropTable.tsx @@ -15,7 +15,7 @@ interface ExtraProp { interface PropTableProps { meta: StoryMeta; extra?: ExtraProp[]; - /** Set for a component that does not forward `className`/`style`. @default true */ + /** Set false for a component that styles itself and does not want `className`/`style` advertised. */ styleProps?: boolean; } diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 42145a2719b..068f9aecd95 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 { Default as CardDefault, meta as cardComponentMeta } from '../stories/card.component.stories'; import { meta as collapsibleMeta } from '../stories/collapsible.stories'; @@ -214,6 +221,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 }; @@ -407,6 +422,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..482b8909284 --- /dev/null +++ b/packages/swingset/src/stories/banner.mdx @@ -0,0 +1,89 @@ +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..0e7c1067bec --- /dev/null +++ b/packages/ui/src/mosaic/components/banner/banner.styles.ts @@ -0,0 +1,65 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontFamilyVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; + +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['2'], + 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'], + }, + icon: { + flexShrink: 0, + height: '1lh', + }, + content: { + gap: space['1'], + display: 'flex', + flexDirection: 'column', + minWidth: 0, + }, + label: { + fontWeight: fontWeightVars['--cl-font-medium'], + }, + description: { + textWrap: 'pretty', + }, +}); + +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'], + }, +}); + +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..0b98320a56d --- /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: ( + <> +