From 8e3e1b7dba90501e095b9454f8065b2f040e83b0 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 3 Nov 2026 13:05:17 -0500 Subject: [PATCH 1/2] feat(mosaic): add Separator --- .changeset/mosaic-separator.md | 2 + .../mosaic/src/components/separator/index.ts | 2 + .../components/separator/separator.styles.ts | 33 +++++++++++ .../components/separator/separator.test.tsx | 55 +++++++++++++++++++ .../src/components/separator/separator.tsx | 45 +++++++++++++++ .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 12 ++++ packages/swingset/src/stories/separator.mdx | 46 ++++++++++++++++ .../src/stories/separator.stories.tsx | 20 +++++++ 9 files changed, 216 insertions(+) create mode 100644 .changeset/mosaic-separator.md create mode 100644 packages/mosaic/src/components/separator/index.ts create mode 100644 packages/mosaic/src/components/separator/separator.styles.ts create mode 100644 packages/mosaic/src/components/separator/separator.test.tsx create mode 100644 packages/mosaic/src/components/separator/separator.tsx create mode 100644 packages/swingset/src/stories/separator.mdx create mode 100644 packages/swingset/src/stories/separator.stories.tsx diff --git a/.changeset/mosaic-separator.md b/.changeset/mosaic-separator.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-separator.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/separator/index.ts b/packages/mosaic/src/components/separator/index.ts new file mode 100644 index 00000000000..49434482dbb --- /dev/null +++ b/packages/mosaic/src/components/separator/index.ts @@ -0,0 +1,2 @@ +export { Separator } from './separator'; +export type { SeparatorProps } from './separator'; diff --git a/packages/mosaic/src/components/separator/separator.styles.ts b/packages/mosaic/src/components/separator/separator.styles.ts new file mode 100644 index 00000000000..16216a64e20 --- /dev/null +++ b/packages/mosaic/src/components/separator/separator.styles.ts @@ -0,0 +1,33 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontFamilyVars, space, typeScaleVars } from '../../tokens.stylex'; + +export const styles = stylex.create({ + plain: { + backgroundColor: colorVars['--cl-color-border'], + height: '1px', + width: '100%', + }, + labelled: { + gap: space['4'], + alignItems: 'center', + color: colorVars['--cl-color-foreground-secondary'], + display: 'flex', + fontFamily: fontFamilyVars['--cl-font-family-sans'], + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + width: '100%', + '::after': { + backgroundColor: colorVars['--cl-color-border'], + content: '""', + flexGrow: 1, + height: '1px', + }, + '::before': { + backgroundColor: colorVars['--cl-color-border'], + content: '""', + flexGrow: 1, + height: '1px', + }, + }, +}); diff --git a/packages/mosaic/src/components/separator/separator.test.tsx b/packages/mosaic/src/components/separator/separator.test.tsx new file mode 100644 index 00000000000..2af7a693c85 --- /dev/null +++ b/packages/mosaic/src/components/separator/separator.test.tsx @@ -0,0 +1,55 @@ +import * as stylex from '@stylexjs/stylex'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { Separator } from './separator'; + +const atoms = stylex.create({ + spaced: { marginTop: '8px' }, +}); + +describe('Mosaic Separator', () => { + it('renders a plain separator without children', () => { + render(); + const separator = screen.getByRole('separator'); + expect(separator).toHaveClass('cl-separator'); + expect(separator).not.toHaveAttribute('data-labelled'); + expect(separator).toBeEmptyDOMElement(); + }); + + it('renders a labelled separator with children', () => { + render(or); + const separator = screen.getByRole('separator'); + expect(separator).toHaveTextContent('or'); + expect(separator).toHaveAttribute('data-labelled', ''); + }); + + it.each([ + ['null', null], + ['false', false], + ['an empty string', ''], + ])('renders the plain separator when children is %s', (_, children) => { + render({children}); + expect(screen.getByRole('separator')).not.toHaveAttribute('data-labelled'); + }); + + it('merges xstyle atoms after the slot atoms', () => { + render(); + expect(screen.getByRole('separator')).toHaveClass('cl-separator', stylex.props(atoms.spaced).className ?? ''); + }); + + it('renders a custom element via render, keeping the styling contract', () => { + render(}>or); + const separator = screen.getByRole('separator'); + expect(separator.tagName).toBe('SECTION'); + expect(separator).toHaveClass('cl-separator'); + expect(separator).toHaveAttribute('data-labelled', ''); + }); + + it('forwards the ref', () => { + const ref = React.createRef(); + render(); + expect(ref.current).toBe(screen.getByRole('separator')); + }); +}); diff --git a/packages/mosaic/src/components/separator/separator.tsx b/packages/mosaic/src/components/separator/separator.tsx new file mode 100644 index 00000000000..632894797a5 --- /dev/null +++ b/packages/mosaic/src/components/separator/separator.tsx @@ -0,0 +1,45 @@ +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 { styles } from './separator.styles'; + +export type SeparatorProps = MosaicComponentProps<'div'>; + +/** + * A horizontal rule between blocks of content. Without children it is a plain 1px line; + * with children it is a labelled divider, with a line on each side of the label. + * + * @example + * + * + * @example + * or + */ +export const Separator = React.forwardRef(function MosaicSeparator( + { children, render, xstyle, ...rest }, + ref, +) { + const labelled = hasLabel(children); + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('separator', { labelled }), + stylex.props(reset.base, labelled ? styles.labelled : styles.plain, xstyle), + rest, + ), + role: 'separator', + children, + }, + }); +}); + +function hasLabel(children: React.ReactNode): boolean { + return children != null && children !== false && children !== ''; +} diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 8da091501ad..70a536937e0 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -98,6 +98,7 @@ const docModules: Record> = { radio: dynamic(() => import('../stories/radio.mdx')), reveal: dynamic(() => import('../stories/reveal.component.mdx')), section: dynamic(() => import('../stories/section.mdx')), + separator: dynamic(() => import('../stories/separator.mdx')), 'segmented-control': dynamic(() => import('../stories/segmented-control.mdx')), select: dynamic(() => import('../stories/select.component.mdx')), switch: dynamic(() => import('../stories/switch.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 5ef28810665..1b188c57eaa 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -305,6 +305,11 @@ import { Disabled as SegmentedControlDisabled, meta as segmentedControlMeta, } from '../stories/segmented-control.stories'; +import { + Default as SeparatorDefault, + Labelled as SeparatorLabelled, + meta as separatorMeta, +} from '../stories/separator.stories'; import { Controlled as SelectComponentControlled, Default as SelectComponentDefault, @@ -903,6 +908,12 @@ const segmentedControlModule: StoryModule = { Disabled: SegmentedControlDisabled, }; +const separatorModule: StoryModule = { + meta: separatorMeta, + Default: SeparatorDefault, + Labelled: SeparatorLabelled, +}; + const switchModule: StoryModule = { meta: switchMeta, Default: SwitchDefault, @@ -1179,6 +1190,7 @@ export const registry: StoryModule[] = [ revealModule, sectionModule, segmentedControlModule, + separatorModule, selectComponentModule, switchModule, tableModule, diff --git a/packages/swingset/src/stories/separator.mdx b/packages/swingset/src/stories/separator.mdx new file mode 100644 index 00000000000..af88be8a4c9 --- /dev/null +++ b/packages/swingset/src/stories/separator.mdx @@ -0,0 +1,46 @@ +import * as SeparatorStories from './separator.stories'; + +# Separator + +Separator draws a horizontal line between blocks of content. Without children it is a plain 1px line. With children it is a labelled divider, with a line on each side of the label. It renders a `div` with `role='separator'`; the label is presentational and is not announced by screen readers. + +## Example + + + +## Usage + +```tsx +import { Separator } from '@clerk/mosaic/components/separator'; + + +or +``` + + + +## Parts + +| Part | Element | Class | Description | +| ----------- | ------- | --------------- | --------------------------------------------------------------- | +| `Separator` | `div` | `.cl-separator` | The line, or with children the label and a line on either side. | + +`Separator` accepts `render` and `xstyle` and forwards its ref. + +## Styling + +`data-labelled` is set when the separator has children. The labelled lines are the root's `::before` and `::after`. Both forms read `--cl-color-border` for the line; the label reads `--cl-color-foreground-secondary`. + +```css +.cl-separator:not([data-labelled]), +.cl-separator::before, +.cl-separator::after { + background-color: var(--cl-color-border-subtle); +} +``` diff --git a/packages/swingset/src/stories/separator.stories.tsx b/packages/swingset/src/stories/separator.stories.tsx new file mode 100644 index 00000000000..8f238b69d81 --- /dev/null +++ b/packages/swingset/src/stories/separator.stories.tsx @@ -0,0 +1,20 @@ +import { Separator } from '@clerk/mosaic/components/separator'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './separator.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + status: 'stable', + title: 'Separator', + source: 'packages/mosaic/src/components/separator/separator.tsx', +}; + +export function Default() { + return ; +} + +export function Labelled() { + return or; +} From 65bcdeed61cd78ce3d27180af2ceb6f106b35881 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 3 Nov 2026 14:21:22 -0500 Subject: [PATCH 2/2] fix(mosaic): include Separator in the CSS build - packages/mosaic/src/styles/index.ts - export Separator from the CSS barrel so its styles reach dist/styles.css - packages/swingset/src/lib/registry.ts - sort the separator stories import (Static analysis lint) --- packages/mosaic/src/styles/index.ts | 2 ++ packages/swingset/src/lib/registry.ts | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts index 47c479b561f..5b0848cf3ae 100644 --- a/packages/mosaic/src/styles/index.ts +++ b/packages/mosaic/src/styles/index.ts @@ -120,6 +120,8 @@ export type { } from '../components/table'; export { SegmentedControl } from '../components/segmented-control'; export type { SegmentedControlItemProps, SegmentedControlRootProps } from '../components/segmented-control'; +export { Separator } from '../components/separator'; +export type { SeparatorProps } from '../components/separator'; export { Tabs } from '../components/tabs'; export type { TabsListProps, diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 1b188c57eaa..460afde6778 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -305,11 +305,6 @@ import { Disabled as SegmentedControlDisabled, meta as segmentedControlMeta, } from '../stories/segmented-control.stories'; -import { - Default as SeparatorDefault, - Labelled as SeparatorLabelled, - meta as separatorMeta, -} from '../stories/separator.stories'; import { Controlled as SelectComponentControlled, Default as SelectComponentDefault, @@ -320,6 +315,11 @@ import { Placeholder as SelectComponentPlaceholder, } from '../stories/select.component.stories'; import { meta as selectMeta } from '../stories/select.stories'; +import { + Default as SeparatorDefault, + Labelled as SeparatorLabelled, + meta as separatorMeta, +} from '../stories/separator.stories'; import { Elements as SkeletonElements, HoldSpace as SkeletonHoldSpace,