Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
65bf84b
feat(headless): add Dialog.Viewport and forward refs on all dialog parts
alexcarpenter Jun 10, 2026
2fdbbd4
refactor(ui): rename mosaic Button `color` variant to `intent`
alexcarpenter Jun 10, 2026
c6d1ad8
feat(ui): bridge headless dialog primitives into mosaic
alexcarpenter Jun 10, 2026
a9e4584
chore(ui): move @floating-ui/react to catalog:repo
alexcarpenter Jun 10, 2026
0477fa8
add changeset
alexcarpenter Jun 10, 2026
7740983
refactor(headless): replace masking type assertions with validated sa…
alexcarpenter Jun 10, 2026
49e21c3
Delete .changeset/whole-loops-find.md
alexcarpenter Jun 10, 2026
920ce9a
docs(headless,ui): add JSDoc to dialog parts and mosaic components
alexcarpenter Jun 10, 2026
9c291ef
fix(ui): preserve generic props and correct return type in withMosaic…
alexcarpenter Jun 10, 2026
aeaceb7
fix(headless): pass pointer events through viewport overlay when non-…
alexcarpenter Jun 10, 2026
3b94b24
fix @clerk/headless usage in ui package
alexcarpenter Jun 10, 2026
d1f8db1
chore(repo): update lockfile after moving @clerk/headless to devDepen…
alexcarpenter Jun 10, 2026
3e82b1b
chore(repo): dedupe lockfile
alexcarpenter Jun 10, 2026
80de9e7
docs(headless): fix data-cl-* tables in Popover and Tooltip READMEs
alexcarpenter Jun 10, 2026
5be6d13
fix(headless): remove dead DialogScopedContext from dialog primitives
alexcarpenter Jun 10, 2026
f83a7a7
fix(ui): use minHeight in mosaic dialog viewport to prevent tall cont…
alexcarpenter Jun 10, 2026
b833043
Merge branch 'main' into carp/mosaic-dialog
alexcarpenter Jun 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/wise-types-satisfy.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,7 +33,7 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete

const defaultProps = {
'data-cl-slot': 'autocomplete-input',
...(getReferenceProps({
...getReferenceProps({
ref: combinedRef,
value: inputValue,
'aria-autocomplete': 'list' as const,
Expand All@@ -50,7 +50,7 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
}
}
},
}) as React.ComponentPropsWithRef<'input'>),
}),
};

return renderElement({
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { FloatingList, useMergeRefs } from '@floating-ui/react';
import React, { useEffect } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteListProps = ComponentProps<'div'>;
Expand All@@ -24,14 +24,15 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>;
const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-list',
ref: combinedRef,
...floatingProps,
};
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };

const merged = mergeProps<'div'>(defaultProps, otherProps);
// The wired id is owned by the primitive: a consumer-supplied id must not
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { useListItem, useMergeRefs } from '@floating-ui/react';
import React, { useEffect, useId } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export interface AutocompleteOptionProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -43,21 +43,25 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
disabled: !!disabled,
};

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-option',
id,
ref: combinedRef,
role: 'option' as const,
role: 'option',
'aria-selected': isActive,
'aria-disabled': disabled || undefined,
...(getItemProps({
} satisfies DefaultProps<'div'>;

const defaultProps = {
...ownProps,
...getItemProps({
onClick() {
if (!disabled) {
handleSelect(value, index, displayLabel);
(refs.domReference.current as HTMLElement | null)?.focus();
}
},
}) as React.ComponentPropsWithRef<'div'>),
}),
};

const merged = mergeProps<'div'>(defaultProps, otherProps);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePositionerProps = ComponentProps<'div'>;
Expand All@@ -22,16 +22,17 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>;
const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-positioner',
'data-cl-side': side,
ref: combinedRef,
style: floatingStyles,
...floatingProps,
};
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };

const merged = mergeProps<'div'>(defaultProps, otherProps);
// The wired id is owned by the primitive: a consumer-supplied id must not
Expand Down
34 changes: 20 additions & 14 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,13 +15,14 @@ import { Dialog } from '@/primitives/dialog';

<Dialog.Root>
<Dialog.Trigger>Open Dialog</Dialog.Trigger>
<Dialog.Backdrop>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Popup>
<Dialog.Title>Confirm Action</Dialog.Title>
<Dialog.Description>Are you sure you want to proceed?</Dialog.Description>
<Dialog.Close>Cancel</Dialog.Close>
</Dialog.Popup>
</Dialog.Backdrop>
</Dialog.Viewport>
</Dialog.Root>;
```

Expand DownExpand Up@@ -51,7 +52,8 @@ const [open, setOpen] = useState(false);
| `Dialog.Root` | — | Root context provider |
| `Dialog.Trigger` | `<button>` | Opens/closes the dialog on click |
| `Dialog.Portal` | — | Portals children (defaults to `document.body`) |
| `Dialog.Backdrop` | `<div>` | Overlay + focus manager host |
| `Dialog.Backdrop` | `<div>` | Semi-transparent overlay surface |
| `Dialog.Viewport` | `<div>` | Fixed centering container; owns scroll lock |
| `Dialog.Popup` | `<div>` | The dialog content container |
| `Dialog.Title` | `<h2>` | Dialog heading, wired to `aria-labelledby` |
| `Dialog.Description` | `<p>` | Dialog description, wired to `aria-describedby` |
Expand All@@ -74,15 +76,15 @@ const [open, setOpen] = useState(false);
| ------ | ------------------------------------------------------------- | --------------- | -------------------------------- |
| `root` | `HTMLElement \| null \| React.RefObject<HTMLElement \| null>` | `document.body` | Container element to portal into |

When `root` is provided, the dialog is "scoped" to that container: `Dialog.Backdrop` skips `FloatingOverlay` (no fixed positioning or scroll lock), and consumers handle positioning via CSS on the container.
When `root` is provided, the dialog is portaled into that container instead of `document.body`. Consumers handle layout via CSS on the container (or by omitting `Dialog.Viewport` and styling their own).

### `Dialog.Backdrop`
### `Dialog.Viewport`

| Prop | Type | Default | Description |
| ------------ | --------- | ------- | ----------------------------------------------------- |
| `lockScroll` | `boolean` | `true` | Prevents body scroll while open (skipped when scoped) |
| Prop | Type | Default | Description |
| ------------ | --------- | ------- | ------------------------------- |
| `lockScroll` | `boolean` | `true` | Prevents body scroll while open |

### `Dialog.Trigger`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close`
### `Dialog.Backdrop`, `Dialog.Trigger`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close`

No additional props beyond standard HTML attributes and the `render` prop.

Expand All@@ -95,18 +97,22 @@ 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 | Open state |
| 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 |

## Important Notes

- **`Dialog.Popup` must be a child of `Dialog.Backdrop`.** The backdrop hosts the portal, overlay, and focus manager — the popup alone does not handle these.
- **`Dialog.Popup` should be a child of `Dialog.Viewport`** for centered, scroll-locked modal behavior. The viewport hosts the fixed overlay container; the popup alone does not handle positioning or scroll lock.
- **Title and Description are optional but recommended.** If omitted, `aria-labelledby` / `aria-describedby` are simply absent from the popup.
- **Nested dialogs are supported.** The `FloatingTree` pattern handles nesting automatically.
- **No positioning middleware.** Dialogs are centered via CSS, not Floating UI positioning.

## Authoring rule for new primitives

Each styleable surface = one part. Layout infrastructure (overlay, scroll lock, focus manager, portal) wraps a `renderElement` call rather than fusing with it. The dialog split — `Backdrop` (semi-transparent surface) vs. `Viewport` (fixed centering + scroll lock) — exists because mosaic needs to style each layer independently. Apply the same decomposition to future primitives that combine positioning with a styled surface.

## ARIA

- Popup: `role="dialog"`, `aria-labelledby` (from Title), `aria-describedby` (from Description)
Expand Down
81 changes: 37 additions & 44 deletions packages/headless/src/primitives/dialog/dialog-backdrop.tsx
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,39 @@
'use client';

import { FloatingOverlay } from '@floating-ui/react';
import { useContext } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { DialogScopedContext, useDialogContext } from './dialog-context';

export interface DialogBackdropProps extends ComponentProps<'div'> {
/** When true, locks body scroll while the dialog is open. Default: true */
lockScroll?: boolean;
}

export function DialogBackdrop(props: DialogBackdropProps) {
const { render, lockScroll = true, ...otherProps } = props;
const { open, mounted, transitionProps } = useDialogContext();
const scoped = useContext(DialogScopedContext);

const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
...transitionProps,
} as React.ComponentPropsWithRef<'div'>;

const backdropElement = renderElement({
defaultTagName: 'div',
render,
enabled: mounted,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});

if (scoped) {
return backdropElement;
}

if (!mounted) {
return null;
}

return <FloatingOverlay lockScroll={lockScroll}>{backdropElement}</FloatingOverlay>;
}
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogBackdrop}. */
export type DialogBackdropProps = ComponentProps<'div'>;

/** Semi-transparent overlay surface rendered behind the dialog. Does not own scroll-lock or positioning — use `Dialog.Viewport` for those. */
export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropProps>(
function DialogBackdrop(props, ref) {
const { render, ...otherProps } = props;
const { open, mounted, transitionProps } = useDialogContext();

if (!mounted) {
return null;
}

const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
ref,
...transitionProps,
} satisfies DefaultProps<'div'>;

return renderElement({
defaultTagName: 'div',
render,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});
},
);
13 changes: 9 additions & 4 deletions packages/headless/src/primitives/dialog/dialog-close.tsx
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,30 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogClose}. */
export type DialogCloseProps = ComponentProps<'button'>;

export function DialogClose(props: DialogCloseProps) {
/** Button that closes the dialog when clicked. Calls `setOpen(false)` from dialog context. */
export const DialogClose = React.forwardRef<HTMLButtonElement, DialogCloseProps>(function DialogClose(props, ref) {
const { render, ...otherProps } = props;
const { setOpen } = useDialogContext();

const defaultProps = {
type: 'button' as const,
'data-cl-slot': 'dialog-close',
ref,
onClick() {
setOpen(false);
},
};
} satisfies DefaultProps<'button'>;

return renderElement({
defaultTagName: 'button',
render,
props: mergeProps<'button'>(defaultProps, otherProps),
});
}
});
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-context.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,6 @@ export interface DialogContextValue {
}

export const DialogContext = createContext<DialogContextValue | null>(null);
export const DialogScopedContext = createContext(false);

export function useDialogContext() {
const ctx = useContext(DialogContext);
Expand Down
35 changes: 21 additions & 14 deletions packages/headless/src/primitives/dialog/dialog-description.tsx
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogDescription}. */
export type DialogDescriptionProps = Omit<ComponentProps<'p'>, 'id'>;

