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 82d9e39eb83..7bbe0bbacb8 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -33,6 +33,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 bfa22e2ee2b..62388ba0985 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -46,6 +46,12 @@ import { meta as inputMeta, Sizes as InputSizes, } from '../stories/input.stories'; +import { + Default as ItemDefault, + Group as ItemGroup, + Interactive as ItemInteractive, + meta as itemMeta, +} from '../stories/item.stories'; import { meta as menuMeta } from '../stories/menu.stories'; import { Default as OrganizationProfileDefault, @@ -149,6 +155,13 @@ const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes, const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault }; +const itemModule: StoryModule = { + meta: itemMeta, + Default: ItemDefault, + Interactive: ItemInteractive, + Group: ItemGroup, +}; + const headingModule: StoryModule = { meta: headingMeta, Default: HeadingDefault, @@ -204,6 +217,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..dd5aea2f3f8 --- /dev/null +++ b/packages/swingset/src/stories/item.mdx @@ -0,0 +1,89 @@ +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.Root`, `Item.Media`, `Item.Content`, `Item.Title`, …). `Item.Root` renders as a `
` by default; pass it a `render` prop to make a row an interactive link or button, which adds hover and cursor affordances. + +## Example + + + +### Interactive + + + +### Group + + + +## Usage + +```tsx +import { Avatar } from '@clerk/ui/mosaic/components/avatar'; +import { Item } from '@clerk/ui/mosaic/components/item'; + + + {children}}> + + + + {org.name[0]} + + + + Test Organization + Member + + + + + +; +``` + +## Parts + +| Part | Class | Description | +| -------------------- | ------------------------ | -------------------------------------------------------------------- | +| `Item.Root` | `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. Renders a `

`. | +| `Item.Actions` | `cl-item-actions` | Trailing controls (buttons, badges). | +| `Item.Header` | `cl-item-header` | Header row above a group: a label with optional actions. | +| `Item.HeaderTitle` | `cl-item-header-title` | Label text within an `Item.Header`. | +| `Item.HeaderActions` | `cl-item-header-actions` | Trailing controls within an `Item.Header`. | +| `Item.Group` | `cl-item-group` | Vertical wrapper around a set of rows (layout only, no role). | +| `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 state as `data-*` attributes on `.cl-item`, so consumers can scope overrides without touching StyleX's hashed atoms: + +| Prop | Attribute | Values | Default | +| --------- | ------------------ | ----------------------------------- | -------- | +| `variant` | `data-variant` | `entity` \| `action` | `entity` | +| `render` | `data-interactive` | present when a `render` is provided | — | + +`variant` sets the row's vertical density (`entity` is standard, `action` is denser) and, on interactive rows, promotes the title color. + +```css +/* Re-theme interactive rows */ +.cl-item[data-interactive] { + background-color: var(--cl-color-card); +} +``` + +`Item.Media` sizes to its child and centers it; its height follows the row. Bring your own icon, avatar, or image at whatever dimensions you need, then size the slot by sizing that 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..7dd6c1fc288 --- /dev/null +++ b/packages/swingset/src/stories/item.stories.tsx @@ -0,0 +1,311 @@ +/** @jsxImportSource @emotion/react */ +import { Avatar } from '@clerk/ui/mosaic/components/avatar'; +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Item } from '@clerk/ui/mosaic/components/item'; +import * as React from 'react'; + +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 CheckMarkIcon(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + + + ); +} + +function EllipsisIcon(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + + + + + + ); +} + +function PlusIcon(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + + + + ); +} + +function SignOutIcon(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + + + ); +} + +export function Default() { + return ( + + + + T + + + + Test Organization + Member + + + + + + ); +} + +export function Interactive() { + return ( + ( + + {children} + + )} + > + + + T + + + + Test Organization + Member + + + ); +} + +export function Group() { + return ( +
+ + + + + C + + + + Cameron Walker + + + + + + + + + + cameron@clerk.com + + + + + + + C + + + + Clerk + 24 members • Basic + + + + ( + + {children} + + )} + > + + + + C + + + + Clerk + 24 members • Basic + + + + + + ( + + {children} + + )} + > + + + D + + + + DesignCloud + 12 members • Pro + + + + + + ( + + {children} + + )} + > + + + + + Add account + + + + + + ( + + )} + > + + + + + Sign out of all accounts + + + +
+ ); +} diff --git a/packages/ui/src/mosaic/components/avatar/avatar.styles.ts b/packages/ui/src/mosaic/components/avatar/avatar.styles.ts index 7e920064739..782cd834ddc 100644 --- a/packages/ui/src/mosaic/components/avatar/avatar.styles.ts +++ b/packages/ui/src/mosaic/components/avatar/avatar.styles.ts @@ -7,6 +7,7 @@ export const styles = stylex.create({ base: { overflow: 'hidden', alignItems: 'center', + aspectRatio: '1 / 1', boxSizing: 'border-box', display: 'inline-flex', flexShrink: 0, @@ -24,6 +25,7 @@ export const styles = stylex.create({ aspectRatio: '1 / 1', display: 'block', objectFit: 'cover', + position: 'absolute', height: '100%', width: '100%', }, @@ -31,8 +33,8 @@ export const styles = stylex.create({ // fallback fills the box, centering its content and inheriting the sized font fallback: { alignItems: 'center', - backgroundColor: colorVars['--cl-color-neutral'], - color: colorVars['--cl-color-neutral-foreground'], + backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 8%, transparent)`, + color: colorVars['--cl-color-neutral'], display: 'flex', fontSize: 'inherit', justifyContent: 'center', @@ -49,8 +51,9 @@ export const shapes = stylex.create({ // size — square box; fallback text scales with the box via inherited font-size export const sizes = stylex.create({ - xs: { fontSize: '0.625rem', height: space['6'], width: space['6'] }, - sm: { fontSize: '0.75rem', height: space['8'], width: space['8'] }, - md: { fontSize: '0.875rem', height: space['10'], width: space['10'] }, + xs: { fontSize: '0.625rem', height: space['5'], width: space['5'] }, + sm: { fontSize: '0.75rem', height: space['7'], width: space['7'] }, + md: { fontSize: '0.875rem', height: space['9'], width: space['9'] }, lg: { fontSize: '1rem', height: space['12'], width: space['12'] }, + fit: { fontSize: '0.75rem', height: '100%', width: '100%' }, }); diff --git a/packages/ui/src/mosaic/components/avatar/avatar.tsx b/packages/ui/src/mosaic/components/avatar/avatar.tsx index 91298e96240..ab997c724a4 100644 --- a/packages/ui/src/mosaic/components/avatar/avatar.tsx +++ b/packages/ui/src/mosaic/components/avatar/avatar.tsx @@ -23,7 +23,7 @@ function useAvatarContext(part: string): AvatarContextValue { export interface AvatarProps extends React.ComponentPropsWithRef<'span'> { shape?: 'circle' | 'square'; - size?: 'lg' | 'md' | 'sm' | 'xs'; + size?: 'fit' | 'lg' | 'md' | 'sm' | 'xs'; } const AvatarRoot = React.forwardRef(function MosaicAvatarRoot( 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..9d8050d9ddf --- /dev/null +++ b/packages/ui/src/mosaic/components/item/index.ts @@ -0,0 +1,2 @@ +export { Item } from './item'; +export type { 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..9b83d54743d --- /dev/null +++ b/packages/ui/src/mosaic/components/item/item.styles.ts @@ -0,0 +1,143 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; + +export const item = stylex.create({ + base: { + margin: 0, + padding: 0, + borderRadius: radiusVars['--cl-radius-element'], + gap: space['3'], + outline: { + default: 'none', + ':focus-visible': `2px solid color-mix(in oklab, ${colorVars['--cl-color-primary']} 50%, transparent)`, + }, + paddingInline: space['2'], + alignItems: 'center', + boxSizing: 'border-box', + color: colorVars['--cl-color-card-foreground'], + display: 'flex', + fontFamily: 'inherit', + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + outlineOffset: '2px', + textAlign: 'start', + width: '100%', + }, + + // interactive rows (rendered as a link/button via `render`) gain hover + cursor + interactive: { + backgroundColor: { + default: null, + ':active': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 8%, transparent)`, + '@media (hover: hover)': { + ':hover': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`, + }, + }, + cursor: 'pointer', + }, + + // vertical density derived from the variant; horizontal padding is shared by the base + entity: { + paddingBlock: space['2'], + }, + action: { + paddingBlock: space['2.5'], + }, +}); + +export const media = stylex.create({ + base: { + alignItems: 'center', + display: 'flex', + flexShrink: 0, + justifyContent: 'center', + width: space['9'], + }, +}); + +export const content = stylex.create({ + base: { + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + justifyContent: 'center', + minWidth: 0, + }, +}); + +export const title = stylex.create({ + base: { + // Non-action rows inherit the row's foreground. Only `action` rows start faded and + // darken to neutral on hover. Both overrides are scoped to the marker that item.tsx + // sets exclusively on interactive `action` rows, so no other row's title is touched. + color: { + default: null, + [stylex.when.ancestor(':not(:hover)')]: colorVars['--cl-color-neutral-faded'], + [stylex.when.ancestor(':hover')]: colorVars['--cl-color-neutral'], + }, + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + }, +}); + +export const description = stylex.create({ + base: { + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-xs-size'], + fontWeight: fontWeightVars['--cl-font-normal'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, +}); + +export const actions = stylex.create({ + base: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + flexShrink: 0, + }, +}); + +export const header = stylex.create({ + base: { + paddingInline: space['2'], + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + width: '100%', + }, +}); + +export const headerTitle = stylex.create({ + base: { + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-xs-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, +}); + +export const headerActions = stylex.create({ + base: { + width: space['7'], + }, +}); + +export const group = stylex.create({ + base: { + padding: space['2'], + width: '100%', + }, +}); + +export const separator = stylex.create({ + base: { + borderStyle: 'none', + backgroundColor: colorVars['--cl-color-border-faded'], + 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..01515263e9d --- /dev/null +++ b/packages/ui/src/mosaic/components/item/item.test.tsx @@ -0,0 +1,110 @@ +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 its base class and is not interactive by default', () => { + render(Hi); + const item = screen.getByText('Hi'); + expect(item).toHaveClass('cl-item'); + expect(item).not.toHaveAttribute('data-interactive'); + }); + + it('reflects the variant prop as a data attribute, defaulting to entity', () => { + const { rerender } = render(Hi); + expect(screen.getByText('Hi')).toHaveAttribute('data-variant', 'entity'); + rerender(Hi); + expect(screen.getByText('Hi')).toHaveAttribute('data-variant', 'action'); + }); + + it('wires consumer className/style through to the element', () => { + render( + + Hi + , + ); + const item = screen.getByText('Hi'); + 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 media with its stable class', () => { + render( + + icon + , + ); + const media = screen.getByText('icon').parentElement; + expect(media).toHaveClass('cl-item-media'); + }); + + 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 and a separator without imposing a role', () => { + render( + + One + + Two + , + ); + const group = screen.getByTestId('group'); + expect(group).toHaveClass('cl-item-group'); + expect(group).not.toHaveAttribute('role'); + 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..3c850591355 --- /dev/null +++ b/packages/ui/src/mosaic/components/item/item.tsx @@ -0,0 +1,238 @@ +import { type RenderProp, 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 { truncationStyles } from '../typography.styles'; +import * as slots from './item.styles'; + +export type ItemProps = Omit, 'render'> & { + /** + * Visual treatment, which also sets the row's vertical density. `entity` (default) + * is a standard row; `action` is a denser row whose title color is promoted on + * interactive rows. + */ + variant?: 'entity' | 'action'; + /** Render a custom element (e.g. a link or button) in place of the default `div`. */ + render?: RenderProp>; +}; + +/** Root row. Renders a `
`, or a custom element (link/button) via `render`. */ +const Root = React.forwardRef(function MosaicItem( + { variant = 'entity', 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', { interactive, variant }), + stylex.props( + slots.item.base, + slots.item[variant], + interactive && slots.item.interactive, + // Marks action rows so `Item.Title` can react to their hover via `when.ancestor`. + interactive && variant === 'action' && stylex.defaultMarker(), + ), + className, + style, + ), + ...rest, + }, + }); +}); + +/** Fixed-width leading i that centers its media (icon, image, or avatar). */ +const Media = React.forwardRef>(function MosaicItemMedia( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('item-media'), stylex.props(slots.media.base), className, style), + ...rest, + }, + }); +}); + +/** Vertical stack (title + description) that grows to fill the row between media and actions. */ +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, + }, + }); +}); + +/** Primary label. On interactive `action` rows its color darkens on hover. */ +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, truncationStyles.singleLine), + className, + style, + ), + ...rest, + }, + }); +}); + +/** Secondary text beneath the title. */ +const Description = React.forwardRef>(function MosaicItemDescription( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('item-description'), + stylex.props(slots.description.base, truncationStyles.singleLine), + className, + style, + ), + ...rest, + }, + }); +}); + +/** Trailing controls (buttons, badges). */ +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, + }, + }); +}); + +/** Header row above a group: a label (`Item.HeaderTitle`) with optional `Item.HeaderActions`. */ +const Header = React.forwardRef>(function MosaicItemHeader( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('item-header'), stylex.props(slots.header.base), className, style), + ...rest, + }, + }); +}); + +/** Label text within an `Item.Header`. */ +const HeaderTitle = React.forwardRef>(function MosaicItemHeaderTitle( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('item-header-title'), stylex.props(slots.headerTitle.base), className, style), + ...rest, + }, + }); +}); + +/** Trailing controls within an `Item.Header`. */ +const HeaderActions = React.forwardRef>(function MosaicItemHeaderActions( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('item-header-actions'), stylex.props(slots.headerActions.base), className, style), + ...rest, + }, + }); +}); + +/** Vertical wrapper around a set of rows. Layout only; the rows carry their own semantics. */ +const Group = React.forwardRef>(function MosaicItemGroup( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('item-group'), stylex.props(slots.group.base), className, style), + ...rest, + }, + }); +}); + +/** Thin divider (`
`) between rows or groups. */ +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.Root`, `Item.Media`, `Item.Content`, + * `Item.Title`, `Item.Description`, `Item.Actions`, `Item.Header`, + * `Item.HeaderTitle`, `Item.HeaderActions`, `Item.Group`, `Item.Separator`. + */ +export const Item = { + Root, + Media, + Content, + Title, + Description, + Actions, + Header, + HeaderTitle, + HeaderActions, + Group, + Separator, +}; diff --git a/packages/ui/src/mosaic/components/typography.styles.ts b/packages/ui/src/mosaic/components/typography.styles.ts index ae0c6904db1..a267425e655 100644 --- a/packages/ui/src/mosaic/components/typography.styles.ts +++ b/packages/ui/src/mosaic/components/typography.styles.ts @@ -43,3 +43,20 @@ export const colors = stylex.create({ negative: { color: colorVars['--cl-color-negative'] }, positive: { color: colorVars['--cl-color-positive'] }, }); + +// Text truncation, composed by any component that clamps copy. +export const truncationStyles = stylex.create({ + // Single-line ellipsis. + singleLine: { + overflow: 'hidden', + display: 'block', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + // Multi-line clamp base; the caller sets `-webkit-line-clamp` via inline style. + multiLine: { + overflow: 'hidden', + WebkitBoxOrient: 'vertical', + display: '-webkit-box', + }, +}); diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 64242a1374d..3e7c18cb3ea 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -14,6 +14,8 @@ export { Heading, HeadingContext } from '../components/heading'; export type { HeadingProps } from '../components/heading'; export { Icon } from '../components/icon'; export type { IconProps } from '../components/icon'; +export { Item } from '../components/item'; +export type { ItemProps } from '../components/item'; export { Text, TextContext } from '../components/text'; export type { TextProps } from '../components/text'; diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 21eeb8c0911..96765b6fd73 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -28,8 +28,9 @@ const colorDefaults = { '--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-neutral': 'light-dark(oklch(0.97 0 0), oklch(0.32 0 0))', + '--cl-color-neutral': 'light-dark(oklch(0.2928 0.0163 285.35), oklch(0.9854 0.0013 286.38))', '--cl-color-neutral-foreground': 'light-dark(oklch(0.24 0 0), oklch(0.96 0 0))', + '--cl-color-neutral-faded': 'light-dark(oklch(0.5697 0.0246 279.94), oklch(0.6953 0.0261 285.7))', '--cl-color-negative': 'light-dark(oklch(0.577 0.245 27.325), oklch(0.637 0.237 25.331))', '--cl-color-negative-foreground': 'oklch(0.985 0 0)', @@ -45,7 +46,9 @@ const colorDefaults = { '--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%))', + + '--cl-color-border': 'light-dark(oklch(0.9475 0.0067 286.27), oklch(0.3321 0.014 285.61))', + '--cl-color-border-faded': 'light-dark(oklch(0.9587 0.0027 286.35), oklch(0.296 0.0126 285.61))', } as const; export const colorVars = stylex.defineVars(colorDefaults); 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/*"] + } } } diff --git a/references/mosaic-architecture.md b/references/mosaic-architecture.md index 11e90006b09..37133a94468 100644 --- a/references/mosaic-architecture.md +++ b/references/mosaic-architecture.md @@ -6,7 +6,7 @@ Mosaic is the next-generation design system for Clerk's UI components, replacing Mosaic uses Emotion for CSS-in-JS but delivers theme tokens via its own React context (not Emotion's `ThemeProvider`). This avoids type conflicts with the existing system's `InternalTheme` augmentation on Emotion's global `Theme` interface. -The public styling contract is **data attributes** (`data-cl-slot`, `data-cl-`, `data-cl-`), the same convention shipping in `@clerk/headless`. There is no classname derivation, no `__state` concatenation, and no central appearance-key registry. Components are authored with **slot recipes** (`defineSlotRecipe`), which own variants, slot identity, state→attribute mapping, and the appearance cascade in one place. +The public styling contract is a **stable per-slot class** plus **`data-` attributes**: a part emits `class="cl-"` for its identity (`.cl-button`, `.cl-item`) and `data-=""` / presence `data-` for its variants and state. Consumers target those, never StyleX's hashed atoms; tokens ship as overridable `--cl-*` custom properties. Newer components are authored with **StyleX** (`stylex.create` + the `themeProps`/`mergeStyleProps` helpers in `props.ts`). The slot-recipe engine described further below (`defineSlotRecipe`, `useRecipe`, and its older `data-cl-slot`/`data-cl-*` attribute contract) is the legacy authoring path still used by un-migrated components during the migration. Once migration is complete, the old system is removed and Mosaic becomes the sole design system. @@ -86,51 +86,45 @@ What we keep: `css` prop (with plain objects), `keyframes`, `Global`, style seri ## Public styling API -Every Mosaic part is targetable through stable data attributes — no classnames or registry keys to learn. A styled element emits: +Every Mosaic part carries a **stable class** and reflects its variants and state as **data attributes** — no hashed classnames or registry keys to learn. `themeProps(slot, variants)` (in `props.ts`) emits: -- `data-cl-slot=""` — the slot identity -- `data-cl-` — boolean state, presence-only (`data-cl-disabled=""`); omitted when the state is false -- `data-cl-=""` — the resolved variant (`data-cl-size="sm"`); boolean variant axes use presence semantics like state +- `class="cl-"` — the slot identity (`.cl-button`, `.cl-item`, `.cl-item-title`) +- `data-=""` — the resolved variant (`data-variant="outline"`, `data-size="sm"`) +- `data-` — boolean state or boolean variant, presence-only (`data-interactive=""`); omitted when the value is false/null -Defaults are emitted too, so e.g. `data-cl-size="md"` vs `"sm"` is always distinguishable. +Consumers target the class and its attributes, never StyleX's hashed `x…` atoms. -Three ways to style a part — all target the **same** attributes: +Two ways to style a part — both hit the same class + attributes: ```css /* 1. Plain CSS / stylesheet */ -[data-cl-slot='button'] { +.cl-button { border-radius: 8px; } -[data-cl-slot='button'][data-cl-size='sm'] { +.cl-button[data-size='sm'] { border-radius: 4px; } -[data-cl-slot='button'][data-cl-disabled] { - opacity: 0.4; +.cl-item[data-interactive] { + background-color: var(--cl-color-card); } ``` ```tsx -// 2. appearance.elements — keyed by slot id; state/variant via nested conditions or attr selectors -appearance={{ - elements: { - button: { - color: 'lime', - _disabled: { opacity: 0.4 }, // condition key - "&[data-cl-size='sm']": { borderRadius: 4 }, // raw attr selector - }, - }, -}} +// 2. className / style props — merged onto the element by `mergeStyleProps`, applied last so they win +