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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-trigger.tsx
Original file line number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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 dialog description');
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 Down Expand 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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