diff --git a/src/shared/components/Modal.tsx b/src/shared/components/Modal.tsx index 74d0043a77d1..503c22a81fb3 100644 --- a/src/shared/components/Modal.tsx +++ b/src/shared/components/Modal.tsx @@ -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 (nothing to return to). diff --git a/tests/unit/ui/modal-width-70vw.test.tsx b/tests/unit/ui/modal-width-70vw.test.tsx new file mode 100644 index 000000000000..fd556a191bbb --- /dev/null +++ b/tests/unit/ui/modal-width-70vw.test.tsx @@ -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> = {}) { + 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( + {}} title="Width" {...props}> + body + + ) + ); + cleanups.push(() => { + act(() => root.unmount()); + container.remove(); + }); + // The kit Dialog (Radix) portals its content to , outside `container`. + const dialogs = document.body.querySelectorAll('[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"); + }); +});