export function DialogDescription(props: DialogDescriptionProps) {
const { render, ...otherProps } = props;
const { descriptionId } = useDialogContext();
/** Accessible dialog description. Wires its `id` to `aria-describedby` on `Dialog.Popup`. */
export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDescriptionProps>(
function DialogDescription(props, ref) {
const { render, ...otherProps } = props;
const { descriptionId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
};
const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
ref,
} satisfies DefaultProps<'p'>;

return renderElement({
defaultTagName: 'p',
render,
props: mergeProps<'p'>(defaultProps, otherProps),
});
}
return renderElement({
defaultTagName: 'p',
render,
props: mergeProps<'p'>(defaultProps, otherProps),
});
},
);
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n 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;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
65bf84b
feat(headless): add Dialog.Viewport and forward refs on all dialog parts
alexcarpenter Jun 10, 2026
2fdbbd4
refactor(ui): rename mosaic Button `color` variant to `intent`
alexcarpenter Jun 10, 2026
c6d1ad8
feat(ui): bridge headless dialog primitives into mosaic
alexcarpenter Jun 10, 2026
a9e4584
chore(ui): move @floating-ui/react to catalog:repo
alexcarpenter Jun 10, 2026
0477fa8
add changeset
alexcarpenter Jun 10, 2026
7740983
refactor(headless): replace masking type assertions with validated sa…
alexcarpenter Jun 10, 2026
49e21c3
Delete .changeset/whole-loops-find.md
alexcarpenter Jun 10, 2026
920ce9a
docs(headless,ui): add JSDoc to dialog parts and mosaic components
alexcarpenter Jun 10, 2026
9c291ef
fix(ui): preserve generic props and correct return type in withMosaic…
alexcarpenter Jun 10, 2026
aeaceb7
fix(headless): pass pointer events through viewport overlay when non-…
alexcarpenter Jun 10, 2026
3b94b24
fix @clerk/headless usage in ui package
alexcarpenter Jun 10, 2026
d1f8db1
chore(repo): update lockfile after moving @clerk/headless to devDepen…
alexcarpenter Jun 10, 2026
3e82b1b
chore(repo): dedupe lockfile
alexcarpenter Jun 10, 2026
80de9e7
docs(headless): fix data-cl-* tables in Popover and Tooltip READMEs
alexcarpenter Jun 10, 2026
5be6d13
fix(headless): remove dead DialogScopedContext from dialog primitives
alexcarpenter Jun 10, 2026
f83a7a7
fix(ui): use minHeight in mosaic dialog viewport to prevent tall cont…
alexcarpenter Jun 10, 2026
b833043
Merge branch 'main' into carp/mosaic-dialog
alexcarpenter Jun 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/wise-types-satisfy.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,7 +33,7 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete

const defaultProps = {
'data-cl-slot': 'autocomplete-input',
...(getReferenceProps({
...getReferenceProps({
ref: combinedRef,
value: inputValue,
'aria-autocomplete': 'list' as const,
Expand All@@ -50,7 +50,7 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
}
}
},
}) as React.ComponentPropsWithRef<'input'>),
}),
};

return renderElement({
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { FloatingList, useMergeRefs } from '@floating-ui/react';
import React, { useEffect } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteListProps = ComponentProps<'div'>;
Expand All@@ -24,14 +24,15 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>;
const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-list',
ref: combinedRef,
...floatingProps,
};
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };

const merged = mergeProps<'div'>(defaultProps, otherProps);
// The wired id is owned by the primitive: a consumer-supplied id must not
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { useListItem, useMergeRefs } from '@floating-ui/react';
import React, { useEffect, useId } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export interface AutocompleteOptionProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -43,21 +43,25 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
disabled: !!disabled,
};

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-option',
id,
ref: combinedRef,
role: 'option' as const,
role: 'option',
'aria-selected': isActive,
'aria-disabled': disabled || undefined,
...(getItemProps({
} satisfies DefaultProps<'div'>;

const defaultProps = {
...ownProps,
...getItemProps({
onClick() {
if (!disabled) {
handleSelect(value, index, displayLabel);
(refs.domReference.current as HTMLElement | null)?.focus();
}
},
}) as React.ComponentPropsWithRef<'div'>),
}),
};

const merged = mergeProps<'div'>(defaultProps, otherProps);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePositionerProps = ComponentProps<'div'>;
Expand All@@ -22,16 +22,17 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>;
const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-positioner',
'data-cl-side': side,
ref: combinedRef,
style: floatingStyles,
...floatingProps,
};
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };

const merged = mergeProps<'div'>(defaultProps, otherProps);
// The wired id is owned by the primitive: a consumer-supplied id must not
Expand Down
34 changes: 20 additions & 14 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,13 +15,14 @@ import { Dialog } from '@/primitives/dialog';

<Dialog.Root>
<Dialog.Trigger>Open Dialog</Dialog.Trigger>
<Dialog.Backdrop>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Popup>
<Dialog.Title>Confirm Action</Dialog.Title>
<Dialog.Description>Are you sure you want to proceed?</Dialog.Description>
<Dialog.Close>Cancel</Dialog.Close>
</Dialog.Popup>
</Dialog.Backdrop>
</Dialog.Viewport>
</Dialog.Root>;
```

Expand DownExpand Up@@ -51,7 +52,8 @@ const [open, setOpen] = useState(false);
| `Dialog.Root` | — | Root context provider |
| `Dialog.Trigger` | `<button>` | Opens/closes the dialog on click |
| `Dialog.Portal` | — | Portals children (defaults to `document.body`) |
| `Dialog.Backdrop` | `<div>` | Overlay + focus manager host |
| `Dialog.Backdrop` | `<div>` | Semi-transparent overlay surface |
| `Dialog.Viewport` | `<div>` | Fixed centering container; owns scroll lock |
| `Dialog.Popup` | `<div>` | The dialog content container |
| `Dialog.Title` | `<h2>` | Dialog heading, wired to `aria-labelledby` |
| `Dialog.Description` | `<p>` | Dialog description, wired to `aria-describedby` |
Expand All@@ -74,15 +76,15 @@ const [open, setOpen] = useState(false);
| ------ | ------------------------------------------------------------- | --------------- | -------------------------------- |
| `root` | `HTMLElement \| null \| React.RefObject<HTMLElement \| null>` | `document.body` | Container element to portal into |

When `root` is provided, the dialog is "scoped" to that container: `Dialog.Backdrop` skips `FloatingOverlay` (no fixed positioning or scroll lock), and consumers handle positioning via CSS on the container.
When `root` is provided, the dialog is portaled into that container instead of `document.body`. Consumers handle layout via CSS on the container (or by omitting `Dialog.Viewport` and styling their own).

### `Dialog.Backdrop`
### `Dialog.Viewport`

| Prop | Type | Default | Description |
| ------------ | --------- | ------- | ----------------------------------------------------- |
| `lockScroll` | `boolean` | `true` | Prevents body scroll while open (skipped when scoped) |
| Prop | Type | Default | Description |
| ------------ | --------- | ------- | ------------------------------- |
| `lockScroll` | `boolean` | `true` | Prevents body scroll while open |

### `Dialog.Trigger`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close`
### `Dialog.Backdrop`, `Dialog.Trigger`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close`

No additional props beyond standard HTML attributes and the `render` prop.

Expand All@@ -95,18 +97,22 @@ 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 | Open state |
| 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 |

## Important Notes

- **`Dialog.Popup` must be a child of `Dialog.Backdrop`.** The backdrop hosts the portal, overlay, and focus manager — the popup alone does not handle these.
- **`Dialog.Popup` should be a child of `Dialog.Viewport`** for centered, scroll-locked modal behavior. The viewport hosts the fixed overlay container; the popup alone does not handle positioning or scroll lock.
- **Title and Description are optional but recommended.** If omitted, `aria-labelledby` / `aria-describedby` are simply absent from the popup.
- **Nested dialogs are supported.** The `FloatingTree` pattern handles nesting automatically.
- **No positioning middleware.** Dialogs are centered via CSS, not Floating UI positioning.

## Authoring rule for new primitives

Each styleable surface = one part. Layout infrastructure (overlay, scroll lock, focus manager, portal) wraps a `renderElement` call rather than fusing with it. The dialog split — `Backdrop` (semi-transparent surface) vs. `Viewport` (fixed centering + scroll lock) — exists because mosaic needs to style each layer independently. Apply the same decomposition to future primitives that combine positioning with a styled surface.

## ARIA

- Popup: `role="dialog"`, `aria-labelledby` (from Title), `aria-describedby` (from Description)
Expand Down
81 changes: 37 additions & 44 deletions packages/headless/src/primitives/dialog/dialog-backdrop.tsx
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,39 @@
'use client';

import { FloatingOverlay } from '@floating-ui/react';
import { useContext } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { DialogScopedContext, useDialogContext } from './dialog-context';

export interface DialogBackdropProps extends ComponentProps<'div'> {
/** When true, locks body scroll while the dialog is open. Default: true */
lockScroll?: boolean;
}

export function DialogBackdrop(props: DialogBackdropProps) {
const { render, lockScroll = true, ...otherProps } = props;
const { open, mounted, transitionProps } = useDialogContext();
const scoped = useContext(DialogScopedContext);

const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
...transitionProps,
} as React.ComponentPropsWithRef<'div'>;

const backdropElement = renderElement({
defaultTagName: 'div',
render,
enabled: mounted,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});

if (scoped) {
return backdropElement;
}

if (!mounted) {
return null;
}

return <FloatingOverlay lockScroll={lockScroll}>{backdropElement}</FloatingOverlay>;
}
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogBackdrop}. */
export type DialogBackdropProps = ComponentProps<'div'>;

/** Semi-transparent overlay surface rendered behind the dialog. Does not own scroll-lock or positioning — use `Dialog.Viewport` for those. */
export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropProps>(
function DialogBackdrop(props, ref) {
const { render, ...otherProps } = props;
const { open, mounted, transitionProps } = useDialogContext();

if (!mounted) {
return null;
}

const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
ref,
...transitionProps,
} satisfies DefaultProps<'div'>;

return renderElement({
defaultTagName: 'div',
render,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});
},
);
13 changes: 9 additions & 4 deletions packages/headless/src/primitives/dialog/dialog-close.tsx
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,30 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogClose}. */
export type DialogCloseProps = ComponentProps<'button'>;

export function DialogClose(props: DialogCloseProps) {
/** Button that closes the dialog when clicked. Calls `setOpen(false)` from dialog context. */
export const DialogClose = React.forwardRef<HTMLButtonElement, DialogCloseProps>(function DialogClose(props, ref) {
const { render, ...otherProps } = props;
const { setOpen } = useDialogContext();

const defaultProps = {
type: 'button' as const,
'data-cl-slot': 'dialog-close',
ref,
onClick() {
setOpen(false);
},
};
} satisfies DefaultProps<'button'>;

return renderElement({
defaultTagName: 'button',
render,
props: mergeProps<'button'>(defaultProps, otherProps),
});
}
});
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-context.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,6 @@ export interface DialogContextValue {
}

export const DialogContext = createContext<DialogContextValue | null>(null);
export const DialogScopedContext = createContext(false);

export function useDialogContext() {
const ctx = useContext(DialogContext);
Expand Down
35 changes: 21 additions & 14 deletions packages/headless/src/primitives/dialog/dialog-description.tsx
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogDescription}. */
export type DialogDescriptionProps = Omit<ComponentProps<'p'>, 'id'>;

