Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a763cec
feat(ui): Introduce mosaic recipes and conditions
alexcarpenter Jun 11, 2026
19f2724
resolve className
alexcarpenter Jun 11, 2026
ddcbced
fix(ui): use useInsertionEffect to wrap emotion styles in @layer
alexcarpenter Jun 11, 2026
5d3c280
fix(swingset): wire cssLayerName and defer render until mounted
alexcarpenter Jun 11, 2026
6f2b1d1
refactor(headless): stop emitting data-cl-slot from dialog parts
alexcarpenter Jun 11, 2026
7187514
feat(ui): migrate mosaic Dialog to slot recipes
alexcarpenter Jun 11, 2026
cfdfa2c
refactor(ui): use color variant on Button and omit native color prop
alexcarpenter Jun 11, 2026
96d10e8
fix(ui): remove emotion @layer insertion effect from MosaicProvider
alexcarpenter Jun 11, 2026
157fec5
feat(swingset): add mosaic Dialog story with group-aware routing
alexcarpenter Jun 11, 2026
fb83adb
test(ui): drop @layer cssLayerName assertions for removed insertion e…
alexcarpenter Jun 11, 2026
32f8997
Update mosaic-architecture.md
alexcarpenter Jun 11, 2026
d46e35c
Merge branch 'main' into carp/mosaic-recipes
alexcarpenter Jun 11, 2026
f64269d
feat(ui): add mosaic block, section, and aio prototype layers
alexcarpenter Jun 11, 2026
5975b38
fix(swingset): sort imports and extract trigger component for ESLint
alexcarpenter Jun 11, 2026
df54c31
feat(ui): add Mosaic Delete Org section and destructive button varian…
kylemac Jun 12, 2026
8bb085d
cleanup
alexcarpenter Jun 12, 2026
f3c3e2d
mock organization store
alexcarpenter Jun 12, 2026
58e57f2
Update destructive.stories.tsx
alexcarpenter Jun 12, 2026
b2ef9ff
rough in skeleton
alexcarpenter Jun 12, 2026
356010b
Update skeleton.tsx
alexcarpenter Jun 12, 2026
4d005fe
rough in tabs
alexcarpenter Jun 12, 2026
88f7d9d
extract panel
alexcarpenter Jun 12, 2026
0aa335b
fix dark mode
alexcarpenter Jun 12, 2026
541c4e6
chore(ui): move alien-signals to devDependencies
alexcarpenter Jun 12, 2026
b7f8e2c
Merge remote-tracking branch 'origin/main' into carp/mosaic-block-des…
alexcarpenter Jun 12, 2026
3907704
fix(swingset): sort imports in registry after merge resolution
alexcarpenter Jun 12, 2026
d4ba05b
Delete .changeset/delete-org-block.md
alexcarpenter Jun 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/calm-aliens-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
18 changes: 12 additions & 6 deletions packages/headless/src/primitives/tabs/tabs-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
'use client';

import { useMergeRefs } from '@floating-ui/react';
import React, { useRef } from 'react';
import React, { useRef, version } from 'react';

import { useTransition } from '../../hooks/use-transition';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useTabsContext } from './tabs-context';

const major = parseInt(version, 10);
const isModernReact = major >= 19 || major === 0;

export function inertProps(active: boolean): Record<string, unknown> {
if (!active) {
return {};
}
return { inert: isModernReact ? true : '' };
}
Comment on lines +10 to +18

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

temporary til #8820 lands


