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: 1 addition & 1 deletion apps/docs/content/docs/web/components/alert-dialog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ import { AlertDialog } from "@/components/panelui/alert-dialog"
- **`AlertDialog.Media`** — A glyph in a disc of the dialog's tone, above the title.
- **`AlertDialog.Title`** — The question. The dialog is announced by it.
- **`AlertDialog.Description`** — What follows from each answer, read out after the title.
- **`AlertDialog.Footer`** — The answers, side by side at equal widths in the band under the panel. Keep it a direct child of `AlertDialog.Content`.
- **`AlertDialog.Footer`** — The answers, side by side at equal widths in the band under the panel. It can sit at any depth inside `AlertDialog.Content`.
- **`AlertDialog.Cancel`** — Closes the dialog and does nothing else. It reads "Cancel" unless given children.
- **`AlertDialog.Action`** — Does what the dialog asks about, then closes it. It takes a [Button](/docs/web/components/button)'s props, a `tone`, and `variant="soft"` for an answer that is not the main one. When `onClick` returns a promise, it waits for it. `hold` makes it an action that must be pressed and held.

Expand Down
8 changes: 6 additions & 2 deletions apps/docs/content/docs/web/components/dialog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ import { Dialog } from "@/components/panelui/dialog"
- **`Dialog.List`** — A list of one-line choices, such as time zones or the projects to move something to. The arrow keys, Home and End move between its items, and it opens scrolled to the selected one.
- **`Dialog.ListGroup`** — A run of items under a `label`, which stays pinned at the top while its items scroll under it.
- **`Dialog.ListItem`** — One choice. `selected` tints and checks it, `onSelect` is called when it is pressed or chosen with Enter, and `meta` puts quieter text at its end in figures that line up.
- **`Dialog.Footer`** — The buttons. In the inset look they sit in the band as equal pills; in `plain` they sit at the end of a ruled strip. Keep it a direct child of `Dialog.Content`.
- **`Dialog.Footer`** — The buttons. In the inset look they sit in the band as equal pills, from any depth inside `Dialog.Content`. In `plain` they stay where written, at the end of a ruled strip, so keep the footer after `Dialog.Body` to hold it outside the scroll.
- **`Dialog.Close`** — Closes the dialog. Pass your own button as `render`, such as Cancel.

## Examples
Expand Down Expand Up @@ -829,7 +829,11 @@ When it opens, focus goes to the first field in it. The close button is drawn in

### Forms

Put the `<form>` in `Dialog.Body` with an `id`, and give the submit button in the footer `type="submit"` and `form` set to that `id`. Enter in a field then submits it, and the footer can stay a direct child of the content.
Put the `<form>` in `Dialog.Body` with an `id`, and give the submit button in the footer `type="submit"` and `form` set to that `id`. Enter in a field then submits it. The inset footer moves to the band even when it is written inside the `<form>`, so it always needs `form`.

### State that starts fresh

Everything inside `Dialog.Content` unmounts once the dialog has closed, and `Dialog.Footer` still reaches the band from inside a component of your own. Keep a form's state in a component rendered inside `Dialog.Content` and it starts empty on every opening, while the root stays mounted so opening and closing both animate.