export function DialogDescription(props: DialogDescriptionProps) {
const { render, ...otherProps } = props;
const { descriptionId } = useDialogContext();
/** Accessible dialog description. Wires its `id` to `aria-describedby` on `Dialog.Popup`. */
export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDescriptionProps>(
function DialogDescription(props, ref) {
const { render, ...otherProps } = props;
const { descriptionId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
};
const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
ref,
} satisfies DefaultProps<'p'>;

return renderElement({
defaultTagName: 'p',
render,
props: mergeProps<'p'>(defaultProps, otherProps),
});
}
return renderElement({
defaultTagName: 'p',
render,
props: mergeProps<'p'>(defaultProps, otherProps),
});
},
);
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
65bf84b
feat(headless): add Dialog.Viewport and forward refs on all dialog parts
alexcarpenter Jun 10, 2026
2fdbbd4
refactor(ui): rename mosaic Button `color` variant to `intent`
alexcarpenter Jun 10, 2026
c6d1ad8
feat(ui): bridge headless dialog primitives into mosaic
alexcarpenter Jun 10, 2026
a9e4584
chore(ui): move @floating-ui/react to catalog:repo
alexcarpenter Jun 10, 2026
0477fa8
add changeset
alexcarpenter Jun 10, 2026
7740983
refactor(headless): replace masking type assertions with validated sa…
alexcarpenter Jun 10, 2026
49e21c3
Delete .changeset/whole-loops-find.md
alexcarpenter Jun 10, 2026
920ce9a
docs(headless,ui): add JSDoc to dialog parts and mosaic components
alexcarpenter Jun 10, 2026
9c291ef
fix(ui): preserve generic props and correct return type in withMosaic…
alexcarpenter Jun 10, 2026
aeaceb7
fix(headless): pass pointer events through viewport overlay when non-…
alexcarpenter Jun 10, 2026
3b94b24
fix @clerk/headless usage in ui package
alexcarpenter Jun 10, 2026
d1f8db1
chore(repo): update lockfile after moving @clerk/headless to devDepen…
alexcarpenter Jun 10, 2026
3e82b1b
chore(repo): dedupe lockfile
alexcarpenter Jun 10, 2026
80de9e7
docs(headless): fix data-cl-* tables in Popover and Tooltip READMEs
alexcarpenter Jun 10, 2026
5be6d13
fix(headless): remove dead DialogScopedContext from dialog primitives
alexcarpenter Jun 10, 2026
f83a7a7
fix(ui): use minHeight in mosaic dialog viewport to prevent tall cont…
alexcarpenter Jun 10, 2026
b833043
Merge branch 'main' into carp/mosaic-dialog
alexcarpenter Jun 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/wise-types-satisfy.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,7 +33,7 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete

const defaultProps = {
'data-cl-slot': 'autocomplete-input',
...(getReferenceProps({
...getReferenceProps({
ref: combinedRef,
value: inputValue,
'aria-autocomplete': 'list' as const,
Expand All@@ -50,7 +50,7 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
}
}
},
}) as React.ComponentPropsWithRef<'input'>),
}),
};

return renderElement({
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { FloatingList, useMergeRefs } from '@floating-ui/react';
import React, { useEffect } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteListProps = ComponentProps<'div'>;
Expand All@@ -24,14 +24,15 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>;
const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-list',
ref: combinedRef,
...floatingProps,
};
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };

const merged = mergeProps<'div'>(defaultProps, otherProps);
// The wired id is owned by the primitive: a consumer-supplied id must not
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { useListItem, useMergeRefs } from '@floating-ui/react';
import React, { useEffect, useId } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export interface AutocompleteOptionProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -43,21 +43,25 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
disabled: !!disabled,
};

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-option',
id,
ref: combinedRef,
role: 'option' as const,
role: 'option',
'aria-selected': isActive,
'aria-disabled': disabled || undefined,
...(getItemProps({
} satisfies DefaultProps<'div'>;

const defaultProps = {
...ownProps,
...getItemProps({
onClick() {
if (!disabled) {
handleSelect(value, index, displayLabel);
(refs.domReference.current as HTMLElement | null)?.focus();
}
},
}) as React.ComponentPropsWithRef<'div'>),
}),
};

const merged = mergeProps<'div'>(defaultProps, otherProps);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePositionerProps = ComponentProps<'div'>;
Expand All@@ -22,16 +22,17 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>;
const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-positioner',
'data-cl-side': side,
ref: combinedRef,
style: floatingStyles,
...floatingProps,
};
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };

const merged = mergeProps<'div'>(defaultProps, otherProps);
// The wired id is owned by the primitive: a consumer-supplied id must not
Expand Down
34 changes: 20 additions & 14 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,13 +15,14 @@ import { Dialog } from '@/primitives/dialog';

<Dialog.Root>
<Dialog.Trigger>Open Dialog</Dialog.Trigger>
<Dialog.Backdrop>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Popup>
<Dialog.Title>Confirm Action</Dialog.Title>
<Dialog.Description>Are you sure you want to proceed?</Dialog.Description>
<Dialog.Close>Cancel</Dialog.Close>
</Dialog.Popup>
</Dialog.Backdrop>
</Dialog.Viewport>
</Dialog.Root>;
```

Expand DownExpand Up@@ -51,7 +52,8 @@ const [open, setOpen] = useState(false);
| `Dialog.Root` | — | Root context provider |
| `Dialog.Trigger` | `<button>` | Opens/closes the dialog on click |
| `Dialog.Portal` | — | Portals children (defaults to `document.body`) |
| `Dialog.Backdrop` | `<div>` | Overlay + focus manager host |
| `Dialog.Backdrop` | `<div>` | Semi-transparent overlay surface |
| `Dialog.Viewport` | `<div>` | Fixed centering container; owns scroll lock |
| `Dialog.Popup` | `<div>` | The dialog content container |
| `Dialog.Title` | `<h2>` | Dialog heading, wired to `aria-labelledby` |
| `Dialog.Description` | `<p>` | Dialog description, wired to `aria-describedby` |
Expand All@@ -74,15 +76,15 @@ const [open, setOpen] = useState(false);
| ------ | ------------------------------------------------------------- | --------------- | -------------------------------- |
| `root` | `HTMLElement \| null \| React.RefObject<HTMLElement \| null>` | `document.body` | Container element to portal into |

When `root` is provided, the dialog is "scoped" to that container: `Dialog.Backdrop` skips `FloatingOverlay` (no fixed positioning or scroll lock), and consumers handle positioning via CSS on the container.
When `root` is provided, the dialog is portaled into that container instead of `document.body`. Consumers handle layout via CSS on the container (or by omitting `Dialog.Viewport` and styling their own).

### `Dialog.Backdrop`
### `Dialog.Viewport`

| Prop | Type | Default | Description |
| ------------ | --------- | ------- | ----------------------------------------------------- |
| `lockScroll` | `boolean` | `true` | Prevents body scroll while open (skipped when scoped) |
| Prop | Type | Default | Description |
| ------------ | --------- | ------- | ------------------------------- |
| `lockScroll` | `boolean` | `true` | Prevents body scroll while open |

### `Dialog.Trigger`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close`
### `Dialog.Backdrop`, `Dialog.Trigger`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close`

No additional props beyond standard HTML attributes and the `render` prop.

Expand All@@ -95,18 +97,22 @@ 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 | Open state |
| 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 |

## Important Notes

- **`Dialog.Popup` must be a child of `Dialog.Backdrop`.** The backdrop hosts the portal, overlay, and focus manager — the popup alone does not handle these.
- **`Dialog.Popup` should be a child of `Dialog.Viewport`** for centered, scroll-locked modal behavior. The viewport hosts the fixed overlay container; the popup alone does not handle positioning or scroll lock.
- **Title and Description are optional but recommended.** If omitted, `aria-labelledby` / `aria-describedby` are simply absent from the popup.
- **Nested dialogs are supported.** The `FloatingTree` pattern handles nesting automatically.
- **No positioning middleware.** Dialogs are centered via CSS, not Floating UI positioning.

## Authoring rule for new primitives

Each styleable surface = one part. Layout infrastructure (overlay, scroll lock, focus manager, portal) wraps a `renderElement` call rather than fusing with it. The dialog split — `Backdrop` (semi-transparent surface) vs. `Viewport` (fixed centering + scroll lock) — exists because mosaic needs to style each layer independently. Apply the same decomposition to future primitives that combine positioning with a styled surface.

## ARIA

- Popup: `role="dialog"`, `aria-labelledby` (from Title), `aria-describedby` (from Description)
Expand Down
81 changes: 37 additions & 44 deletions packages/headless/src/primitives/dialog/dialog-backdrop.tsx
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,39 @@
'use client';

import { FloatingOverlay } from '@floating-ui/react';
import { useContext } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { DialogScopedContext, useDialogContext } from './dialog-context';

export interface DialogBackdropProps extends ComponentProps<'div'> {
/** When true, locks body scroll while the dialog is open. Default: true */
lockScroll?: boolean;
}

export function DialogBackdrop(props: DialogBackdropProps) {
const { render, lockScroll = true, ...otherProps } = props;
const { open, mounted, transitionProps } = useDialogContext();
const scoped = useContext(DialogScopedContext);

const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
...transitionProps,
} as React.ComponentPropsWithRef<'div'>;

const backdropElement = renderElement({
defaultTagName: 'div',
render,
enabled: mounted,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});

if (scoped) {
return backdropElement;
}

if (!mounted) {
return null;
}

return <FloatingOverlay lockScroll={lockScroll}>{backdropElement}</FloatingOverlay>;
}
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogBackdrop}. */
export type DialogBackdropProps = ComponentProps<'div'>;

/** Semi-transparent overlay surface rendered behind the dialog. Does not own scroll-lock or positioning — use `Dialog.Viewport` for those. */
export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropProps>(
function DialogBackdrop(props, ref) {
const { render, ...otherProps } = props;
const { open, mounted, transitionProps } = useDialogContext();

if (!mounted) {
return null;
}

const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
ref,
...transitionProps,
} satisfies DefaultProps<'div'>;

return renderElement({
defaultTagName: 'div',
render,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});
},
);
13 changes: 9 additions & 4 deletions packages/headless/src/primitives/dialog/dialog-close.tsx
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,30 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogClose}. */
export type DialogCloseProps = ComponentProps<'button'>;

export function DialogClose(props: DialogCloseProps) {
/** Button that closes the dialog when clicked. Calls `setOpen(false)` from dialog context. */
export const DialogClose = React.forwardRef<HTMLButtonElement, DialogCloseProps>(function DialogClose(props, ref) {
const { render, ...otherProps } = props;
const { setOpen } = useDialogContext();

const defaultProps = {
type: 'button' as const,
'data-cl-slot': 'dialog-close',
ref,
onClick() {
setOpen(false);
},
};
} satisfies DefaultProps<'button'>;

return renderElement({
defaultTagName: 'button',
render,
props: mergeProps<'button'>(defaultProps, otherProps),
});
}
});
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-context.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,6 @@ export interface DialogContextValue {
}

export const DialogContext = createContext<DialogContextValue | null>(null);
export const DialogScopedContext = createContext(false);

export function useDialogContext() {
const ctx = useContext(DialogContext);
Expand Down
35 changes: 21 additions & 14 deletions packages/headless/src/primitives/dialog/dialog-description.tsx
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogDescription}. */
export type DialogDescriptionProps = Omit<ComponentProps<'p'>, 'id'>;

