diff --git a/.changeset/mosaic-item.md b/.changeset/mosaic-item.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-item.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 35bdb3e9da1..4b2ba12e3c9 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -31,6 +31,7 @@ const docModules: Record> = { button: dynamic(() => import('../stories/button.mdx')), card: dynamic(() => import('../stories/card.component.mdx')), input: dynamic(() => import('../stories/input.mdx')), + item: dynamic(() => import('../stories/item.mdx')), dialog: dynamic(() => import('../stories/dialog.component.mdx')), heading: dynamic(() => import('../stories/heading.mdx')), icon: dynamic(() => import('../stories/icon.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index dee445b26f0..2e92a5815d4 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -33,6 +33,13 @@ import { meta as inputMeta, Sizes as InputSizes, } from '../stories/input.stories'; +import { + Default as ItemDefault, + Group as ItemGroup, + Interactive as ItemInteractive, + meta as itemMeta, + Variants as ItemVariants, +} from '../stories/item.stories'; import { meta as menuMeta } from '../stories/menu.stories'; import { Default as OrganizationProfileDefault, @@ -121,6 +128,14 @@ const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes, const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault }; +const itemModule: StoryModule = { + meta: itemMeta, + Default: ItemDefault, + Variants: ItemVariants, + Interactive: ItemInteractive, + Group: ItemGroup, +}; + const headingModule: StoryModule = { meta: headingMeta, Default: HeadingDefault, @@ -174,6 +189,7 @@ export const registry: StoryModule[] = [ buttonModule, cardComponentModule, inputModule, + itemModule, dialogComponentModule, headingModule, iconModule, diff --git a/packages/swingset/src/stories/item.mdx b/packages/swingset/src/stories/item.mdx new file mode 100644 index 00000000000..7d13e113c43 --- /dev/null +++ b/packages/swingset/src/stories/item.mdx @@ -0,0 +1,92 @@ +import * as ItemStories from './item.stories'; + +# Item + +Item is a flexible row for lists of accounts, organizations, and settings in Mosaic. It's composed from parts via dot syntax (`Item.Media`, `Item.Content`, `Item.Title`, …) and renders as a `
` by default. Pass a `render` prop to make a row an interactive link or button, which adds hover and cursor affordances. + +## Example + + + +### Variants + + + +### Interactive + + + +### Group + + + +## Usage + +```tsx +import { Item } from '@clerk/ui/mosaic/components/item'; + + + {children}}> + + + + + Test Organization + Member + + + + + +; +``` + +## Parts + +| Part | Slot (`data-cl-slot` / class) | Description | +| ------------------ | ----------------------------- | -------------------------------------------------------------------- | +| `Item` | `cl-item` | Root row. Renders a `
`, or a custom element via `render`. | +| `Item.Media` | `cl-item-media` | Leading (or trailing) media: icon, image, or avatar. | +| `Item.Content` | `cl-item-content` | Vertical stack that grows to fill the row between media and actions. | +| `Item.Title` | `cl-item-title` | Primary label. | +| `Item.Description` | `cl-item-description` | Secondary text, clamped to two lines. Renders a `

`. | +| `Item.Actions` | `cl-item-actions` | Trailing controls (buttons, badges). | +| `Item.Footer` | `cl-item-footer` | Full-width band below the content row for supplementary metadata. | +| `Item.Group` | `cl-item-group` | Vertical list wrapper (`role="list"`). | +| `Item.Separator` | `cl-item-separator` | Thin divider (`


`) between rows. | + +Every part accepts a `render` prop for element polymorphism and forwards a ref. + +## Styling + +The root reflects its props as `data-*` attributes on `.cl-item`, so consumers can scope overrides without touching StyleX's hashed atoms: + +| Prop | Attribute | Values | Default | +| --------- | ------------------ | ----------------------------------- | --------- | +| `variant` | `data-variant` | `default` \| `outline` \| `muted` | `default` | +| `size` | `data-size` | `default` \| `sm` | `default` | +| `render` | `data-interactive` | present when a `render` is provided | — | + +```css +/* Tighten a specific list's rows and re-theme the muted variant */ +.cl-item[data-size='sm'] { + --cl-spacing: 0.2rem; +} +.cl-item[data-variant='muted'] { + background-color: var(--cl-color-card); +} +``` + +`Item.Media` also carries `data-variant` (`default` | `icon` | `image`): `icon` renders a bordered `--cl-color-muted` tile, `image` a rounded avatar frame that clips its child. Colors, radii, and spacing all resolve from the Mosaic tokens (`--cl-color-*`, `--cl-radius-*`, `--cl-spacing`). diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx new file mode 100644 index 00000000000..c88d6945fe8 --- /dev/null +++ b/packages/swingset/src/stories/item.stories.tsx @@ -0,0 +1,153 @@ +/** @jsxImportSource @emotion/react */ +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Item } from '@clerk/ui/mosaic/components/item'; + +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 './item.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + title: 'Item', + source: 'packages/ui/src/mosaic/components/item/item.tsx', +}; + +function BuildingIcon() { + return ( + + + + ); +} + +function ArrowIcon() { + return ( + + + + ); +} + +export function Default() { + return ( + + + + + + Test Organization + Member + + + + + + ); +} + +export function Variants() { + return ( +
+ + + Default + Transparent background, no border + + + + + Outline + Bordered container + + + + + Muted + Subtle filled background + + +
+ ); +} + +export function Interactive() { + return ( + ( + + {children} + + )} + > + + + + + Test Organization + Member + + + + + + ); +} + +export function Group() { + return ( + + ( + + {children} + + )} + > + + Clerk + + + + ( + + {children} + + )} + > + + Clerk Cloud + + + + ); +} diff --git a/packages/ui/src/mosaic/components/item/index.ts b/packages/ui/src/mosaic/components/item/index.ts new file mode 100644 index 00000000000..9344113ffd9 --- /dev/null +++ b/packages/ui/src/mosaic/components/item/index.ts @@ -0,0 +1,2 @@ +export { Item } from './item'; +export type { ItemMediaProps, ItemProps } from './item'; diff --git a/packages/ui/src/mosaic/components/item/item.styles.ts b/packages/ui/src/mosaic/components/item/item.styles.ts new file mode 100644 index 00000000000..4fbae953b20 --- /dev/null +++ b/packages/ui/src/mosaic/components/item/item.styles.ts @@ -0,0 +1,154 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; + +export const item = stylex.create({ + base: { + borderColor: 'transparent', + borderRadius: radiusVars['--cl-radius-element'], + borderStyle: 'solid', + borderWidth: '1px', + outline: { + default: 'none', + ':focus-visible': `2px solid color-mix(in oklab, ${colorVars['--cl-color-primary']} 50%, transparent)`, + }, + alignItems: 'center', + boxSizing: 'border-box', + color: colorVars['--cl-color-card-foreground'], + display: 'flex', + flexWrap: 'wrap', + fontFamily: 'inherit', + fontSize: typeScaleVars['--cl-text-label-size'], + lineHeight: typeScaleVars['--cl-text-label-leading'], + outlineOffset: '2px', + textAlign: 'start', + transitionDuration: '150ms', + transitionProperty: 'background-color, border-color, color', + width: '100%', + }, + + variantDefault: { backgroundColor: 'transparent' }, + variantOutline: { borderColor: colorVars['--cl-color-border'], backgroundColor: 'transparent' }, + variantMuted: { + backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-muted']} 50%, transparent)`, + }, + + // size — generous block padding; the inline axis stays constant + sizeMd: { padding: space['4'], gap: space['4'] }, + sizeSm: { gap: space['2.5'], paddingBlock: space['3'], paddingInline: space['4'] }, + + // interactive rows (rendered as a link/button via asChild) gain hover + cursor + interactive: { + backgroundColor: { + default: null, + ':active': `color-mix(in oklab, ${colorVars['--cl-color-muted']} 70%, transparent)`, + '@media (hover: hover)': { + ':hover': `color-mix(in oklab, ${colorVars['--cl-color-muted']} 50%, transparent)`, + }, + }, + cursor: 'pointer', + }, + + disabled: { cursor: 'not-allowed', opacity: 0.5, pointerEvents: 'none' }, +}); + +export const media = stylex.create({ + base: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + flexShrink: 0, + justifyContent: 'center', + }, + icon: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-inner'], + borderStyle: 'solid', + borderWidth: '1px', + backgroundColor: colorVars['--cl-color-muted'], + boxSizing: 'border-box', + height: space['8'], + width: space['8'], + }, + image: { + borderRadius: radiusVars['--cl-radius-inner'], + overflow: 'hidden', + height: space['10'], + width: space['10'], + }, +}); + +export const content = stylex.create({ + base: { + gap: space['1'], + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + justifyContent: 'center', + minWidth: 0, + }, +}); + +export const title = stylex.create({ + base: { + gap: space['2'], + alignItems: 'center', + color: colorVars['--cl-color-card-foreground'], + display: 'flex', + fontSize: typeScaleVars['--cl-text-label-size'], + fontWeight: typeScaleVars['--cl-text-label-weight'], + lineHeight: typeScaleVars['--cl-text-label-leading'], + width: 'fit-content', + }, +}); + +export const description = stylex.create({ + base: { + overflow: 'hidden', + WebkitBoxOrient: 'vertical', + WebkitLineClamp: 2, + color: colorVars['--cl-color-muted-foreground'], + display: '-webkit-box', + fontSize: typeScaleVars['--cl-text-label-size'], + fontWeight: 400, + lineHeight: typeScaleVars['--cl-text-label-leading'], + }, +}); + +export const actions = stylex.create({ + base: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + flexShrink: 0, + }, +}); + +// ItemHeader / ItemFooter share the same full-width band layout. +export const band = stylex.create({ + base: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + flexBasis: '100%', + justifyContent: 'space-between', + }, +}); + +export const group = stylex.create({ + base: { + display: 'flex', + flexDirection: 'column', + }, +}); + +export const separator = stylex.create({ + base: { + margin: 0, + borderStyle: 'none', + backgroundColor: colorVars['--cl-color-border'], + flexShrink: 0, + height: '1px', + width: '100%', + }, +}); diff --git a/packages/ui/src/mosaic/components/item/item.test.tsx b/packages/ui/src/mosaic/components/item/item.test.tsx new file mode 100644 index 00000000000..4364476937a --- /dev/null +++ b/packages/ui/src/mosaic/components/item/item.test.tsx @@ -0,0 +1,108 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { Item } from './item'; + +describe('Mosaic Item', () => { + it('renders a div with its children', () => { + render(Hi); + expect(screen.getByText('Hi')).toBeInTheDocument(); + }); + + it('applies default variants when none are passed', () => { + render(Hi); + const item = screen.getByText('Hi'); + expect(item).toHaveClass('cl-item'); + expect(item).toHaveAttribute('data-variant', 'default'); + expect(item).toHaveAttribute('data-size', 'default'); + expect(item).not.toHaveAttribute('data-interactive'); + }); + + it('wires variant props and consumer className/style through to the element', () => { + render( + + Hi + , + ); + const item = screen.getByText('Hi'); + expect(item).toHaveAttribute('data-variant', 'outline'); + expect(item).toHaveAttribute('data-size', 'sm'); + expect(item).toHaveClass('cl-item', 'my-item'); + expect(item).toHaveStyle({ marginTop: '8px' }); + }); + + it('renders a custom element via render and marks it interactive', () => { + render( + ( + + {children} + + )} + > + + Settings + + , + ); + const link = screen.getByRole('link', { name: 'Settings' }); + expect(link).toHaveClass('cl-item'); + expect(link).toHaveAttribute('data-interactive', ''); + expect(link).toHaveAttribute('href', '/settings'); + }); + + it('renders the media variants', () => { + render( + + icon + , + ); + const media = screen.getByText('icon').parentElement; + expect(media).toHaveClass('cl-item-media'); + expect(media).toHaveAttribute('data-variant', 'icon'); + }); + + it('renders the composed slots with their stable classes', () => { + render( + + + Test Organization + Member + + + + + , + ); + expect(screen.getByText('Test Organization')).toHaveClass('cl-item-title'); + expect(screen.getByText('Member')).toHaveClass('cl-item-description'); + expect(screen.getByRole('button', { name: 'Manage' }).parentElement).toHaveClass('cl-item-actions'); + }); + + it('renders a group as a list and a separator', () => { + render( + + One + + Two + , + ); + expect(screen.getByRole('list')).toHaveClass('cl-item-group'); + expect(screen.getByTestId('sep')).toHaveClass('cl-item-separator'); + }); + + it('forwards the ref to the root element', () => { + const ref = React.createRef(); + render(Hi); + expect(ref.current).toBe(screen.getByText('Hi')); + }); +}); diff --git a/packages/ui/src/mosaic/components/item/item.tsx b/packages/ui/src/mosaic/components/item/item.tsx new file mode 100644 index 00000000000..8e751bcc623 --- /dev/null +++ b/packages/ui/src/mosaic/components/item/item.tsx @@ -0,0 +1,193 @@ +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 * as slots from './item.styles'; + +export type ItemProps = Omit, 'render'> & { + variant?: 'default' | 'outline' | 'muted'; + size?: 'default' | 'sm'; + /** Render a custom element (e.g. a link or button) in place of the default `div`. */ + render?: RenderProp>; +}; + +const Root = React.forwardRef(function MosaicItem( + { variant = 'default', size = 'default', render, className, style, ...rest }, + ref, +) { + // A custom render (link/button row) opts into hover + cursor affordances. + const interactive = Boolean(render); + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('item', { variant, size, interactive }), + stylex.props( + slots.item.base, + variant === 'outline' && slots.item.variantOutline, + variant === 'muted' && slots.item.variantMuted, + variant === 'default' && slots.item.variantDefault, + size === 'sm' ? slots.item.sizeSm : slots.item.sizeMd, + interactive && slots.item.interactive, + ), + className, + style, + ), + ...rest, + }, + }); +}); + +export type ItemMediaProps = ComponentProps<'div'> & { + variant?: 'default' | 'icon' | 'image'; +}; + +const Media = React.forwardRef(function MosaicItemMedia( + { variant = 'default', render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('item-media', { variant }), + stylex.props( + slots.media.base, + variant === 'icon' && slots.media.icon, + variant === 'image' && slots.media.image, + ), + className, + style, + ), + ...rest, + }, + }); +}); + +const Content = React.forwardRef>(function MosaicItemContent( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('item-content'), stylex.props(slots.content.base), className, style), + ...rest, + }, + }); +}); + +const Title = React.forwardRef>(function MosaicItemTitle( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('item-title'), stylex.props(slots.title.base), className, style), + ...rest, + }, + }); +}); + +const Description = React.forwardRef>(function MosaicItemDescription( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'p', + render, + ref, + props: { + ...mergeStyleProps(themeProps('item-description'), stylex.props(slots.description.base), className, style), + ...rest, + }, + }); +}); + +const Actions = React.forwardRef>(function MosaicItemActions( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('item-actions'), stylex.props(slots.actions.base), className, style), + ...rest, + }, + }); +}); + +const Footer = React.forwardRef>(function MosaicItemFooter( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('item-footer'), stylex.props(slots.band.base), className, style), + ...rest, + }, + }); +}); + +const Group = React.forwardRef>(function MosaicItemGroup( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + role: 'list', + ...mergeStyleProps(themeProps('item-group'), stylex.props(slots.group.base), className, style), + ...rest, + }, + }); +}); + +const Separator = React.forwardRef>(function MosaicItemSeparator( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'hr', + render, + ref, + props: { + ...mergeStyleProps(themeProps('item-separator'), stylex.props(slots.separator.base), className, style), + ...rest, + }, + }); +}); + +/** + * Mosaic `Item` — a row for lists of accounts, organizations, and settings. + * Composed via dot syntax: `Item.Media`, `Item.Content`, `Item.Title`, + * `Item.Description`, `Item.Actions`, `Item.Footer`, `Item.Group`, + * `Item.Separator`. + */ +export const Item = Object.assign(Root, { + Media, + Content, + Title, + Description, + Actions, + Footer, + Group, + Separator, +}); diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index f1716fae927..ca19bc8e3f1 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -7,6 +7,9 @@ export { Button } from '../components/button'; export type { ButtonProps } from '../components/button'; +export { Item } from '../components/item'; +export type { ItemMediaProps, ItemProps } from '../components/item'; + import { colorVars, radiusVars, space, spacingVars, typeScaleVars } from '../tokens.stylex'; export { colorVars, radiusVars, space, spacingVars, typeScaleVars }; diff --git a/packages/ui/tsconfig.mosaic.json b/packages/ui/tsconfig.mosaic.json index d4b1a2302da..05fde7579f6 100644 --- a/packages/ui/tsconfig.mosaic.json +++ b/packages/ui/tsconfig.mosaic.json @@ -1,6 +1,18 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "jsxImportSource": "react" + "jsxImportSource": "react", + "paths": { + // Resolve the private, unpublished `@clerk/headless` to its SOURCE for the Mosaic + // declaration bundle. Its published `dist/*.d.ts` are re-export barrels that + // rolldown-plugin-dts can't follow when inlining, so building types against source + // (the monorepo default) lets Mosaic components import headless types directly. + "@clerk/headless/utils": ["../headless/src/utils/index.ts"], + "@clerk/headless/*": ["../headless/src/*"], + // Preserve the base config's test-only aliases (extends replaces `paths` wholesale). + "@/core/*": ["../clerk-js/src/core/*"], + "@/*": ["./src/*"], + "@/ui*": ["./src/*"] + } } }