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
5 changes: 5 additions & 0 deletions .changeset/nested-layer-dialogs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/kumo": patch
---

Support nested LayerDialog drawers with independently layered backdrops and responsive mobile actions.
55 changes: 55 additions & 0 deletions packages/kumo-docs-astro/src/components/demos/LayerDialogDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,61 @@ export function LayerDialogCleanupDemo() {
);
}

export function LayerDialogNestedDemo() {
return (
<LayerDialog.Root>
<LayerDialog.Trigger
render={(props) => <Button {...props}>Edit deployment</Button>}
/>
<LayerDialog.Content>
<LayerDialog.Title>Edit deployment</LayerDialog.Title>
<LayerDialog.Description>
Review the deployment settings before saving.
</LayerDialog.Description>
<LayerDialog.Body>
<div className="flex flex-col gap-4">
<Text variant="secondary">
Opening a second dialog from this body should keep the first
dialog beneath it and restore focus when it closes.
</Text>
<LayerDialog.Alert>
<LayerDialog.Trigger
render={(props) => (
<Button variant="secondary-destructive" {...props}>
Discard changes
</Button>
)}
/>
<LayerDialog.Content size="sm">
<LayerDialog.Title>Discard unsaved changes?</LayerDialog.Title>
<LayerDialog.Description>
Your deployment edits will be permanently lost.
</LayerDialog.Description>
<LayerDialog.Body>
<Text variant="secondary">
This nested alert is independently portaled and should
dismiss back to the edit dialog.
</Text>
</LayerDialog.Body>
<LayerDialog.Actions>
<LayerDialog.Actions.Primary variant="destructive">
Discard changes
</LayerDialog.Actions.Primary>
</LayerDialog.Actions>
</LayerDialog.Content>
</LayerDialog.Alert>
</div>
</LayerDialog.Body>
<LayerDialog.Actions>
<LayerDialog.Actions.Primary>
Save changes
</LayerDialog.Actions.Primary>
</LayerDialog.Actions>
</LayerDialog.Content>
</LayerDialog.Root>
);
}