export function DialogDescription(props: DialogDescriptionProps) {
const { render, ...otherProps } = props;
const { descriptionId } = useDialogContext();
/** Accessible dialog description. Wires its `id` to `aria-describedby` on `Dialog.Popup`. */
export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDescriptionProps>(
function DialogDescription(props, ref) {
const { render, ...otherProps } = props;
const { descriptionId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
};
const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
ref,
} satisfies DefaultProps<'p'>;

return renderElement({
defaultTagName: 'p',
render,
props: mergeProps<'p'>(defaultProps, otherProps),
});
}
return renderElement({
defaultTagName: 'p',
render,
props: mergeProps<'p'>(defaultProps, otherProps),
});
},
);
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
65bf84b
feat(headless): add Dialog.Viewport and forward refs on all dialog parts
alexcarpenter Jun 10, 2026
2fdbbd4
refactor(ui): rename mosaic Button `color` variant to `intent`
alexcarpenter Jun 10, 2026
c6d1ad8
feat(ui): bridge headless dialog primitives into mosaic
alexcarpenter Jun 10, 2026
a9e4584
chore(ui): move @floating-ui/react to catalog:repo
alexcarpenter Jun 10, 2026
0477fa8
add changeset
alexcarpenter Jun 10, 2026
7740983
refactor(headless): replace masking type assertions with validated sa…
alexcarpenter Jun 10, 2026
49e21c3
Delete .changeset/whole-loops-find.md
alexcarpenter Jun 10, 2026
920ce9a
docs(headless,ui): add JSDoc to dialog parts and mosaic components
alexcarpenter Jun 10, 2026
9c291ef
fix(ui): preserve generic props and correct return type in withMosaic…
alexcarpenter Jun 10, 2026
aeaceb7
fix(headless): pass pointer events through viewport overlay when non-…
alexcarpenter Jun 10, 2026
3b94b24
fix @clerk/headless usage in ui package
alexcarpenter Jun 10, 2026
d1f8db1
chore(repo): update lockfile after moving @clerk/headless to devDepen…
alexcarpenter Jun 10, 2026
3e82b1b
chore(repo): dedupe lockfile
alexcarpenter Jun 10, 2026
80de9e7
docs(headless): fix data-cl-* tables in Popover and Tooltip READMEs
alexcarpenter Jun 10, 2026
5be6d13
fix(headless): remove dead DialogScopedContext from dialog primitives
alexcarpenter Jun 10, 2026
f83a7a7
fix(ui): use minHeight in mosaic dialog viewport to prevent tall cont…
alexcarpenter Jun 10, 2026
b833043
Merge branch 'main' into carp/mosaic-dialog
alexcarpenter Jun 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/wise-types-satisfy.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,7 +33,7 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete

const defaultProps = {
'data-cl-slot': 'autocomplete-input',
...(getReferenceProps({
...getReferenceProps({
ref: combinedRef,
value: inputValue,
'aria-autocomplete': 'list' as const,
Expand All@@ -50,7 +50,7 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
}
}
},
}) as React.ComponentPropsWithRef<'input'>),
}),
};

return renderElement({
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { FloatingList, useMergeRefs } from '@floating-ui/react';
import React, { useEffect } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteListProps = ComponentProps<'div'>;
Expand All@@ -24,14 +24,15 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>;
const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-list',
ref: combinedRef,
...floatingProps,
};
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };

const merged = mergeProps<'div'>(defaultProps, otherProps);
// The wired id is owned by the primitive: a consumer-supplied id must not
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { useListItem, useMergeRefs } from '@floating-ui/react';
import React, { useEffect, useId } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export interface AutocompleteOptionProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -43,21 +43,25 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
disabled: !!disabled,
};

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-option',
id,
ref: combinedRef,
role: 'option' as const,
role: 'option',
'aria-selected': isActive,
'aria-disabled': disabled || undefined,
...(getItemProps({
} satisfies DefaultProps<'div'>;

const defaultProps = {
...ownProps,
...getItemProps({
onClick() {
if (!disabled) {
handleSelect(value, index, displayLabel);
(refs.domReference.current as HTMLElement | null)?.focus();
}
},
}) as React.ComponentPropsWithRef<'div'>),
}),
};

const merged = mergeProps<'div'>(defaultProps, otherProps);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePositionerProps = ComponentProps<'div'>;
Expand All@@ -22,16 +22,17 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>;
const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-positioner',
'data-cl-side': side,
ref: combinedRef,
style: floatingStyles,
...floatingProps,
};
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };

const merged = mergeProps<'div'>(defaultProps, otherProps);
// The wired id is owned by the primitive: a consumer-supplied id must not
Expand Down
34 changes: 20 additions & 14 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,13 +15,14 @@ import { Dialog } from '@/primitives/dialog';

<Dialog.Root>
<Dialog.Trigger>Open Dialog</Dialog.Trigger>
<Dialog.Backdrop>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Popup>
<Dialog.Title>Confirm Action</Dialog.Title>
<Dialog.Description>Are you sure you want to proceed?</Dialog.Description>
<Dialog.Close>Cancel</Dialog.Close>
</Dialog.Popup>
</Dialog.Backdrop>
</Dialog.Viewport>
</Dialog.Root>;
```

Expand DownExpand Up@@ -51,7 +52,8 @@ const [open, setOpen] = useState(false);
| `Dialog.Root` | — | Root context provider |
| `Dialog.Trigger` | `<button>` | Opens/closes the dialog on click |
| `Dialog.Portal` | — | Portals children (defaults to `document.body`) |
| `Dialog.Backdrop` | `<div>` | Overlay + focus manager host |
| `Dialog.Backdrop` | `<div>` | Semi-transparent overlay surface |
| `Dialog.Viewport` | `<div>` | Fixed centering container; owns scroll lock |
| `Dialog.Popup` | `<div>` | The dialog content container |
| `Dialog.Title` | `<h2>` | Dialog heading, wired to `aria-labelledby` |
| `Dialog.Description` | `<p>` | Dialog description, wired to `aria-describedby` |
Expand All@@ -74,15 +76,15 @@ const [open, setOpen] = useState(false);
| ------ | ------------------------------------------------------------- | --------------- | -------------------------------- |
| `root` | `HTMLElement \| null \| React.RefObject<HTMLElement \| null>` | `document.body` | Container element to portal into |

When `root` is provided, the dialog is "scoped" to that container: `Dialog.Backdrop` skips `FloatingOverlay` (no fixed positioning or scroll lock), and consumers handle positioning via CSS on the container.
When `root` is provided, the dialog is portaled into that container instead of `document.body`. Consumers handle layout via CSS on the container (or by omitting `Dialog.Viewport` and styling their own).

### `Dialog.Backdrop`
### `Dialog.Viewport`

| Prop | Type | Default | Description |
| ------------ | --------- | ------- | ----------------------------------------------------- |
| `lockScroll` | `boolean` | `true` | Prevents body scroll while open (skipped when scoped) |
| Prop | Type | Default | Description |
| ------------ | --------- | ------- | ------------------------------- |
| `lockScroll` | `boolean` | `true` | Prevents body scroll while open |

### `Dialog.Trigger`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close`
### `Dialog.Backdrop`, `Dialog.Trigger`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close`

No additional props beyond standard HTML attributes and the `render` prop.

Expand All@@ -95,18 +97,22 @@ 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 | Open state |
| 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 |

## Important Notes

- **`Dialog.Popup` must be a child of `Dialog.Backdrop`.** The backdrop hosts the portal, overlay, and focus manager — the popup alone does not handle these.
- **`Dialog.Popup` should be a child of `Dialog.Viewport`** for centered, scroll-locked modal behavior. The viewport hosts the fixed overlay container; the popup alone does not handle positioning or scroll lock.
- **Title and Description are optional but recommended.** If omitted, `aria-labelledby` / `aria-describedby` are simply absent from the popup.
- **Nested dialogs are supported.** The `FloatingTree` pattern handles nesting automatically.
- **No positioning middleware.** Dialogs are centered via CSS, not Floating UI positioning.

## Authoring rule for new primitives

Each styleable surface = one part. Layout infrastructure (overlay, scroll lock, focus manager, portal) wraps a `renderElement` call rather than fusing with it. The dialog split — `Backdrop` (semi-transparent surface) vs. `Viewport` (fixed centering + scroll lock) — exists because mosaic needs to style each layer independently. Apply the same decomposition to future primitives that combine positioning with a styled surface.

## ARIA

- Popup: `role="dialog"`, `aria-labelledby` (from Title), `aria-describedby` (from Description)
Expand Down
81 changes: 37 additions & 44 deletions packages/headless/src/primitives/dialog/dialog-backdrop.tsx
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,39 @@
'use client';

import { FloatingOverlay } from '@floating-ui/react';
import { useContext } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { DialogScopedContext, useDialogContext } from './dialog-context';

export interface DialogBackdropProps extends ComponentProps<'div'> {
/** When true, locks body scroll while the dialog is open. Default: true */
lockScroll?: boolean;
}

export function DialogBackdrop(props: DialogBackdropProps) {
const { render, lockScroll = true, ...otherProps } = props;
const { open, mounted, transitionProps } = useDialogContext();
const scoped = useContext(DialogScopedContext);

const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
...transitionProps,
} as React.ComponentPropsWithRef<'div'>;

const backdropElement = renderElement({
defaultTagName: 'div',
render,
enabled: mounted,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});

if (scoped) {
return backdropElement;
}

if (!mounted) {
return null;
}

return <FloatingOverlay lockScroll={lockScroll}>{backdropElement}</FloatingOverlay>;
}
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogBackdrop}. */
export type DialogBackdropProps = ComponentProps<'div'>;

/** Semi-transparent overlay surface rendered behind the dialog. Does not own scroll-lock or positioning — use `Dialog.Viewport` for those. */
export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropProps>(
function DialogBackdrop(props, ref) {
const { render, ...otherProps } = props;
const { open, mounted, transitionProps } = useDialogContext();

if (!mounted) {
return null;
}

const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
ref,
...transitionProps,
} satisfies DefaultProps<'div'>;

return renderElement({
defaultTagName: 'div',
render,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});
},
);
13 changes: 9 additions & 4 deletions packages/headless/src/primitives/dialog/dialog-close.tsx
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,30 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogClose}. */
export type DialogCloseProps = ComponentProps<'button'>;

export function DialogClose(props: DialogCloseProps) {
/** Button that closes the dialog when clicked. Calls `setOpen(false)` from dialog context. */
export const DialogClose = React.forwardRef<HTMLButtonElement, DialogCloseProps>(function DialogClose(props, ref) {
const { render, ...otherProps } = props;
const { setOpen } = useDialogContext();

const defaultProps = {
type: 'button' as const,
'data-cl-slot': 'dialog-close',
ref,
onClick() {
setOpen(false);
},
};
} satisfies DefaultProps<'button'>;

return renderElement({
defaultTagName: 'button',
render,
props: mergeProps<'button'>(defaultProps, otherProps),
});
}
});
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-context.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,6 @@ export interface DialogContextValue {
}

export const DialogContext = createContext<DialogContextValue | null>(null);
export const DialogScopedContext = createContext(false);

export function useDialogContext() {
const ctx = useContext(DialogContext);
Expand Down
35 changes: 21 additions & 14 deletions packages/headless/src/primitives/dialog/dialog-description.tsx
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogDescription}. */
export type DialogDescriptionProps = Omit<ComponentProps<'p'>, 'id'>;