### On a phone

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/public/r/web/alert-dialog.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/docs/public/r/web/dialog.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ For a task rather than a yes or no, such as a form to fill in, use [Dialog](/doc
- `AlertDialog.Media` — A glyph in a disc of the dialog's tone, above the title.
- `AlertDialog.Title` — The question. The dialog is announced by it.
- `AlertDialog.Description` — What follows from each answer, read out after the title.
- `AlertDialog.Footer` — The answers, side by side at equal widths in the band under the panel. Keep it a direct child of `AlertDialog.Content`.
- `AlertDialog.Footer` — The answers, side by side at equal widths in the band under the panel. It can sit at any depth inside `AlertDialog.Content`.
- `AlertDialog.Cancel` — Closes the dialog and does nothing else. It reads "Cancel" unless given children.
- `AlertDialog.Action` — Does what the dialog asks about, then closes it. It takes a [Button](/docs/web/components/button)'s props, a `tone`, and `variant="soft"` for an answer that is not the main one. When `onClick` returns a promise, it waits for it. `hold` makes it an action that must be pressed and held.

Expand Down
8 changes: 6 additions & 2 deletions apps/docs/public/skills/panelui-web/components/dialog.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ For a yes-or-no question, use [Alert Dialog](/docs/web/components/alert-dialog).
- `Dialog.List` — A list of one-line choices, such as time zones or the projects to move something to. The arrow keys, Home and End move between its items, and it opens scrolled to the selected one.
- `Dialog.ListGroup` — A run of items under a `label`, which stays pinned at the top while its items scroll under it.
- `Dialog.ListItem` — One choice. `selected` tints and checks it, `onSelect` is called when it is pressed or chosen with Enter, and `meta` puts quieter text at its end in figures that line up.
- `Dialog.Footer` — The buttons. In the inset look they sit in the band as equal pills; in `plain` they sit at the end of a ruled strip. Keep it a direct child of `Dialog.Content`.
- `Dialog.Footer` — The buttons. In the inset look they sit in the band as equal pills, from any depth inside `Dialog.Content`. In `plain` they stay where written, at the end of a ruled strip, so keep the footer after `Dialog.Body` to hold it outside the scroll.
- `Dialog.Close` — Closes the dialog. Pass your own button as `render`, such as Cancel.

## Props
Expand Down Expand Up @@ -165,7 +165,11 @@ When it opens, focus goes to the first field in it. The close button is drawn in

#### Forms

Put the `<form>` in `Dialog.Body` with an `id`, and give the submit button in the footer `type="submit"` and `form` set to that `id`. Enter in a field then submits it, and the footer can stay a direct child of the content.
Put the `<form>` in `Dialog.Body` with an `id`, and give the submit button in the footer `type="submit"` and `form` set to that `id`. Enter in a field then submits it. The inset footer moves to the band even when it is written inside the `<form>`, so it always needs `form`.

#### State that starts fresh

Everything inside `Dialog.Content` unmounts once the dialog has closed, and `Dialog.Footer` still reaches the band from inside a component of your own. Keep a form's state in a component rendered inside `Dialog.Content` and it starts empty on every opening, while the root stays mounted so opening and closing both animate.

#### On a phone

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/scripts/web-usage/alert-dialog.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"Media": "A glyph in a disc of the dialog's tone, above the title.",
"Title": "The question. The dialog is announced by it.",
"Description": "What follows from each answer, read out after the title.",
"Footer": "The answers, side by side at equal widths in the band under the panel. Keep it a direct child of `AlertDialog.Content`.",
"Footer": "The answers, side by side at equal widths in the band under the panel. It can sit at any depth inside `AlertDialog.Content`.",
"Cancel": "Closes the dialog and does nothing else. It reads \"Cancel\" unless given children.",
"Action": "Does what the dialog asks about, then closes it. It takes a [Button](/docs/web/components/button)'s props, a `tone`, and `variant=\"soft\"` for an answer that is not the main one. When `onClick` returns a promise, it waits for it. `hold` makes it an action that must be pressed and held."
},
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/scripts/web-usage/dialog.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"List": "A list of one-line choices, such as time zones or the projects to move something to. The arrow keys, Home and End move between its items, and it opens scrolled to the selected one.",
"ListGroup": "A run of items under a `label`, which stays pinned at the top while its items scroll under it.",
"ListItem": "One choice. `selected` tints and checks it, `onSelect` is called when it is pressed or chosen with Enter, and `meta` puts quieter text at its end in figures that line up.",
"Footer": "The buttons. In the inset look they sit in the band as equal pills; in `plain` they sit at the end of a ruled strip. Keep it a direct child of `Dialog.Content`.",
"Footer": "The buttons. In the inset look they sit in the band as equal pills, from any depth inside `Dialog.Content`. In `plain` they stay where written, at the end of a ruled strip, so keep the footer after `Dialog.Body` to hold it outside the scroll.",
"Close": "Closes the dialog. Pass your own button as `render`, such as Cancel."
},
"examples": [
Expand Down Expand Up @@ -52,5 +52,5 @@
"height": 180
}
],
"notes": "### Closing\n\nA press outside, Escape and the close button close it, and so does any `Dialog.Close`. `dismissible={false}` stops a press outside from closing it, for a form whose changes would be lost; Escape still works. Hold `open` yourself with `onOpenChange` to close it from code, for example once a form has saved.\n\n### Focus\n\nWhen it opens, focus goes to the first field in it. The close button is drawn in the corner but comes last in the order, so the way out is not the first thing focused. Focus returns to the trigger when it closes.\n\n### Forms\n\nPut the `<form>` in `Dialog.Body` with an `id`, and give the submit button in the footer `type=\"submit\"` and `form` set to that `id`. Enter in a field then submits it, and the footer can stay a direct child of the content.\n\n### On a phone\n\nUnder 640px wide the dialog sits at the bottom of the screen, 8px clear of the edges, and can be as tall as the screen less that gap."
"notes": "### Closing\n\nA press outside, Escape and the close button close it, and so does any `Dialog.Close`. `dismissible={false}` stops a press outside from closing it, for a form whose changes would be lost; Escape still works. Hold `open` yourself with `onOpenChange` to close it from code, for example once a form has saved.\n\n### Focus\n\nWhen it opens, focus goes to the first field in it. The close button is drawn in the corner but comes last in the order, so the way out is not the first thing focused. Focus returns to the trigger when it closes.\n\n### Forms\n\nPut the `<form>` in `Dialog.Body` with an `id`, and give the submit button in the footer `type=\"submit\"` and `form` set to that `id`. Enter in a field then submits it. The inset footer moves to the band even when it is written inside the `<form>`, so it always needs `form`.\n\n### State that starts fresh\n\nEverything inside `Dialog.Content` unmounts once the dialog has closed, and `Dialog.Footer` still reaches the band from inside a component of your own. Keep a form's state in a component rendered inside `Dialog.Content` and it starts empty on every opening, while the root stays mounted so opening and closing both animate.\n\n### On a phone\n\nUnder 640px wide the dialog sits at the bottom of the screen, 8px clear of the edges, and can be as tall as the screen less that gap."
}
27 changes: 14 additions & 13 deletions packages/web/src/components/alert-dialog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,7 @@
// press outside; the role is still `alertdialog`, set on the popup.
import { Dialog as Primitive } from '@base-ui/react/dialog';
import {
Children,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
Expand All @@ -62,6 +60,7 @@ import {
type ReactElement,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import { tv } from 'tailwind-variants';
import { Button, type ButtonProps } from '../button';
import { mergeProps } from '@base-ui/react/merge-props';
Expand Down Expand Up @@ -167,6 +166,8 @@ interface AlertDialogContextValue {
tone: AlertDialogTone;
/** Set by `AlertDialog.Content`. */
inline: boolean;
/** Where the footer portals to. `undefined` outside `AlertDialog.Content`. */
band?: HTMLElement | null;
}

const AlertDialogContext = createContext<AlertDialogContextValue | null>(null);
Expand Down Expand Up @@ -275,8 +276,6 @@ export interface AlertDialogContentProps extends Omit<ComponentProps<'div'>, 'ch
inline?: boolean;
}

const isFooter = (child: ReactNode) => isValidElement(child) && child.type === AlertDialogFooter;

/** The dialog: the panel with the question, in a band that holds the answers. */
function AlertDialogContent({
tone = 'neutral',
Expand All @@ -289,17 +288,16 @@ function AlertDialogContent({
}: AlertDialogContentProps) {
const dialog = useAlertDialog('AlertDialog.Content');
const slots = alertDialogVariants({ palette: tone, size, blur });
const context = useMemo(() => ({ ...dialog, tone, inline }), [dialog, tone, inline]);
const [band, setBand] = useState<HTMLElement | null>(null);
const context = useMemo(() => ({ ...dialog, tone, inline, band }), [dialog, tone, inline, band]);

// The footer leaves the panel for the band under it.
const all = Children.toArray(children);
const footer = all.filter(isFooter);
// The footer leaves the panel for the band under it, from any depth.
const inside = (
<AlertDialogContext.Provider value={context}>
<div data-slot="alert-dialog-panel" className={slots.panel()}>
{all.filter((child) => !isFooter(child))}
{children}
</div>
{footer}
<div data-slot="alert-dialog-band" className="contents" ref={setBand} />
</AlertDialogContext.Provider>
);

Expand Down Expand Up @@ -380,18 +378,21 @@ AlertDialogDescription.displayName = 'AlertDialog.Description';
export interface AlertDialogFooterProps extends ComponentProps<'div'> {}

/**
* The answers, side by side at equal widths in the band under the panel.
* Keep it a direct child of `AlertDialog.Content`, so the band can find it.
* The answers, side by side at equal widths in the band under the panel,
* wherever they are written inside `AlertDialog.Content`.
*/
function AlertDialogFooter({ className, render, ...props }: AlertDialogFooterProps & RenderProp<'div'>) {
return useRender({
const band = useContext(AlertDialogContext)?.band;
const footer = useRender({
defaultTagName: 'div',
render,
props: mergeProps(
{ 'data-slot': 'alert-dialog-footer', className: alertDialogVariants().footer({ className }) },
props
),
});
if (band === undefined) return footer;
return band ? createPortal(footer, band) : null;
}
AlertDialogFooter.displayName = 'AlertDialog.Footer';

Expand Down
32 changes: 17 additions & 15 deletions packages/web/src/components/dialog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,19 @@
import { Dialog as Primitive } from '@base-ui/react/dialog';
import { ScrollArea } from '@base-ui/react/scroll-area';
import {
Children,
createContext,
isValidElement,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
type ComponentProps,
type KeyboardEvent,
type ReactElement,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import { tv } from 'tailwind-variants';
import { mergeProps } from '@base-ui/react/merge-props';
import { useRender } from '@base-ui/react/use-render';
Expand Down Expand Up @@ -181,6 +181,8 @@ interface DialogContextValue {
inline: boolean;
showClose: boolean;
variant: 'inset' | 'plain';
/** Where an inset footer portals to. `undefined` outside `Dialog.Content`. */
band?: HTMLElement | null;
}

const DialogContext = createContext<DialogContextValue>({ inline: false, showClose: true, variant: 'inset' });
Expand Down Expand Up @@ -268,8 +270,6 @@ export interface DialogContentProps extends Omit<ComponentProps<'div'>, 'childre
inline?: boolean;
}

const isFooter = (child: ReactNode) => isValidElement(child) && child.type === DialogFooter;

/** The dialog itself, with the dimmed page behind it. */
function DialogContent({
variant = 'inset',
Expand All @@ -282,20 +282,18 @@ function DialogContent({
...props
}: DialogContentProps) {
const slots = dialogVariants({ variant, size, blur });
const context = useMemo(() => ({ inline, showClose, variant }), [inline, showClose, variant]);
const [band, setBand] = useState<HTMLElement | null>(null);
const context = useMemo(() => ({ inline, showClose, variant, band }), [inline, showClose, variant, band]);

// In the inset look the footer leaves the panel for the band under it.
const all = Children.toArray(children);
const footer = variant === 'inset' ? all.filter(isFooter) : [];
const rest = variant === 'inset' ? all.filter((child) => !isFooter(child)) : all;
// In the inset look the footer leaves the panel for the band under it, from any depth.
const inside = (
<DialogContext.Provider value={context}>
<div data-slot="dialog-panel" className={slots.panel()}>
{rest}
{children}
{/* Last in the order, so focus starts on the task, not the way out. */}
{showClose ? <DialogClose aria-label="Close" className={slots.close()} icon /> : null}
</div>
{footer}
{variant === 'inset' ? <div data-slot="dialog-band" className="contents" ref={setBand} /> : null}
</DialogContext.Provider>
);

Expand Down Expand Up @@ -417,19 +415,23 @@ export interface DialogFooterProps extends ComponentProps<'div'> {}

/**
* The buttons. In the inset look they sit in the band under the panel as
* equal pills; in `plain` they sit at the end of a ruled strip. Keep it a
* direct child of `Dialog.Content`, so the band can find it.
* equal pills, wherever they are written inside `Dialog.Content`; in `plain`
* they sit at the end of a ruled strip. Moving to the band takes them out of
* any `<form>`, so a submit button needs `form` set to the form's `id`.
*/
function DialogFooter({ className, render, ...props }: DialogFooterProps & RenderProp<'div'>) {
const { variant } = useContext(DialogContext);
return useRender({
const { variant, band } = useContext(DialogContext);
const footer = useRender({
defaultTagName: 'div',
render,
props: mergeProps(
{ 'data-slot': 'dialog-footer', className: dialogVariants({ variant }).footer({ className }) },
props
),
});
if (variant !== 'inset' || band === undefined) return footer;
// The band mounts in the same commit, so the footer lands there before the first paint.
return band ? createPortal(footer, band) : null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
DialogFooter.displayName = 'Dialog.Footer';

Expand Down
2 changes: 1 addition & 1 deletion skills/panelui-web/components/alert-dialog.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ For a task rather than a yes or no, such as a form to fill in, use [Dialog](/doc
- `AlertDialog.Media` — A glyph in a disc of the dialog's tone, above the title.
- `AlertDialog.Title` — The question. The dialog is announced by it.
- `AlertDialog.Description` — What follows from each answer, read out after the title.
- `AlertDialog.Footer` — The answers, side by side at equal widths in the band under the panel. Keep it a direct child of `AlertDialog.Content`.
- `AlertDialog.Footer` — The answers, side by side at equal widths in the band under the panel. It can sit at any depth inside `AlertDialog.Content`.
- `AlertDialog.Cancel` — Closes the dialog and does nothing else. It reads "Cancel" unless given children.
- `AlertDialog.Action` — Does what the dialog asks about, then closes it. It takes a [Button](/docs/web/components/button)'s props, a `tone`, and `variant="soft"` for an answer that is not the main one. When `onClick` returns a promise, it waits for it. `hold` makes it an action that must be pressed and held.

Expand Down
Loading
Loading