Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/delete-org-block.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
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>
);
}
10 changes: 10 additions & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ 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')),
},
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')),
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
26 changes: 26 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,14 +17,27 @@ 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 { meta as popoverMeta } from '../stories/popover.stories';
import { meta as selectMeta } from '../stories/select.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 buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled };

const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes, Disabled: InputDisabled, Invalid };
Expand All @@ -40,6 +58,14 @@ const tabsModule: StoryModule = { meta: tabsMeta };
const tooltipModule: StoryModule = { meta: tooltipMeta };

export const registry: StoryModule[] = [
// AIO
organizationProfileModule,
// Sections
leaveOrganizationModule,
deleteOrganizationModule,
// Blocks
destructiveModule,
// Components
buttonModule,
inputModule,
dialogComponentModule,
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 resourceName="Alex's Organization" />;
}
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}
/>
48 changes: 48 additions & 0 deletions packages/swingset/src/stories/destructive.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/** @jsxImportSource @emotion/react */
import { Destructive } from '@clerk/ui/mosaic/block/destructive';
import { Button } from '@clerk/ui/mosaic/components/button';
import type { HTMLAttributes } from 'react';
import { useState } from 'react';

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

export const meta: StoryMeta = {
group: 'Blocks',
title: 'Destructive',
source: 'packages/ui/src/mosaic/block/destructive.tsx',
};

function DestructiveTrigger(props: Omit<HTMLAttributes<HTMLElement>, 'color'>) {
return (
<Button
{...props}
color='destructive'
>
Leave organization
</Button>
);
}

export function Default() {
const [open, setOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);

const handleDelete = async () => {
setIsDeleting(true);
await new Promise<void>(resolve => setTimeout(resolve, 2000));
setIsDeleting(false);
setOpen(false);
};

return (
<Destructive
trigger={DestructiveTrigger}
open={open}
onOpenChange={setOpen}
resourceType='organization'
resourceName="Alex's Organization"
onDelete={handleDelete}
isDeleting={isDeleting}
/>
);
}
16 changes: 16 additions & 0 deletions packages/swingset/src/stories/leave-organization.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as LeaveOrganizationStories from './leave-organization.stories';

# Leave Organization

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

<Story
name='Default'
storyModule={LeaveOrganizationStories}
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/leave-organization.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/** @jsxImportSource @emotion/react */
import { LeaveOrganization } from '@clerk/ui/mosaic/sections/leave-organization';

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

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

export function Default() {
return <LeaveOrganization resourceName="Alex's Organization" />;
}
10 changes: 10 additions & 0 deletions packages/swingset/src/stories/organization-profile.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as OrganizationProfileStories from './organization-profile.stories';

# Organization Profile

The full Organization Profile AIO — assembles all organization-related sections into a single view.

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