export function DialogDescription(props: DialogDescriptionProps) {
const { render, ...otherProps } = props;
const { descriptionId } = useDialogContext();
/** Accessible dialog description. Wires its `id` to `aria-describedby` on `Dialog.Popup`. */
export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDescriptionProps>(
function DialogDescription(props, ref) {
const { render, ...otherProps } = props;
const { descriptionId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
};
const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
ref,
} satisfies DefaultProps<'p'>;

return renderElement({
defaultTagName: 'p',
render,
props: mergeProps<'p'>(defaultProps, otherProps),
});
}
return renderElement({
defaultTagName: 'p',
render,
props: mergeProps<'p'>(defaultProps, otherProps),
});
},
);
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
65bf84b
feat(headless): add Dialog.Viewport and forward refs on all dialog parts
alexcarpenter Jun 10, 2026
2fdbbd4
refactor(ui): rename mosaic Button `color` variant to `intent`
alexcarpenter Jun 10, 2026
c6d1ad8
feat(ui): bridge headless dialog primitives into mosaic
alexcarpenter Jun 10, 2026
a9e4584
chore(ui): move @floating-ui/react to catalog:repo
alexcarpenter Jun 10, 2026
0477fa8
add changeset
alexcarpenter Jun 10, 2026
7740983
refactor(headless): replace masking type assertions with validated sa…
alexcarpenter Jun 10, 2026
49e21c3
Delete .changeset/whole-loops-find.md
alexcarpenter Jun 10, 2026
920ce9a
docs(headless,ui): add JSDoc to dialog parts and mosaic components
alexcarpenter Jun 10, 2026
9c291ef
fix(ui): preserve generic props and correct return type in withMosaic…
alexcarpenter Jun 10, 2026
aeaceb7
fix(headless): pass pointer events through viewport overlay when non-…
alexcarpenter Jun 10, 2026
3b94b24
fix @clerk/headless usage in ui package
alexcarpenter Jun 10, 2026
d1f8db1
chore(repo): update lockfile after moving @clerk/headless to devDepen…
alexcarpenter Jun 10, 2026
3e82b1b
chore(repo): dedupe lockfile
alexcarpenter Jun 10, 2026
80de9e7
docs(headless): fix data-cl-* tables in Popover and Tooltip READMEs
alexcarpenter Jun 10, 2026
5be6d13
fix(headless): remove dead DialogScopedContext from dialog primitives
alexcarpenter Jun 10, 2026
f83a7a7
fix(ui): use minHeight in mosaic dialog viewport to prevent tall cont…
alexcarpenter Jun 10, 2026
b833043
Merge branch 'main' into carp/mosaic-dialog
alexcarpenter Jun 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/wise-types-satisfy.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,7 +33,7 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete

const defaultProps = {
'data-cl-slot': 'autocomplete-input',
...(getReferenceProps({
...getReferenceProps({
ref: combinedRef,
value: inputValue,
'aria-autocomplete': 'list' as const,
Expand All@@ -50,7 +50,7 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
}
}
},
}) as React.ComponentPropsWithRef<'input'>),
}),
};

return renderElement({
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { FloatingList, useMergeRefs } from '@floating-ui/react';
import React, { useEffect } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteListProps = ComponentProps<'div'>;
Expand All@@ -24,14 +24,15 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>;
const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-list',
ref: combinedRef,
...floatingProps,
};
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };

const merged = mergeProps<'div'>(defaultProps, otherProps);
// The wired id is owned by the primitive: a consumer-supplied id must not
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { useListItem, useMergeRefs } from '@floating-ui/react';
import React, { useEffect, useId } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export interface AutocompleteOptionProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -43,21 +43,25 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
disabled: !!disabled,
};

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-option',
id,
ref: combinedRef,
role: 'option' as const,
role: 'option',
'aria-selected': isActive,
'aria-disabled': disabled || undefined,
...(getItemProps({
} satisfies DefaultProps<'div'>;

const defaultProps = {
...ownProps,
...getItemProps({
onClick() {
if (!disabled) {
handleSelect(value, index, displayLabel);
(refs.domReference.current as HTMLElement | null)?.focus();
}
},
}) as React.ComponentPropsWithRef<'div'>),
}),
};

const merged = mergeProps<'div'>(defaultProps, otherProps);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePositionerProps = ComponentProps<'div'>;
Expand All@@ -22,16 +22,17 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>;
const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-positioner',
'data-cl-side': side,
ref: combinedRef,
style: floatingStyles,
...floatingProps,
};
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };

const merged = mergeProps<'div'>(defaultProps, otherProps);
// The wired id is owned by the primitive: a consumer-supplied id must not
Expand Down
34 changes: 20 additions & 14 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,13 +15,14 @@ import { Dialog } from '@/primitives/dialog';

<Dialog.Root>
<Dialog.Trigger>Open Dialog</Dialog.Trigger>
<Dialog.Backdrop>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Popup>
<Dialog.Title>Confirm Action</Dialog.Title>
<Dialog.Description>Are you sure you want to proceed?</Dialog.Description>
<Dialog.Close>Cancel</Dialog.Close>
</Dialog.Popup>
</Dialog.Backdrop>
</Dialog.Viewport>
</Dialog.Root>;
```

Expand DownExpand Up@@ -51,7 +52,8 @@ const [open, setOpen] = useState(false);
| `Dialog.Root` | — | Root context provider |
| `Dialog.Trigger` | `<button>` | Opens/closes the dialog on click |
| `Dialog.Portal` | — | Portals children (defaults to `document.body`) |
| `Dialog.Backdrop` | `<div>` | Overlay + focus manager host |
| `Dialog.Backdrop` | `<div>` | Semi-transparent overlay surface |
| `Dialog.Viewport` | `<div>` | Fixed centering container; owns scroll lock |
| `Dialog.Popup` | `<div>` | The dialog content container |
| `Dialog.Title` | `<h2>` | Dialog heading, wired to `aria-labelledby` |
| `Dialog.Description` | `<p>` | Dialog description, wired to `aria-describedby` |
Expand All@@ -74,15 +76,15 @@ const [open, setOpen] = useState(false);
| ------ | ------------------------------------------------------------- | --------------- | -------------------------------- |
| `root` | `HTMLElement \| null \| React.RefObject<HTMLElement \| null>` | `document.body` | Container element to portal into |

When `root` is provided, the dialog is "scoped" to that container: `Dialog.Backdrop` skips `FloatingOverlay` (no fixed positioning or scroll lock), and consumers handle positioning via CSS on the container.
When `root` is provided, the dialog is portaled into that container instead of `document.body`. Consumers handle layout via CSS on the container (or by omitting `Dialog.Viewport` and styling their own).

### `Dialog.Backdrop`
### `Dialog.Viewport`

| Prop | Type | Default | Description |
| ------------ | --------- | ------- | ----------------------------------------------------- |
| `lockScroll` | `boolean` | `true` | Prevents body scroll while open (skipped when scoped) |
| Prop | Type | Default | Description |
| ------------ | --------- | ------- | ------------------------------- |
| `lockScroll` | `boolean` | `true` | Prevents body scroll while open |

### `Dialog.Trigger`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close`
### `Dialog.Backdrop`, `Dialog.Trigger`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close`

No additional props beyond standard HTML attributes and the `render` prop.

Expand All@@ -95,18 +97,22 @@ 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 | Open state |
| 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 |

## Important Notes

- **`Dialog.Popup` must be a child of `Dialog.Backdrop`.** The backdrop hosts the portal, overlay, and focus manager — the popup alone does not handle these.
- **`Dialog.Popup` should be a child of `Dialog.Viewport`** for centered, scroll-locked modal behavior. The viewport hosts the fixed overlay container; the popup alone does not handle positioning or scroll lock.
- **Title and Description are optional but recommended.** If omitted, `aria-labelledby` / `aria-describedby` are simply absent from the popup.
- **Nested dialogs are supported.** The `FloatingTree` pattern handles nesting automatically.
- **No positioning middleware.** Dialogs are centered via CSS, not Floating UI positioning.

## Authoring rule for new primitives

Each styleable surface = one part. Layout infrastructure (overlay, scroll lock, focus manager, portal) wraps a `renderElement` call rather than fusing with it. The dialog split — `Backdrop` (semi-transparent surface) vs. `Viewport` (fixed centering + scroll lock) — exists because mosaic needs to style each layer independently. Apply the same decomposition to future primitives that combine positioning with a styled surface.

## ARIA

- Popup: `role="dialog"`, `aria-labelledby` (from Title), `aria-describedby` (from Description)
Expand Down
81 changes: 37 additions & 44 deletions packages/headless/src/primitives/dialog/dialog-backdrop.tsx
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,39 @@
'use client';

import { FloatingOverlay } from '@floating-ui/react';
import { useContext } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { DialogScopedContext, useDialogContext } from './dialog-context';

export interface DialogBackdropProps extends ComponentProps<'div'> {
/** When true, locks body scroll while the dialog is open. Default: true */
lockScroll?: boolean;
}

export function DialogBackdrop(props: DialogBackdropProps) {
const { render, lockScroll = true, ...otherProps } = props;
const { open, mounted, transitionProps } = useDialogContext();
const scoped = useContext(DialogScopedContext);

const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
...transitionProps,
} as React.ComponentPropsWithRef<'div'>;

const backdropElement = renderElement({
defaultTagName: 'div',
render,
enabled: mounted,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});

if (scoped) {
return backdropElement;
}

if (!mounted) {
return null;
}

return <FloatingOverlay lockScroll={lockScroll}>{backdropElement}</FloatingOverlay>;
}
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogBackdrop}. */
export type DialogBackdropProps = ComponentProps<'div'>;

/** Semi-transparent overlay surface rendered behind the dialog. Does not own scroll-lock or positioning — use `Dialog.Viewport` for those. */
export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropProps>(
function DialogBackdrop(props, ref) {
const { render, ...otherProps } = props;
const { open, mounted, transitionProps } = useDialogContext();

if (!mounted) {
return null;
}

const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
ref,
...transitionProps,
} satisfies DefaultProps<'div'>;

return renderElement({
defaultTagName: 'div',
render,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});
},
);
13 changes: 9 additions & 4 deletions packages/headless/src/primitives/dialog/dialog-close.tsx
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,30 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogClose}. */
export type DialogCloseProps = ComponentProps<'button'>;

export function DialogClose(props: DialogCloseProps) {
/** Button that closes the dialog when clicked. Calls `setOpen(false)` from dialog context. */
export const DialogClose = React.forwardRef<HTMLButtonElement, DialogCloseProps>(function DialogClose(props, ref) {
const { render, ...otherProps } = props;
const { setOpen } = useDialogContext();

const defaultProps = {
type: 'button' as const,
'data-cl-slot': 'dialog-close',
ref,
onClick() {
setOpen(false);
},
};
} satisfies DefaultProps<'button'>;

return renderElement({
defaultTagName: 'button',
render,
props: mergeProps<'button'>(defaultProps, otherProps),
});
}
});
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-context.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,6 @@ export interface DialogContextValue {
}

export const DialogContext = createContext<DialogContextValue | null>(null);
export const DialogScopedContext = createContext(false);

export function useDialogContext() {
const ctx = useContext(DialogContext);
Expand Down
35 changes: 21 additions & 14 deletions packages/headless/src/primitives/dialog/dialog-description.tsx
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogDescription}. */
export type DialogDescriptionProps = Omit<ComponentProps<'p'>, 'id'>;