export interface TabsPanelProps extends ComponentProps<'div'> {
value: string;
/** When true, removes `hidden` so the panel stays in layout flow. */
Expand Down Expand Up @@ -51,11 +61,7 @@ export const TabsPanel = React.forwardRef<HTMLDivElement, TabsPanelProps>(functi
role: 'tabpanel' as const,
'aria-labelledby': tabId,
tabIndex: 0,
// `inert` must be a truthy string, not a boolean or empty string, to stay
// correct across React 18 and 19: React 18 drops a boolean `true` and React
// 19 treats `''` as falsy. `'true'` renders the (presence-based) attribute in
// both. Matches the existing pattern in packages/ui PricingTableMatrix.
inert: !isSelected ? 'true' : undefined,
...inertProps(!isSelected),
hidden: !isSelected && !shouldForceMount ? true : undefined,
ref: combinedRef,
...(shouldForceMount
Expand Down
2 changes: 2 additions & 0 deletions packages/swingset/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const nextConfig = {
webpack(config) {
config.resolve.alias['@clerk/ui/mosaic'] = resolve(__dirname, '../ui/src/mosaic');
// Consume @clerk/headless primitives from source (no dist build needed), mirroring Mosaic.
// `/utils` lives outside `primitives/`, so alias it first (more specific wins).
config.resolve.alias['@clerk/headless/utils'] = resolve(__dirname, '../headless/src/utils');
config.resolve.alias['@clerk/headless'] = resolve(__dirname, '../headless/src/primitives');
return config;
},
Expand Down
63 changes: 63 additions & 0 deletions packages/swingset/src/components/Composition.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
'use client';

import Link from 'next/link';

export interface CompositionPiece {
/** Display name of the piece (e.g. `Destructive`). */
name: string;
/** Route to the piece's page in swingset (e.g. `/blocks/destructive`). */
href: string;
/** Which Mosaic layer the piece lives in (e.g. `Blocks`, `Components`, `Primitives`). */
layer: string;
}

// Mosaic layers, high → low. Drives the order the composition groups render in.
// Plural to match the sidebar group names.
const LAYER_ORDER = ['AIO', 'Sections', 'Blocks', 'Components', 'Primitives'];

function layerRank(layer: string): number {
const i = LAYER_ORDER.indexOf(layer);
return i === -1 ? LAYER_ORDER.length : i;
}

/**
* The linked pieces shown inside a `<Story>`'s attached "Composition" footer, sorted
* and grouped by Mosaic layer. Each piece links to its own page.
*/
export function CompositionPanel({ pieces }: { pieces: CompositionPiece[] }) {
const groups = new Map<string, CompositionPiece[]>();
for (const piece of pieces) {
if (!groups.has(piece.layer)) {
groups.set(piece.layer, []);
}
groups.get(piece.layer)?.push(piece);
}

const sortedLayers = Array.from(groups.keys()).sort((a, b) => layerRank(a) - layerRank(b) || a.localeCompare(b));

return (
<div className='flex flex-col gap-4 p-3'>
{sortedLayers.map(layer => (
<section
key={layer}
className='flex flex-col gap-2'
>
<div className='text-brand text-[10px] font-semibold uppercase tracking-widest'>{layer}</div>
{groups
.get(layer)
?.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(piece => (
<Link
key={piece.href}
href={piece.href}
className='text-muted-foreground hover:text-foreground font-mono text-xs'
>
{`<${piece.name} />`}
</Link>
))}
</section>
))}
</div>
);
}
14 changes: 14 additions & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,24 @@ import { ViewSource } from './ViewSource';
// MDX docs keyed by `group` slug → `component` slug. Group-aware so identically-named
// entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct.
const docModules: Record<string, Record<string, React.ComponentType>> = {
aio: {
'organization-profile': dynamic(() => import('../stories/organization-profile.mdx')),
},
panels: {
'organization-profile-general': dynamic(() => import('../stories/organization-profile-general.mdx')),
},
sections: {
'leave-organization': dynamic(() => import('../stories/leave-organization.mdx')),
'delete-organization': dynamic(() => import('../stories/delete-organization.mdx')),
},
blocks: {
destructive: dynamic(() => import('../stories/destructive.mdx')),
},
components: {
button: dynamic(() => import('../stories/button.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
tabs: dynamic(() => import('../stories/tabs.component.mdx')),
},
primitives: {
// Headless primitives — alphabetical.
Expand Down
41 changes: 38 additions & 3 deletions packages/swingset/src/components/StoryEmbed.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,65 @@
'use client';

import { MosaicProvider } from '@clerk/ui/mosaic/MosaicProvider';
import { Layers2Icon } from 'lucide-react';
import type React from 'react';
import { useState } from 'react';

import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { generateKnobs, initKnobValues } from '@/lib/generateKnobs';
import type { StoryModule } from '@/lib/types';

import type { CompositionPiece } from './Composition';
import { CompositionPanel } from './Composition';

interface StoryEmbedProps {
name: string;
storyModule: StoryModule;
/** When provided, a collapsible "Composition" footer is attached to the example card. */
composition?: CompositionPiece[];
}

export function StoryEmbed({ name, storyModule }: StoryEmbedProps) {
export function StoryEmbed({ name, storyModule, composition }: StoryEmbedProps) {
const StoryComp = storyModule[name] as React.ComponentType<Record<string, unknown>>;
const [compositionOpen, setCompositionOpen] = useState(false);

if (!StoryComp) {
return <div className='rounded bg-red-50 p-3 text-sm text-red-500'>Story &quot;{name}&quot; not found</div>;
}

const knobs = generateKnobs(storyModule.meta);
const defaultValues = initKnobValues(knobs);

return (
<div className='not-prose border-border bg-background my-4 flex min-h-20 items-center justify-center rounded-lg border p-6'>
const preview = (
<div className='flex min-h-20 items-center justify-center p-6'>
<MosaicProvider>
<StoryComp {...defaultValues} />
</MosaicProvider>
</div>
);

if (!composition) {
return <div className='not-prose border-border bg-background my-4 rounded-lg border'>{preview}</div>;
}

return (
<Collapsible
open={compositionOpen}
onOpenChange={setCompositionOpen}
className='not-prose border-border bg-background my-4 overflow-hidden rounded-lg border'
>
{preview}

<div className='flex items-center justify-start gap-1 border-t px-2 py-1.5'>
<CollapsibleTrigger className='text-muted-foreground hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground flex items-center gap-1 rounded px-2 py-1 text-xs'>
<Layers2Icon className='size-3' />
Composition
</CollapsibleTrigger>
</div>

<CollapsibleContent className='border-t'>
<CompositionPanel pieces={composition} />
</CollapsibleContent>
</Collapsible>
);
}
2 changes: 1 addition & 1 deletion packages/swingset/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
isActive={pathname === href}
render={<Link href={href} />}
>
<span className='truncate'>{mod.meta.title}</span>
<span className='truncate'>{mod.meta.label ?? mod.meta.title}</span>
<span className='text-sidebar-foreground/50 shrink-0 font-mono text-[10px] leading-none'>
{`<${mod.meta.title} />`}
</span>
Expand Down
40 changes: 40 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { meta as accordionMeta } from '../stories/accordion.stories';
import { meta as autocompleteMeta } from '../stories/autocomplete.stories';
import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories';
import { meta as collapsibleMeta } from '../stories/collapsible.stories';
import {
Default as DeleteOrganizationDefault,
meta as deleteOrganizationMeta,
} from '../stories/delete-organization.stories';
import { Default as DestructiveDefault, meta as destructiveMeta } from '../stories/destructive.stories';
import { Default as DialogDefault, meta as dialogComponentMeta } from '../stories/dialog.component.stories';
import { meta as dialogMeta } from '../stories/dialog.stories';
import {
Expand All @@ -12,20 +17,44 @@ import {
meta as inputMeta,
Sizes as InputSizes,
} from '../stories/input.stories';
import {
Default as LeaveOrganizationDefault,
meta as leaveOrganizationMeta,
} from '../stories/leave-organization.stories';
import { meta as menuMeta } from '../stories/menu.stories';
import {
Default as OrganizationProfileDefault,
meta as organizationProfileMeta,
} from '../stories/organization-profile.stories';
import {
Default as OrganizationProfileGeneralDefault,
meta as organizationProfileGeneralMeta,
} from '../stories/organization-profile-general.stories';
import { meta as popoverMeta } from '../stories/popover.stories';
import { meta as selectMeta } from '../stories/select.stories';
import { Default as TabsComponentDefault, meta as tabsComponentMeta } from '../stories/tabs.component.stories';
import { meta as tabsMeta } from '../stories/tabs.stories';
import { meta as tooltipMeta } from '../stories/tooltip.stories';
import { toSlug } from './slug';
import type { StoryModule } from './types';

const destructiveModule: StoryModule = { meta: destructiveMeta, Default: DestructiveDefault };
const leaveOrganizationModule: StoryModule = { meta: leaveOrganizationMeta, Default: LeaveOrganizationDefault };
const deleteOrganizationModule: StoryModule = { meta: deleteOrganizationMeta, Default: DeleteOrganizationDefault };
const organizationProfileModule: StoryModule = { meta: organizationProfileMeta, Default: OrganizationProfileDefault };
const organizationProfileGeneralModule: StoryModule = {
meta: organizationProfileGeneralMeta,
Default: OrganizationProfileGeneralDefault,
};

const buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled };

const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes, Disabled: InputDisabled, Invalid };

const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault };

const tabsComponentModule: StoryModule = { meta: tabsComponentMeta, Default: TabsComponentDefault };

// Headless primitives carry just `meta` (no story functions). Like every component
// they're documented as a single overview page; their live demos come from `<Story>` /
// `<Preview>` embeds in the MDX, which import the stories module directly.
Expand All @@ -40,9 +69,20 @@ const tabsModule: StoryModule = { meta: tabsMeta };
const tooltipModule: StoryModule = { meta: tooltipMeta };

export const registry: StoryModule[] = [
// AIO
organizationProfileModule,
// Panels
organizationProfileGeneralModule,
// Sections
leaveOrganizationModule,
deleteOrganizationModule,
// Blocks
destructiveModule,
// Components
buttonModule,
inputModule,
dialogComponentModule,
tabsComponentModule,
// Primitives — alphabetical within the group.
accordionModule,
autocompleteModule,
Expand Down
2 changes: 1 addition & 1 deletion packages/swingset/src/lib/slug.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export function toSlug(str: string): string {
return str
.replace(/([A-Z])/g, (m, c, i) => (i > 0 ? '-' : '') + c.toLowerCase())
.replace(/([a-z])([A-Z])/g, '$1-$2')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-/, '')
Expand Down
6 changes: 6 additions & 0 deletions packages/swingset/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export type KnobValues = Record<string, string | boolean | number>;
export interface StoryMeta {
group: string;
title: string;
/**
* Optional human-friendly label shown in the sidebar. Falls back to `title` when
* omitted. Use this when the desired sidebar text differs from the component name
* (which still drives the slug and the `<Title />` tag).
*/
label?: string;
/**
* Path to the file that exports the documented component, relative to the monorepo
* root (e.g. `packages/ui/src/mosaic/components/button.tsx`). Rendered as a "View
Expand Down
16 changes: 16 additions & 0 deletions packages/swingset/src/stories/delete-organization.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as DeleteOrganizationStories from './delete-organization.stories';

# Delete Organization

A section that owns the open/deleting state and wires the `Destructive` block to the delete-organization flow.

<Story
name='Default'
storyModule={DeleteOrganizationStories}
composition={[
{ name: 'Destructive', href: '/blocks/destructive', layer: 'Blocks' },
{ name: 'Button', href: '/components/button', layer: 'Components' },
{ name: 'Input', href: '/components/input', layer: 'Components' },
{ name: 'Dialog', href: '/components/dialog', layer: 'Components' },
]}
/>
15 changes: 15 additions & 0 deletions packages/swingset/src/stories/delete-organization.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/** @jsxImportSource @emotion/react */
import { DeleteOrganization } from '@clerk/ui/mosaic/sections/delete-organization';

import type { StoryMeta } from '@/lib/types';

export const meta: StoryMeta = {
group: 'Sections',
title: 'DeleteOrganization',
label: 'Delete Org',
source: 'packages/ui/src/mosaic/sections/delete-organization.tsx',
};

export function Default() {
return <DeleteOrganization />;
}
10 changes: 10 additions & 0 deletions packages/swingset/src/stories/destructive.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as DestructiveStories from './destructive.stories';

# Destructive

A controlled block that composes a trigger button, a confirmation dialog, and a guarded input — the user must type the resource name exactly before the action is enabled.

<Story
name='Default'
storyModule={DestructiveStories}
/>
Loading
Loading