export function LayerDialogTopAlignDemo() {
return (
<LayerDialog.Root>
Expand Down
13 changes: 10 additions & 3 deletions packages/kumo-docs-astro/src/pages/components/layer-dialog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
LayerDialogCleanupDemo,
LayerDialogInformationalDemo,
LayerDialogMaxHeightDemo,
LayerDialogNestedDemo,
LayerDialogPendingDemo,
LayerDialogSizeDemo,
LayerDialogTopAlignDemo,
Expand Down Expand Up @@ -88,7 +89,7 @@ export default function Example() {
`LayerDialog.Content` accepts exactly one `Title`, one `Body`, an optional `Description`, and an optional `Actions`. It chooses its dismissal UI automatically:

- Without `Actions`: show an X in the title frame and no footer.
- With `Actions`: remove the X and show a footer with **Close** or **Cancel** and one primary action.
- With `Actions`: remove the X and show **Close** or **Cancel** plus one primary action. On mobile, they remain visible beneath the scrolling body behind a hairline divider; on desktop, they remain in the fixed footer.

Consumers cannot mix these layouts or add more primary actions. Use the X for read-only content, `Actions` when the user must commit a change, and `LayerDialog.Alert` for destructive or critical confirmations.

Expand All @@ -103,13 +104,13 @@ Consumers cannot mix these layouts or add more primary actions. Use the X for re

<ComponentSection>
## Standard action
Adding `Actions` selects a fixed footer with exactly one Kumo primary action and an automatic **Close** button. Consumers cannot add custom footer controls, change button sizes, or add extra CTAs. The primary action accepts `variant="primary"` (default) or `variant="destructive"`.
Adding `Actions` selects exactly one Kumo primary action and an automatic **Close** button. On mobile, the actions stay visible beneath the scrolling body behind a hairline divider; on desktop, they occupy the fixed footer. Consumers cannot add custom controls, change button sizes, or add extra CTAs. The primary action accepts `variant="primary"` (default) or `variant="destructive"`.
<ComponentExample demo="LayerDialogActionDemo"><LayerDialogActionDemo client:load /></ComponentExample>
</ComponentSection>

<ComponentSection>
## Cancellation wording
When `LayerDialog.Actions` is present, LayerDialog renders a secondary button that dismisses the dialog. Its default label is "Close", or "Cancel" for `LayerDialog.Alert`.
When `LayerDialog.Actions` is present, LayerDialog renders a dismiss button. It uses the secondary button treatment on mobile and the ghost treatment in the desktop footer. Its default label is "Close", or "Cancel" for `LayerDialog.Alert`.

Use `dismissLabel` only when this dialog needs more specific wording, such as "Keep editing" or "Discard changes". The label does not change the button's behavior: it always closes the dialog.
<ComponentExample demo="LayerDialogCancelDemo"><LayerDialogCancelDemo client:load /></ComponentExample>
Expand All @@ -123,6 +124,12 @@ Pass `variant="destructive"` to the primary action when the confirmation is irre
<ComponentExample demo="LayerDialogAlertDemo"><LayerDialogAlertDemo client:load /></ComponentExample>
</ComponentSection>

<ComponentSection>
## Nested dialogs
Nest one `LayerDialog.Root` or `LayerDialog.Alert` inside the parent dialog's body when the user must confirm a related action. Each dialog portals independently; Base UI keeps the nested drawer active above its parent and returns focus to the parent trigger when it closes. The nested drawer renders its own backdrop over the parent, so the parent remains intact but clearly inactive. No z-index overrides are needed.
<ComponentExample demo="LayerDialogNestedDemo"><LayerDialogNestedDemo client:load /></ComponentExample>
</ComponentSection>

<ComponentSection>
## Pending work
`dismissDisabled` blocks every user-initiated dismissal together while asynchronous work is in flight. Programmatic closes, through `actionsRef.current.close()` or a controlled `open` prop, still work so a successful action can dismiss the dialog. The single primary action owns its separate loading or disabled state.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,10 @@ describe("LayerDialog dismissal", () => {
);
});

it("does not turn a nested Root into an alert", () => {
it("keeps nested dialogs distinct and renders a backdrop for each layer", () => {
const initialBackdropCount = document.querySelectorAll(
"[data-layer-dialog-backdrop]",
).length;
const { getAllByRole, getByRole } = render(
<LayerDialog.Alert open>
<LayerDialog.Content>
Expand All @@ -335,6 +338,9 @@ describe("LayerDialog dismissal", () => {
expect(getAllByRole("alertdialog", { hidden: true })).toHaveLength(1);
expect(getByRole("dialog", { hidden: true })).toBeDefined();
expect(getByRole("button", { hidden: true, name: "Close" })).toBeDefined();
expect(
document.querySelectorAll("[data-layer-dialog-backdrop]"),
).toHaveLength(initialBackdropCount + 2);
});

it("describes the popup with its Description slot when present", () => {
Expand Down
44 changes: 35 additions & 9 deletions packages/kumo/src/components/layer-dialog/layer-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,15 @@ const AlertContext = createContext(false);
interface BodySlots {
title: ReactNode;
description: ReactNode;
actions: ReactNode;
showCloseButton: boolean;
closeLabel: string;
}

const BodySlotsContext = createContext<BodySlots>({
title: null,
description: null,
actions: null,
showCloseButton: false,
closeLabel: "",
});
Expand Down Expand Up @@ -272,13 +274,18 @@ function LayerDialogContent({
const bodySlots: BodySlots = {
title: title.element,
description: description.element ?? null,
actions: actions.element ?? null,
showCloseButton: actions.count === 0,
closeLabel: closeLabel ?? layerDialog.close,
};

return (
<DrawerBase.Portal container={container}>
<DrawerBase.Backdrop className="fixed inset-0 bg-kumo-recessed opacity-80 transition-opacity duration-[450ms] ease-[cubic-bezier(0.32,0.72,0,1)] data-[ending-style]:opacity-0 data-[ending-style]:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-[starting-style]:opacity-0 data-[swiping]:duration-0 motion-reduce:transition-none sm:duration-200 sm:data-[ending-style]:duration-200" />
<DrawerBase.Backdrop
forceRender
data-layer-dialog-backdrop
className="fixed inset-0 bg-kumo-recessed opacity-80 transition-opacity duration-[450ms] ease-[cubic-bezier(0.32,0.72,0,1)] data-[ending-style]:opacity-0 data-[ending-style]:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-[starting-style]:opacity-0 data-[swiping]:duration-0 motion-reduce:transition-none sm:duration-200 sm:data-[ending-style]:duration-200"
/>
{/*
Desktop sizing contract: the viewport owns the vertical breathing room
(via the verticalAlign variant) and the popup fills it with
Expand Down Expand Up @@ -311,7 +318,7 @@ function LayerDialogContent({
<BodySlotsContext.Provider value={bodySlots}>
{body.element}
</BodySlotsContext.Provider>
{actions.element}
{isDesktop && actions.element}
</DrawerBase.Content>
</LayerCard>
</DrawerBase.Popup>
Expand Down Expand Up @@ -376,8 +383,9 @@ function LayerDialogBody({ children }: LayerDialogBodyProps) {
const [condensed, setCondensed] = useState(false);
const descriptionClipRef = useRef<HTMLDivElement>(null);
const dismissDisabled = useContext(DismissDisabledContext);
const { title, description, showCloseButton, closeLabel } =
const { title, description, actions, showCloseButton, closeLabel } =
useContext(BodySlotsContext);
const isDesktop = useContext(DesktopContext);

const handleScroll = (event: UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
Expand Down Expand Up @@ -410,7 +418,7 @@ function LayerDialogBody({ children }: LayerDialogBodyProps) {

return (
<LayerCard.Primary className="min-h-0 flex-1 gap-0 p-0">
<div className="z-10 flex shrink-0 items-start justify-between gap-4 rounded-t-lg bg-kumo-base px-4.5 py-4">
<div className="z-10 flex shrink-0 items-start justify-between gap-4 rounded-t-lg bg-kumo-base px-4 py-4 sm:px-4.5">
<div className="flex min-w-0 flex-col">
{title}
{description && (
Expand Down Expand Up @@ -441,9 +449,15 @@ function LayerDialogBody({ children }: LayerDialogBodyProps) {
className="min-h-0 flex-1 overscroll-none [mask-image:linear-gradient(to_bottom,transparent_0,black_min(24px,var(--scroll-area-overflow-y-start,24px)),black_calc(100%-min(24px,var(--scroll-area-overflow-y-end,24px))),transparent_100%)]"
onScroll={handleScroll}
>
<ScrollAreaBase.Content className="px-4.5 pb-4.5">
{content}
</ScrollAreaBase.Content>
{isDesktop ? (
<ScrollAreaBase.Content className="px-4.5 pb-4.5">
{content}
</ScrollAreaBase.Content>
) : (
<ScrollAreaBase.Content className="px-4 pb-4">
{content}
</ScrollAreaBase.Content>
)}
</ScrollAreaBase.Viewport>
<ScrollAreaBase.Scrollbar
keepMounted
Expand All @@ -453,6 +467,11 @@ function LayerDialogBody({ children }: LayerDialogBodyProps) {
<ScrollAreaBase.Thumb className="w-full rounded-full bg-kumo-contrast opacity-10 transition-opacity hover:opacity-20 active:opacity-30" />
</ScrollAreaBase.Scrollbar>
</ScrollAreaBase.Root>
{!isDesktop && actions && (
<div className="shrink-0 border-t border-kumo-hairline p-4">
{actions}
</div>
)}
</LayerCard.Primary>
);
}
Expand Down Expand Up @@ -531,6 +550,7 @@ const LayerDialogActions = Object.assign(
}: LayerDialogActionsProps) {
const dismissDisabled = useContext(DismissDisabledContext);
const isAlert = useContext(AlertContext);
const isDesktop = useContext(DesktopContext);
const { layerDialog } = useKumoLocale();
const label =
dismissLabel ?? (isAlert ? layerDialog.cancel : layerDialog.close);
Expand All @@ -542,7 +562,12 @@ const LayerDialogActions = Object.assign(
}

return (
<div className="flex w-full shrink-0 items-center justify-between gap-2 pt-1.75">
<div
className={cn(
"flex w-full items-center justify-between gap-2",
isDesktop && "shrink-0 pt-1.75",
)}
>
<LayerDialogDismiss disabled={dismissDisabled} label={label} />
{children}
</div>
Expand All @@ -561,12 +586,13 @@ function LayerDialogDismiss({
disabled: boolean;
label: string;
}) {
const isDesktop = useContext(DesktopContext);
const close = (closeProps: ComponentPropsWithoutRef<"button">) => (
<Button
{...closeProps}
className="hover:bg-kumo-fill/50"
disabled={disabled}
variant="ghost"
variant={isDesktop ? "ghost" : "secondary"}
>
{label}
</Button>
Expand Down
Loading