export function DialogDescription(props: DialogDescriptionProps) {
const { render, ...otherProps } = props;
const { descriptionId } = useDialogContext();
/** Accessible dialog description. Wires its `id` to `aria-describedby` on `Dialog.Popup`. */
export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDescriptionProps>(
function DialogDescription(props, ref) {
const { render, ...otherProps } = props;
const { descriptionId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
};
const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
ref,
} satisfies DefaultProps<'p'>;

return renderElement({
defaultTagName: 'p',
render,
props: mergeProps<'p'>(defaultProps, otherProps),
});
}
return renderElement({
defaultTagName: 'p',
render,
props: mergeProps<'p'>(defaultProps, otherProps),
});
},
);
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
65bf84b
feat(headless): add Dialog.Viewport and forward refs on all dialog parts
alexcarpenter Jun 10, 2026
2fdbbd4
refactor(ui): rename mosaic Button `color` variant to `intent`
alexcarpenter Jun 10, 2026
c6d1ad8
feat(ui): bridge headless dialog primitives into mosaic
alexcarpenter Jun 10, 2026
a9e4584
chore(ui): move @floating-ui/react to catalog:repo
alexcarpenter Jun 10, 2026
0477fa8
add changeset
alexcarpenter Jun 10, 2026
7740983
refactor(headless): replace masking type assertions with validated sa…
alexcarpenter Jun 10, 2026
49e21c3
Delete .changeset/whole-loops-find.md
alexcarpenter Jun 10, 2026
920ce9a
docs(headless,ui): add JSDoc to dialog parts and mosaic components
alexcarpenter Jun 10, 2026
9c291ef
fix(ui): preserve generic props and correct return type in withMosaic…
alexcarpenter Jun 10, 2026
aeaceb7
fix(headless): pass pointer events through viewport overlay when non-…
alexcarpenter Jun 10, 2026
3b94b24
fix @clerk/headless usage in ui package
alexcarpenter Jun 10, 2026
d1f8db1
chore(repo): update lockfile after moving @clerk/headless to devDepen…
alexcarpenter Jun 10, 2026
3e82b1b
chore(repo): dedupe lockfile
alexcarpenter Jun 10, 2026
80de9e7
docs(headless): fix data-cl-* tables in Popover and Tooltip READMEs
alexcarpenter Jun 10, 2026
5be6d13
fix(headless): remove dead DialogScopedContext from dialog primitives
alexcarpenter Jun 10, 2026
f83a7a7
fix(ui): use minHeight in mosaic dialog viewport to prevent tall cont…
alexcarpenter Jun 10, 2026
b833043
Merge branch 'main' into carp/mosaic-dialog
alexcarpenter Jun 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/wise-types-satisfy.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,7 +33,7 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete

const defaultProps = {
'data-cl-slot': 'autocomplete-input',
...(getReferenceProps({
...getReferenceProps({
ref: combinedRef,
value: inputValue,
'aria-autocomplete': 'list' as const,
Expand All@@ -50,7 +50,7 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
}
}
},
}) as React.ComponentPropsWithRef<'input'>),
}),
};

return renderElement({
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { FloatingList, useMergeRefs } from '@floating-ui/react';
import React, { useEffect } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteListProps = ComponentProps<'div'>;
Expand All@@ -24,14 +24,15 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>;
const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-list',
ref: combinedRef,
...floatingProps,
};
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };

const merged = mergeProps<'div'>(defaultProps, otherProps);
// The wired id is owned by the primitive: a consumer-supplied id must not
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { useListItem, useMergeRefs } from '@floating-ui/react';
import React, { useEffect, useId } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export interface AutocompleteOptionProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -43,21 +43,25 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
disabled: !!disabled,
};

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-option',
id,
ref: combinedRef,
role: 'option' as const,
role: 'option',
'aria-selected': isActive,
'aria-disabled': disabled || undefined,
...(getItemProps({
} satisfies DefaultProps<'div'>;

const defaultProps = {
...ownProps,
...getItemProps({
onClick() {
if (!disabled) {
handleSelect(value, index, displayLabel);
(refs.domReference.current as HTMLElement | null)?.focus();
}
},
}) as React.ComponentPropsWithRef<'div'>),
}),
};

const merged = mergeProps<'div'>(defaultProps, otherProps);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePositionerProps = ComponentProps<'div'>;
Expand All@@ -22,16 +22,17 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>;
const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-positioner',
'data-cl-side': side,
ref: combinedRef,
style: floatingStyles,
...floatingProps,
};
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };

const merged = mergeProps<'div'>(defaultProps, otherProps);
// The wired id is owned by the primitive: a consumer-supplied id must not
Expand Down
34 changes: 20 additions & 14 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,13 +15,14 @@ import { Dialog } from '@/primitives/dialog';

<Dialog.Root>
<Dialog.Trigger>Open Dialog</Dialog.Trigger>
<Dialog.Backdrop>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Popup>
<Dialog.Title>Confirm Action</Dialog.Title>
<Dialog.Description>Are you sure you want to proceed?</Dialog.Description>
<Dialog.Close>Cancel</Dialog.Close>
</Dialog.Popup>
</Dialog.Backdrop>
</Dialog.Viewport>
</Dialog.Root>;
```

Expand DownExpand Up@@ -51,7 +52,8 @@ const [open, setOpen] = useState(false);
| `Dialog.Root` | — | Root context provider |
| `Dialog.Trigger` | `<button>` | Opens/closes the dialog on click |
| `Dialog.Portal` | — | Portals children (defaults to `document.body`) |
| `Dialog.Backdrop` | `<div>` | Overlay + focus manager host |
| `Dialog.Backdrop` | `<div>` | Semi-transparent overlay surface |
| `Dialog.Viewport` | `<div>` | Fixed centering container; owns scroll lock |
| `Dialog.Popup` | `<div>` | The dialog content container |
| `Dialog.Title` | `<h2>` | Dialog heading, wired to `aria-labelledby` |
| `Dialog.Description` | `<p>` | Dialog description, wired to `aria-describedby` |
Expand All@@ -74,15 +76,15 @@ const [open, setOpen] = useState(false);
| ------ | ------------------------------------------------------------- | --------------- | -------------------------------- |
| `root` | `HTMLElement \| null \| React.RefObject<HTMLElement \| null>` | `document.body` | Container element to portal into |

When `root` is provided, the dialog is "scoped" to that container: `Dialog.Backdrop` skips `FloatingOverlay` (no fixed positioning or scroll lock), and consumers handle positioning via CSS on the container.
When `root` is provided, the dialog is portaled into that container instead of `document.body`. Consumers handle layout via CSS on the container (or by omitting `Dialog.Viewport` and styling their own).

### `Dialog.Backdrop`
### `Dialog.Viewport`

| Prop | Type | Default | Description |
| ------------ | --------- | ------- | ----------------------------------------------------- |
| `lockScroll` | `boolean` | `true` | Prevents body scroll while open (skipped when scoped) |
| Prop | Type | Default | Description |
| ------------ | --------- | ------- | ------------------------------- |
| `lockScroll` | `boolean` | `true` | Prevents body scroll while open |

### `Dialog.Trigger`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close`
### `Dialog.Backdrop`, `Dialog.Trigger`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close`

No additional props beyond standard HTML attributes and the `render` prop.

Expand All@@ -95,18 +97,22 @@ 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 | Open state |
| 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 |

## Important Notes

- **`Dialog.Popup` must be a child of `Dialog.Backdrop`.** The backdrop hosts the portal, overlay, and focus manager — the popup alone does not handle these.
- **`Dialog.Popup` should be a child of `Dialog.Viewport`** for centered, scroll-locked modal behavior. The viewport hosts the fixed overlay container; the popup alone does not handle positioning or scroll lock.
- **Title and Description are optional but recommended.** If omitted, `aria-labelledby` / `aria-describedby` are simply absent from the popup.
- **Nested dialogs are supported.** The `FloatingTree` pattern handles nesting automatically.
- **No positioning middleware.** Dialogs are centered via CSS, not Floating UI positioning.

## Authoring rule for new primitives

Each styleable surface = one part. Layout infrastructure (overlay, scroll lock, focus manager, portal) wraps a `renderElement` call rather than fusing with it. The dialog split — `Backdrop` (semi-transparent surface) vs. `Viewport` (fixed centering + scroll lock) — exists because mosaic needs to style each layer independently. Apply the same decomposition to future primitives that combine positioning with a styled surface.

## ARIA

- Popup: `role="dialog"`, `aria-labelledby` (from Title), `aria-describedby` (from Description)
Expand Down
81 changes: 37 additions & 44 deletions packages/headless/src/primitives/dialog/dialog-backdrop.tsx
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,39 @@
'use client';

import { FloatingOverlay } from '@floating-ui/react';
import { useContext } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { DialogScopedContext, useDialogContext } from './dialog-context';

export interface DialogBackdropProps extends ComponentProps<'div'> {
/** When true, locks body scroll while the dialog is open. Default: true */
lockScroll?: boolean;
}

export function DialogBackdrop(props: DialogBackdropProps) {
const { render, lockScroll = true, ...otherProps } = props;
const { open, mounted, transitionProps } = useDialogContext();
const scoped = useContext(DialogScopedContext);

const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
...transitionProps,
} as React.ComponentPropsWithRef<'div'>;

const backdropElement = renderElement({
defaultTagName: 'div',
render,
enabled: mounted,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});

if (scoped) {
return backdropElement;
}

if (!mounted) {
return null;
}

return <FloatingOverlay lockScroll={lockScroll}>{backdropElement}</FloatingOverlay>;
}
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogBackdrop}. */
export type DialogBackdropProps = ComponentProps<'div'>;

/** Semi-transparent overlay surface rendered behind the dialog. Does not own scroll-lock or positioning — use `Dialog.Viewport` for those. */
export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropProps>(
function DialogBackdrop(props, ref) {
const { render, ...otherProps } = props;
const { open, mounted, transitionProps } = useDialogContext();

if (!mounted) {
return null;
}

const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
ref,
...transitionProps,
} satisfies DefaultProps<'div'>;

return renderElement({
defaultTagName: 'div',
render,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});
},
);
13 changes: 9 additions & 4 deletions packages/headless/src/primitives/dialog/dialog-close.tsx
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,30 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogClose}. */
export type DialogCloseProps = ComponentProps<'button'>;

export function DialogClose(props: DialogCloseProps) {
/** Button that closes the dialog when clicked. Calls `setOpen(false)` from dialog context. */
export const DialogClose = React.forwardRef<HTMLButtonElement, DialogCloseProps>(function DialogClose(props, ref) {
const { render, ...otherProps } = props;
const { setOpen } = useDialogContext();

const defaultProps = {
type: 'button' as const,
'data-cl-slot': 'dialog-close',
ref,
onClick() {
setOpen(false);
},
};
} satisfies DefaultProps<'button'>;

return renderElement({
defaultTagName: 'button',
render,
props: mergeProps<'button'>(defaultProps, otherProps),
});
}
});
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-context.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,6 @@ export interface DialogContextValue {
}

export const DialogContext = createContext<DialogContextValue | null>(null);
export const DialogScopedContext = createContext(false);

export function useDialogContext() {
const ctx = useContext(DialogContext);
Expand Down
35 changes: 21 additions & 14 deletions packages/headless/src/primitives/dialog/dialog-description.tsx
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogDescription}. */
export type DialogDescriptionProps = Omit<ComponentProps<'p'>, 'id'>;

