diff --git a/.changeset/mosaic-card-stylex.md b/.changeset/mosaic-card-stylex.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-card-stylex.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/stories/card.component.mdx b/packages/swingset/src/stories/card.component.mdx index 5f282a30ea2..38444cd4842 100644 --- a/packages/swingset/src/stories/card.component.mdx +++ b/packages/swingset/src/stories/card.component.mdx @@ -20,11 +20,11 @@ A styled surface container that groups related content into Header, Content, and ```tsx import { Card } from '@clerk/ui/mosaic/components/card'; - + Heading Body content. Footer actions - + ``` --- diff --git a/packages/swingset/src/stories/card.component.stories.tsx b/packages/swingset/src/stories/card.component.stories.tsx index ae72fe95628..201fd081626 100644 --- a/packages/swingset/src/stories/card.component.stories.tsx +++ b/packages/swingset/src/stories/card.component.stories.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @emotion/react */ import { Button } from '@clerk/ui/mosaic/components/button'; import type { CardProps } from '@clerk/ui/mosaic/components/card'; -import { Card, cardRecipe } from '@clerk/ui/mosaic/components/card'; +import { Card } from '@clerk/ui/mosaic/components/card'; import { Heading } from '@clerk/ui/mosaic/components/heading'; import { Text } from '@clerk/ui/mosaic/components/text'; @@ -14,8 +14,18 @@ export { default as __source } from './card.component.stories?raw'; export const meta: StoryMeta = { group: 'Components', title: 'Card', - source: 'packages/ui/src/mosaic/components/card.tsx', - styles: cardRecipe, + source: 'packages/ui/src/mosaic/components/card/card.tsx', + styleEngine: 'stylex', + styles: { + _variants: { + alignment: { start: {}, center: {} }, + elevation: { card: {}, flush: {}, overlay: {} }, + }, + _defaultVariants: { + alignment: 'start', + elevation: 'card', + }, + }, }; function knobsAsProps(props: Record) { @@ -24,7 +34,7 @@ function knobsAsProps(props: Record) { export function Default(props: Record) { return ( - @@ -36,13 +46,13 @@ export function Default(props: Record) { - + ); } export function Centered() { return ( - @@ -54,6 +64,6 @@ export function Centered() { - + ); } diff --git a/packages/swingset/src/stories/popover.component.mdx b/packages/swingset/src/stories/popover.component.mdx index 08dc13b078a..9ce80bcbbb2 100644 --- a/packages/swingset/src/stories/popover.component.mdx +++ b/packages/swingset/src/stories/popover.component.mdx @@ -29,7 +29,7 @@ import { Popover } from '@clerk/ui/mosaic/components/popover'; } /> - + Flexible inner content. } /> - + Ada Lovelace ada@example.com @@ -36,7 +36,7 @@ export function Default() { Sign out of all accounts - + ); @@ -48,11 +48,11 @@ export function Default() { const labelledTrigger = (label: string) => ; const panel = (label: string) => ( - + {label} - + ); export function Placement() { diff --git a/packages/ui/src/mosaic/components/card.tsx b/packages/ui/src/mosaic/components/card.tsx deleted file mode 100644 index e616f6f79a9..00000000000 --- a/packages/ui/src/mosaic/components/card.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import React from 'react'; - -import type { RecipeVariantProps } from '../slot-recipe'; -import { defineSlotRecipe, useRecipe } from '../slot-recipe'; -import { TextContext } from './text'; - -export const cardRecipe = defineSlotRecipe(theme => ({ - slots: { - root: { slot: 'card-root' }, - header: { slot: 'card-header' }, - content: { slot: 'card-content' }, - footer: { slot: 'card-footer' }, - }, - base: { - root: { - backgroundColor: theme.color.card, - color: theme.color.cardForeground, - borderRadius: theme.rounded.lg, - display: 'flex', - flexDirection: 'column', - rowGap: theme.spacing(5), - width: '100%', - overflow: 'hidden', - border: `1px solid ${theme.color.border}`, - }, - header: { - display: 'flex', - flexDirection: 'column', - paddingBlockStart: theme.spacing(5), - paddingInline: theme.spacing(4), - }, - content: { - paddingInline: theme.spacing(4), - flex: '1 1 auto', - }, - footer: { - paddingBlockEnd: theme.spacing(5), - paddingInline: theme.spacing(4), - }, - }, - variants: { - alignment: { - start: { - header: { alignItems: 'flex-start', textAlign: 'start' }, - }, - center: { - header: { alignItems: 'center', textAlign: 'center' }, - }, - }, - }, - defaultVariants: { - alignment: 'start', - }, -})); - -type CardVariantProps = RecipeVariantProps; - -const CardVariantContext = React.createContext({}); - -declare module '../registry' { - interface MosaicSlotRegistry { - 'card-root': true; - 'card-header': true; - 'card-content': true; - 'card-footer': true; - } -} - -const Root = React.forwardRef>(function CardRoot(props, ref) { - const variantProps = React.useContext(CardVariantContext); - const { root } = useRecipe(cardRecipe, { variants: variantProps }); - return ( -
- ); -}); - -const Header = React.forwardRef>(function CardHeader(props, ref) { - const variantProps = React.useContext(CardVariantContext); - const { header } = useRecipe(cardRecipe, { variants: variantProps }); - return ( - -
- - ); -}); - -const Content = React.forwardRef>( - function CardContent(props, ref) { - const { content } = useRecipe(cardRecipe); - return ( -
- ); - }, -); - -const Footer = React.forwardRef>(function CardFooter(props, ref) { - const { footer } = useRecipe(cardRecipe); - return ( -
- ); -}); - -export interface CardProps extends React.ComponentPropsWithoutRef<'div'> { - alignment?: CardVariantProps['alignment']; -} - -export function Card({ alignment, children, ...props }: CardProps) { - return ( - - {children} - - ); -} - -Card.Root = Root; -Card.Header = Header; -Card.Content = Content; -Card.Footer = Footer; diff --git a/packages/ui/src/mosaic/components/card/card.styles.ts b/packages/ui/src/mosaic/components/card/card.styles.ts new file mode 100644 index 00000000000..035231712d8 --- /dev/null +++ b/packages/ui/src/mosaic/components/card/card.styles.ts @@ -0,0 +1,75 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + root: { + color: colorVars['--cl-color-card-foreground'], + display: 'flex', + flexDirection: 'column', + rowGap: space['5'], + width: '100%', + }, + header: { + paddingInline: space['4'], + display: 'flex', + flexDirection: 'column', + paddingBlockStart: space['5'], + }, + content: { + paddingInline: space['4'], + flexBasis: 'auto', + flexGrow: '1', + flexShrink: '1', + }, + footer: { + gap: space['2'], + paddingBlock: space['4'], + paddingInline: space['6'], + alignItems: 'center', + boxSizing: 'border-box', + display: 'flex', + flexShrink: 0, + justifyContent: 'space-between', + borderTopColor: colorVars['--cl-color-border'], + borderTopStyle: 'solid', + borderTopWidth: '1px', + width: '100%', + }, +}); + +export const elevations = stylex.create({ + card: { + borderRadius: radiusVars['--cl-radius-container'], + overflow: 'hidden', + backgroundColor: colorVars['--cl-color-card'], + boxShadow: `0 12px 12px -7px light-dark(oklch(0.2046 0 0 / 12%), transparent), + 0 24px 24px -10px light-dark(oklch(0.2046 0 0 / 4%), transparent), + 0 0 0 1px light-dark(oklch(0.2046 0 0 / 4%), oklch(1 0 0 / 10%))`, + }, + flush: { + borderRadius: radiusVars['--cl-radius-container'], + overflow: 'visible', + backgroundColor: 'transparent', + boxShadow: 'none', + }, + overlay: { + borderRadius: radiusVars['--cl-radius-container'], + overflow: 'hidden', + backgroundColor: colorVars['--cl-color-card'], + boxShadow: `0 12px 12px -7px light-dark(oklch(0.2046 0 0 / 12%), transparent), + 0 24px 24px -10px light-dark(oklch(0.2046 0 0 / 4%), transparent), + 0 0 0 1px light-dark(oklch(0.2046 0 0 / 4%), oklch(1 0 0 / 10%))`, + }, +}); + +export const headerAlignments = stylex.create({ + start: { + alignItems: 'flex-start', + textAlign: 'start', + }, + center: { + alignItems: 'center', + textAlign: 'center', + }, +}); diff --git a/packages/ui/src/mosaic/components/card/card.test.tsx b/packages/ui/src/mosaic/components/card/card.test.tsx new file mode 100644 index 00000000000..2561a816d6e --- /dev/null +++ b/packages/ui/src/mosaic/components/card/card.test.tsx @@ -0,0 +1,154 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { Text } from '../text'; +import { Card } from './card'; + +describe('Mosaic Card', () => { + it('renders each compound slot with the default alignment', () => { + render( + + Header + Content + Footer + , + ); + + expect(screen.getByTestId('root')).toHaveClass('cl-card-root'); + expect(screen.getByTestId('root')).toHaveAttribute('data-alignment', 'start'); + expect(screen.getByTestId('root')).toHaveAttribute('data-elevation', 'card'); + expect(screen.getByTestId('header')).toHaveClass('cl-card-header'); + expect(screen.getByTestId('header')).toHaveAttribute('data-alignment', 'start'); + expect(screen.getByTestId('content')).toHaveClass('cl-card-content'); + expect(screen.getByTestId('footer')).toHaveClass('cl-card-footer'); + expect(screen.getByTestId('footer')).toHaveAttribute('data-elevation', 'card'); + }); + + it('reflects centered alignment on the root and header', () => { + render( + + Header + , + ); + + expect(screen.getByTestId('root')).toHaveAttribute('data-alignment', 'center'); + expect(screen.getByTestId('header')).toHaveAttribute('data-alignment', 'center'); + }); + + it('reflects flush elevation on the root and footer', () => { + render( + + + , + ); + + expect(screen.getByTestId('root')).toHaveAttribute('data-elevation', 'flush'); + expect(screen.getByTestId('footer')).toHaveAttribute('data-elevation', 'flush'); + }); + + it('reflects overlay elevation on the root and footer', () => { + render( + + + , + ); + + expect(screen.getByTestId('root')).toHaveAttribute('data-elevation', 'overlay'); + expect(screen.getByTestId('footer')).toHaveAttribute('data-elevation', 'overlay'); + }); + + it('provides the neutral text color to header copy', () => { + render( + + + Supporting copy + + , + ); + + expect(screen.getByText('Supporting copy')).toHaveAttribute('data-color', 'neutral'); + }); + + it('lets consumer className and style win on every slot', () => { + render( + + + + + , + ); + + expect(screen.getByTestId('root')).toHaveClass('cl-card-root', 'my-card'); + expect(screen.getByTestId('root')).toHaveStyle({ width: '20rem' }); + expect(screen.getByTestId('header')).toHaveClass('cl-card-header', 'my-header'); + expect(screen.getByTestId('header')).toHaveStyle({ textAlign: 'right' }); + expect(screen.getByTestId('content')).toHaveClass('cl-card-content', 'my-content'); + expect(screen.getByTestId('content')).toHaveStyle({ paddingInline: 0 }); + expect(screen.getByTestId('footer')).toHaveClass('cl-card-footer', 'my-footer'); + expect(screen.getByTestId('footer')).toHaveStyle({ paddingBlockEnd: 0 }); + }); + + it('forwards refs and arbitrary props from compound slots', () => { + const rootRef = React.createRef(); + const headerRef = React.createRef(); + const contentRef = React.createRef(); + const footerRef = React.createRef(); + + render( + + + + + , + ); + + expect(rootRef.current).toBe(screen.getByLabelText('Card')); + expect(headerRef.current).toHaveClass('cl-card-header'); + expect(contentRef.current).toHaveClass('cl-card-content'); + expect(footerRef.current).toHaveClass('cl-card-footer'); + }); + + it('supports custom elements through render on every slot', () => { + render( +
}> +
Header
} /> +
Content
} /> +
Footer
} /> + , + ); + + expect(screen.getByText('Header').tagName).toBe('HEADER'); + expect(screen.getByText('Content').tagName).toBe('MAIN'); + expect(screen.getByText('Footer').tagName).toBe('FOOTER'); + expect(screen.getByText('Header').closest('section')).toHaveClass('cl-card-root'); + }); +}); diff --git a/packages/ui/src/mosaic/components/card/card.tsx b/packages/ui/src/mosaic/components/card/card.tsx new file mode 100644 index 00000000000..134aca83714 --- /dev/null +++ b/packages/ui/src/mosaic/components/card/card.tsx @@ -0,0 +1,109 @@ +import { useRender } from '@clerk/headless/utils'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicComponentProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { TextContext } from '../text'; +import { elevations, headerAlignments, styles } from './card.styles'; + +type CardAlignment = 'start' | 'center'; +type CardElevation = 'card' | 'flush' | 'overlay'; + +const DEFAULT_ALIGNMENT: CardAlignment = 'start'; +const DEFAULT_ELEVATION: CardElevation = 'card'; + +const CardVariantContext = React.createContext<{ alignment: CardAlignment; elevation: CardElevation }>({ + alignment: DEFAULT_ALIGNMENT, + elevation: DEFAULT_ELEVATION, +}); + +/** Props for `Card.Root`, including native `div` props and the Mosaic `render` escape hatch. */ +export interface CardProps extends MosaicComponentProps<'div'> { + /** Alignment applied to `Card.Header`. @default 'start' */ + alignment?: CardAlignment; + /** Surface treatment applied to the card. @default 'card' */ + elevation?: CardElevation; +} + +const Root = React.forwardRef(function CardRoot( + { alignment = DEFAULT_ALIGNMENT, elevation = DEFAULT_ELEVATION, render, className, style, ...rest }, + ref, +) { + const element = useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('card-root', { alignment, elevation }), + stylex.props(styles.root, elevations[elevation]), + className, + style, + ), + ...rest, + }, + }); + + return {element}; +}); + +const Header = React.forwardRef>(function CardHeader( + { render, className, style, ...rest }, + ref, +) { + const { alignment } = React.useContext(CardVariantContext); + const element = useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('card-header', { alignment }), + stylex.props(styles.header, headerAlignments[alignment]), + className, + style, + ), + ...rest, + }, + }); + + return {element}; +}); + +const Content = React.forwardRef>(function CardContent( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('card-content'), stylex.props(styles.content), className, style), + ...rest, + }, + }); +}); + +const Footer = React.forwardRef>(function CardFooter( + { render, className, style, ...rest }, + ref, +) { + const { elevation } = React.useContext(CardVariantContext); + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('card-footer', { elevation }), stylex.props(styles.footer), className, style), + ...rest, + }, + }); +}); + +/** + * A styled surface composed through `Card.Root`, `Card.Header`, `Card.Content`, and `Card.Footer`. + * Every part accepts the Mosaic `render` prop and forwards its ref. + */ +export const Card = { Root, Header, Content, Footer }; diff --git a/packages/ui/src/mosaic/components/card/index.ts b/packages/ui/src/mosaic/components/card/index.ts new file mode 100644 index 00000000000..659228ff6fd --- /dev/null +++ b/packages/ui/src/mosaic/components/card/index.ts @@ -0,0 +1,2 @@ +export { Card } from './card'; +export type { CardProps } from './card'; diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index e18ee008525..b6094e9f27c 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -12,6 +12,8 @@ export { Badge } from '../components/badge'; export type { BadgeProps } from '../components/badge'; export { Button } from '../components/button'; export type { ButtonProps } from '../components/button'; +export { Card } from '../components/card'; +export type { CardProps } from '../components/card'; export { Heading, HeadingContext } from '../components/heading'; export type { HeadingProps } from '../components/heading'; export { Icon } from '../components/icon';