Skip to content

Conversation

@maxy-shpfy
Copy link
Collaborator

@maxy-shpfy maxy-shpfy commented Dec 7, 2025

Dialog System Usage Guide

Contributes to https://github.com/Shopify/oasis-frontend/issues/393

⚠️ This is PoC, highly vibed to proof the concept, not to be the final solution.

Type of Change

  • New feature
  • Improvement

Motivation

Existing Dialog system is based on using and managing components in place. This causing a lot of friction:

  • with "reusable" dialogs, e.g. confirmation dialog
  • with reseting state for long-live dialogs between open/reopen, e.g. ComponentDuplicateDialog
  • multi-step or multi-mode dialogs are hard to implement (ManageLibrariesDialog)
  • nested dialogs (aka dialog inside dialog)
  • handling esc button
  • handling back/forward browser navigation
  • waiting for "modal result" in mutations and other "async" modes.

Key Features

  • Stack-based dialog management for nested dialogs
  • URL synchronization for deep-linking and browser history support
  • Animated transitions between dialogs with forward/backward animations
  • TypeScript support with proper generic typing for dialog results
  • Comprehensive test coverage for all dialog functionality
  • Support for customizing dialog size, close behavior, and other options

Screen Recording 2025-12-06 at 5.33.55 PM.mov (uploaded via Graphite)

Overview

The dialog system provides an imperative, async/await API for managing dialogs with full router integration. Dialogs are rendered in a stack, allowing nested dialogs with automatic back navigation.

Basic Usage

Opening a Custom Dialog

Any component can be a dialog. It receives close and cancel props:

import { useDialog } from "@/hooks/useDialog";
import { DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import type { DialogProps } from "@/providers/DialogProvider/types";

interface MyDialogResult {
  message: string;
}

function MyDialog({ close, cancel }: DialogProps<MyDialogResult>) {
  const [message, setMessage] = useState("Hello!");

  return (
    <>
      <DialogTitle>My Dialog</DialogTitle>
      <DialogDescription>Enter a message</DialogDescription>

      <Input value={message} onChange={(e) => setMessage(e.target.value)} />

      <DialogFooter>
        <Button variant="outline" onClick={cancel}>
          Cancel
        </Button>
        <Button onClick={() => close({ message })}>Confirm</Button>
      </DialogFooter>
    </>
  );
}

// Using the dialog
function MyComponent() {
  const { open: openDialog } = useDialog();

  const handleOpen = async () => {
    try {
      const result = await openDialog<MyDialogResult>({
        component: MyDialog,
        routeKey: "my-dialog", // Optional: enables router integration
      });

      console.log("User submitted:", result.message);
    } catch (error) {
      // Dialog was cancelled (ESC, overlay click, or cancel button)
      console.log("Dialog cancelled");
    }
  };

  return <Button onClick={handleOpen}>Open Dialog</Button>;
}

Handling Cancellation with DialogCancelledError

For more precise error handling, use DialogCancelledError:

import { DialogCancelledError } from "@/providers/DialogProvider/types";

const handleOpen = async () => {
  try {
    const result = await openDialog<MyDialogResult>({
      component: MyDialog,
      routeKey: "my-dialog",
    });
    // Handle success
  } catch (error) {
    if (error instanceof DialogCancelledError) {
      // User cancelled - this is expected, not an error
      return;
    }
    // Handle actual errors
    throw error;
  }
};

Dialog with Additional Props

For dialogs that need extra props beyond close and cancel:

import type { DialogProps } from "@/providers/DialogProvider/types";

interface ExtraProps {
  title: string;
  description: string;
}

function ConfirmationDialog({
  close,
  cancel,
  title,
  description,
}: DialogProps<{ confirmed: boolean }> & ExtraProps) {
  return (
    <>
      <DialogTitle>{title}</DialogTitle>
      <DialogDescription>{description}</DialogDescription>

      <DialogFooter>
        <Button variant="outline" onClick={() => close({ confirmed: false })}>
          Cancel
        </Button>
        <Button onClick={() => close({ confirmed: true })}>Confirm</Button>
      </DialogFooter>
    </>
  );
}

// Using the dialog with extra props
const result = await openDialog<
  { confirmed: boolean },
  { title: string; description: string }
>({
  component: ConfirmationDialog,
  props: {
    title: "Delete Item?",
    description: "This action cannot be undone.",
  },
  routeKey: "confirm-delete",
});

if (result.confirmed) {
  // Perform delete
}

Creating a Reusable Confirmation Hook

You can create custom hooks for common dialog patterns:

import { useCallback } from "react";
import { useDialog } from "@/hooks/useDialog";
import { DialogCancelledError } from "@/providers/DialogProvider/types";

interface ConfirmationResult {
  confirmed: boolean;
}

export function useConfirmationDialog() {
  const { open: openDialog } = useDialog();

  return useCallback(
    async (options: { title: string; description: string }) => {
      return await openDialog<ConfirmationResult, typeof options>({
        component: ConfirmationDialog,
        props: options,
        routeKey: "confirmation",
      });
    },
    [openDialog],
  );
}

// Usage
const confirmDialog = useConfirmationDialog();

const result = await confirmDialog({
  title: "Are you sure?",
  description: "This cannot be undone.",
}).catch((error) => {
  if (error instanceof DialogCancelledError) {
    return null;
  }
  throw error;
});

if (result?.confirmed) {
  // User confirmed
}

Router Integration

When you provide a routeKey, the dialog URL will be synchronized:

  • Opening a dialog updates the URL: ?dialog=your-route-key&dialogId=abc123
  • Browser back button closes the dialog (triggers cancellation)
  • Multiple dialogs stack in URL history

Dialog Stacking

Dialogs can be opened from within other dialogs, creating a stack:

  • When multiple dialogs are open, a "Back" button appears to navigate to the previous dialog
  • Each dialog maintains its own promise that resolves/rejects independently
  • The topmost dialog is always displayed
  • Back navigation cancels the current dialog (rejects with DialogCancelledError)

Using with TanStack Query (useMutation)

The dialog system works well with TanStack Query mutations:

import { useMutation } from "@tanstack/react-query";
import { useDialog } from "@/hooks/useDialog";
import { DialogCancelledError } from "@/providers/DialogProvider/types";

function MyComponent() {
  const { open: openDialog } = useDialog();

  const { mutate: showDialog, isPending } = useMutation({
    mutationFn: async () => {
      const result = await openDialog<{ message: string }>({
        component: MyDialog,
        routeKey: "my-dialog",
      });
      return result;
    },
    onSuccess: (result) => {
      // Handle successful dialog completion
      console.log("Got result:", result.message);
    },
    onError: (error) => {
      if (error instanceof DialogCancelledError) {
        // User cancelled - ignore
        return;
      }
      // Handle actual errors
      console.error("Dialog error:", error);
    },
  });

  return (
    <Button onClick={() => showDialog()} disabled={isPending}>
      {isPending ? <Spinner /> : null}
      Open Dialog
    </Button>
  );
}

Dialog Options

Option Type Default Description
component ComponentType<DialogProps<T, TProps>> required The component to render as dialog content
props TProps {} Additional props to pass to the component
routeKey string undefined Enables router integration with this key
size 'sm' | 'md' | 'lg' | 'xl' | 'full' 'md' Dialog size preset
closeOnEsc boolean true Allow ESC key to close dialog
closeOnOverlayClick boolean true Allow clicking overlay to close

Size Classes

  • sm: max-w-sm (~384px)
  • md: max-w-lg (~512px)
  • lg: max-w-2xl (~672px)
  • xl: max-w-4xl (~896px)
  • full: max-w-[90vw]

API Reference

useDialog()

Main hook for working with dialogs:

const { open, close, closeAll } = useDialog();

// Open a dialog (returns a promise)
const result = await open<TResult, TProps>(config: DialogConfig<TResult, TProps>);

// Close a specific dialog programmatically with a result
close(dialogId: string, result?: any);

// Close all open dialogs (all promises will reject)
closeAll();

DialogProps<T, TProps>

Props passed to every dialog component:

type DialogProps<T = any, TProps = {}> = {
  close: (result?: T) => void; // Resolve the dialog promise with a result
  cancel: () => void; // Reject the dialog promise with DialogCancelledError
} & TProps; // Additional custom props

DialogCancelledError

Error thrown when a dialog is cancelled:

import { DialogCancelledError } from "@/providers/DialogProvider/types";

// Useful for distinguishing cancellation from actual errors
if (error instanceof DialogCancelledError) {
  // User cancelled the dialog
}

Migration from Old System

Before (Old System)

const [open, setOpen] = useState(false);

<Dialog open={open} onOpenChange={setOpen}>
  <DialogContent>
    <MyComponent
      onSave={(data) => {
        handleSave(data);
        setOpen(false);
      }}
      onCancel={() => setOpen(false)}
    />
  </DialogContent>
</Dialog>;

After (New System)

const { open: openDialog } = useDialog();

try {
  const data = await openDialog({
    component: MyComponent,
    routeKey: "my-dialog",
  });
  handleSave(data);
} catch {
  // Cancelled
}

Copy link
Collaborator Author

maxy-shpfy commented Dec 7, 2025

@maxy-shpfy maxy-shpfy force-pushed the 12-06-poc_create_dialog_stackable_system_compatible_with_router_and_promises branch from ec859e0 to 4d89784 Compare December 7, 2025 23:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants