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
14 changes: 10 additions & 4 deletions src/shared/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,18 @@ const TOASTER_SELECTOR = '[data-slot="toaster"]';
const isFromToaster = (event: Event) =>
event.target instanceof Element && event.target.closest(TOASTER_SELECTOR) !== null;

// Dialogs take ~70% of the viewport from the `sm` breakpoint up, so settings-style modals
// (API key permissions, provider config, routing editors) stop cramming their forms into a
// 28rem column. Below `sm` they keep the kit's `calc(100vw - 3rem)` width with its 30rem cap
// lifted (`max-w-none`), i.e. full width minus the mobile gutter. Only `sm` keeps a fixed
// narrow width — it is the confirm/alert dialog size.
const WIDE = "max-w-none sm:w-[70vw] sm:max-w-[70vw]";
const SIZES = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
full: "max-w-4xl",
md: WIDE,
lg: WIDE,
xl: WIDE,
full: WIDE,
} as const;

// The element focused right now, unless it is <body> (nothing to return to).
Expand Down
67 changes: 67 additions & 0 deletions tests/unit/ui/modal-width-70vw.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";

vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));

const { default: Modal } = await import("../../../src/shared/components/Modal");

const cleanups: Array<() => void> = [];

function renderModal(props: Partial<React.ComponentProps<typeof Modal>> = {}) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
act(() =>
root.render(
<Modal isOpen onClose={() => {}} title="Width" {...props}>
body
</Modal>
)
);
cleanups.push(() => {
act(() => root.unmount());
container.remove();
});
// The kit Dialog (Radix) portals its content to <body>, outside `container`.
const dialogs = document.body.querySelectorAll<HTMLElement>('[role="dialog"]');
return dialogs[dialogs.length - 1];
}

afterEach(() => {
while (cleanups.length) cleanups.pop()!();
});

describe("Modal width", () => {
it("defaults (md) to ~70% of the viewport from the sm breakpoint, full width below", () => {
const dialog = renderModal();
// Below `sm` the kit Dialog's own `calc(100vw - 3rem)` width applies (mobile gutter);
// `max-w-none` lifts its 30rem cap so the dialog spans the full width minus the gutter.
expect(dialog.className).toContain("max-w-none");
expect(dialog.className).toContain("sm:w-[70vw]");
expect(dialog.className).toContain("sm:max-w-[70vw]");
expect(dialog.className).not.toContain("max-w-md");
});

it("uses 70vw for every non-sm size", () => {
for (const size of ["lg", "xl", "full"] as const) {
const dialog = renderModal({ size });
expect(dialog.className, size).toContain("sm:w-[70vw]");
expect(dialog.className, size).toContain("sm:max-w-[70vw]");
expect(dialog.className, size).not.toMatch(/(^|\s)max-w-(md|lg|xl|4xl)(\s|$)/);
cleanups.pop()!();
}
});

it("keeps the sm (confirm/alert) dialog narrow", () => {
const dialog = renderModal({ size: "sm" });
expect(dialog.className).toContain("max-w-sm");
expect(dialog.className).not.toContain("70vw");
});
});
Loading