Skip to content

Commit 157fec5

Browse files
committed
feat(swingset): add mosaic Dialog story with group-aware routing
Adds a styled mosaic Dialog story (Components) that uses a Button as the trigger via the render prop. Routes are now group-aware (/components/<c> and /primitives/<c>) so the styled Dialog and the headless Dialog primitive no longer collide on a title-only slug.
1 parent 96d10e8 commit 157fec5

9 files changed

Lines changed: 169 additions & 43 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { DocsViewer } from '@/components/DocsViewer';
2+
3+
interface Props {
4+
params: Promise<{ group: string; component: string }>;
5+
}
6+
7+
export default async function ComponentPage({ params }: Props) {
8+
const { group, component } = await params;
9+
return (
10+
<DocsViewer
11+
group={group}
12+
slug={component}
13+
/>
14+
);
15+
}

packages/swingset/src/app/components/[component]/page.tsx

Lines changed: 0 additions & 10 deletions
This file was deleted.

packages/swingset/src/components/ClientRoot.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,10 @@ import { ThemeToggle } from './ThemeToggle';
1919

2020
function useBreadcrumb() {
2121
const pathname = usePathname();
22-
// /components/button → ["Button"]
23-
// /components/button/primary → ["Button", "Primary"]
24-
const parts = pathname
25-
.replace(/^\/components\//, '')
26-
.split('/')
27-
.filter(Boolean);
22+
// /components/button → ["Button"]
23+
// /primitives/dialog → ["Dialog"]
24+
// The first segment is the group; drop it and surface the component (plus any sub-path).
25+
const parts = pathname.split('/').filter(Boolean).slice(1);
2826
return parts.map(p => p.charAt(0).toUpperCase() + p.slice(1).replace(/-/g, ' '));
2927
}
3028

packages/swingset/src/components/DocsViewer.tsx

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,40 +2,52 @@
22

33
import dynamic from 'next/dynamic';
44

5-
import { getModuleBySlug } from '@/lib/registry';
5+
import { getModule } from '@/lib/registry';
66

77
import { PlaygroundProvider } from './PlaygroundContext';
88
import { ViewSource } from './ViewSource';
99

10-
const docModules: Record<string, React.ComponentType> = {
11-
button: dynamic(() => import('../stories/button.mdx')),
12-
input: dynamic(() => import('../stories/input.mdx')),
13-
// Headless primitives — alphabetical.
14-
accordion: dynamic(() => import('../stories/accordion.mdx')),
15-
autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
16-
collapsible: dynamic(() => import('../stories/collapsible.mdx')),
17-
dialog: dynamic(() => import('../stories/dialog.mdx')),
18-
menu: dynamic(() => import('../stories/menu.mdx')),
19-
popover: dynamic(() => import('../stories/popover.mdx')),
20-
select: dynamic(() => import('../stories/select.mdx')),
21-
tabs: dynamic(() => import('../stories/tabs.mdx')),
22-
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
10+
// MDX docs keyed by `group` slug → `component` slug. Group-aware so identically-named
11+
// entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct.
12+
const docModules: Record<string, Record<string, React.ComponentType>> = {
13+
components: {
14+
button: dynamic(() => import('../stories/button.mdx')),
15+
input: dynamic(() => import('../stories/input.mdx')),
16+
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
17+
},
18+
primitives: {
19+
// Headless primitives — alphabetical.
20+
accordion: dynamic(() => import('../stories/accordion.mdx')),
21+
autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
22+
collapsible: dynamic(() => import('../stories/collapsible.mdx')),
23+
dialog: dynamic(() => import('../stories/dialog.mdx')),
24+
menu: dynamic(() => import('../stories/menu.mdx')),
25+
popover: dynamic(() => import('../stories/popover.mdx')),
26+
select: dynamic(() => import('../stories/select.mdx')),
27+
tabs: dynamic(() => import('../stories/tabs.mdx')),
28+
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
29+
},
2330
};
2431

2532
interface DocsViewerProps {
33+
group: string;
2634
slug: string;
2735
}
2836

29-
export function DocsViewer({ slug }: DocsViewerProps) {
30-
const DocContent = docModules[slug];
37+
export function DocsViewer({ group, slug }: DocsViewerProps) {
38+
const DocContent = docModules[group]?.[slug];
3139
if (!DocContent) {
32-
return <div className='text-muted-foreground p-8 text-sm'>No docs found for &quot;{slug}&quot;.</div>;
40+
return (
41+
<div className='text-muted-foreground p-8 text-sm'>
42+
No docs found for &quot;{group}/{slug}&quot;.
43+
</div>
44+
);
3345
}
34-
const meta = getModuleBySlug(slug)?.meta;
46+
const meta = getModule(group, slug)?.meta;
3547
return (
36-
// Keyed by slug so navigating between components resets the playground state.
48+
// Keyed by group/slug so navigating between components resets the playground state.
3749
<PlaygroundProvider
38-
key={slug}
50+
key={`${group}/${slug}`}
3951
meta={meta}
4052
>
4153
<article className='prose relative mx-auto w-full min-w-0 max-w-3xl p-8'>

packages/swingset/src/components/app-sidebar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
5858
<span className='text-sidebar-foreground/70 text-[10px] font-medium'>Mosaic - Swingset</span>
5959
</SidebarHeader>
6060
<SidebarContent className='gap-0'>
61-
{groups.map(({ group, components }) => (
61+
{groups.map(({ group, groupSlug, components }) => (
6262
<SidebarGroup
6363
key={group}
6464
className='py-1'
@@ -70,7 +70,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
7070
<SidebarGroupContent>
7171
<SidebarMenu>
7272
{components.map(({ mod, componentSlug }) => {
73-
const href = `/components/${componentSlug}`;
73+
const href = `/${groupSlug}/${componentSlug}`;
7474
return (
7575
<SidebarMenuItem key={mod.meta.title}>
7676
<SidebarMenuButton

packages/swingset/src/components/ui/sidebar.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,10 @@ function SidebarProvider({
124124
...style,
125125
} as React.CSSProperties
126126
}
127-
className={cn('group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', className)}
127+
className={cn(
128+
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar isolate flex min-h-svh w-full',
129+
className,
130+
)}
128131
{...props}
129132
>
130133
{children}

packages/swingset/src/lib/registry.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { meta as accordionMeta } from '../stories/accordion.stories';
33
import { meta as autocompleteMeta } from '../stories/autocomplete.stories';
44
import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories';
55
import { meta as collapsibleMeta } from '../stories/collapsible.stories';
6+
import { Default as DialogDefault, meta as dialogComponentMeta } from '../stories/dialog.component.stories';
67
import { meta as dialogMeta } from '../stories/dialog.stories';
78
import {
89
Default,
@@ -23,6 +24,8 @@ const buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled }
2324

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

27+
const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault };
28+
2629
// Headless primitives carry just `meta` (no story functions). Like every component
2730
// they're documented as a single overview page; their live demos come from `<Story>` /
2831
// `<Preview>` embeds in the MDX, which import the stories module directly.
@@ -39,6 +42,7 @@ const tooltipModule: StoryModule = { meta: tooltipMeta };
3942
export const registry: StoryModule[] = [
4043
buttonModule,
4144
inputModule,
45+
dialogComponentModule,
4246
// Primitives — alphabetical within the group.
4347
accordionModule,
4448
autocompleteModule,
@@ -51,13 +55,18 @@ export const registry: StoryModule[] = [
5155
tooltipModule,
5256
];
5357

54-
/** Look up a component's story module from its slug (derived from `meta.title`). */
55-
export function getModuleBySlug(slug: string): StoryModule | undefined {
56-
return registry.find(mod => toSlug(mod.meta.title) === slug);
58+
/**
59+
* Look up a component's story module by its group + component slug (both derived from `meta`).
60+
* Group-aware so identically-titled entries in different groups (e.g. the headless `Dialog`
61+
* primitive and the styled `Dialog` component) resolve to distinct pages.
62+
*/
63+
export function getModule(groupSlug: string, componentSlug: string): StoryModule | undefined {
64+
return registry.find(mod => toSlug(mod.meta.group) === groupSlug && toSlug(mod.meta.title) === componentSlug);
5765
}
5866

5967
export function getSidebarGroups(): Array<{
6068
group: string;
69+
groupSlug: string;
6170
components: Array<{ mod: StoryModule; componentSlug: string }>;
6271
}> {
6372
const groupMap = new Map<string, Array<{ mod: StoryModule; componentSlug: string }>>();
@@ -70,5 +79,9 @@ export function getSidebarGroups(): Array<{
7079
groupMap.get(group)?.push({ mod, componentSlug: toSlug(title) });
7180
}
7281

73-
return Array.from(groupMap.entries()).map(([group, components]) => ({ group, components }));
82+
return Array.from(groupMap.entries()).map(([group, components]) => ({
83+
group,
84+
groupSlug: toSlug(group),
85+
components,
86+
}));
7487
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import * as DialogStories from './dialog.component.stories';
2+
3+
# Dialog
4+
5+
The styled Mosaic `Dialog` — the headless `@clerk/headless` dialog primitives composed with
6+
Mosaic slot recipes. It inherits open state, portalling, focus management, dismissal (outside
7+
press / Escape), and ARIA wiring from the primitive, and adds Mosaic's themed styling for each
8+
part. Slot identity (`data-cl-slot`) is applied by this styled layer, not by the headless parts.
9+
10+
## Example
11+
12+
The trigger below is a Mosaic `Button`, wired in through the trigger's `render` prop. Open it,
13+
then press Escape or click the backdrop to dismiss.
14+
15+
<Story
16+
name='Default'
17+
storyModule={DialogStories}
18+
/>
19+
20+
## Usage
21+
22+
```tsx
23+
import { Button } from '@clerk/ui/mosaic/components/button';
24+
import { Dialog } from '@clerk/ui/mosaic/components/dialog';
25+
26+
<Dialog.Root>
27+
<Dialog.Trigger render={props => <Button {...props}>Open dialog</Button>} />
28+
<Dialog.Portal>
29+
<Dialog.Backdrop />
30+
<Dialog.Viewport>
31+
<Dialog.Popup>
32+
<Dialog.Title>Confirm action</Dialog.Title>
33+
<Dialog.Description>Are you sure you want to proceed?</Dialog.Description>
34+
<Dialog.Close>Cancel</Dialog.Close>
35+
</Dialog.Popup>
36+
</Dialog.Viewport>
37+
</Dialog.Portal>
38+
</Dialog.Root>;
39+
```
40+
41+
Any part accepts a `render` prop for polymorphic rendering — pass a function that receives the
42+
part's computed props and spreads them onto your element, as the trigger does with `Button`.
43+
44+
## Parts
45+
46+
| Part | Slot | Description |
47+
| -------------------- | -------------------- | ------------------------------------------------------- |
48+
| `Dialog.Root` | none (context) | Owns open state, ARIA ids, and the transition lifecycle |
49+
| `Dialog.Trigger` | `dialog-trigger` | Toggles the dialog open on click |
50+
| `Dialog.Portal` | none (portal) | Portals its children; renders nothing until mounted |
51+
| `Dialog.Backdrop` | `dialog-backdrop` | Themed overlay behind the dialog |
52+
| `Dialog.Viewport` | `dialog-viewport` | Fixed centering container; owns scroll lock |
53+
| `Dialog.Popup` | `dialog-popup` | The dialog surface (`role="dialog"`, focus-trapped) |
54+
| `Dialog.Title` | `dialog-title` | Heading; wired to the popup's `aria-labelledby` |
55+
| `Dialog.Description` | `dialog-description` | Description; wired to the popup's `aria-describedby` |
56+
| `Dialog.Close` | `dialog-close` | Closes the dialog on click |
57+
58+
## Styling
59+
60+
Each styled part is themed by the Mosaic dialog recipe and stays targetable through its
61+
`data-cl-slot` plus the state attributes the primitive emits (`data-cl-open`,
62+
`data-cl-starting-style`, `data-cl-ending-style`). Override per slot through
63+
`appearance.elements` — e.g. `{ 'dialog-popup': { borderRadius: 24 } }`.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/** @jsxImportSource @emotion/react */
2+
import { Button } from '@clerk/ui/mosaic/components/button';
3+
import { Dialog } from '@clerk/ui/mosaic/components/dialog';
4+
import type { HTMLAttributes } from 'react';
5+
6+
import type { StoryMeta } from '@/lib/types';
7+
8+
export const meta: StoryMeta = {
9+
group: 'Components',
10+
title: 'Dialog',
11+
source: 'packages/ui/src/mosaic/components/dialog.tsx',
12+
};
13+
14+
export function Default() {
15+
return (
16+
<Dialog.Root>
17+
<Dialog.Trigger
18+
render={(props: Omit<HTMLAttributes<HTMLElement>, 'color'>) => <Button {...props}>Open dialog</Button>}
19+
/>
20+
<Dialog.Portal>
21+
<Dialog.Backdrop />
22+
<Dialog.Viewport>
23+
<Dialog.Popup>
24+
<Dialog.Title>Confirm action</Dialog.Title>
25+
<Dialog.Description>Are you sure you want to proceed? This action cannot be undone.</Dialog.Description>
26+
<Dialog.Close>Cancel</Dialog.Close>
27+
</Dialog.Popup>
28+
</Dialog.Viewport>
29+
</Dialog.Portal>
30+
</Dialog.Root>
31+
);
32+
}

0 commit comments

Comments
 (0)