Skip to content
Merged
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/mosaic-slot-recipes.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment thread
alexcarpenter marked this conversation as resolved.
9 changes: 5 additions & 4 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,10 +97,11 @@ No additional props beyond standard HTML attributes and the `render` prop.

## Data Attributes

| Attribute | Applies To | Description |
| --------------------------------- | ---------------------------------- | --------------------------------------- |
| `data-cl-slot` | All parts | Part identifier (e.g. `"dialog-popup"`) |
| `data-cl-open` / `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Open state |
| Attribute | Applies To | Description |
| --------------------------------- | ---------------------------------- | ----------- |
| `data-cl-open` / `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Open state |

Slot identity (`data-cl-slot`) is applied by the styled (mosaic) layer, not by the headless parts.

## Important Notes

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,6 @@ export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropPro
const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
ref,
...transitionProps,
} satisfies DefaultProps<'div'>;
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-close.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@ export const DialogClose = React.forwardRef<HTMLButtonElement, DialogCloseProps>

const defaultProps = {
type: 'button' as const,
'data-cl-slot': 'dialog-close',
ref,
onClick() {
setOpen(false);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@ export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDe
const { descriptionId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
ref,
} satisfies DefaultProps<'p'>;
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-popup.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,6 @@ export const DialogPopup = React.forwardRef<HTMLDivElement, DialogPopupProps>(fu
}

const ownProps = {
'data-cl-slot': 'dialog-popup',
ref: combinedRef,
'aria-labelledby': labelId,
'aria-describedby': descriptionId,
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-title.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,6 @@ export const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps
const { labelId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-title',
id: labelId,
ref,
} satisfies DefaultProps<'h2'>;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,7 +25,6 @@ export const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerPr

const ownProps = {
type: 'button',
'data-cl-slot': 'dialog-trigger',
ref: combinedRef,
} satisfies DefaultProps<'button'>;

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,6 @@ export const DialogViewport = React.forwardRef<HTMLDivElement, DialogViewportPro
const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-viewport',
ref,
...transitionProps,
style: modal ? undefined : { pointerEvents: 'auto' as const },
Expand Down
73 changes: 27 additions & 46 deletions packages/headless/src/primitives/dialog/dialog.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,12 +7,15 @@ import { Dialog } from './index';

afterEach(() => cleanup());

// Headless parts no longer emit `data-cl-slot` — slot identity is applied by the styled
// (mosaic) layer. Tests locate the surface-only parts (backdrop, viewport, trigger) via
// `data-testid` and everything else via its accessible role or text.
function renderDialog(props: Partial<React.ComponentProps<typeof Dialog.Root>> = {}) {
return render(
<Dialog.Root {...props}>
<Dialog.Trigger>Open dialog</Dialog.Trigger>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Trigger data-testid='dialog-trigger'>Open dialog</Dialog.Trigger>
<Dialog.Backdrop data-testid='dialog-backdrop' />
<Dialog.Viewport data-testid='dialog-viewport'>
<Dialog.Popup>
<Dialog.Title>Dialog Title</Dialog.Title>
<Dialog.Description>Some dialog description</Dialog.Description>
Expand All@@ -25,25 +28,6 @@ function renderDialog(props: Partial<React.ComponentProps<typeof Dialog.Root>> =
}

describe('Dialog', () => {
describe('slot attributes', () => {
it('renders trigger with data-cl-slot', () => {
renderDialog();
const trigger = screen.getByRole('button', { name: 'Open dialog' });
expect(trigger).toHaveAttribute('data-cl-slot', 'dialog-trigger');
});

it('renders all parts with correct slot attributes when open', () => {
renderDialog({ defaultOpen: true });

expect(document.querySelector('[data-cl-slot="dialog-backdrop"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-viewport"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-title"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-description"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-close"]')).toBeInTheDocument();
});
});

describe('open/close', () => {
it('opens on trigger click', async () => {
const user = userEvent.setup();
Expand All@@ -53,7 +37,7 @@ describe('Dialog', () => {
await user.click(trigger);

expect(trigger).toHaveAttribute('data-cl-open', '');
expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});

it('closes on Escape', async () => {
Expand DownExpand Up@@ -93,7 +77,7 @@ describe('Dialog', () => {
it('respects controlled open prop', () => {
renderDialog({ open: true });

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});

it('does not open when controlled open is false', async () => {
Expand All@@ -102,29 +86,29 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});

describe('ARIA attributes', () => {
it('popup has aria-labelledby linked to title', () => {
renderDialog({ defaultOpen: true });

const title = document.querySelector('[data-cl-slot="dialog-title"]');
const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
const title = screen.getByText('Dialog Title');
const popup = screen.getByRole('dialog');

expect(title).toHaveAttribute('id');
expect(popup).toHaveAttribute('aria-labelledby', title?.getAttribute('id'));
expect(popup).toHaveAttribute('aria-labelledby', title.getAttribute('id'));
});

it('popup has aria-describedby linked to description', () => {
renderDialog({ defaultOpen: true });

const desc = document.querySelector('[data-cl-slot="dialog-description"]');
const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
const desc = screen.getByText('Some dialogdescription');
const popup = screen.getByRole('dialog');

expect(desc).toHaveAttribute('id');
expect(popup).toHaveAttribute('aria-describedby', desc?.getAttribute('id'));
expect(popup).toHaveAttribute('aria-describedby', desc.getAttribute('id'));
});

it('popup has role=dialog', () => {
Expand All@@ -137,7 +121,7 @@ describe('Dialog', () => {
describe('animation lifecycle', () => {
it('backdrop is not rendered when closed', () => {
renderDialog();
expect(document.querySelector('[data-cl-slot="dialog-backdrop"]')).not.toBeInTheDocument();
expect(screen.queryByTestId('dialog-backdrop')).not.toBeInTheDocument();
});

it('applies data-cl-open on popup when open', async () => {
Expand All@@ -146,8 +130,7 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
expect(popup).toHaveAttribute('data-cl-open', '');
expect(screen.getByRole('dialog')).toHaveAttribute('data-cl-open', '');
});

it('applies data-cl-open on backdrop when open', async () => {
Expand All@@ -156,8 +139,7 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const backdrop = document.querySelector('[data-cl-slot="dialog-backdrop"]');
expect(backdrop).toHaveAttribute('data-cl-open', '');
expect(screen.getByTestId('dialog-backdrop')).toHaveAttribute('data-cl-open', '');
});

it('applies data-cl-open on viewport when open', async () => {
Expand All@@ -166,13 +148,12 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const viewport = document.querySelector('[data-cl-slot="dialog-viewport"]');
expect(viewport).toHaveAttribute('data-cl-open', '');
expect(screen.getByTestId('dialog-viewport')).toHaveAttribute('data-cl-open', '');
});

it('viewport is not rendered when closed', () => {
renderDialog();
expect(document.querySelector('[data-cl-slot="dialog-viewport"]')).not.toBeInTheDocument();
expect(screen.queryByTestId('dialog-viewport')).not.toBeInTheDocument();
});
});

Expand DownExpand Up@@ -204,12 +185,12 @@ describe('Dialog', () => {
</Dialog.Root>,
);

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByText('Popup content')).not.toBeInTheDocument();

await user.click(screen.getByRole('button', { name: 'Open' }));

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Popup content')).toBeInTheDocument();
});
});
Expand All@@ -223,8 +204,8 @@ describe('Dialog', () => {

it('trigger has data-cl-open when dialog is visible', () => {
renderDialog({ defaultOpen: true });
// When modal is open, trigger's container gets aria-hidden, so use querySelector
const trigger = document.querySelector('[data-cl-slot="dialog-trigger"]');
// When modal is open, the trigger's container gets aria-hidden, so query by test id.
const trigger = screen.getByTestId('dialog-trigger');
expect(trigger).toHaveAttribute('data-cl-open', '');
});
});
Expand All@@ -246,7 +227,7 @@ describe('Dialog', () => {
<Dialog.Root modal={false}>
<Dialog.Trigger>Open dialog</Dialog.Trigger>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Viewport data-testid='dialog-viewport'>
<Dialog.Popup>
<Dialog.Title>Dialog Title</Dialog.Title>
<Dialog.Close>Close</Dialog.Close>
Expand All@@ -258,9 +239,9 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const viewport = document.querySelector('[data-cl-slot="dialog-viewport"]');
const viewport = screen.getByTestId('dialog-viewport');
expect(viewport).toHaveStyle({ pointerEvents: 'auto' });
expect(viewport?.parentElement).toHaveStyle({ pointerEvents: 'none' });
expect(viewport.parentElement).toHaveStyle({ pointerEvents: 'none' });

await user.click(screen.getByRole('button', { name: 'Background button' }));
expect(onBackgroundClick).toHaveBeenCalledTimes(1);
Expand Down
15 changes: 15 additions & 0 deletions packages/swingset/src/app/[group]/[component]/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import { DocsViewer } from '@/components/DocsViewer';

interface Props {
params: Promise<{ group: string; component: string }>;
}

export default async function ComponentPage({ params }: Props) {
const { group, component } = await params;
return (
<DocsViewer
group={group}
slug={component}
/>
);
}
10 changes: 0 additions & 10 deletions packages/swingset/src/app/components/[component]/page.tsx

This file was deleted.

10 changes: 4 additions & 6 deletions packages/swingset/src/components/ClientRoot.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,12 +19,10 @@ import { ThemeToggle } from './ThemeToggle';

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

Expand Down
52 changes: 32 additions & 20 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,40 +2,52 @@

import dynamic from 'next/dynamic';

import { getModuleBySlug } from '@/lib/registry';
import { getModule } from '@/lib/registry';

import { PlaygroundProvider } from './PlaygroundContext';
import { ViewSource } from './ViewSource';

const docModules: Record<string, React.ComponentType> = {
button: dynamic(() => import('../stories/button.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
// Headless primitives — alphabetical.
accordion: dynamic(() => import('../stories/accordion.mdx')),
autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
collapsible: dynamic(() => import('../stories/collapsible.mdx')),
dialog: dynamic(() => import('../stories/dialog.mdx')),
menu: dynamic(() => import('../stories/menu.mdx')),
popover: dynamic(() => import('../stories/popover.mdx')),
select: dynamic(() => import('../stories/select.mdx')),
tabs: dynamic(() => import('../stories/tabs.mdx')),
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
// 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>> = {
components: {
button: dynamic(() => import('../stories/button.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
},
primitives: {
// Headless primitives — alphabetical.
accordion: dynamic(() => import('../stories/accordion.mdx')),
autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
collapsible: dynamic(() => import('../stories/collapsible.mdx')),
dialog: dynamic(() => import('../stories/dialog.mdx')),
menu: dynamic(() => import('../stories/menu.mdx')),
popover: dynamic(() => import('../stories/popover.mdx')),
select: dynamic(() => import('../stories/select.mdx')),
tabs: dynamic(() => import('../stories/tabs.mdx')),
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
},
};

interface DocsViewerProps {
group: string;
slug: string;
}

export function DocsViewer({ slug }: DocsViewerProps) {
const DocContent = docModules[slug];
export function DocsViewer({ group, slug }: DocsViewerProps) {
const DocContent = docModules[group]?.[slug];
if (!DocContent) {
return <div className='text-muted-foreground p-8 text-sm'>No docs found for &quot;{slug}&quot;.</div>;
return (
<div className='text-muted-foreground p-8 text-sm'>
No docs found for &quot;{group}/{slug}&quot;.
</div>
);
}
const meta = getModuleBySlug(slug)?.meta;
const meta = getModule(group, slug)?.meta;
return (
// Keyed by slug so navigating between components resets the playground state.
// Keyed by group/slug so navigating between components resets the playground state.
<PlaygroundProvider
key={slug}
key={`${group}/${slug}`}
meta={meta}
>
<article className='prose relative mx-auto w-full min-w-0 max-w-3xl p-8'>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(ui): Introduce mosaic recipes and conditions by alexcarpenter · Pull Request #8830 · clerk/javascript · GitHub
Skip to content
Merged
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/mosaic-slot-recipes.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment thread
alexcarpenter marked this conversation as resolved.
9 changes: 5 additions & 4 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,10 +97,11 @@ No additional props beyond standard HTML attributes and the `render` prop.

## Data Attributes

| Attribute | Applies To | Description |
| --------------------------------- | ---------------------------------- | --------------------------------------- |
| `data-cl-slot` | All parts | Part identifier (e.g. `"dialog-popup"`) |
| `data-cl-open` / `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Open state |
| Attribute | Applies To | Description |
| --------------------------------- | ---------------------------------- | ----------- |
| `data-cl-open` / `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Open state |

Slot identity (`data-cl-slot`) is applied by the styled (mosaic) layer, not by the headless parts.

## Important Notes

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,6 @@ export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropPro
const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
ref,
...transitionProps,
} satisfies DefaultProps<'div'>;
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-close.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@ export const DialogClose = React.forwardRef<HTMLButtonElement, DialogCloseProps>

const defaultProps = {
type: 'button' as const,
'data-cl-slot': 'dialog-close',
ref,
onClick() {
setOpen(false);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@ export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDe
const { descriptionId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
ref,
} satisfies DefaultProps<'p'>;
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-popup.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,6 @@ export const DialogPopup = React.forwardRef<HTMLDivElement, DialogPopupProps>(fu
}

const ownProps = {
'data-cl-slot': 'dialog-popup',
ref: combinedRef,
'aria-labelledby': labelId,
'aria-describedby': descriptionId,
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-title.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,6 @@ export const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps
const { labelId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-title',
id: labelId,
ref,
} satisfies DefaultProps<'h2'>;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,7 +25,6 @@ export const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerPr

const ownProps = {
type: 'button',
'data-cl-slot': 'dialog-trigger',
ref: combinedRef,
} satisfies DefaultProps<'button'>;

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,6 @@ export const DialogViewport = React.forwardRef<HTMLDivElement, DialogViewportPro
const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-viewport',
ref,
...transitionProps,
style: modal ? undefined : { pointerEvents: 'auto' as const },
Expand Down
73 changes: 27 additions & 46 deletions packages/headless/src/primitives/dialog/dialog.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,12 +7,15 @@ import { Dialog } from './index';

afterEach(() => cleanup());

// Headless parts no longer emit `data-cl-slot` — slot identity is applied by the styled
// (mosaic) layer. Tests locate the surface-only parts (backdrop, viewport, trigger) via
// `data-testid` and everything else via its accessible role or text.
function renderDialog(props: Partial<React.ComponentProps<typeof Dialog.Root>> = {}) {
return render(
<Dialog.Root {...props}>
<Dialog.Trigger>Open dialog</Dialog.Trigger>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Trigger data-testid='dialog-trigger'>Open dialog</Dialog.Trigger>
<Dialog.Backdrop data-testid='dialog-backdrop' />
<Dialog.Viewport data-testid='dialog-viewport'>
<Dialog.Popup>
<Dialog.Title>Dialog Title</Dialog.Title>
<Dialog.Description>Some dialog description</Dialog.Description>
Expand All@@ -25,25 +28,6 @@ function renderDialog(props: Partial<React.ComponentProps<typeof Dialog.Root>> =
}

describe('Dialog', () => {
describe('slot attributes', () => {
it('renders trigger with data-cl-slot', () => {
renderDialog();
const trigger = screen.getByRole('button', { name: 'Open dialog' });
expect(trigger).toHaveAttribute('data-cl-slot', 'dialog-trigger');
});

it('renders all parts with correct slot attributes when open', () => {
renderDialog({ defaultOpen: true });

expect(document.querySelector('[data-cl-slot="dialog-backdrop"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-viewport"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-title"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-description"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-close"]')).toBeInTheDocument();
});
});

describe('open/close', () => {
it('opens on trigger click', async () => {
const user = userEvent.setup();
Expand All@@ -53,7 +37,7 @@ describe('Dialog', () => {
await user.click(trigger);

expect(trigger).toHaveAttribute('data-cl-open', '');
expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});

it('closes on Escape', async () => {
Expand DownExpand Up@@ -93,7 +77,7 @@ describe('Dialog', () => {
it('respects controlled open prop', () => {
renderDialog({ open: true });

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});

it('does not open when controlled open is false', async () => {
Expand All@@ -102,29 +86,29 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});

describe('ARIA attributes', () => {
it('popup has aria-labelledby linked to title', () => {
renderDialog({ defaultOpen: true });

const title = document.querySelector('[data-cl-slot="dialog-title"]');
const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
const title = screen.getByText('Dialog Title');
const popup = screen.getByRole('dialog');

expect(title).toHaveAttribute('id');
expect(popup).toHaveAttribute('aria-labelledby', title?.getAttribute('id'));
expect(popup).toHaveAttribute('aria-labelledby', title.getAttribute('id'));
});

it('popup has aria-describedby linked to description', () => {
renderDialog({ defaultOpen: true });

const desc = document.querySelector('[data-cl-slot="dialog-description"]');
const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
const desc = screen.getByText('Some dialogdescription');
const popup = screen.getByRole('dialog');

expect(desc).toHaveAttribute('id');
expect(popup).toHaveAttribute('aria-describedby', desc?.getAttribute('id'));
expect(popup).toHaveAttribute('aria-describedby', desc.getAttribute('id'));
});

it('popup has role=dialog', () => {
Expand All@@ -137,7 +121,7 @@ describe('Dialog', () => {
describe('animation lifecycle', () => {
it('backdrop is not rendered when closed', () => {
renderDialog();
expect(document.querySelector('[data-cl-slot="dialog-backdrop"]')).not.toBeInTheDocument();
expect(screen.queryByTestId('dialog-backdrop')).not.toBeInTheDocument();
});

it('applies data-cl-open on popup when open', async () => {
Expand All@@ -146,8 +130,7 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
expect(popup).toHaveAttribute('data-cl-open', '');
expect(screen.getByRole('dialog')).toHaveAttribute('data-cl-open', '');
});

it('applies data-cl-open on backdrop when open', async () => {
Expand All@@ -156,8 +139,7 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const backdrop = document.querySelector('[data-cl-slot="dialog-backdrop"]');
expect(backdrop).toHaveAttribute('data-cl-open', '');
expect(screen.getByTestId('dialog-backdrop')).toHaveAttribute('data-cl-open', '');
});

it('applies data-cl-open on viewport when open', async () => {
Expand All@@ -166,13 +148,12 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const viewport = document.querySelector('[data-cl-slot="dialog-viewport"]');
expect(viewport).toHaveAttribute('data-cl-open', '');
expect(screen.getByTestId('dialog-viewport')).toHaveAttribute('data-cl-open', '');
});

it('viewport is not rendered when closed', () => {
renderDialog();
expect(document.querySelector('[data-cl-slot="dialog-viewport"]')).not.toBeInTheDocument();
expect(screen.queryByTestId('dialog-viewport')).not.toBeInTheDocument();
});
});

Expand DownExpand Up@@ -204,12 +185,12 @@ describe('Dialog', () => {
</Dialog.Root>,
);

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByText('Popup content')).not.toBeInTheDocument();

await user.click(screen.getByRole('button', { name: 'Open' }));

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Popup content')).toBeInTheDocument();
});
});
Expand All@@ -223,8 +204,8 @@ describe('Dialog', () => {

it('trigger has data-cl-open when dialog is visible', () => {
renderDialog({ defaultOpen: true });
// When modal is open, trigger's container gets aria-hidden, so use querySelector
const trigger = document.querySelector('[data-cl-slot="dialog-trigger"]');
// When modal is open, the trigger's container gets aria-hidden, so query by test id.
const trigger = screen.getByTestId('dialog-trigger');
expect(trigger).toHaveAttribute('data-cl-open', '');
});
});
Expand All@@ -246,7 +227,7 @@ describe('Dialog', () => {
<Dialog.Root modal={false}>
<Dialog.Trigger>Open dialog</Dialog.Trigger>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Viewport data-testid='dialog-viewport'>
<Dialog.Popup>
<Dialog.Title>Dialog Title</Dialog.Title>
<Dialog.Close>Close</Dialog.Close>
Expand All@@ -258,9 +239,9 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const viewport = document.querySelector('[data-cl-slot="dialog-viewport"]');
const viewport = screen.getByTestId('dialog-viewport');
expect(viewport).toHaveStyle({ pointerEvents: 'auto' });
expect(viewport?.parentElement).toHaveStyle({ pointerEvents: 'none' });
expect(viewport.parentElement).toHaveStyle({ pointerEvents: 'none' });

await user.click(screen.getByRole('button', { name: 'Background button' }));
expect(onBackgroundClick).toHaveBeenCalledTimes(1);
Expand Down
15 changes: 15 additions & 0 deletions packages/swingset/src/app/[group]/[component]/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import { DocsViewer } from '@/components/DocsViewer';

interface Props {
params: Promise<{ group: string; component: string }>;
}

export default async function ComponentPage({ params }: Props) {
const { group, component } = await params;
return (
<DocsViewer
group={group}
slug={component}
/>
);
}
10 changes: 0 additions & 10 deletions packages/swingset/src/app/components/[component]/page.tsx

This file was deleted.

10 changes: 4 additions & 6 deletions packages/swingset/src/components/ClientRoot.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,12 +19,10 @@ import { ThemeToggle } from './ThemeToggle';

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

Expand Down
52 changes: 32 additions & 20 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,40 +2,52 @@

import dynamic from 'next/dynamic';

import { getModuleBySlug } from '@/lib/registry';
import { getModule } from '@/lib/registry';

import { PlaygroundProvider } from './PlaygroundContext';
import { ViewSource } from './ViewSource';

const docModules: Record<string, React.ComponentType> = {
button: dynamic(() => import('../stories/button.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
// Headless primitives — alphabetical.
accordion: dynamic(() => import('../stories/accordion.mdx')),
autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
collapsible: dynamic(() => import('../stories/collapsible.mdx')),
dialog: dynamic(() => import('../stories/dialog.mdx')),
menu: dynamic(() => import('../stories/menu.mdx')),
popover: dynamic(() => import('../stories/popover.mdx')),
select: dynamic(() => import('../stories/select.mdx')),
tabs: dynamic(() => import('../stories/tabs.mdx')),
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
// 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>> = {
components: {
button: dynamic(() => import('../stories/button.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
},
primitives: {
// Headless primitives — alphabetical.
accordion: dynamic(() => import('../stories/accordion.mdx')),
autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
collapsible: dynamic(() => import('../stories/collapsible.mdx')),
dialog: dynamic(() => import('../stories/dialog.mdx')),
menu: dynamic(() => import('../stories/menu.mdx')),
popover: dynamic(() => import('../stories/popover.mdx')),
select: dynamic(() => import('../stories/select.mdx')),
tabs: dynamic(() => import('../stories/tabs.mdx')),
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
},
};

interface DocsViewerProps {
group: string;
slug: string;
}

export function DocsViewer({ slug }: DocsViewerProps) {
const DocContent = docModules[slug];
export function DocsViewer({ group, slug }: DocsViewerProps) {
const DocContent = docModules[group]?.[slug];
if (!DocContent) {
return <div className='text-muted-foreground p-8 text-sm'>No docs found for &quot;{slug}&quot;.</div>;
return (
<div className='text-muted-foreground p-8 text-sm'>
No docs found for &quot;{group}/{slug}&quot;.
</div>
);
}
const meta = getModuleBySlug(slug)?.meta;
const meta = getModule(group, slug)?.meta;
return (
// Keyed by slug so navigating between components resets the playground state.
// Keyed by group/slug so navigating between components resets the playground state.
<PlaygroundProvider
key={slug}
key={`${group}/${slug}`}
meta={meta}
>
<article className='prose relative mx-auto w-full min-w-0 max-w-3xl p-8'>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): Introduce mosaic recipes and conditions by alexcarpenter · Pull Request #8830 · clerk/javascript · GitHub
Skip to content
Merged
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/mosaic-slot-recipes.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment thread
alexcarpenter marked this conversation as resolved.
9 changes: 5 additions & 4 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,10 +97,11 @@ No additional props beyond standard HTML attributes and the `render` prop.

## Data Attributes

| Attribute | Applies To | Description |
| --------------------------------- | ---------------------------------- | --------------------------------------- |
| `data-cl-slot` | All parts | Part identifier (e.g. `"dialog-popup"`) |
| `data-cl-open` / `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Open state |
| Attribute | Applies To | Description |
| --------------------------------- | ---------------------------------- | ----------- |
| `data-cl-open` / `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Open state |

Slot identity (`data-cl-slot`) is applied by the styled (mosaic) layer, not by the headless parts.

## Important Notes

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,6 @@ export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropPro
const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
ref,
...transitionProps,
} satisfies DefaultProps<'div'>;
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-close.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@ export const DialogClose = React.forwardRef<HTMLButtonElement, DialogCloseProps>

const defaultProps = {
type: 'button' as const,
'data-cl-slot': 'dialog-close',
ref,
onClick() {
setOpen(false);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@ export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDe
const { descriptionId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
ref,
} satisfies DefaultProps<'p'>;
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-popup.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,6 @@ export const DialogPopup = React.forwardRef<HTMLDivElement, DialogPopupProps>(fu
}

const ownProps = {
'data-cl-slot': 'dialog-popup',
ref: combinedRef,
'aria-labelledby': labelId,
'aria-describedby': descriptionId,
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-title.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,6 @@ export const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps
const { labelId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-title',
id: labelId,
ref,
} satisfies DefaultProps<'h2'>;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,7 +25,6 @@ export const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerPr

const ownProps = {
type: 'button',
'data-cl-slot': 'dialog-trigger',
ref: combinedRef,
} satisfies DefaultProps<'button'>;

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,6 @@ export const DialogViewport = React.forwardRef<HTMLDivElement, DialogViewportPro
const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-viewport',
ref,
...transitionProps,
style: modal ? undefined : { pointerEvents: 'auto' as const },
Expand Down
73 changes: 27 additions & 46 deletions packages/headless/src/primitives/dialog/dialog.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,12 +7,15 @@ import { Dialog } from './index';

afterEach(() => cleanup());

// Headless parts no longer emit `data-cl-slot` — slot identity is applied by the styled
// (mosaic) layer. Tests locate the surface-only parts (backdrop, viewport, trigger) via
// `data-testid` and everything else via its accessible role or text.
function renderDialog(props: Partial<React.ComponentProps<typeof Dialog.Root>> = {}) {
return render(
<Dialog.Root {...props}>
<Dialog.Trigger>Open dialog</Dialog.Trigger>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Trigger data-testid='dialog-trigger'>Open dialog</Dialog.Trigger>
<Dialog.Backdrop data-testid='dialog-backdrop' />
<Dialog.Viewport data-testid='dialog-viewport'>
<Dialog.Popup>
<Dialog.Title>Dialog Title</Dialog.Title>
<Dialog.Description>Some dialog description</Dialog.Description>
Expand All@@ -25,25 +28,6 @@ function renderDialog(props: Partial<React.ComponentProps<typeof Dialog.Root>> =
}

describe('Dialog', () => {
describe('slot attributes', () => {
it('renders trigger with data-cl-slot', () => {
renderDialog();
const trigger = screen.getByRole('button', { name: 'Open dialog' });
expect(trigger).toHaveAttribute('data-cl-slot', 'dialog-trigger');
});

it('renders all parts with correct slot attributes when open', () => {
renderDialog({ defaultOpen: true });

expect(document.querySelector('[data-cl-slot="dialog-backdrop"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-viewport"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-title"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-description"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-close"]')).toBeInTheDocument();
});
});

describe('open/close', () => {
it('opens on trigger click', async () => {
const user = userEvent.setup();
Expand All@@ -53,7 +37,7 @@ describe('Dialog', () => {
await user.click(trigger);

expect(trigger).toHaveAttribute('data-cl-open', '');
expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});

it('closes on Escape', async () => {
Expand DownExpand Up@@ -93,7 +77,7 @@ describe('Dialog', () => {
it('respects controlled open prop', () => {
renderDialog({ open: true });

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});

it('does not open when controlled open is false', async () => {
Expand All@@ -102,29 +86,29 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});

describe('ARIA attributes', () => {
it('popup has aria-labelledby linked to title', () => {
renderDialog({ defaultOpen: true });

const title = document.querySelector('[data-cl-slot="dialog-title"]');
const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
const title = screen.getByText('Dialog Title');
const popup = screen.getByRole('dialog');

expect(title).toHaveAttribute('id');
expect(popup).toHaveAttribute('aria-labelledby', title?.getAttribute('id'));
expect(popup).toHaveAttribute('aria-labelledby', title.getAttribute('id'));
});

it('popup has aria-describedby linked to description', () => {
renderDialog({ defaultOpen: true });

const desc = document.querySelector('[data-cl-slot="dialog-description"]');
const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
const desc = screen.getByText('Some dialogdescription');
const popup = screen.getByRole('dialog');

expect(desc).toHaveAttribute('id');
expect(popup).toHaveAttribute('aria-describedby', desc?.getAttribute('id'));
expect(popup).toHaveAttribute('aria-describedby', desc.getAttribute('id'));
});

it('popup has role=dialog', () => {
Expand All@@ -137,7 +121,7 @@ describe('Dialog', () => {
describe('animation lifecycle', () => {
it('backdrop is not rendered when closed', () => {
renderDialog();
expect(document.querySelector('[data-cl-slot="dialog-backdrop"]')).not.toBeInTheDocument();
expect(screen.queryByTestId('dialog-backdrop')).not.toBeInTheDocument();
});

it('applies data-cl-open on popup when open', async () => {
Expand All@@ -146,8 +130,7 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
expect(popup).toHaveAttribute('data-cl-open', '');
expect(screen.getByRole('dialog')).toHaveAttribute('data-cl-open', '');
});

it('applies data-cl-open on backdrop when open', async () => {
Expand All@@ -156,8 +139,7 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const backdrop = document.querySelector('[data-cl-slot="dialog-backdrop"]');
expect(backdrop).toHaveAttribute('data-cl-open', '');
expect(screen.getByTestId('dialog-backdrop')).toHaveAttribute('data-cl-open', '');
});

it('applies data-cl-open on viewport when open', async () => {
Expand All@@ -166,13 +148,12 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const viewport = document.querySelector('[data-cl-slot="dialog-viewport"]');
expect(viewport).toHaveAttribute('data-cl-open', '');
expect(screen.getByTestId('dialog-viewport')).toHaveAttribute('data-cl-open', '');
});

it('viewport is not rendered when closed', () => {
renderDialog();
expect(document.querySelector('[data-cl-slot="dialog-viewport"]')).not.toBeInTheDocument();
expect(screen.queryByTestId('dialog-viewport')).not.toBeInTheDocument();
});
});

Expand DownExpand Up@@ -204,12 +185,12 @@ describe('Dialog', () => {
</Dialog.Root>,
);

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByText('Popup content')).not.toBeInTheDocument();

await user.click(screen.getByRole('button', { name: 'Open' }));

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Popup content')).toBeInTheDocument();
});
});
Expand All@@ -223,8 +204,8 @@ describe('Dialog', () => {

it('trigger has data-cl-open when dialog is visible', () => {
renderDialog({ defaultOpen: true });
// When modal is open, trigger's container gets aria-hidden, so use querySelector
const trigger = document.querySelector('[data-cl-slot="dialog-trigger"]');
// When modal is open, the trigger's container gets aria-hidden, so query by test id.
const trigger = screen.getByTestId('dialog-trigger');
expect(trigger).toHaveAttribute('data-cl-open', '');
});
});
Expand All@@ -246,7 +227,7 @@ describe('Dialog', () => {
<Dialog.Root modal={false}>
<Dialog.Trigger>Open dialog</Dialog.Trigger>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Viewport data-testid='dialog-viewport'>
<Dialog.Popup>
<Dialog.Title>Dialog Title</Dialog.Title>
<Dialog.Close>Close</Dialog.Close>
Expand All@@ -258,9 +239,9 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const viewport = document.querySelector('[data-cl-slot="dialog-viewport"]');
const viewport = screen.getByTestId('dialog-viewport');
expect(viewport).toHaveStyle({ pointerEvents: 'auto' });
expect(viewport?.parentElement).toHaveStyle({ pointerEvents: 'none' });
expect(viewport.parentElement).toHaveStyle({ pointerEvents: 'none' });

await user.click(screen.getByRole('button', { name: 'Background button' }));
expect(onBackgroundClick).toHaveBeenCalledTimes(1);
Expand Down
15 changes: 15 additions & 0 deletions packages/swingset/src/app/[group]/[component]/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import { DocsViewer } from '@/components/DocsViewer';

interface Props {
params: Promise<{ group: string; component: string }>;
}

export default async function ComponentPage({ params }: Props) {
const { group, component } = await params;
return (
<DocsViewer
group={group}
slug={component}
/>
);
}
10 changes: 0 additions & 10 deletions packages/swingset/src/app/components/[component]/page.tsx

This file was deleted.

10 changes: 4 additions & 6 deletions packages/swingset/src/components/ClientRoot.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,12 +19,10 @@ import { ThemeToggle } from './ThemeToggle';

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

Expand Down
52 changes: 32 additions & 20 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,40 +2,52 @@

import dynamic from 'next/dynamic';

import { getModuleBySlug } from '@/lib/registry';
import { getModule } from '@/lib/registry';

import { PlaygroundProvider } from './PlaygroundContext';
import { ViewSource } from './ViewSource';

const docModules: Record<string, React.ComponentType> = {
button: dynamic(() => import('../stories/button.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
// Headless primitives — alphabetical.
accordion: dynamic(() => import('../stories/accordion.mdx')),
autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
collapsible: dynamic(() => import('../stories/collapsible.mdx')),
dialog: dynamic(() => import('../stories/dialog.mdx')),
menu: dynamic(() => import('../stories/menu.mdx')),
popover: dynamic(() => import('../stories/popover.mdx')),
select: dynamic(() => import('../stories/select.mdx')),
tabs: dynamic(() => import('../stories/tabs.mdx')),
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
// 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>> = {
components: {
button: dynamic(() => import('../stories/button.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
},
primitives: {
// Headless primitives — alphabetical.
accordion: dynamic(() => import('../stories/accordion.mdx')),
autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
collapsible: dynamic(() => import('../stories/collapsible.mdx')),
dialog: dynamic(() => import('../stories/dialog.mdx')),
menu: dynamic(() => import('../stories/menu.mdx')),
popover: dynamic(() => import('../stories/popover.mdx')),
select: dynamic(() => import('../stories/select.mdx')),
tabs: dynamic(() => import('../stories/tabs.mdx')),
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
},
};

interface DocsViewerProps {
group: string;
slug: string;
}

export function DocsViewer({ slug }: DocsViewerProps) {
const DocContent = docModules[slug];
export function DocsViewer({ group, slug }: DocsViewerProps) {
const DocContent = docModules[group]?.[slug];
if (!DocContent) {
return <div className='text-muted-foreground p-8 text-sm'>No docs found for &quot;{slug}&quot;.</div>;
return (
<div className='text-muted-foreground p-8 text-sm'>
No docs found for &quot;{group}/{slug}&quot;.
</div>
);
}
const meta = getModuleBySlug(slug)?.meta;
const meta = getModule(group, slug)?.meta;
return (
// Keyed by slug so navigating between components resets the playground state.
// Keyed by group/slug so navigating between components resets the playground state.
<PlaygroundProvider
key={slug}
key={`${group}/${slug}`}
meta={meta}
>
<article className='prose relative mx-auto w-full min-w-0 max-w-3xl p-8'>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): Introduce mosaic recipes and conditions by alexcarpenter · Pull Request #8830 · clerk/javascript · GitHub
Skip to content
Merged
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/mosaic-slot-recipes.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment thread
alexcarpenter marked this conversation as resolved.
9 changes: 5 additions & 4 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,10 +97,11 @@ No additional props beyond standard HTML attributes and the `render` prop.

## Data Attributes

| Attribute | Applies To | Description |
| --------------------------------- | ---------------------------------- | --------------------------------------- |
| `data-cl-slot` | All parts | Part identifier (e.g. `"dialog-popup"`) |
| `data-cl-open` / `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Open state |
| Attribute | Applies To | Description |
| --------------------------------- | ---------------------------------- | ----------- |
| `data-cl-open` / `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Open state |

Slot identity (`data-cl-slot`) is applied by the styled (mosaic) layer, not by the headless parts.

## Important Notes

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,6 @@ export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropPro
const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
ref,
...transitionProps,
} satisfies DefaultProps<'div'>;
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-close.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@ export const DialogClose = React.forwardRef<HTMLButtonElement, DialogCloseProps>

const defaultProps = {
type: 'button' as const,
'data-cl-slot': 'dialog-close',
ref,
onClick() {
setOpen(false);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@ export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDe
const { descriptionId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
ref,
} satisfies DefaultProps<'p'>;
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-popup.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,6 @@ export const DialogPopup = React.forwardRef<HTMLDivElement, DialogPopupProps>(fu
}

const ownProps = {
'data-cl-slot': 'dialog-popup',
ref: combinedRef,
'aria-labelledby': labelId,
'aria-describedby': descriptionId,
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-title.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,6 @@ export const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps
const { labelId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-title',
id: labelId,
ref,
} satisfies DefaultProps<'h2'>;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,7 +25,6 @@ export const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerPr

const ownProps = {
type: 'button',
'data-cl-slot': 'dialog-trigger',
ref: combinedRef,
} satisfies DefaultProps<'button'>;

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,6 @@ export const DialogViewport = React.forwardRef<HTMLDivElement, DialogViewportPro
const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-viewport',
ref,
...transitionProps,
style: modal ? undefined : { pointerEvents: 'auto' as const },
Expand Down
73 changes: 27 additions & 46 deletions packages/headless/src/primitives/dialog/dialog.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,12 +7,15 @@ import { Dialog } from './index';

afterEach(() => cleanup());

// Headless parts no longer emit `data-cl-slot` — slot identity is applied by the styled
// (mosaic) layer. Tests locate the surface-only parts (backdrop, viewport, trigger) via
// `data-testid` and everything else via its accessible role or text.
function renderDialog(props: Partial<React.ComponentProps<typeof Dialog.Root>> = {}) {
return render(
<Dialog.Root {...props}>
<Dialog.Trigger>Open dialog</Dialog.Trigger>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Trigger data-testid='dialog-trigger'>Open dialog</Dialog.Trigger>
<Dialog.Backdrop data-testid='dialog-backdrop' />
<Dialog.Viewport data-testid='dialog-viewport'>
<Dialog.Popup>
<Dialog.Title>Dialog Title</Dialog.Title>
<Dialog.Description>Some dialog description</Dialog.Description>
Expand All@@ -25,25 +28,6 @@ function renderDialog(props: Partial<React.ComponentProps<typeof Dialog.Root>> =
}

describe('Dialog', () => {
describe('slot attributes', () => {
it('renders trigger with data-cl-slot', () => {
renderDialog();
const trigger = screen.getByRole('button', { name: 'Open dialog' });
expect(trigger).toHaveAttribute('data-cl-slot', 'dialog-trigger');
});

it('renders all parts with correct slot attributes when open', () => {
renderDialog({ defaultOpen: true });

expect(document.querySelector('[data-cl-slot="dialog-backdrop"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-viewport"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-title"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-description"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-close"]')).toBeInTheDocument();
});
});

describe('open/close', () => {
it('opens on trigger click', async () => {
const user = userEvent.setup();
Expand All@@ -53,7 +37,7 @@ describe('Dialog', () => {
await user.click(trigger);

expect(trigger).toHaveAttribute('data-cl-open', '');
expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});

it('closes on Escape', async () => {
Expand DownExpand Up@@ -93,7 +77,7 @@ describe('Dialog', () => {
it('respects controlled open prop', () => {
renderDialog({ open: true });

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});

it('does not open when controlled open is false', async () => {
Expand All@@ -102,29 +86,29 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});

describe('ARIA attributes', () => {
it('popup has aria-labelledby linked to title', () => {
renderDialog({ defaultOpen: true });

const title = document.querySelector('[data-cl-slot="dialog-title"]');
const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
const title = screen.getByText('Dialog Title');
const popup = screen.getByRole('dialog');

expect(title).toHaveAttribute('id');
expect(popup).toHaveAttribute('aria-labelledby', title?.getAttribute('id'));
expect(popup).toHaveAttribute('aria-labelledby', title.getAttribute('id'));
});

it('popup has aria-describedby linked to description', () => {
renderDialog({ defaultOpen: true });

const desc = document.querySelector('[data-cl-slot="dialog-description"]');
const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
const desc = screen.getByText('Some dialogdescription');
const popup = screen.getByRole('dialog');

expect(desc).toHaveAttribute('id');
expect(popup).toHaveAttribute('aria-describedby', desc?.getAttribute('id'));
expect(popup).toHaveAttribute('aria-describedby', desc.getAttribute('id'));
});

it('popup has role=dialog', () => {
Expand All@@ -137,7 +121,7 @@ describe('Dialog', () => {
describe('animation lifecycle', () => {
it('backdrop is not rendered when closed', () => {
renderDialog();
expect(document.querySelector('[data-cl-slot="dialog-backdrop"]')).not.toBeInTheDocument();
expect(screen.queryByTestId('dialog-backdrop')).not.toBeInTheDocument();
});

it('applies data-cl-open on popup when open', async () => {
Expand All@@ -146,8 +130,7 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
expect(popup).toHaveAttribute('data-cl-open', '');
expect(screen.getByRole('dialog')).toHaveAttribute('data-cl-open', '');
});

it('applies data-cl-open on backdrop when open', async () => {
Expand All@@ -156,8 +139,7 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const backdrop = document.querySelector('[data-cl-slot="dialog-backdrop"]');
expect(backdrop).toHaveAttribute('data-cl-open', '');
expect(screen.getByTestId('dialog-backdrop')).toHaveAttribute('data-cl-open', '');
});

it('applies data-cl-open on viewport when open', async () => {
Expand All@@ -166,13 +148,12 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const viewport = document.querySelector('[data-cl-slot="dialog-viewport"]');
expect(viewport).toHaveAttribute('data-cl-open', '');
expect(screen.getByTestId('dialog-viewport')).toHaveAttribute('data-cl-open', '');
});

it('viewport is not rendered when closed', () => {
renderDialog();
expect(document.querySelector('[data-cl-slot="dialog-viewport"]')).not.toBeInTheDocument();
expect(screen.queryByTestId('dialog-viewport')).not.toBeInTheDocument();
});
});

Expand DownExpand Up@@ -204,12 +185,12 @@ describe('Dialog', () => {
</Dialog.Root>,
);

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByText('Popup content')).not.toBeInTheDocument();

await user.click(screen.getByRole('button', { name: 'Open' }));

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Popup content')).toBeInTheDocument();
});
});
Expand All@@ -223,8 +204,8 @@ describe('Dialog', () => {

it('trigger has data-cl-open when dialog is visible', () => {
renderDialog({ defaultOpen: true });
// When modal is open, trigger's container gets aria-hidden, so use querySelector
const trigger = document.querySelector('[data-cl-slot="dialog-trigger"]');
// When modal is open, the trigger's container gets aria-hidden, so query by test id.
const trigger = screen.getByTestId('dialog-trigger');
expect(trigger).toHaveAttribute('data-cl-open', '');
});
});
Expand All@@ -246,7 +227,7 @@ describe('Dialog', () => {
<Dialog.Root modal={false}>
<Dialog.Trigger>Open dialog</Dialog.Trigger>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Viewport data-testid='dialog-viewport'>
<Dialog.Popup>
<Dialog.Title>Dialog Title</Dialog.Title>
<Dialog.Close>Close</Dialog.Close>
Expand All@@ -258,9 +239,9 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const viewport = document.querySelector('[data-cl-slot="dialog-viewport"]');
const viewport = screen.getByTestId('dialog-viewport');
expect(viewport).toHaveStyle({ pointerEvents: 'auto' });
expect(viewport?.parentElement).toHaveStyle({ pointerEvents: 'none' });
expect(viewport.parentElement).toHaveStyle({ pointerEvents: 'none' });

await user.click(screen.getByRole('button', { name: 'Background button' }));
expect(onBackgroundClick).toHaveBeenCalledTimes(1);
Expand Down
15 changes: 15 additions & 0 deletions packages/swingset/src/app/[group]/[component]/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import { DocsViewer } from '@/components/DocsViewer';

interface Props {
params: Promise<{ group: string; component: string }>;
}

export default async function ComponentPage({ params }: Props) {
const { group, component } = await params;
return (
<DocsViewer
group={group}
slug={component}
/>
);
}
10 changes: 0 additions & 10 deletions packages/swingset/src/app/components/[component]/page.tsx

This file was deleted.

10 changes: 4 additions & 6 deletions packages/swingset/src/components/ClientRoot.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,12 +19,10 @@ import { ThemeToggle } from './ThemeToggle';

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

Expand Down
52 changes: 32 additions & 20 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,40 +2,52 @@

import dynamic from 'next/dynamic';

import { getModuleBySlug } from '@/lib/registry';
import { getModule } from '@/lib/registry';

import { PlaygroundProvider } from './PlaygroundContext';
import { ViewSource } from './ViewSource';

const docModules: Record<string, React.ComponentType> = {
button: dynamic(() => import('../stories/button.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
// Headless primitives — alphabetical.
accordion: dynamic(() => import('../stories/accordion.mdx')),
autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
collapsible: dynamic(() => import('../stories/collapsible.mdx')),
dialog: dynamic(() => import('../stories/dialog.mdx')),
menu: dynamic(() => import('../stories/menu.mdx')),
popover: dynamic(() => import('../stories/popover.mdx')),
select: dynamic(() => import('../stories/select.mdx')),
tabs: dynamic(() => import('../stories/tabs.mdx')),
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
// 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>> = {
components: {
button: dynamic(() => import('../stories/button.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
},
primitives: {
// Headless primitives — alphabetical.
accordion: dynamic(() => import('../stories/accordion.mdx')),
autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
collapsible: dynamic(() => import('../stories/collapsible.mdx')),
dialog: dynamic(() => import('../stories/dialog.mdx')),
menu: dynamic(() => import('../stories/menu.mdx')),
popover: dynamic(() => import('../stories/popover.mdx')),
select: dynamic(() => import('../stories/select.mdx')),
tabs: dynamic(() => import('../stories/tabs.mdx')),
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
},
};

interface DocsViewerProps {
group: string;
slug: string;
}

export function DocsViewer({ slug }: DocsViewerProps) {
const DocContent = docModules[slug];
export function DocsViewer({ group, slug }: DocsViewerProps) {
const DocContent = docModules[group]?.[slug];
if (!DocContent) {
return <div className='text-muted-foreground p-8 text-sm'>No docs found for &quot;{slug}&quot;.</div>;
return (
<div className='text-muted-foreground p-8 text-sm'>
No docs found for &quot;{group}/{slug}&quot;.
</div>
);
}
const meta = getModuleBySlug(slug)?.meta;
const meta = getModule(group, slug)?.meta;
return (
// Keyed by slug so navigating between components resets the playground state.
// Keyed by group/slug so navigating between components resets the playground state.
<PlaygroundProvider
key={slug}
key={`${group}/${slug}`}
meta={meta}
>
<article className='prose relative mx-auto w-full min-w-0 max-w-3xl p-8'>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(ui): Introduce mosaic recipes and conditions by alexcarpenter · Pull Request #8830 · clerk/javascript · GitHub
Skip to content
Merged
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/mosaic-slot-recipes.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment thread
alexcarpenter marked this conversation as resolved.
9 changes: 5 additions & 4 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,10 +97,11 @@ No additional props beyond standard HTML attributes and the `render` prop.

## Data Attributes

| Attribute | Applies To | Description |
| --------------------------------- | ---------------------------------- | --------------------------------------- |
| `data-cl-slot` | All parts | Part identifier (e.g. `"dialog-popup"`) |
| `data-cl-open` / `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Open state |
| Attribute | Applies To | Description |
| --------------------------------- | ---------------------------------- | ----------- |
| `data-cl-open` / `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Open state |

Slot identity (`data-cl-slot`) is applied by the styled (mosaic) layer, not by the headless parts.

## Important Notes

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,6 @@ export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropPro
const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
ref,
...transitionProps,
} satisfies DefaultProps<'div'>;
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-close.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@ export const DialogClose = React.forwardRef<HTMLButtonElement, DialogCloseProps>

const defaultProps = {
type: 'button' as const,
'data-cl-slot': 'dialog-close',
ref,
onClick() {
setOpen(false);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@ export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDe
const { descriptionId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
ref,
} satisfies DefaultProps<'p'>;
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-popup.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,6 @@ export const DialogPopup = React.forwardRef<HTMLDivElement, DialogPopupProps>(fu
}

const ownProps = {
'data-cl-slot': 'dialog-popup',
ref: combinedRef,
'aria-labelledby': labelId,
'aria-describedby': descriptionId,
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-title.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,6 @@ export const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps
const { labelId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-title',
id: labelId,
ref,
} satisfies DefaultProps<'h2'>;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,7 +25,6 @@ export const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerPr

const ownProps = {
type: 'button',
'data-cl-slot': 'dialog-trigger',
ref: combinedRef,
} satisfies DefaultProps<'button'>;

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,6 @@ export const DialogViewport = React.forwardRef<HTMLDivElement, DialogViewportPro
const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-viewport',
ref,
...transitionProps,
style: modal ? undefined : { pointerEvents: 'auto' as const },
Expand Down
73 changes: 27 additions & 46 deletions packages/headless/src/primitives/dialog/dialog.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,12 +7,15 @@ import { Dialog } from './index';

afterEach(() => cleanup());

// Headless parts no longer emit `data-cl-slot` — slot identity is applied by the styled
// (mosaic) layer. Tests locate the surface-only parts (backdrop, viewport, trigger) via
// `data-testid` and everything else via its accessible role or text.
function renderDialog(props: Partial<React.ComponentProps<typeof Dialog.Root>> = {}) {
return render(
<Dialog.Root {...props}>
<Dialog.Trigger>Open dialog</Dialog.Trigger>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Trigger data-testid='dialog-trigger'>Open dialog</Dialog.Trigger>
<Dialog.Backdrop data-testid='dialog-backdrop' />
<Dialog.Viewport data-testid='dialog-viewport'>
<Dialog.Popup>
<Dialog.Title>Dialog Title</Dialog.Title>
<Dialog.Description>Some dialog description</Dialog.Description>
Expand All@@ -25,25 +28,6 @@ function renderDialog(props: Partial<React.ComponentProps<typeof Dialog.Root>> =
}

describe('Dialog', () => {
describe('slot attributes', () => {
it('renders trigger with data-cl-slot', () => {
renderDialog();
const trigger = screen.getByRole('button', { name: 'Open dialog' });
expect(trigger).toHaveAttribute('data-cl-slot', 'dialog-trigger');
});

it('renders all parts with correct slot attributes when open', () => {
renderDialog({ defaultOpen: true });

expect(document.querySelector('[data-cl-slot="dialog-backdrop"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-viewport"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-title"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-description"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-close"]')).toBeInTheDocument();
});
});

describe('open/close', () => {
it('opens on trigger click', async () => {
const user = userEvent.setup();
Expand All@@ -53,7 +37,7 @@ describe('Dialog', () => {
await user.click(trigger);

expect(trigger).toHaveAttribute('data-cl-open', '');
expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});

it('closes on Escape', async () => {
Expand DownExpand Up@@ -93,7 +77,7 @@ describe('Dialog', () => {
it('respects controlled open prop', () => {
renderDialog({ open: true });

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});

it('does not open when controlled open is false', async () => {
Expand All@@ -102,29 +86,29 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});

describe('ARIA attributes', () => {
it('popup has aria-labelledby linked to title', () => {
renderDialog({ defaultOpen: true });

const title = document.querySelector('[data-cl-slot="dialog-title"]');
const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
const title = screen.getByText('Dialog Title');
const popup = screen.getByRole('dialog');

expect(title).toHaveAttribute('id');
expect(popup).toHaveAttribute('aria-labelledby', title?.getAttribute('id'));
expect(popup).toHaveAttribute('aria-labelledby', title.getAttribute('id'));
});

it('popup has aria-describedby linked to description', () => {
renderDialog({ defaultOpen: true });

const desc = document.querySelector('[data-cl-slot="dialog-description"]');
const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
const desc = screen.getByText('Some dialogdescription');
const popup = screen.getByRole('dialog');

expect(desc).toHaveAttribute('id');
expect(popup).toHaveAttribute('aria-describedby', desc?.getAttribute('id'));
expect(popup).toHaveAttribute('aria-describedby', desc.getAttribute('id'));
});

it('popup has role=dialog', () => {
Expand All@@ -137,7 +121,7 @@ describe('Dialog', () => {
describe('animation lifecycle', () => {
it('backdrop is not rendered when closed', () => {
renderDialog();
expect(document.querySelector('[data-cl-slot="dialog-backdrop"]')).not.toBeInTheDocument();
expect(screen.queryByTestId('dialog-backdrop')).not.toBeInTheDocument();
});

it('applies data-cl-open on popup when open', async () => {
Expand All@@ -146,8 +130,7 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
expect(popup).toHaveAttribute('data-cl-open', '');
expect(screen.getByRole('dialog')).toHaveAttribute('data-cl-open', '');
});

it('applies data-cl-open on backdrop when open', async () => {
Expand All@@ -156,8 +139,7 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const backdrop = document.querySelector('[data-cl-slot="dialog-backdrop"]');
expect(backdrop).toHaveAttribute('data-cl-open', '');
expect(screen.getByTestId('dialog-backdrop')).toHaveAttribute('data-cl-open', '');
});

it('applies data-cl-open on viewport when open', async () => {
Expand All@@ -166,13 +148,12 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const viewport = document.querySelector('[data-cl-slot="dialog-viewport"]');
expect(viewport).toHaveAttribute('data-cl-open', '');
expect(screen.getByTestId('dialog-viewport')).toHaveAttribute('data-cl-open', '');
});

it('viewport is not rendered when closed', () => {
renderDialog();
expect(document.querySelector('[data-cl-slot="dialog-viewport"]')).not.toBeInTheDocument();
expect(screen.queryByTestId('dialog-viewport')).not.toBeInTheDocument();
});
});

Expand DownExpand Up@@ -204,12 +185,12 @@ describe('Dialog', () => {
</Dialog.Root>,
);

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByText('Popup content')).not.toBeInTheDocument();

await user.click(screen.getByRole('button', { name: 'Open' }));

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Popup content')).toBeInTheDocument();
});
});
Expand All@@ -223,8 +204,8 @@ describe('Dialog', () => {

it('trigger has data-cl-open when dialog is visible', () => {
renderDialog({ defaultOpen: true });
// When modal is open, trigger's container gets aria-hidden, so use querySelector
const trigger = document.querySelector('[data-cl-slot="dialog-trigger"]');
// When modal is open, the trigger's container gets aria-hidden, so query by test id.
const trigger = screen.getByTestId('dialog-trigger');
expect(trigger).toHaveAttribute('data-cl-open', '');
});
});
Expand All@@ -246,7 +227,7 @@ describe('Dialog', () => {
<Dialog.Root modal={false}>
<Dialog.Trigger>Open dialog</Dialog.Trigger>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Viewport data-testid='dialog-viewport'>
<Dialog.Popup>
<Dialog.Title>Dialog Title</Dialog.Title>
<Dialog.Close>Close</Dialog.Close>
Expand All@@ -258,9 +239,9 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const viewport = document.querySelector('[data-cl-slot="dialog-viewport"]');
const viewport = screen.getByTestId('dialog-viewport');
expect(viewport).toHaveStyle({ pointerEvents: 'auto' });
expect(viewport?.parentElement).toHaveStyle({ pointerEvents: 'none' });
expect(viewport.parentElement).toHaveStyle({ pointerEvents: 'none' });

await user.click(screen.getByRole('button', { name: 'Background button' }));
expect(onBackgroundClick).toHaveBeenCalledTimes(1);
Expand Down
15 changes: 15 additions & 0 deletions packages/swingset/src/app/[group]/[component]/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import { DocsViewer } from '@/components/DocsViewer';

interface Props {
params: Promise<{ group: string; component: string }>;
}

export default async function ComponentPage({ params }: Props) {
const { group, component } = await params;
return (
<DocsViewer
group={group}
slug={component}
/>
);
}
10 changes: 0 additions & 10 deletions packages/swingset/src/app/components/[component]/page.tsx

This file was deleted.

10 changes: 4 additions & 6 deletions packages/swingset/src/components/ClientRoot.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,12 +19,10 @@ import { ThemeToggle } from './ThemeToggle';

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

Expand Down
52 changes: 32 additions & 20 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,40 +2,52 @@

import dynamic from 'next/dynamic';

import { getModuleBySlug } from '@/lib/registry';
import { getModule } from '@/lib/registry';

import { PlaygroundProvider } from './PlaygroundContext';
import { ViewSource } from './ViewSource';

const docModules: Record<string, React.ComponentType> = {
button: dynamic(() => import('../stories/button.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
// Headless primitives — alphabetical.
accordion: dynamic(() => import('../stories/accordion.mdx')),
autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
collapsible: dynamic(() => import('../stories/collapsible.mdx')),
dialog: dynamic(() => import('../stories/dialog.mdx')),
menu: dynamic(() => import('../stories/menu.mdx')),
popover: dynamic(() => import('../stories/popover.mdx')),
select: dynamic(() => import('../stories/select.mdx')),
tabs: dynamic(() => import('../stories/tabs.mdx')),
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
// 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>> = {
components: {
button: dynamic(() => import('../stories/button.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
},
primitives: {
// Headless primitives — alphabetical.
accordion: dynamic(() => import('../stories/accordion.mdx')),
autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
collapsible: dynamic(() => import('../stories/collapsible.mdx')),
dialog: dynamic(() => import('../stories/dialog.mdx')),
menu: dynamic(() => import('../stories/menu.mdx')),
popover: dynamic(() => import('../stories/popover.mdx')),
select: dynamic(() => import('../stories/select.mdx')),
tabs: dynamic(() => import('../stories/tabs.mdx')),
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
},
};

interface DocsViewerProps {
group: string;
slug: string;
}

export function DocsViewer({ slug }: DocsViewerProps) {
const DocContent = docModules[slug];
export function DocsViewer({ group, slug }: DocsViewerProps) {
const DocContent = docModules[group]?.[slug];
if (!DocContent) {
return <div className='text-muted-foreground p-8 text-sm'>No docs found for &quot;{slug}&quot;.</div>;
return (
<div className='text-muted-foreground p-8 text-sm'>
No docs found for &quot;{group}/{slug}&quot;.
</div>
);
}
const meta = getModuleBySlug(slug)?.meta;
const meta = getModule(group, slug)?.meta;
return (
// Keyed by slug so navigating between components resets the playground state.
// Keyed by group/slug so navigating between components resets the playground state.
<PlaygroundProvider
key={slug}
key={`${group}/${slug}`}
meta={meta}
>
<article className='prose relative mx-auto w-full min-w-0 max-w-3xl p-8'>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): Introduce mosaic recipes and conditions by alexcarpenter · Pull Request #8830 · clerk/javascript · GitHub
Skip to content
Merged
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/mosaic-slot-recipes.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment thread
alexcarpenter marked this conversation as resolved.
9 changes: 5 additions & 4 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,10 +97,11 @@ No additional props beyond standard HTML attributes and the `render` prop.

## Data Attributes

| Attribute | Applies To | Description |
| --------------------------------- | ---------------------------------- | --------------------------------------- |
| `data-cl-slot` | All parts | Part identifier (e.g. `"dialog-popup"`) |
| `data-cl-open` / `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Open state |
| Attribute | Applies To | Description |
| --------------------------------- | ---------------------------------- | ----------- |
| `data-cl-open` / `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Open state |

Slot identity (`data-cl-slot`) is applied by the styled (mosaic) layer, not by the headless parts.

## Important Notes

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,6 @@ export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropPro
const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
ref,
...transitionProps,
} satisfies DefaultProps<'div'>;
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-close.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@ export const DialogClose = React.forwardRef<HTMLButtonElement, DialogCloseProps>

const defaultProps = {
type: 'button' as const,
'data-cl-slot': 'dialog-close',
ref,
onClick() {
setOpen(false);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@ export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDe
const { descriptionId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
ref,
} satisfies DefaultProps<'p'>;
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-popup.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,6 @@ export const DialogPopup = React.forwardRef<HTMLDivElement, DialogPopupProps>(fu
}

const ownProps = {
'data-cl-slot': 'dialog-popup',
ref: combinedRef,
'aria-labelledby': labelId,
'aria-describedby': descriptionId,
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-title.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,6 @@ export const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps
const { labelId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-title',
id: labelId,
ref,
} satisfies DefaultProps<'h2'>;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,7 +25,6 @@ export const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerPr

const ownProps = {
type: 'button',
'data-cl-slot': 'dialog-trigger',
ref: combinedRef,
} satisfies DefaultProps<'button'>;

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,6 @@ export const DialogViewport = React.forwardRef<HTMLDivElement, DialogViewportPro
const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-viewport',
ref,
...transitionProps,
style: modal ? undefined : { pointerEvents: 'auto' as const },
Expand Down
73 changes: 27 additions & 46 deletions packages/headless/src/primitives/dialog/dialog.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,12 +7,15 @@ import { Dialog } from './index';

afterEach(() => cleanup());

// Headless parts no longer emit `data-cl-slot` — slot identity is applied by the styled
// (mosaic) layer. Tests locate the surface-only parts (backdrop, viewport, trigger) via
// `data-testid` and everything else via its accessible role or text.
function renderDialog(props: Partial<React.ComponentProps<typeof Dialog.Root>> = {}) {
return render(
<Dialog.Root {...props}>
<Dialog.Trigger>Open dialog</Dialog.Trigger>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Trigger data-testid='dialog-trigger'>Open dialog</Dialog.Trigger>
<Dialog.Backdrop data-testid='dialog-backdrop' />
<Dialog.Viewport data-testid='dialog-viewport'>
<Dialog.Popup>
<Dialog.Title>Dialog Title</Dialog.Title>
<Dialog.Description>Some dialog description</Dialog.Description>
Expand All@@ -25,25 +28,6 @@ function renderDialog(props: Partial<React.ComponentProps<typeof Dialog.Root>> =
}

describe('Dialog', () => {
describe('slot attributes', () => {
it('renders trigger with data-cl-slot', () => {
renderDialog();
const trigger = screen.getByRole('button', { name: 'Open dialog' });
expect(trigger).toHaveAttribute('data-cl-slot', 'dialog-trigger');
});

it('renders all parts with correct slot attributes when open', () => {
renderDialog({ defaultOpen: true });

expect(document.querySelector('[data-cl-slot="dialog-backdrop"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-viewport"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-title"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-description"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-close"]')).toBeInTheDocument();
});
});

describe('open/close', () => {
it('opens on trigger click', async () => {
const user = userEvent.setup();
Expand All@@ -53,7 +37,7 @@ describe('Dialog', () => {
await user.click(trigger);

expect(trigger).toHaveAttribute('data-cl-open', '');
expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});

it('closes on Escape', async () => {
Expand DownExpand Up@@ -93,7 +77,7 @@ describe('Dialog', () => {
it('respects controlled open prop', () => {
renderDialog({ open: true });

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});

it('does not open when controlled open is false', async () => {
Expand All@@ -102,29 +86,29 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});

describe('ARIA attributes', () => {
it('popup has aria-labelledby linked to title', () => {
renderDialog({ defaultOpen: true });

const title = document.querySelector('[data-cl-slot="dialog-title"]');
const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
const title = screen.getByText('Dialog Title');
const popup = screen.getByRole('dialog');

expect(title).toHaveAttribute('id');
expect(popup).toHaveAttribute('aria-labelledby', title?.getAttribute('id'));
expect(popup).toHaveAttribute('aria-labelledby', title.getAttribute('id'));
});

it('popup has aria-describedby linked to description', () => {
renderDialog({ defaultOpen: true });

const desc = document.querySelector('[data-cl-slot="dialog-description"]');
const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
const desc = screen.getByText('Some dialogdescription');
const popup = screen.getByRole('dialog');

expect(desc).toHaveAttribute('id');
expect(popup).toHaveAttribute('aria-describedby', desc?.getAttribute('id'));
expect(popup).toHaveAttribute('aria-describedby', desc.getAttribute('id'));
});

it('popup has role=dialog', () => {
Expand All@@ -137,7 +121,7 @@ describe('Dialog', () => {
describe('animation lifecycle', () => {
it('backdrop is not rendered when closed', () => {
renderDialog();
expect(document.querySelector('[data-cl-slot="dialog-backdrop"]')).not.toBeInTheDocument();
expect(screen.queryByTestId('dialog-backdrop')).not.toBeInTheDocument();
});

it('applies data-cl-open on popup when open', async () => {
Expand All@@ -146,8 +130,7 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
expect(popup).toHaveAttribute('data-cl-open', '');
expect(screen.getByRole('dialog')).toHaveAttribute('data-cl-open', '');
});

it('applies data-cl-open on backdrop when open', async () => {
Expand All@@ -156,8 +139,7 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const backdrop = document.querySelector('[data-cl-slot="dialog-backdrop"]');
expect(backdrop).toHaveAttribute('data-cl-open', '');
expect(screen.getByTestId('dialog-backdrop')).toHaveAttribute('data-cl-open', '');
});

it('applies data-cl-open on viewport when open', async () => {
Expand All@@ -166,13 +148,12 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const viewport = document.querySelector('[data-cl-slot="dialog-viewport"]');
expect(viewport).toHaveAttribute('data-cl-open', '');
expect(screen.getByTestId('dialog-viewport')).toHaveAttribute('data-cl-open', '');
});

it('viewport is not rendered when closed', () => {
renderDialog();
expect(document.querySelector('[data-cl-slot="dialog-viewport"]')).not.toBeInTheDocument();
expect(screen.queryByTestId('dialog-viewport')).not.toBeInTheDocument();
});
});

Expand DownExpand Up@@ -204,12 +185,12 @@ describe('Dialog', () => {
</Dialog.Root>,
);

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByText('Popup content')).not.toBeInTheDocument();

await user.click(screen.getByRole('button', { name: 'Open' }));

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Popup content')).toBeInTheDocument();
});
});
Expand All@@ -223,8 +204,8 @@ describe('Dialog', () => {

it('trigger has data-cl-open when dialog is visible', () => {
renderDialog({ defaultOpen: true });
// When modal is open, trigger's container gets aria-hidden, so use querySelector
const trigger = document.querySelector('[data-cl-slot="dialog-trigger"]');
// When modal is open, the trigger's container gets aria-hidden, so query by test id.
const trigger = screen.getByTestId('dialog-trigger');
expect(trigger).toHaveAttribute('data-cl-open', '');
});
});
Expand All@@ -246,7 +227,7 @@ describe('Dialog', () => {
<Dialog.Root modal={false}>
<Dialog.Trigger>Open dialog</Dialog.Trigger>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Viewport data-testid='dialog-viewport'>
<Dialog.Popup>
<Dialog.Title>Dialog Title</Dialog.Title>
<Dialog.Close>Close</Dialog.Close>
Expand All@@ -258,9 +239,9 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const viewport = document.querySelector('[data-cl-slot="dialog-viewport"]');
const viewport = screen.getByTestId('dialog-viewport');
expect(viewport).toHaveStyle({ pointerEvents: 'auto' });
expect(viewport?.parentElement).toHaveStyle({ pointerEvents: 'none' });
expect(viewport.parentElement).toHaveStyle({ pointerEvents: 'none' });

await user.click(screen.getByRole('button', { name: 'Background button' }));
expect(onBackgroundClick).toHaveBeenCalledTimes(1);
Expand Down
15 changes: 15 additions & 0 deletions packages/swingset/src/app/[group]/[component]/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import { DocsViewer } from '@/components/DocsViewer';

interface Props {
params: Promise<{ group: string; component: string }>;
}

export default async function ComponentPage({ params }: Props) {
const { group, component } = await params;
return (
<DocsViewer
group={group}
slug={component}
/>
);
}
10 changes: 0 additions & 10 deletions packages/swingset/src/app/components/[component]/page.tsx

This file was deleted.

10 changes: 4 additions & 6 deletions packages/swingset/src/components/ClientRoot.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,12 +19,10 @@ import { ThemeToggle } from './ThemeToggle';

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

Expand Down
52 changes: 32 additions & 20 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,40 +2,52 @@

import dynamic from 'next/dynamic';

import { getModuleBySlug } from '@/lib/registry';
import { getModule } from '@/lib/registry';

import { PlaygroundProvider } from './PlaygroundContext';
import { ViewSource } from './ViewSource';

const docModules: Record<string, React.ComponentType> = {
button: dynamic(() => import('../stories/button.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
// Headless primitives — alphabetical.
accordion: dynamic(() => import('../stories/accordion.mdx')),
autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
collapsible: dynamic(() => import('../stories/collapsible.mdx')),
dialog: dynamic(() => import('../stories/dialog.mdx')),
menu: dynamic(() => import('../stories/menu.mdx')),
popover: dynamic(() => import('../stories/popover.mdx')),
select: dynamic(() => import('../stories/select.mdx')),
tabs: dynamic(() => import('../stories/tabs.mdx')),
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
// 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>> = {
components: {
button: dynamic(() => import('../stories/button.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
},
primitives: {
// Headless primitives — alphabetical.
accordion: dynamic(() => import('../stories/accordion.mdx')),
autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
collapsible: dynamic(() => import('../stories/collapsible.mdx')),
dialog: dynamic(() => import('../stories/dialog.mdx')),
menu: dynamic(() => import('../stories/menu.mdx')),
popover: dynamic(() => import('../stories/popover.mdx')),
select: dynamic(() => import('../stories/select.mdx')),
tabs: dynamic(() => import('../stories/tabs.mdx')),
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
},
};

interface DocsViewerProps {
group: string;
slug: string;
}

export function DocsViewer({ slug }: DocsViewerProps) {
const DocContent = docModules[slug];
export function DocsViewer({ group, slug }: DocsViewerProps) {
const DocContent = docModules[group]?.[slug];
if (!DocContent) {
return <div className='text-muted-foreground p-8 text-sm'>No docs found for &quot;{slug}&quot;.</div>;
return (
<div className='text-muted-foreground p-8 text-sm'>
No docs found for &quot;{group}/{slug}&quot;.
</div>
);
}
const meta = getModuleBySlug(slug)?.meta;
const meta = getModule(group, slug)?.meta;
return (
// Keyed by slug so navigating between components resets the playground state.
// Keyed by group/slug so navigating between components resets the playground state.
<PlaygroundProvider
key={slug}
key={`${group}/${slug}`}
meta={meta}
>
<article className='prose relative mx-auto w-full min-w-0 max-w-3xl p-8'>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): Introduce mosaic recipes and conditions by alexcarpenter · Pull Request #8830 · clerk/javascript · GitHub
Skip to content
Merged
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/mosaic-slot-recipes.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment thread
alexcarpenter marked this conversation as resolved.
9 changes: 5 additions & 4 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,10 +97,11 @@ No additional props beyond standard HTML attributes and the `render` prop.

## Data Attributes

| Attribute | Applies To | Description |
| --------------------------------- | ---------------------------------- | --------------------------------------- |
| `data-cl-slot` | All parts | Part identifier (e.g. `"dialog-popup"`) |
| `data-cl-open` / `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Open state |
| Attribute | Applies To | Description |
| --------------------------------- | ---------------------------------- | ----------- |
| `data-cl-open` / `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Open state |

Slot identity (`data-cl-slot`) is applied by the styled (mosaic) layer, not by the headless parts.

## Important Notes

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,6 @@ export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropPro
const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
ref,
...transitionProps,
} satisfies DefaultProps<'div'>;
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-close.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@ export const DialogClose = React.forwardRef<HTMLButtonElement, DialogCloseProps>

const defaultProps = {
type: 'button' as const,
'data-cl-slot': 'dialog-close',
ref,
onClick() {
setOpen(false);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@ export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDe
const { descriptionId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
ref,
} satisfies DefaultProps<'p'>;
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-popup.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,6 @@ export const DialogPopup = React.forwardRef<HTMLDivElement, DialogPopupProps>(fu
}

const ownProps = {
'data-cl-slot': 'dialog-popup',
ref: combinedRef,
'aria-labelledby': labelId,
'aria-describedby': descriptionId,
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-title.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,6 @@ export const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps
const { labelId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-title',
id: labelId,
ref,
} satisfies DefaultProps<'h2'>;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,7 +25,6 @@ export const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerPr

const ownProps = {
type: 'button',
'data-cl-slot': 'dialog-trigger',
ref: combinedRef,
} satisfies DefaultProps<'button'>;

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,6 @@ export const DialogViewport = React.forwardRef<HTMLDivElement, DialogViewportPro
const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-viewport',
ref,
...transitionProps,
style: modal ? undefined : { pointerEvents: 'auto' as const },
Expand Down
73 changes: 27 additions & 46 deletions packages/headless/src/primitives/dialog/dialog.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,12 +7,15 @@ import { Dialog } from './index';

afterEach(() => cleanup());

// Headless parts no longer emit `data-cl-slot` — slot identity is applied by the styled
// (mosaic) layer. Tests locate the surface-only parts (backdrop, viewport, trigger) via
// `data-testid` and everything else via its accessible role or text.
function renderDialog(props: Partial<React.ComponentProps<typeof Dialog.Root>> = {}) {
return render(
<Dialog.Root {...props}>
<Dialog.Trigger>Open dialog</Dialog.Trigger>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Trigger data-testid='dialog-trigger'>Open dialog</Dialog.Trigger>
<Dialog.Backdrop data-testid='dialog-backdrop' />
<Dialog.Viewport data-testid='dialog-viewport'>
<Dialog.Popup>
<Dialog.Title>Dialog Title</Dialog.Title>
<Dialog.Description>Some dialog description</Dialog.Description>
Expand All@@ -25,25 +28,6 @@ function renderDialog(props: Partial<React.ComponentProps<typeof Dialog.Root>> =
}

describe('Dialog', () => {
describe('slot attributes', () => {
it('renders trigger with data-cl-slot', () => {
renderDialog();
const trigger = screen.getByRole('button', { name: 'Open dialog' });
expect(trigger).toHaveAttribute('data-cl-slot', 'dialog-trigger');
});

it('renders all parts with correct slot attributes when open', () => {
renderDialog({ defaultOpen: true });

expect(document.querySelector('[data-cl-slot="dialog-backdrop"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-viewport"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-title"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-description"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-close"]')).toBeInTheDocument();
});
});

describe('open/close', () => {
it('opens on trigger click', async () => {
const user = userEvent.setup();
Expand All@@ -53,7 +37,7 @@ describe('Dialog', () => {
await user.click(trigger);

expect(trigger).toHaveAttribute('data-cl-open', '');
expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});

it('closes on Escape', async () => {
Expand DownExpand Up@@ -93,7 +77,7 @@ describe('Dialog', () => {
it('respects controlled open prop', () => {
renderDialog({ open: true });

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});

it('does not open when controlled open is false', async () => {
Expand All@@ -102,29 +86,29 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});

describe('ARIA attributes', () => {
it('popup has aria-labelledby linked to title', () => {
renderDialog({ defaultOpen: true });

const title = document.querySelector('[data-cl-slot="dialog-title"]');
const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
const title = screen.getByText('Dialog Title');
const popup = screen.getByRole('dialog');

expect(title).toHaveAttribute('id');
expect(popup).toHaveAttribute('aria-labelledby', title?.getAttribute('id'));
expect(popup).toHaveAttribute('aria-labelledby', title.getAttribute('id'));
});

it('popup has aria-describedby linked to description', () => {
renderDialog({ defaultOpen: true });

const desc = document.querySelector('[data-cl-slot="dialog-description"]');
const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
const desc = screen.getByText('Some dialogdescription');
const popup = screen.getByRole('dialog');

expect(desc).toHaveAttribute('id');
expect(popup).toHaveAttribute('aria-describedby', desc?.getAttribute('id'));
expect(popup).toHaveAttribute('aria-describedby', desc.getAttribute('id'));
});

it('popup has role=dialog', () => {
Expand All@@ -137,7 +121,7 @@ describe('Dialog', () => {
describe('animation lifecycle', () => {
it('backdrop is not rendered when closed', () => {
renderDialog();
expect(document.querySelector('[data-cl-slot="dialog-backdrop"]')).not.toBeInTheDocument();
expect(screen.queryByTestId('dialog-backdrop')).not.toBeInTheDocument();
});

it('applies data-cl-open on popup when open', async () => {
Expand All@@ -146,8 +130,7 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
expect(popup).toHaveAttribute('data-cl-open', '');
expect(screen.getByRole('dialog')).toHaveAttribute('data-cl-open', '');
});

it('applies data-cl-open on backdrop when open', async () => {
Expand All@@ -156,8 +139,7 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const backdrop = document.querySelector('[data-cl-slot="dialog-backdrop"]');
expect(backdrop).toHaveAttribute('data-cl-open', '');
expect(screen.getByTestId('dialog-backdrop')).toHaveAttribute('data-cl-open', '');
});

it('applies data-cl-open on viewport when open', async () => {
Expand All@@ -166,13 +148,12 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const viewport = document.querySelector('[data-cl-slot="dialog-viewport"]');
expect(viewport).toHaveAttribute('data-cl-open', '');
expect(screen.getByTestId('dialog-viewport')).toHaveAttribute('data-cl-open', '');
});

it('viewport is not rendered when closed', () => {
renderDialog();
expect(document.querySelector('[data-cl-slot="dialog-viewport"]')).not.toBeInTheDocument();
expect(screen.queryByTestId('dialog-viewport')).not.toBeInTheDocument();
});
});

Expand DownExpand Up@@ -204,12 +185,12 @@ describe('Dialog', () => {
</Dialog.Root>,
);

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByText('Popup content')).not.toBeInTheDocument();

await user.click(screen.getByRole('button', { name: 'Open' }));

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Popup content')).toBeInTheDocument();
});
});
Expand All@@ -223,8 +204,8 @@ describe('Dialog', () => {

it('trigger has data-cl-open when dialog is visible', () => {
renderDialog({ defaultOpen: true });
// When modal is open, trigger's container gets aria-hidden, so use querySelector
const trigger = document.querySelector('[data-cl-slot="dialog-trigger"]');
// When modal is open, the trigger's container gets aria-hidden, so query by test id.
const trigger = screen.getByTestId('dialog-trigger');
expect(trigger).toHaveAttribute('data-cl-open', '');
});
});
Expand All@@ -246,7 +227,7 @@ describe('Dialog', () => {
<Dialog.Root modal={false}>
<Dialog.Trigger>Open dialog</Dialog.Trigger>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Viewport data-testid='dialog-viewport'>
<Dialog.Popup>
<Dialog.Title>Dialog Title</Dialog.Title>
<Dialog.Close>Close</Dialog.Close>
Expand All@@ -258,9 +239,9 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const viewport = document.querySelector('[data-cl-slot="dialog-viewport"]');
const viewport = screen.getByTestId('dialog-viewport');
expect(viewport).toHaveStyle({ pointerEvents: 'auto' });
expect(viewport?.parentElement).toHaveStyle({ pointerEvents: 'none' });
expect(viewport.parentElement).toHaveStyle({ pointerEvents: 'none' });

await user.click(screen.getByRole('button', { name: 'Background button' }));
expect(onBackgroundClick).toHaveBeenCalledTimes(1);
Expand Down
15 changes: 15 additions & 0 deletions packages/swingset/src/app/[group]/[component]/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import { DocsViewer } from '@/components/DocsViewer';

interface Props {
params: Promise<{ group: string; component: string }>;
}

export default async function ComponentPage({ params }: Props) {
const { group, component } = await params;
return (
<DocsViewer
group={group}
slug={component}
/>
);
}
10 changes: 0 additions & 10 deletions packages/swingset/src/app/components/[component]/page.tsx

This file was deleted.

10 changes: 4 additions & 6 deletions packages/swingset/src/components/ClientRoot.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,12 +19,10 @@ import { ThemeToggle } from './ThemeToggle';

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

Expand Down
52 changes: 32 additions & 20 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,40 +2,52 @@

import dynamic from 'next/dynamic';

import { getModuleBySlug } from '@/lib/registry';
import { getModule } from '@/lib/registry';

import { PlaygroundProvider } from './PlaygroundContext';
import { ViewSource } from './ViewSource';

const docModules: Record<string, React.ComponentType> = {
button: dynamic(() => import('../stories/button.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
// Headless primitives — alphabetical.
accordion: dynamic(() => import('../stories/accordion.mdx')),
autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
collapsible: dynamic(() => import('../stories/collapsible.mdx')),
dialog: dynamic(() => import('../stories/dialog.mdx')),
menu: dynamic(() => import('../stories/menu.mdx')),
popover: dynamic(() => import('../stories/popover.mdx')),
select: dynamic(() => import('../stories/select.mdx')),
tabs: dynamic(() => import('../stories/tabs.mdx')),
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
// 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>> = {
components: {
button: dynamic(() => import('../stories/button.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
},
primitives: {
// Headless primitives — alphabetical.
accordion: dynamic(() => import('../stories/accordion.mdx')),
autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
collapsible: dynamic(() => import('../stories/collapsible.mdx')),
dialog: dynamic(() => import('../stories/dialog.mdx')),
menu: dynamic(() => import('../stories/menu.mdx')),
popover: dynamic(() => import('../stories/popover.mdx')),
select: dynamic(() => import('../stories/select.mdx')),
tabs: dynamic(() => import('../stories/tabs.mdx')),
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
},
};

interface DocsViewerProps {
group: string;
slug: string;
}

export function DocsViewer({ slug }: DocsViewerProps) {
const DocContent = docModules[slug];
export function DocsViewer({ group, slug }: DocsViewerProps) {
const DocContent = docModules[group]?.[slug];
if (!DocContent) {
return <div className='text-muted-foreground p-8 text-sm'>No docs found for &quot;{slug}&quot;.</div>;
return (
<div className='text-muted-foreground p-8 text-sm'>
No docs found for &quot;{group}/{slug}&quot;.
</div>
);
}
const meta = getModuleBySlug(slug)?.meta;
const meta = getModule(group, slug)?.meta;
return (
// Keyed by slug so navigating between components resets the playground state.
// Keyed by group/slug so navigating between components resets the playground state.
<PlaygroundProvider
key={slug}
key={`${group}/${slug}`}
meta={meta}
>
<article className='prose relative mx-auto w-full min-w-0 max-w-3xl p-8'>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(ui): Introduce mosaic recipes and conditions by alexcarpenter · Pull Request #8830 · clerk/javascript · GitHub
Skip to content
Merged
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/mosaic-slot-recipes.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment thread
alexcarpenter marked this conversation as resolved.
9 changes: 5 additions & 4 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,10 +97,11 @@ No additional props beyond standard HTML attributes and the `render` prop.

## Data Attributes

| Attribute | Applies To | Description |
| --------------------------------- | ---------------------------------- | --------------------------------------- |
| `data-cl-slot` | All parts | Part identifier (e.g. `"dialog-popup"`) |
| `data-cl-open` / `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Open state |
| Attribute | Applies To | Description |
| --------------------------------- | ---------------------------------- | ----------- |
| `data-cl-open` / `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Open state |

Slot identity (`data-cl-slot`) is applied by the styled (mosaic) layer, not by the headless parts.

## Important Notes

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,6 @@ export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropPro
const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
ref,
...transitionProps,
} satisfies DefaultProps<'div'>;
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-close.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@ export const DialogClose = React.forwardRef<HTMLButtonElement, DialogCloseProps>

const defaultProps = {
type: 'button' as const,
'data-cl-slot': 'dialog-close',
ref,
onClick() {
setOpen(false);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@ export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDe
const { descriptionId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
ref,
} satisfies DefaultProps<'p'>;
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-popup.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,6 @@ export const DialogPopup = React.forwardRef<HTMLDivElement, DialogPopupProps>(fu
}

const ownProps = {
'data-cl-slot': 'dialog-popup',
ref: combinedRef,
'aria-labelledby': labelId,
'aria-describedby': descriptionId,
Expand Down
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-title.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,6 @@ export const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps
const { labelId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-title',
id: labelId,
ref,
} satisfies DefaultProps<'h2'>;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,7 +25,6 @@ export const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerPr

const ownProps = {
type: 'button',
'data-cl-slot': 'dialog-trigger',
ref: combinedRef,
} satisfies DefaultProps<'button'>;

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,6 @@ export const DialogViewport = React.forwardRef<HTMLDivElement, DialogViewportPro
const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-viewport',
ref,
...transitionProps,
style: modal ? undefined : { pointerEvents: 'auto' as const },
Expand Down
73 changes: 27 additions & 46 deletions packages/headless/src/primitives/dialog/dialog.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,12 +7,15 @@ import { Dialog } from './index';

afterEach(() => cleanup());

// Headless parts no longer emit `data-cl-slot` — slot identity is applied by the styled
// (mosaic) layer. Tests locate the surface-only parts (backdrop, viewport, trigger) via
// `data-testid` and everything else via its accessible role or text.
function renderDialog(props: Partial<React.ComponentProps<typeof Dialog.Root>> = {}) {
return render(
<Dialog.Root {...props}>
<Dialog.Trigger>Open dialog</Dialog.Trigger>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Trigger data-testid='dialog-trigger'>Open dialog</Dialog.Trigger>
<Dialog.Backdrop data-testid='dialog-backdrop' />
<Dialog.Viewport data-testid='dialog-viewport'>
<Dialog.Popup>
<Dialog.Title>Dialog Title</Dialog.Title>
<Dialog.Description>Some dialog description</Dialog.Description>
Expand All@@ -25,25 +28,6 @@ function renderDialog(props: Partial<React.ComponentProps<typeof Dialog.Root>> =
}

describe('Dialog', () => {
describe('slot attributes', () => {
it('renders trigger with data-cl-slot', () => {
renderDialog();
const trigger = screen.getByRole('button', { name: 'Open dialog' });
expect(trigger).toHaveAttribute('data-cl-slot', 'dialog-trigger');
});

it('renders all parts with correct slot attributes when open', () => {
renderDialog({ defaultOpen: true });

expect(document.querySelector('[data-cl-slot="dialog-backdrop"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-viewport"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-title"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-description"]')).toBeInTheDocument();
expect(document.querySelector('[data-cl-slot="dialog-close"]')).toBeInTheDocument();
});
});

describe('open/close', () => {
it('opens on trigger click', async () => {
const user = userEvent.setup();
Expand All@@ -53,7 +37,7 @@ describe('Dialog', () => {
await user.click(trigger);

expect(trigger).toHaveAttribute('data-cl-open', '');
expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});

it('closes on Escape', async () => {
Expand DownExpand Up@@ -93,7 +77,7 @@ describe('Dialog', () => {
it('respects controlled open prop', () => {
renderDialog({ open: true });

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});

it('does not open when controlled open is false', async () => {
Expand All@@ -102,29 +86,29 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});

describe('ARIA attributes', () => {
it('popup has aria-labelledby linked to title', () => {
renderDialog({ defaultOpen: true });

const title = document.querySelector('[data-cl-slot="dialog-title"]');
const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
const title = screen.getByText('Dialog Title');
const popup = screen.getByRole('dialog');

expect(title).toHaveAttribute('id');
expect(popup).toHaveAttribute('aria-labelledby', title?.getAttribute('id'));
expect(popup).toHaveAttribute('aria-labelledby', title.getAttribute('id'));
});

it('popup has aria-describedby linked to description', () => {
renderDialog({ defaultOpen: true });

const desc = document.querySelector('[data-cl-slot="dialog-description"]');
const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
const desc = screen.getByText('Some dialogdescription');
const popup = screen.getByRole('dialog');

expect(desc).toHaveAttribute('id');
expect(popup).toHaveAttribute('aria-describedby', desc?.getAttribute('id'));
expect(popup).toHaveAttribute('aria-describedby', desc.getAttribute('id'));
});

it('popup has role=dialog', () => {
Expand All@@ -137,7 +121,7 @@ describe('Dialog', () => {
describe('animation lifecycle', () => {
it('backdrop is not rendered when closed', () => {
renderDialog();
expect(document.querySelector('[data-cl-slot="dialog-backdrop"]')).not.toBeInTheDocument();
expect(screen.queryByTestId('dialog-backdrop')).not.toBeInTheDocument();
});

it('applies data-cl-open on popup when open', async () => {
Expand All@@ -146,8 +130,7 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const popup = document.querySelector('[data-cl-slot="dialog-popup"]');
expect(popup).toHaveAttribute('data-cl-open', '');
expect(screen.getByRole('dialog')).toHaveAttribute('data-cl-open', '');
});

it('applies data-cl-open on backdrop when open', async () => {
Expand All@@ -156,8 +139,7 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const backdrop = document.querySelector('[data-cl-slot="dialog-backdrop"]');
expect(backdrop).toHaveAttribute('data-cl-open', '');
expect(screen.getByTestId('dialog-backdrop')).toHaveAttribute('data-cl-open', '');
});

it('applies data-cl-open on viewport when open', async () => {
Expand All@@ -166,13 +148,12 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const viewport = document.querySelector('[data-cl-slot="dialog-viewport"]');
expect(viewport).toHaveAttribute('data-cl-open', '');
expect(screen.getByTestId('dialog-viewport')).toHaveAttribute('data-cl-open', '');
});

it('viewport is not rendered when closed', () => {
renderDialog();
expect(document.querySelector('[data-cl-slot="dialog-viewport"]')).not.toBeInTheDocument();
expect(screen.queryByTestId('dialog-viewport')).not.toBeInTheDocument();
});
});

Expand DownExpand Up@@ -204,12 +185,12 @@ describe('Dialog', () => {
</Dialog.Root>,
);

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByText('Popup content')).not.toBeInTheDocument();

await user.click(screen.getByRole('button', { name: 'Open' }));

expect(document.querySelector('[data-cl-slot="dialog-popup"]')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Popup content')).toBeInTheDocument();
});
});
Expand All@@ -223,8 +204,8 @@ describe('Dialog', () => {

it('trigger has data-cl-open when dialog is visible', () => {
renderDialog({ defaultOpen: true });
// When modal is open, trigger's container gets aria-hidden, so use querySelector
const trigger = document.querySelector('[data-cl-slot="dialog-trigger"]');
// When modal is open, the trigger's container gets aria-hidden, so query by test id.
const trigger = screen.getByTestId('dialog-trigger');
expect(trigger).toHaveAttribute('data-cl-open', '');
});
});
Expand All@@ -246,7 +227,7 @@ describe('Dialog', () => {
<Dialog.Root modal={false}>
<Dialog.Trigger>Open dialog</Dialog.Trigger>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Viewport data-testid='dialog-viewport'>
<Dialog.Popup>
<Dialog.Title>Dialog Title</Dialog.Title>
<Dialog.Close>Close</Dialog.Close>
Expand All@@ -258,9 +239,9 @@ describe('Dialog', () => {

await user.click(screen.getByRole('button', { name: 'Open dialog' }));

const viewport = document.querySelector('[data-cl-slot="dialog-viewport"]');
const viewport = screen.getByTestId('dialog-viewport');
expect(viewport).toHaveStyle({ pointerEvents: 'auto' });
expect(viewport?.parentElement).toHaveStyle({ pointerEvents: 'none' });
expect(viewport.parentElement).toHaveStyle({ pointerEvents: 'none' });

await user.click(screen.getByRole('button', { name: 'Background button' }));
expect(onBackgroundClick).toHaveBeenCalledTimes(1);
Expand Down
15 changes: 15 additions & 0 deletions packages/swingset/src/app/[group]/[component]/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import { DocsViewer } from '@/components/DocsViewer';

interface Props {
params: Promise<{ group: string; component: string }>;
}

export default async function ComponentPage({ params }: Props) {
const { group, component } = await params;
return (
<DocsViewer
group={group}
slug={component}
/>
);
}
10 changes: 0 additions & 10 deletions packages/swingset/src/app/components/[component]/page.tsx

This file was deleted.

10 changes: 4 additions & 6 deletions packages/swingset/src/components/ClientRoot.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,12 +19,10 @@ import { ThemeToggle } from './ThemeToggle';

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

Expand Down
52 changes: 32 additions & 20 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,40 +2,52 @@

import dynamic from 'next/dynamic';

import { getModuleBySlug } from '@/lib/registry';
import { getModule } from '@/lib/registry';

import { PlaygroundProvider } from './PlaygroundContext';
import { ViewSource } from './ViewSource';

const docModules: Record<string, React.ComponentType> = {
button: dynamic(() => import('../stories/button.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
// Headless primitives — alphabetical.
accordion: dynamic(() => import('../stories/accordion.mdx')),
autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
collapsible: dynamic(() => import('../stories/collapsible.mdx')),
dialog: dynamic(() => import('../stories/dialog.mdx')),
menu: dynamic(() => import('../stories/menu.mdx')),
popover: dynamic(() => import('../stories/popover.mdx')),
select: dynamic(() => import('../stories/select.mdx')),
tabs: dynamic(() => import('../stories/tabs.mdx')),
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
// 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>> = {
components: {
button: dynamic(() => import('../stories/button.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
},
primitives: {
// Headless primitives — alphabetical.
accordion: dynamic(() => import('../stories/accordion.mdx')),
autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
collapsible: dynamic(() => import('../stories/collapsible.mdx')),
dialog: dynamic(() => import('../stories/dialog.mdx')),
menu: dynamic(() => import('../stories/menu.mdx')),
popover: dynamic(() => import('../stories/popover.mdx')),
select: dynamic(() => import('../stories/select.mdx')),
tabs: dynamic(() => import('../stories/tabs.mdx')),
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
},
};

interface DocsViewerProps {
group: string;
slug: string;
}

export function DocsViewer({ slug }: DocsViewerProps) {
const DocContent = docModules[slug];
export function DocsViewer({ group, slug }: DocsViewerProps) {
const DocContent = docModules[group]?.[slug];
if (!DocContent) {
return <div className='text-muted-foreground p-8 text-sm'>No docs found for &quot;{slug}&quot;.</div>;
return (
<div className='text-muted-foreground p-8 text-sm'>
No docs found for &quot;{group}/{slug}&quot;.
</div>
);
}
const meta = getModuleBySlug(slug)?.meta;
const meta = getModule(group, slug)?.meta;
return (
// Keyed by slug so navigating between components resets the playground state.
// Keyed by group/slug so navigating between components resets the playground state.
<PlaygroundProvider
key={slug}
key={`${group}/${slug}`}
meta={meta}
>
<article className='prose relative mx-auto w-full min-w-0 max-w-3xl p-8'>
Expand Down
Loading
Loading