Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/spicy-clocks-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Comment thread
maxyinger marked this conversation as resolved.
26 changes: 18 additions & 8 deletions packages/headless/src/hooks/use-return-focus.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { FloatingEvents } from '@floating-ui/react';
import type { FloatingEvents, OpenChangeReason } from '@floating-ui/react';
import { renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { useReturnFocus } from './use-return-focus';

function createEvents(): FloatingEvents & { close: (event?: Event) => void } {
function createEvents(): FloatingEvents & { close: (event?: Event, reason?: OpenChangeReason) => void } {
const handlers = new Map<string, Array<(data: unknown) => void>>();

return {
Expand All @@ -20,8 +20,8 @@ function createEvents(): FloatingEvents & { close: (event?: Event) => void } {
(handlers.get(event) ?? []).filter(h => h !== handler),
);
},
close(event) {
this.emit('openchange', { open: false, event });
close(event, reason) {
this.emit('openchange', { open: false, event, reason });
},
};
}
Expand Down Expand Up @@ -60,7 +60,17 @@ describe('useReturnFocus', () => {
const { events, result, open } = renderReturnFocus(trigger);
open(true);

events.close(new KeyboardEvent('keydown', { key: 'Escape' }));
events.close(new KeyboardEvent('keydown', { key: 'Escape' }), 'escape-key');

expect(result.current.current).toBe(trigger);
});

it('keeps the trigger when a forwarded event carries no dismissal reason', () => {
const { events, result, open } = renderReturnFocus(trigger);
open(true);

// A Close button forwards its click through `setOpen` with no floating-ui reason.
events.close(new MouseEvent('click', { detail: 1 }));

expect(result.current.current).toBe(trigger);
});
Expand All @@ -76,19 +86,19 @@ describe('useReturnFocus', () => {
expect(result.current.current).toBe(trigger);
});

it('leaves focus alone when the close came from a pointer', () => {
it('leaves focus alone when the close came from a pointer dismissal', () => {
const { events, result, open } = renderReturnFocus(trigger);
open(true);

events.close(new MouseEvent('mousedown', { detail: 1 }));
events.close(new MouseEvent('mousedown', { detail: 1 }), 'outside-press');

expect(result.current.current).toBeNull();
});

it('restores the trigger on the next open', () => {
const { events, result, open } = renderReturnFocus(trigger);
open(true);
events.close(new MouseEvent('mousedown', { detail: 1 }));
events.close(new MouseEvent('mousedown', { detail: 1 }), 'outside-press');

open(false);
open(true);
Expand Down
13 changes: 7 additions & 6 deletions packages/headless/src/hooks/use-return-focus.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import type { FloatingContext } from '@floating-ui/react';
import type { FloatingContext, OpenChangeReason } from '@floating-ui/react';
import { useEffect, useRef } from 'react';

import { isKeyboardEvent } from '../utils/interaction-modality';
Expand Down Expand Up @@ -32,11 +32,12 @@ export function useReturnFocus(
}, [open, trigger]);

useEffect(() => {
// Closes routed straight through the consumer's own state setter (a Close button, an
// item click) never reach floating-ui, so only what floating-ui itself drives can
// downgrade the default.
function onOpenChange({ open, event }: { open: boolean; event?: Event }) {
if (!open && event && !isKeyboardEvent(event)) {
// Only a pointer dismissal downgrades the default, and a `reason` is what marks a close as
// one floating-ui's interaction hooks drove (outside press, a trigger press). An event
// forwarded without a reason — a Close button press — keeps the trigger, and programmatic
// closes carry no event at all.
function onOpenChange({ open, event, reason }: { open: boolean; event?: Event; reason?: OpenChangeReason }) {
if (!open && event && reason && !isKeyboardEvent(event)) {
returnFocusRef.current = null;
}
}
Expand Down
110 changes: 102 additions & 8 deletions packages/headless/src/primitives/dialog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,80 @@ const [open, setOpen] = useState(false);
<Dialog.Root modal={false}>{/* Focus is not trapped, page remains interactive */}</Dialog.Root>
```

### Detached triggers

A trigger does not have to be nested inside its root. `Dialog.createHandle()` returns a handle;
pass the same handle to both, and the trigger drives the root from anywhere in the tree. The
handle also has imperative `open()` / `close()` / `isOpen` members; calls made while no root is
mounted are ignored.

```tsx
const feedbackDialog = Dialog.createHandle();

<Dialog.Trigger handle={feedbackDialog}>Give feedback</Dialog.Trigger>;

<Dialog.Root handle={feedbackDialog}>{/* ... */}</Dialog.Root>;
```

### Multiple triggers and payloads

Each trigger can carry an `id` and a `payload`. The root's children can be a function receiving
the active trigger's payload, so one dialog renders per-trigger content. Type the payload through
the handle: `Dialog.createHandle<Payload>()`. The payload is captured when the trigger opens the
dialog; for data that can change while it is open, carry an id and read live state inside.

```tsx
const detail = Dialog.createHandle<{ name: string }>();

<Dialog.Trigger handle={detail} id='a' payload={{ name: 'Alice' }}>Alice</Dialog.Trigger>
<Dialog.Trigger handle={detail} id='b' payload={{ name: 'Bob' }}>Bob</Dialog.Trigger>

<Dialog.Root handle={detail}>
{({ payload }) => <Dialog.Popup>{payload?.name}</Dialog.Popup>}
</Dialog.Root>
```

In controlled mode, track which trigger is active with `triggerId` — `onOpenChange`'s second
argument reports the trigger behind each change:

```tsx
const [open, setOpen] = useState(false);
const [triggerId, setTriggerId] = useState<string | null>(null);

<Dialog.Root
open={open}
triggerId={triggerId}
onOpenChange={(next, details) => {
setOpen(next);
setTriggerId(details.triggerId);
}}
>
{/* ... */}
</Dialog.Root>;
```

Setting `triggerId` alongside a programmatic `open` also attributes the open to that trigger —
the dialog returns focus to it on close, exactly as if it had been clicked.

### Custom focus management

`initialFocus` and `finalFocus` on `Dialog.Popup` control where focus moves on open and close.
Each accepts `true` (the default behaviour), `false` (do not move focus), a ref, or a function of
the interaction type behind the open/close (`'mouse' | 'touch' | 'pen' | 'keyboard' | ''`, empty
for programmatic) returning any of those:

```tsx
<Dialog.Popup
initialFocus={interactionType => (interactionType === 'keyboard' ? firstFieldRef.current : false)}
finalFocus={finalFocusRef}
>
{/* ... */}
</Dialog.Popup>
```

The defaults stay what they were: first tabbable element on open; on close, the trigger — unless
the close was pointer-driven, where focus is left where the pointer put it (see `useReturnFocus`).

## Parts

| Part | Default Element | Description |
Expand All @@ -63,13 +137,16 @@ const [open, setOpen] = useState(false);

### `Dialog.Root`

| Prop | Type | Default | Description |
| -------------- | ----------------------------------- | ------- | --------------------------------------- |
| `open` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
| `modal` | `boolean` | `true` | Traps focus and blocks page interaction |
| `closedBy` | `'any' \| 'closerequest' \| 'none'` | `'any'` | Which gestures dismiss the dialog |
| Prop | Type | Default | Description |
| -------------- | ----------------------------------------------------------- | ------- | --------------------------------------------------------------------- |
| `open` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
| `onOpenChange` | `(open: boolean, details: DialogOpenChangeDetails) => void` | — | Called when open state changes; `details` names the trigger behind it |
| `modal` | `boolean` | `true` | Traps focus and blocks page interaction |
| `closedBy` | `'any' \| 'closerequest' \| 'none'` | `'any'` | Which gestures dismiss the dialog |
| `handle` | `DialogHandle` | — | Connects detached triggers (see `Dialog.createHandle()`) |
| `triggerId` | `string \| null` | — | Controls which trigger the open is attributed to |
| `children` | `ReactNode \| ({ payload }) => ReactNode` | — | Content, or a render function of the active trigger's `payload` |

#### `closedBy`

Expand Down Expand Up @@ -107,7 +184,24 @@ When `root` is provided, the dialog is portaled into that container instead of `
| ------------ | --------- | ------- | ------------------------------- |
| `lockScroll` | `boolean` | `true` | Prevents body scroll while open |

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

| Prop | Type | Default | Description |
| --------- | -------------- | ------- | -------------------------------------------------------- |
| `handle` | `DialogHandle` | — | Drives a root elsewhere in the tree (detached trigger) |
| `id` | `string` | auto | Names this trigger for the root's `triggerId` |
| `payload` | `Payload` | — | Delivered to the root's children render function on open |

### `Dialog.Popup`

| Prop | Type | Default | Description |
| -------------- | ------------------- | ------- | --------------------------------------- |
| `initialFocus` | `DialogFocusTarget` | `true` | Where focus moves when the dialog opens |
| `finalFocus` | `DialogFocusTarget` | `true` | Where focus returns when it closes |

`DialogFocusTarget` is `boolean | RefObject | (interactionType) => boolean | void | HTMLElement | null`.

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

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

Expand Down
6 changes: 3 additions & 3 deletions packages/headless/src/primitives/dialog/dialog-close.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ import { useDialogContext } from './dialog-context';
/** Props for {@link DialogClose}. */
export type DialogCloseProps = ComponentProps<'button'>;

/** Button that closes the dialog when clicked. Calls `setOpen(false)` from dialog context. */
/** Button that closes the dialog when clicked, forwarding the event so `finalFocus` sees the interaction type behind the close. */
export const DialogClose = React.forwardRef<HTMLButtonElement, DialogCloseProps>(function DialogClose(props, ref) {
const { render, ...otherProps } = props;
const { setOpen } = useDialogContext();

const defaultProps = {
type: 'button' as const,
onClick() {
setOpen(false);
onClick(event: React.MouseEvent) {
setOpen(false, event.nativeEvent);
},
} satisfies DefaultProps<'button'>;

Expand Down
16 changes: 14 additions & 2 deletions packages/headless/src/primitives/dialog/dialog-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,24 @@ import type { ExtendedRefs, FloatingContext, ReferenceType, UseInteractionsRetur
import { createContext, useContext } from 'react';

import type { TransitionProps } from '../../hooks/use-transition';
import type { DialogHandle } from './dialog-handle';

export interface DialogContextValue {
open: boolean;
setOpen: (open: boolean) => void;
/** The optional event marks the change as user-driven, letting `finalFocus` resolve its interaction type. */
setOpen: (open: boolean, event?: Event) => void;
floatingContext: FloatingContext;
refs: ExtendedRefs<ReferenceType>;
getReferenceProps: UseInteractionsReturn['getReferenceProps'];
getFloatingProps: UseInteractionsReturn['getFloatingProps'];
popupRef: React.RefObject<HTMLDivElement | null>;
/** Where focus goes when the dialog closes, or `null` to leave focus alone. */
returnFocusRef: React.MutableRefObject<HTMLElement | null>;
/**
* The store connecting this root to its triggers — the `handle` prop when one was passed,
* otherwise a private store the root created. Triggers nested inside the root reach it here;
* detached triggers hold the same object through their `handle` prop.
*/
store: DialogHandle;
modal: boolean;
/**
* Whether this dialog opened from inside another floating element, so a stacked overlay can
Expand All @@ -39,3 +46,8 @@ export function useDialogContext() {
}
return ctx;
}

/** Context access for parts that can also live outside the root — a trigger given a `handle`. */
export function useOptionalDialogContext() {
return useContext(DialogContext);
}
Loading
Loading