From 3d4bddb000c438495f580539f20531dec32aceb4 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 9 Oct 2026 18:49:52 -0400 Subject: [PATCH 1/2] feat(mosaic): add an Entity component for the members table Item gives a row a fixed height and inline padding, which pushes the member cell's content away from the table edge. Entity sizes to its content instead. --- .changeset/mosaic-entity.md | 2 + .../src/components/entity/entity.styles.ts | 55 +++++++++++ .../src/components/entity/entity.test.tsx | 63 +++++++++++++ .../mosaic/src/components/entity/entity.tsx | 93 +++++++++++++++++++ .../mosaic/src/components/entity/index.ts | 1 + .../members-table-tab.styles.ts | 3 - .../members-table-tab.view.tsx | 20 ++-- .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 16 ++++ packages/swingset/src/stories/entity.mdx | 86 +++++++++++++++++ .../swingset/src/stories/entity.stories.tsx | 87 +++++++++++++++++ 11 files changed, 414 insertions(+), 13 deletions(-) create mode 100644 .changeset/mosaic-entity.md create mode 100644 packages/mosaic/src/components/entity/entity.styles.ts create mode 100644 packages/mosaic/src/components/entity/entity.test.tsx create mode 100644 packages/mosaic/src/components/entity/entity.tsx create mode 100644 packages/mosaic/src/components/entity/index.ts create mode 100644 packages/swingset/src/stories/entity.mdx create mode 100644 packages/swingset/src/stories/entity.stories.tsx diff --git a/.changeset/mosaic-entity.md b/.changeset/mosaic-entity.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-entity.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/entity/entity.styles.ts b/packages/mosaic/src/components/entity/entity.styles.ts new file mode 100644 index 00000000000..e29c954237a --- /dev/null +++ b/packages/mosaic/src/components/entity/entity.styles.ts @@ -0,0 +1,55 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontFamilyVars, fontWeightVars, space, typeScaleVars } from '../../tokens.stylex'; + +export const entity = stylex.create({ + base: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + fontFamily: fontFamilyVars['--cl-font-family-sans'], + minWidth: 0, + }, +}); + +export const media = stylex.create({ + base: { + alignItems: 'center', + aspectRatio: '1/1', + display: 'flex', + flexShrink: 0, + justifyContent: 'center', + width: space['9.5'], + }, +}); + +export const content = stylex.create({ + base: { + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + minWidth: 0, + }, +}); + +export const label = stylex.create({ + base: { + gap: space['2'], + alignItems: 'center', + color: colorVars['--cl-color-foreground'], + display: 'flex', + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + minWidth: 0, + }, +}); + +export const description = stylex.create({ + base: { + color: colorVars['--cl-color-foreground-secondary'], + fontSize: typeScaleVars['--cl-text-xs-size'], + fontWeight: fontWeightVars['--cl-font-normal'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, +}); diff --git a/packages/mosaic/src/components/entity/entity.test.tsx b/packages/mosaic/src/components/entity/entity.test.tsx new file mode 100644 index 00000000000..00ba3e87f6f --- /dev/null +++ b/packages/mosaic/src/components/entity/entity.test.tsx @@ -0,0 +1,63 @@ +import * as stylex from '@stylexjs/stylex'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { Entity } from './entity'; + +const overrides = stylex.create({ + root: { marginTop: '8px' }, +}); + +const atoms = (style: stylex.StyleXStyles) => + (stylex.props(style).className ?? '').split(' ').filter(name => /^x[a-z0-9]+$/.test(name)); + +describe('Mosaic Entity', () => { + it('renders the composed parts with their stable classes', () => { + render( + + + avatar + + + Cameron Walker + cameron@clerk.com + + , + ); + expect(screen.getByTestId('root')).toHaveClass('cl-entity'); + expect(screen.getByTestId('media')).toHaveClass('cl-entity-media'); + expect(screen.getByTestId('content')).toHaveClass('cl-entity-content'); + expect(screen.getByTestId('label')).toHaveClass('cl-entity-label'); + expect(screen.getByText('cameron@clerk.com')).toHaveClass('cl-entity-description'); + }); + + it('gives the label text its own box to truncate, leaving a badge beside it whole', () => { + render( + + Cameron Walker + Default + , + ); + const label = screen.getByTestId('label'); + expect(screen.getByText('Cameron Walker')).not.toBe(label); + expect(screen.getByText('Cameron Walker').parentElement).toBe(label); + expect(screen.getByText('Default').parentElement).toBe(label); + }); + + it('applies xstyle to the root element', () => { + render(Hi); + expect(screen.getByText('Hi')).toHaveClass('cl-entity', ...atoms(overrides.root)); + }); + + it('merges a render-sourced className instead of clobbering its own', () => { + render(}>Hi); + expect(screen.getByText('Hi')).toHaveClass('cl-entity-content', 'cl-entity-description'); + }); + + 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/mosaic/src/components/entity/entity.tsx b/packages/mosaic/src/components/entity/entity.tsx new file mode 100644 index 00000000000..0f3056ff5e6 --- /dev/null +++ b/packages/mosaic/src/components/entity/entity.tsx @@ -0,0 +1,93 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import { useRender } from '../../primitives/utils'; +import type { MosaicComponentProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../styles/reset.styles'; +import { truncationStyles } from '../../styles/typography.styles'; +import { withTruncatableLabel } from '../../utils/truncatable-label'; +import * as slots from './entity.styles'; + +const Root = React.forwardRef>(function MosaicEntity( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('entity'), stylex.props(reset.base, slots.entity.base, xstyle), rest), + }, + }); +}); + +const Media = React.forwardRef>(function MosaicEntityMedia( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('entity-media'), stylex.props(reset.base, slots.media.base, xstyle), rest), + }, + }); +}); + +const Content = React.forwardRef>(function MosaicEntityContent( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('entity-content'), stylex.props(reset.base, slots.content.base, xstyle), rest), + }, + }); +}); + +const Label = React.forwardRef>(function MosaicEntityLabel( + { children, render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('entity-label'), stylex.props(reset.base, slots.label.base, xstyle), rest), + children: withTruncatableLabel(children), + }, + }); +}); + +const Description = React.forwardRef>(function MosaicEntityDescription( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('entity-description'), + stylex.props(reset.base, slots.description.base, truncationStyles.singleLine, xstyle), + rest, + ), + }, + }); +}); + +export const Entity = { + Root, + Media, + Content, + Label, + Description, +}; diff --git a/packages/mosaic/src/components/entity/index.ts b/packages/mosaic/src/components/entity/index.ts new file mode 100644 index 00000000000..df2ed8a10b9 --- /dev/null +++ b/packages/mosaic/src/components/entity/index.ts @@ -0,0 +1 @@ +export { Entity } from './entity'; diff --git a/packages/mosaic/src/features/organization-profile/members-table-tab.styles.ts b/packages/mosaic/src/features/organization-profile/members-table-tab.styles.ts index 85f3d871191..74a8f6f6fa8 100644 --- a/packages/mosaic/src/features/organization-profile/members-table-tab.styles.ts +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.styles.ts @@ -1,8 +1,5 @@ import * as stylex from '@stylexjs/stylex'; -import { space } from '../../tokens.stylex'; - export const styles = stylex.create({ deprovisionedRow: { opacity: 0.5 }, - name: { gap: space['2'], alignItems: 'center', display: 'flex' }, }); diff --git a/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx index 648241b9523..ca31a4a76fc 100644 --- a/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx @@ -8,8 +8,8 @@ import { Badge } from '../../components/badge'; import { Banner } from '../../components/banner'; import { Button } from '../../components/button'; import { EmptyState } from '../../components/empty-state'; +import { Entity } from '../../components/entity'; import { Icon } from '../../components/icon'; -import { Item } from '../../components/item'; import { Pagination } from '../../components/pagination'; import { Select } from '../../components/select'; import { Spinner } from '../../components/spinner'; @@ -317,8 +317,8 @@ function MemberRow({ /> ) : null} - - + + - - - + + + {member.name} {member.isCurrentUser ? ( {m.you} @@ -342,10 +342,10 @@ function MemberRow({ ) : member.isBanned ? ( {m.banned} ) : null} - - {member.email} - - + + {member.email} + + {member.joinedAtLabel} diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index d9b5899decc..55d1553b36f 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -80,6 +80,7 @@ const docModules: Record> = { dialog: dynamic(() => import('../stories/dialog.component.mdx')), drawer: dynamic(() => import('../stories/drawer.component.mdx')), 'empty-state': dynamic(() => import('../stories/empty-state.mdx')), + entity: dynamic(() => import('../stories/entity.mdx')), heading: dynamic(() => import('../stories/heading.mdx')), icon: dynamic(() => import('../stories/icon.mdx')), 'icon-frame': dynamic(() => import('../stories/icon-frame.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index a3fdf4645fb..eca26bc32c0 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -81,6 +81,13 @@ import { meta as emptyStateMeta, NoResults as EmptyStateNoResults, } from '../stories/empty-state.stories'; +import { + Default as EntityDefault, + meta as entityMeta, + LabelOnly as EntityLabelOnly, + Truncation as EntityTruncation, + WithBadge as EntityWithBadge, +} from '../stories/entity.stories'; import { Default as FieldDefault, meta as fieldMeta, @@ -593,6 +600,14 @@ const dataListModule: StoryModule = { Plain: DataListPlain, }; +const entityModule: StoryModule = { + meta: entityMeta, + Default: EntityDefault, + WithBadge: EntityWithBadge, + LabelOnly: EntityLabelOnly, + Truncation: EntityTruncation, +}; + const itemModule: StoryModule = { meta: itemMeta, Default: ItemDefault, @@ -994,6 +1009,7 @@ export const registry: StoryModule[] = [ dialogComponentModule, drawerComponentModule, emptyStateModule, + entityModule, headingModule, iconModule, iconFrameModule, diff --git a/packages/swingset/src/stories/entity.mdx b/packages/swingset/src/stories/entity.mdx new file mode 100644 index 00000000000..2dfad36b43d --- /dev/null +++ b/packages/swingset/src/stories/entity.mdx @@ -0,0 +1,86 @@ +import * as EntityStories from './entity.stories'; + +# Entity + +Entity shows a person or organization as media, a label and a description, such as a member in a table cell. It's composed from parts via dot syntax (`Entity.Root`, `Entity.Media`, `Entity.Content`, `Entity.Label`, `Entity.Description`). It has no padding and no fixed height, so it sizes to its content and sits flush inside its container. + +## Example + + + +### With badge + +Elements inside `Entity.Label`, such as a `Badge`, sit beside the name. + + + +### Label only + + + +### Truncation + +the label text and the description each truncate to a single line. A badge in the label stays whole. + + + +## Usage + +```tsx +import { Avatar } from '@clerk/mosaic/components/avatar'; +import { Badge } from '@clerk/mosaic/components/badge'; +import { Entity } from '@clerk/mosaic/components/entity'; + + + + + + + + + + + {member.name} + You + + {member.email} + +; +``` + +Give `Entity.Media` a child that fills it, such as an `Avatar.Root` with `size='fit'`. + +## Parts + +| Part | Class | Description | +| -------------------- | ----------------------- | --------------------------------------------------------------------- | +| `Entity.Root` | `cl-entity` | Root row. Renders a `
`. | +| `Entity.Media` | `cl-entity-media` | 38px square leading column that centers its child. | +| `Entity.Content` | `cl-entity-content` | Vertical stack that fills the row beside the media. | +| `Entity.Label` | `cl-entity-label` | The entity's name. Its text truncates; elements beside it stay whole. | +| `Entity.Description` | `cl-entity-description` | Secondary text beneath the label. Truncates to a single line. | + +Every part accepts a `render` prop for element polymorphism and forwards a ref. + +## Styling + +Each part carries its `.cl-entity-*` class and reflects no `data-*` attributes. Colors, type and spacing resolve from the Mosaic tokens (`--cl-color-foreground`, `--cl-color-foreground-secondary`, `--cl-text-sm-*`, `--cl-text-xs-*`, `--cl-spacing`). + +```css +/* Enlarge the media column */ +.cl-entity-media { + width: 3rem; +} +``` diff --git a/packages/swingset/src/stories/entity.stories.tsx b/packages/swingset/src/stories/entity.stories.tsx new file mode 100644 index 00000000000..9feb5dfa2d6 --- /dev/null +++ b/packages/swingset/src/stories/entity.stories.tsx @@ -0,0 +1,87 @@ +import { Avatar } from '@clerk/mosaic/components/avatar'; +import { Badge } from '@clerk/mosaic/components/badge'; +import { Entity } from '@clerk/mosaic/components/entity'; + +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 './entity.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + status: 'stable', + title: 'Entity', + source: 'packages/mosaic/src/components/entity/entity.tsx', +}; + +export function Default() { + return ( + + + + + + + + Cameron Walker + cameron@clerk.com + + + ); +} + +export function WithBadge() { + return ( + + + + + + + + + Cameron Walker + Default + + cameron@clerk.com + + + ); +} + +export function LabelOnly() { + return ( + + + + + + + + cameron@clerk.com + + + ); +} + +export function Truncation() { + return ( +
+ + + + + + + + + Cameron Alexander Walker-Montgomery + Default + + cameron.alexander.walker@clerk.com + + +
+ ); +} From 7988ebbde28c1290ae7b44ec4064fe8326fcf7b7 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 9 Oct 2026 18:53:57 -0400 Subject: [PATCH 2/2] fix(swingset): sort the Entity story imports --- packages/swingset/src/lib/registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index eca26bc32c0..1de0aac484f 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -83,8 +83,8 @@ import { } from '../stories/empty-state.stories'; import { Default as EntityDefault, - meta as entityMeta, LabelOnly as EntityLabelOnly, + meta as entityMeta, Truncation as EntityTruncation, WithBadge as EntityWithBadge, } from '../stories/entity.stories';