Skip to content
Open
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 number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Original file line number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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