Skip to content

Commit b151416

Browse files
feat(ui,headless): mosaic dialog foundations (#8808)
1 parent 955f095 commit b151416

43 files changed

Lines changed: 590 additions & 193 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/wise-types-satisfy.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/headless/src/primitives/autocomplete/autocomplete-input.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
3333

3434
const defaultProps = {
3535
'data-cl-slot': 'autocomplete-input',
36-
...(getReferenceProps({
36+
...getReferenceProps({
3737
ref: combinedRef,
3838
value: inputValue,
3939
'aria-autocomplete': 'list' as const,
@@ -50,7 +50,7 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
5050
}
5151
}
5252
},
53-
}) as React.ComponentPropsWithRef<'input'>),
53+
}),
5454
};
5555

5656
return renderElement({

packages/headless/src/primitives/autocomplete/autocomplete-list.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { FloatingList, useMergeRefs } from '@floating-ui/react';
44
import React, { useEffect } from 'react';
55

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

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

27-
const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>;
27+
const floatingProps = getFloatingProps();
2828
const wiredId = floatingProps.id;
2929

30-
const defaultProps = {
30+
const ownProps = {
3131
'data-cl-slot': 'autocomplete-list',
3232
ref: combinedRef,
33-
...floatingProps,
34-
};
33+
} satisfies DefaultProps<'div'>;
34+
35+
const defaultProps = { ...ownProps, ...floatingProps };
3536

3637
const merged = mergeProps<'div'>(defaultProps, otherProps);
3738
// The wired id is owned by the primitive: a consumer-supplied id must not

packages/headless/src/primitives/autocomplete/autocomplete-option.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useListItem, useMergeRefs } from '@floating-ui/react';
44
import React, { useEffect, useId } from 'react';
55

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

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

46-
const defaultProps = {
46+
const ownProps = {
4747
'data-cl-slot': 'autocomplete-option',
4848
id,
4949
ref: combinedRef,
50-
role: 'option' as const,
50+
role: 'option',
5151
'aria-selected': isActive,
5252
'aria-disabled': disabled || undefined,
53-
...(getItemProps({
53+
} satisfies DefaultProps<'div'>;
54+
55+
const defaultProps = {
56+
...ownProps,
57+
...getItemProps({
5458
onClick() {
5559
if (!disabled) {
5660
handleSelect(value, index, displayLabel);
5761
(refs.domReference.current as HTMLElement | null)?.focus();
5862
}
5963
},
60-
}) as React.ComponentPropsWithRef<'div'>),
64+
}),
6165
};
6266

6367
const merged = mergeProps<'div'>(defaultProps, otherProps);

packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react';
44
import React from 'react';
55

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

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

25-
const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>;
25+
const floatingProps = getFloatingProps();
2626
const wiredId = floatingProps.id;
2727

28-
const defaultProps = {
28+
const ownProps = {
2929
'data-cl-slot': 'autocomplete-positioner',
3030
'data-cl-side': side,
3131
ref: combinedRef,
3232
style: floatingStyles,
33-
...floatingProps,
34-
};
33+
} satisfies DefaultProps<'div'>;
34+
35+
const defaultProps = { ...ownProps, ...floatingProps };
3536

3637
const merged = mergeProps<'div'>(defaultProps, otherProps);
3738
// The wired id is owned by the primitive: a consumer-supplied id must not

packages/headless/src/primitives/dialog/README.md

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@ import { Dialog } from '@/primitives/dialog';
1515

1616
<Dialog.Root>
1717
<Dialog.Trigger>Open Dialog</Dialog.Trigger>
18-
<Dialog.Backdrop>
18+
<Dialog.Backdrop />
19+
<Dialog.Viewport>
1920
<Dialog.Popup>
2021
<Dialog.Title>Confirm Action</Dialog.Title>
2122
<Dialog.Description>Are you sure you want to proceed?</Dialog.Description>
2223
<Dialog.Close>Cancel</Dialog.Close>
2324
</Dialog.Popup>
24-
</Dialog.Backdrop>
25+
</Dialog.Viewport>
2526
</Dialog.Root>;
2627
```
2728

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

77-
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.
79+
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).
7880

79-
### `Dialog.Backdrop`
81+
### `Dialog.Viewport`
8082

81-
| Prop | Type | Default | Description |
82-
| ------------ | --------- | ------- | ----------------------------------------------------- |
83-
| `lockScroll` | `boolean` | `true` | Prevents body scroll while open (skipped when scoped) |
83+
| Prop | Type | Default | Description |
84+
| ------------ | --------- | ------- | ------------------------------- |
85+
| `lockScroll` | `boolean` | `true` | Prevents body scroll while open |
8486

85-
### `Dialog.Trigger`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close`
87+
### `Dialog.Backdrop`, `Dialog.Trigger`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close`
8688

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

@@ -95,18 +97,22 @@ No additional props beyond standard HTML attributes and the `render` prop.
9597

9698
## Data Attributes
9799

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

103105
## Important Notes
104106

105-
- **`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.
107+
- **`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.
106108
- **Title and Description are optional but recommended.** If omitted, `aria-labelledby` / `aria-describedby` are simply absent from the popup.
107109
- **Nested dialogs are supported.** The `FloatingTree` pattern handles nesting automatically.
108110
- **No positioning middleware.** Dialogs are centered via CSS, not Floating UI positioning.
109111

112+
## Authoring rule for new primitives
113+
114+
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.
115+
110116
## ARIA
111117

112118
- Popup: `role="dialog"`, `aria-labelledby` (from Title), `aria-describedby` (from Description)
Lines changed: 37 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,39 @@
11
'use client';
22

3-
import { FloatingOverlay } from '@floating-ui/react';
4-
import { useContext } from 'react';
5-
6-
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
7-
import { DialogScopedContext, useDialogContext } from './dialog-context';
8-
9-
export interface DialogBackdropProps extends ComponentProps<'div'> {
10-
/** When true, locks body scroll while the dialog is open. Default: true */
11-
lockScroll?: boolean;
12-
}
13-
14-
export function DialogBackdrop(props: DialogBackdropProps) {
15-
const { render, lockScroll = true, ...otherProps } = props;
16-
const { open, mounted, transitionProps } = useDialogContext();
17-
const scoped = useContext(DialogScopedContext);
18-
19-
const state = { open };
20-
21-
const defaultProps = {
22-
'data-cl-slot': 'dialog-backdrop',
23-
...transitionProps,
24-
} as React.ComponentPropsWithRef<'div'>;
25-
26-
const backdropElement = renderElement({
27-
defaultTagName: 'div',
28-
render,
29-
enabled: mounted,
30-
state,
31-
stateAttributesMapping: {
32-
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
33-
},
34-
props: mergeProps<'div'>(defaultProps, otherProps),
35-
});
36-
37-
if (scoped) {
38-
return backdropElement;
39-
}
40-
41-
if (!mounted) {
42-
return null;
43-
}
44-
45-
return <FloatingOverlay lockScroll={lockScroll}>{backdropElement}</FloatingOverlay>;
46-
}
3+
import React from 'react';
4+
5+
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
6+
import { useDialogContext } from './dialog-context';
7+
8+
/** Props for {@link DialogBackdrop}. */
9+
export type DialogBackdropProps = ComponentProps<'div'>;
10+
11+
/** Semi-transparent overlay surface rendered behind the dialog. Does not own scroll-lock or positioning — use `Dialog.Viewport` for those. */
12+
export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropProps>(
13+
function DialogBackdrop(props, ref) {
14+
const { render, ...otherProps } = props;
15+
const { open, mounted, transitionProps } = useDialogContext();
16+
17+
if (!mounted) {
18+
return null;
19+
}
20+
21+
const state = { open };
22+
23+
const defaultProps = {
24+
'data-cl-slot': 'dialog-backdrop',
25+
ref,
26+
...transitionProps,
27+
} satisfies DefaultProps<'div'>;
28+
29+
return renderElement({
30+
defaultTagName: 'div',
31+
render,
32+
state,
33+
stateAttributesMapping: {
34+
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
35+
},
36+
props: mergeProps<'div'>(defaultProps, otherProps),
37+
});
38+
},
39+
);
Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,30 @@
11
'use client';
22

3-
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
3+
import React from 'react';
4+
5+
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
46
import { useDialogContext } from './dialog-context';
57

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

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

1216
const defaultProps = {
1317
type: 'button' as const,
1418
'data-cl-slot': 'dialog-close',
19+
ref,
1520
onClick() {
1621
setOpen(false);
1722
},
18-
};
23+
} satisfies DefaultProps<'button'>;
1924

2025
return renderElement({
2126
defaultTagName: 'button',
2227
render,
2328
props: mergeProps<'button'>(defaultProps, otherProps),
2429
});
25-
}
30+
});

packages/headless/src/primitives/dialog/dialog-context.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ export interface DialogContextValue {
1919
}
2020

2121
export const DialogContext = createContext<DialogContextValue | null>(null);
22-
export const DialogScopedContext = createContext(false);
2322

2423
export function useDialogContext() {
2524
const ctx = useContext(DialogContext);
Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
11
'use client';
22

3-
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
3+
import React from 'react';
4+
5+
import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils';
46
import { useDialogContext } from './dialog-context';
57

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

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

12-
const defaultProps = {
13-
'data-cl-slot': 'dialog-description',
14-
id: descriptionId,
15-
};
17+
const defaultProps = {
18+
'data-cl-slot': 'dialog-description',
19+
id: descriptionId,
20+
ref,
21+
} satisfies DefaultProps<'p'>;
1622

17-
return renderElement({
18-
defaultTagName: 'p',
19-
render,
20-
props: mergeProps<'p'>(defaultProps, otherProps),
21-
});
22-
}
23+
return renderElement({
24+
defaultTagName: 'p',
25+
render,
26+
props: mergeProps<'p'>(defaultProps, otherProps),
27+
});
28+
},
29+
);

0 commit comments

Comments
 (0)