export function DialogDescription(props: DialogDescriptionProps) {
const { render, ...otherProps } = props;
const { descriptionId } = useDialogContext();
/** Accessible dialog description. Wires its `id` to `aria-describedby` on `Dialog.Popup`. */
export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDescriptionProps>(
function DialogDescription(props, ref) {
const { render, ...otherProps } = props;
const { descriptionId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
};
const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
ref,
} satisfies DefaultProps<'p'>;

return renderElement({
defaultTagName: 'p',
render,
props: mergeProps<'p'>(defaultProps, otherProps),
});
}
return renderElement({
defaultTagName: 'p',
render,
props: mergeProps<'p'>(defaultProps, otherProps),
});
},
);
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
65bf84b
feat(headless): add Dialog.Viewport and forward refs on all dialog parts
alexcarpenter Jun 10, 2026
2fdbbd4
refactor(ui): rename mosaic Button `color` variant to `intent`
alexcarpenter Jun 10, 2026
c6d1ad8
feat(ui): bridge headless dialog primitives into mosaic
alexcarpenter Jun 10, 2026
a9e4584
chore(ui): move @floating-ui/react to catalog:repo
alexcarpenter Jun 10, 2026
0477fa8
add changeset
alexcarpenter Jun 10, 2026
7740983
refactor(headless): replace masking type assertions with validated sa…
alexcarpenter Jun 10, 2026
49e21c3
Delete .changeset/whole-loops-find.md
alexcarpenter Jun 10, 2026
920ce9a
docs(headless,ui): add JSDoc to dialog parts and mosaic components
alexcarpenter Jun 10, 2026
9c291ef
fix(ui): preserve generic props and correct return type in withMosaic…
alexcarpenter Jun 10, 2026
aeaceb7
fix(headless): pass pointer events through viewport overlay when non-…
alexcarpenter Jun 10, 2026
3b94b24
fix @clerk/headless usage in ui package
alexcarpenter Jun 10, 2026
d1f8db1
chore(repo): update lockfile after moving @clerk/headless to devDepen…
alexcarpenter Jun 10, 2026
3e82b1b
chore(repo): dedupe lockfile
alexcarpenter Jun 10, 2026
80de9e7
docs(headless): fix data-cl-* tables in Popover and Tooltip READMEs
alexcarpenter Jun 10, 2026
5be6d13
fix(headless): remove dead DialogScopedContext from dialog primitives
alexcarpenter Jun 10, 2026
f83a7a7
fix(ui): use minHeight in mosaic dialog viewport to prevent tall cont…
alexcarpenter Jun 10, 2026
b833043
Merge branch 'main' into carp/mosaic-dialog
alexcarpenter Jun 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/wise-types-satisfy.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,7 +33,7 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete

const defaultProps = {
'data-cl-slot': 'autocomplete-input',
...(getReferenceProps({
...getReferenceProps({
ref: combinedRef,
value: inputValue,
'aria-autocomplete': 'list' as const,
Expand All@@ -50,7 +50,7 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
}
}
},
}) as React.ComponentPropsWithRef<'input'>),
}),
};

return renderElement({
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { FloatingList, useMergeRefs } from '@floating-ui/react';
import React, { useEffect } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteListProps = ComponentProps<'div'>;
Expand All@@ -24,14 +24,15 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>;
const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-list',
ref: combinedRef,
...floatingProps,
};
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };

const merged = mergeProps<'div'>(defaultProps, otherProps);
// The wired id is owned by the primitive: a consumer-supplied id must not
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { useListItem, useMergeRefs } from '@floating-ui/react';
import React, { useEffect, useId } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export interface AutocompleteOptionProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -43,21 +43,25 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
disabled: !!disabled,
};

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-option',
id,
ref: combinedRef,
role: 'option' as const,
role: 'option',
'aria-selected': isActive,
'aria-disabled': disabled || undefined,
...(getItemProps({
} satisfies DefaultProps<'div'>;

const defaultProps = {
...ownProps,
...getItemProps({
onClick() {
if (!disabled) {
handleSelect(value, index, displayLabel);
(refs.domReference.current as HTMLElement | null)?.focus();
}
},
}) as React.ComponentPropsWithRef<'div'>),
}),
};

const merged = mergeProps<'div'>(defaultProps, otherProps);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePositionerProps = ComponentProps<'div'>;
Expand All@@ -22,16 +22,17 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>;
const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-positioner',
'data-cl-side': side,
ref: combinedRef,
style: floatingStyles,
...floatingProps,
};
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };

const merged = mergeProps<'div'>(defaultProps, otherProps);
// The wired id is owned by the primitive: a consumer-supplied id must not
Expand Down
34 changes: 20 additions & 14 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,13 +15,14 @@ import { Dialog } from '@/primitives/dialog';

<Dialog.Root>
<Dialog.Trigger>Open Dialog</Dialog.Trigger>
<Dialog.Backdrop>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Popup>
<Dialog.Title>Confirm Action</Dialog.Title>
<Dialog.Description>Are you sure you want to proceed?</Dialog.Description>
<Dialog.Close>Cancel</Dialog.Close>
</Dialog.Popup>
</Dialog.Backdrop>
</Dialog.Viewport>
</Dialog.Root>;
```

Expand DownExpand Up@@ -51,7 +52,8 @@ const [open, setOpen] = useState(false);
| `Dialog.Root` | — | Root context provider |
| `Dialog.Trigger` | `<button>` | Opens/closes the dialog on click |
| `Dialog.Portal` | — | Portals children (defaults to `document.body`) |
| `Dialog.Backdrop` | `<div>` | Overlay + focus manager host |
| `Dialog.Backdrop` | `<div>` | Semi-transparent overlay surface |
| `Dialog.Viewport` | `<div>` | Fixed centering container; owns scroll lock |
| `Dialog.Popup` | `<div>` | The dialog content container |
| `Dialog.Title` | `<h2>` | Dialog heading, wired to `aria-labelledby` |
| `Dialog.Description` | `<p>` | Dialog description, wired to `aria-describedby` |
Expand All@@ -74,15 +76,15 @@ const [open, setOpen] = useState(false);
| ------ | ------------------------------------------------------------- | --------------- | -------------------------------- |
| `root` | `HTMLElement \| null \| React.RefObject<HTMLElement \| null>` | `document.body` | Container element to portal into |

When `root` is provided, the dialog is "scoped" to that container: `Dialog.Backdrop` skips `FloatingOverlay` (no fixed positioning or scroll lock), and consumers handle positioning via CSS on the container.
When `root` is provided, the dialog is portaled into that container instead of `document.body`. Consumers handle layout via CSS on the container (or by omitting `Dialog.Viewport` and styling their own).

### `Dialog.Backdrop`
### `Dialog.Viewport`

| Prop | Type | Default | Description |
| ------------ | --------- | ------- | ----------------------------------------------------- |
| `lockScroll` | `boolean` | `true` | Prevents body scroll while open (skipped when scoped) |
| Prop | Type | Default | Description |
| ------------ | --------- | ------- | ------------------------------- |
| `lockScroll` | `boolean` | `true` | Prevents body scroll while open |

### `Dialog.Trigger`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close`
### `Dialog.Backdrop`, `Dialog.Trigger`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close`

No additional props beyond standard HTML attributes and the `render` prop.

Expand All@@ -95,18 +97,22 @@ 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 | Open state |
| 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 |

## Important Notes

- **`Dialog.Popup` must be a child of `Dialog.Backdrop`.** The backdrop hosts the portal, overlay, and focus manager — the popup alone does not handle these.
- **`Dialog.Popup` should be a child of `Dialog.Viewport`** for centered, scroll-locked modal behavior. The viewport hosts the fixed overlay container; the popup alone does not handle positioning or scroll lock.
- **Title and Description are optional but recommended.** If omitted, `aria-labelledby` / `aria-describedby` are simply absent from the popup.
- **Nested dialogs are supported.** The `FloatingTree` pattern handles nesting automatically.
- **No positioning middleware.** Dialogs are centered via CSS, not Floating UI positioning.

## Authoring rule for new primitives

Each styleable surface = one part. Layout infrastructure (overlay, scroll lock, focus manager, portal) wraps a `renderElement` call rather than fusing with it. The dialog split — `Backdrop` (semi-transparent surface) vs. `Viewport` (fixed centering + scroll lock) — exists because mosaic needs to style each layer independently. Apply the same decomposition to future primitives that combine positioning with a styled surface.

## ARIA

- Popup: `role="dialog"`, `aria-labelledby` (from Title), `aria-describedby` (from Description)
Expand Down
81 changes: 37 additions & 44 deletions packages/headless/src/primitives/dialog/dialog-backdrop.tsx
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,39 @@
'use client';

import { FloatingOverlay } from '@floating-ui/react';
import { useContext } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { DialogScopedContext, useDialogContext } from './dialog-context';

export interface DialogBackdropProps extends ComponentProps<'div'> {
/** When true, locks body scroll while the dialog is open. Default: true */
lockScroll?: boolean;
}

export function DialogBackdrop(props: DialogBackdropProps) {
const { render, lockScroll = true, ...otherProps } = props;
const { open, mounted, transitionProps } = useDialogContext();
const scoped = useContext(DialogScopedContext);

const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
...transitionProps,
} as React.ComponentPropsWithRef<'div'>;

const backdropElement = renderElement({
defaultTagName: 'div',
render,
enabled: mounted,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});

if (scoped) {
return backdropElement;
}

if (!mounted) {
return null;
}

return <FloatingOverlay lockScroll={lockScroll}>{backdropElement}</FloatingOverlay>;
}
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogBackdrop}. */
export type DialogBackdropProps = ComponentProps<'div'>;

/** Semi-transparent overlay surface rendered behind the dialog. Does not own scroll-lock or positioning — use `Dialog.Viewport` for those. */
export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropProps>(
function DialogBackdrop(props, ref) {
const { render, ...otherProps } = props;
const { open, mounted, transitionProps } = useDialogContext();

if (!mounted) {
return null;
}

const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
ref,
...transitionProps,
} satisfies DefaultProps<'div'>;

return renderElement({
defaultTagName: 'div',
render,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});
},
);
13 changes: 9 additions & 4 deletions packages/headless/src/primitives/dialog/dialog-close.tsx
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,30 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogClose}. */
export type DialogCloseProps = ComponentProps<'button'>;

export function DialogClose(props: DialogCloseProps) {
/** Button that closes the dialog when clicked. Calls `setOpen(false)` from dialog context. */
export const DialogClose = React.forwardRef<HTMLButtonElement, DialogCloseProps>(function DialogClose(props, ref) {
const { render, ...otherProps } = props;
const { setOpen } = useDialogContext();

const defaultProps = {
type: 'button' as const,
'data-cl-slot': 'dialog-close',
ref,
onClick() {
setOpen(false);
},
};
} satisfies DefaultProps<'button'>;

return renderElement({
defaultTagName: 'button',
render,
props: mergeProps<'button'>(defaultProps, otherProps),
});
}
});
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-context.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,6 @@ export interface DialogContextValue {
}

export const DialogContext = createContext<DialogContextValue | null>(null);
export const DialogScopedContext = createContext(false);

export function useDialogContext() {
const ctx = useContext(DialogContext);
Expand Down
35 changes: 21 additions & 14 deletions packages/headless/src/primitives/dialog/dialog-description.tsx
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogDescription}. */
export type DialogDescriptionProps = Omit<ComponentProps<'p'>, 'id'>;

export function DialogDescription(props: DialogDescriptionProps) {
const { render, ...otherProps } = props;
const { descriptionId } = useDialogContext();
/** Accessible dialog description. Wires its `id` to `aria-describedby` on `Dialog.Popup`. */
export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDescriptionProps>(
function DialogDescription(props, ref) {
const { render, ...otherProps } = props;
const { descriptionId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
};
const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
ref,
} satisfies DefaultProps<'p'>;

return renderElement({
defaultTagName: 'p',
render,
props: mergeProps<'p'>(defaultProps, otherProps),
});
}
return renderElement({
defaultTagName: 'p',
render,
props: mergeProps<'p'>(defaultProps, otherProps),
});
},
);
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
65bf84b
feat(headless): add Dialog.Viewport and forward refs on all dialog parts
alexcarpenter Jun 10, 2026
2fdbbd4
refactor(ui): rename mosaic Button `color` variant to `intent`
alexcarpenter Jun 10, 2026
c6d1ad8
feat(ui): bridge headless dialog primitives into mosaic
alexcarpenter Jun 10, 2026
a9e4584
chore(ui): move @floating-ui/react to catalog:repo
alexcarpenter Jun 10, 2026
0477fa8
add changeset
alexcarpenter Jun 10, 2026
7740983
refactor(headless): replace masking type assertions with validated sa…
alexcarpenter Jun 10, 2026
49e21c3
Delete .changeset/whole-loops-find.md
alexcarpenter Jun 10, 2026
920ce9a
docs(headless,ui): add JSDoc to dialog parts and mosaic components
alexcarpenter Jun 10, 2026
9c291ef
fix(ui): preserve generic props and correct return type in withMosaic…
alexcarpenter Jun 10, 2026
aeaceb7
fix(headless): pass pointer events through viewport overlay when non-…
alexcarpenter Jun 10, 2026
3b94b24
fix @clerk/headless usage in ui package
alexcarpenter Jun 10, 2026
d1f8db1
chore(repo): update lockfile after moving @clerk/headless to devDepen…
alexcarpenter Jun 10, 2026
3e82b1b
chore(repo): dedupe lockfile
alexcarpenter Jun 10, 2026
80de9e7
docs(headless): fix data-cl-* tables in Popover and Tooltip READMEs
alexcarpenter Jun 10, 2026
5be6d13
fix(headless): remove dead DialogScopedContext from dialog primitives
alexcarpenter Jun 10, 2026
f83a7a7
fix(ui): use minHeight in mosaic dialog viewport to prevent tall cont…
alexcarpenter Jun 10, 2026
b833043
Merge branch 'main' into carp/mosaic-dialog
alexcarpenter Jun 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/wise-types-satisfy.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,7 +33,7 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete

const defaultProps = {
'data-cl-slot': 'autocomplete-input',
...(getReferenceProps({
...getReferenceProps({
ref: combinedRef,
value: inputValue,
'aria-autocomplete': 'list' as const,
Expand All@@ -50,7 +50,7 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
}
}
},
}) as React.ComponentPropsWithRef<'input'>),
}),
};

return renderElement({
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { FloatingList, useMergeRefs } from '@floating-ui/react';
import React, { useEffect } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteListProps = ComponentProps<'div'>;
Expand All@@ -24,14 +24,15 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>;
const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-list',
ref: combinedRef,
...floatingProps,
};
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };

const merged = mergeProps<'div'>(defaultProps, otherProps);
// The wired id is owned by the primitive: a consumer-supplied id must not
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { useListItem, useMergeRefs } from '@floating-ui/react';
import React, { useEffect, useId } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export interface AutocompleteOptionProps extends ComponentProps<'div'> {
Expand DownExpand Up@@ -43,21 +43,25 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
disabled: !!disabled,
};

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-option',
id,
ref: combinedRef,
role: 'option' as const,
role: 'option',
'aria-selected': isActive,
'aria-disabled': disabled || undefined,
...(getItemProps({
} satisfies DefaultProps<'div'>;

const defaultProps = {
...ownProps,
...getItemProps({
onClick() {
if (!disabled) {
handleSelect(value, index, displayLabel);
(refs.domReference.current as HTMLElement | null)?.focus();
}
},
}) as React.ComponentPropsWithRef<'div'>),
}),
};

const merged = mergeProps<'div'>(defaultProps, otherProps);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePositionerProps = ComponentProps<'div'>;
Expand All@@ -22,16 +22,17 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
// eslint-disable-next-line @typescript-eslint/unbound-method
const combinedRef = useMergeRefs([refs.setFloating, ref]);

const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>;
const floatingProps = getFloatingProps();
const wiredId = floatingProps.id;

const defaultProps = {
const ownProps = {
'data-cl-slot': 'autocomplete-positioner',
'data-cl-side': side,
ref: combinedRef,
style: floatingStyles,
...floatingProps,
};
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...floatingProps };

const merged = mergeProps<'div'>(defaultProps, otherProps);
// The wired id is owned by the primitive: a consumer-supplied id must not
Expand Down
34 changes: 20 additions & 14 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,13 +15,14 @@ import { Dialog } from '@/primitives/dialog';

<Dialog.Root>
<Dialog.Trigger>Open Dialog</Dialog.Trigger>
<Dialog.Backdrop>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Popup>
<Dialog.Title>Confirm Action</Dialog.Title>
<Dialog.Description>Are you sure you want to proceed?</Dialog.Description>
<Dialog.Close>Cancel</Dialog.Close>
</Dialog.Popup>
</Dialog.Backdrop>
</Dialog.Viewport>
</Dialog.Root>;
```

Expand DownExpand Up@@ -51,7 +52,8 @@ const [open, setOpen] = useState(false);
| `Dialog.Root` | — | Root context provider |
| `Dialog.Trigger` | `<button>` | Opens/closes the dialog on click |
| `Dialog.Portal` | — | Portals children (defaults to `document.body`) |
| `Dialog.Backdrop` | `<div>` | Overlay + focus manager host |
| `Dialog.Backdrop` | `<div>` | Semi-transparent overlay surface |
| `Dialog.Viewport` | `<div>` | Fixed centering container; owns scroll lock |
| `Dialog.Popup` | `<div>` | The dialog content container |
| `Dialog.Title` | `<h2>` | Dialog heading, wired to `aria-labelledby` |
| `Dialog.Description` | `<p>` | Dialog description, wired to `aria-describedby` |
Expand All@@ -74,15 +76,15 @@ const [open, setOpen] = useState(false);
| ------ | ------------------------------------------------------------- | --------------- | -------------------------------- |
| `root` | `HTMLElement \| null \| React.RefObject<HTMLElement \| null>` | `document.body` | Container element to portal into |

When `root` is provided, the dialog is "scoped" to that container: `Dialog.Backdrop` skips `FloatingOverlay` (no fixed positioning or scroll lock), and consumers handle positioning via CSS on the container.
When `root` is provided, the dialog is portaled into that container instead of `document.body`. Consumers handle layout via CSS on the container (or by omitting `Dialog.Viewport` and styling their own).

### `Dialog.Backdrop`
### `Dialog.Viewport`

| Prop | Type | Default | Description |
| ------------ | --------- | ------- | ----------------------------------------------------- |
| `lockScroll` | `boolean` | `true` | Prevents body scroll while open (skipped when scoped) |
| Prop | Type | Default | Description |
| ------------ | --------- | ------- | ------------------------------- |
| `lockScroll` | `boolean` | `true` | Prevents body scroll while open |

### `Dialog.Trigger`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close`
### `Dialog.Backdrop`, `Dialog.Trigger`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close`

No additional props beyond standard HTML attributes and the `render` prop.

Expand All@@ -95,18 +97,22 @@ 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 | Open state |
| 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 |

## Important Notes

- **`Dialog.Popup` must be a child of `Dialog.Backdrop`.** The backdrop hosts the portal, overlay, and focus manager — the popup alone does not handle these.
- **`Dialog.Popup` should be a child of `Dialog.Viewport`** for centered, scroll-locked modal behavior. The viewport hosts the fixed overlay container; the popup alone does not handle positioning or scroll lock.
- **Title and Description are optional but recommended.** If omitted, `aria-labelledby` / `aria-describedby` are simply absent from the popup.
- **Nested dialogs are supported.** The `FloatingTree` pattern handles nesting automatically.
- **No positioning middleware.** Dialogs are centered via CSS, not Floating UI positioning.

## Authoring rule for new primitives

Each styleable surface = one part. Layout infrastructure (overlay, scroll lock, focus manager, portal) wraps a `renderElement` call rather than fusing with it. The dialog split — `Backdrop` (semi-transparent surface) vs. `Viewport` (fixed centering + scroll lock) — exists because mosaic needs to style each layer independently. Apply the same decomposition to future primitives that combine positioning with a styled surface.

## ARIA

- Popup: `role="dialog"`, `aria-labelledby` (from Title), `aria-describedby` (from Description)
Expand Down
81 changes: 37 additions & 44 deletions packages/headless/src/primitives/dialog/dialog-backdrop.tsx
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,39 @@
'use client';

import { FloatingOverlay } from '@floating-ui/react';
import { useContext } from 'react';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { DialogScopedContext, useDialogContext } from './dialog-context';

export interface DialogBackdropProps extends ComponentProps<'div'> {
/** When true, locks body scroll while the dialog is open. Default: true */
lockScroll?: boolean;
}

export function DialogBackdrop(props: DialogBackdropProps) {
const { render, lockScroll = true, ...otherProps } = props;
const { open, mounted, transitionProps } = useDialogContext();
const scoped = useContext(DialogScopedContext);

const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
...transitionProps,
} as React.ComponentPropsWithRef<'div'>;

const backdropElement = renderElement({
defaultTagName: 'div',
render,
enabled: mounted,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});

if (scoped) {
return backdropElement;
}

if (!mounted) {
return null;
}

return <FloatingOverlay lockScroll={lockScroll}>{backdropElement}</FloatingOverlay>;
}
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogBackdrop}. */
export type DialogBackdropProps = ComponentProps<'div'>;

/** Semi-transparent overlay surface rendered behind the dialog. Does not own scroll-lock or positioning — use `Dialog.Viewport` for those. */
export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropProps>(
function DialogBackdrop(props, ref) {
const { render, ...otherProps } = props;
const { open, mounted, transitionProps } = useDialogContext();

if (!mounted) {
return null;
}

const state = { open };

const defaultProps = {
'data-cl-slot': 'dialog-backdrop',
ref,
...transitionProps,
} satisfies DefaultProps<'div'>;

return renderElement({
defaultTagName: 'div',
render,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});
},
);
13 changes: 9 additions & 4 deletions packages/headless/src/primitives/dialog/dialog-close.tsx
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,30 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogClose}. */
export type DialogCloseProps = ComponentProps<'button'>;

export function DialogClose(props: DialogCloseProps) {
/** Button that closes the dialog when clicked. Calls `setOpen(false)` from dialog context. */
export const DialogClose = React.forwardRef<HTMLButtonElement, DialogCloseProps>(function DialogClose(props, ref) {
const { render, ...otherProps } = props;
const { setOpen } = useDialogContext();

const defaultProps = {
type: 'button' as const,
'data-cl-slot': 'dialog-close',
ref,
onClick() {
setOpen(false);
},
};
} satisfies DefaultProps<'button'>;

return renderElement({
defaultTagName: 'button',
render,
props: mergeProps<'button'>(defaultProps, otherProps),
});
}
});
1 change: 0 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-context.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,6 @@ export interface DialogContextValue {
}

export const DialogContext = createContext<DialogContextValue | null>(null);
export const DialogScopedContext = createContext(false);

export function useDialogContext() {
const ctx = useContext(DialogContext);
Expand Down
35 changes: 21 additions & 14 deletions packages/headless/src/primitives/dialog/dialog-description.tsx
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogDescription}. */
export type DialogDescriptionProps = Omit<ComponentProps<'p'>, 'id'>;

export function DialogDescription(props: DialogDescriptionProps) {
const { render, ...otherProps } = props;
const { descriptionId } = useDialogContext();
/** Accessible dialog description. Wires its `id` to `aria-describedby` on `Dialog.Popup`. */
export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDescriptionProps>(
function DialogDescription(props, ref) {
const { render, ...otherProps } = props;
const { descriptionId } = useDialogContext();

const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
};
const defaultProps = {
'data-cl-slot': 'dialog-description',
id: descriptionId,
ref,
} satisfies DefaultProps<'p'>;

return renderElement({
defaultTagName: 'p',
render,
props: mergeProps<'p'>(defaultProps, otherProps),
});
}
return renderElement({
defaultTagName: 'p',
render,
props: mergeProps<'p'>(defaultProps, otherProps),
});
},
);
Loading
Loading