import * as React from "react" import { cn } from "../../lib/utils" import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "./dialog" import { useOverlayPresentation } from "../../lib/overlayPresentation" /** * Drop-in replacement for `@wordpress/components`' `` that portals INSIDE the Akashic root. * * Why * WP's `` `createPortal`s to `document.body` and exposes **no prop to change the target**, so * everything it renders lands OUTSIDE `#akashic-health-records-patient-portal-root`. Two consequences: * * 1. Our scoped CSS never reaches it. Every rule is `#akashic-...-root....`, and the design tokens * (`--primary`, `--danger`, `--success`, `--muted`) are declared on the root rather than `:root` * - so `btn btn-*` and `var(--...)` are undefined inside a WP modal. * 2. **The direction that actually matters: the host theme's and other plugins' CSS DOES reach it.** * A body-level node sits in the theme's cascade with none of our containment. That is the bleed * this shim exists to stop (AHR-64 / AHR-217). * * `ui/dialog` already portals through `getAkashicPortalContainer()` `#akashic-overlay-root`, which is * rendered inside the app tree (`src/App.tsx`), so routing modals through it fixes both directions. * * How to use * Change the import only; leave the JSX alone. Drop `Modal` from the `@wordpress/components` named * import (keeping the others) and import `Modal` from this file instead. The JSX - `title`, * `onRequestClose`, `size`, `style`, `headerActions` - is unchanged. * * (Deliberately described rather than shown: a literal example import here got rewritten by the * codemod that performed the original 72-file swap, because the script could not tell an example * inside a comment from real code.) * * This is a COMPATIBILITY SHIM, not the destination - it stops the bleed everywhere today while the * real conversion happens file by file. New code must use `ui/dialog` directly. * * **How this shim goes away (Spencer, 2026-07-29):** *"We only want to replace them as we come across * them right now"* - converting all 72 call sites at once is its own project, deliberately not bundled * into unrelated work. So: **when you open one of these files for any other reason, convert its modal to * real `ui/dialog` JSX as part of that pass and drop the shim import.** Frame `Dialog`/`DialogContent`/ * `DialogHeader`/`DialogTitle`, `headerActions` `DialogFooter` (Save bottom-right, no Cancel - the X * dismisses), and the body's WP `Flex`/`Button`/`Notice` Tailwind + `ui/*` + lucide. * **Delete this file when the last import of it is gone** - that import count is the progress bar. * * It deliberately changes the modal FRAME (Radix chrome, not WP chrome) and the focus-trap / * scroll-lock / escape behaviour. That is app-wide and needs a visual + behavioural pass in a browser; * the unit tests pin containment and API compatibility, not appearance. */ export type WpModalSize = "small" | "medium" | "large" | "fill" export interface ModalProps { /** WP renders this as the modal heading. Optional - several call sites omit it. */ title?: React.ReactNode /** WP's close callback. Fired for the X, Esc, and outside-click (unless suppressed below). */ onRequestClose?: () => void /** WP size preset. `large` dominates our call sites (41), then `medium` (6). */ size?: WpModalSize /** Inline overrides - ~55 call sites pass `{ width: '70%' }`-style sizing. Must survive. */ style?: React.CSSProperties /** WP renders these beside the title. 39 call sites rely on it. */ headerActions?: React.ReactNode className?: string /** WP default true. When false, the close X is hidden. */ isDismissible?: boolean /** WP default true. */ shouldCloseOnClickOutside?: boolean /** WP default true. */ shouldCloseOnEsc?: boolean /** WP flag to suppress the header chrome entirely. */ __experimentalHideHeader?: boolean children?: React.ReactNode } /** * WP presets Tailwind max-widths. `sm:` prefixed so narrow viewports still get the * `max-w-[calc(100%-2rem)]` from DialogContent rather than a fixed width. */ const SIZE_CLASS: Record = { small: "sm:max-w-sm", medium: "sm:max-w-lg", large: "sm:max-w-3xl", fill: "sm:max-w-[95vw]", } // A phone sheet spans the screen; a call site's modal width would pin it short and to the left. const withoutWidth = ({ width: _w, minWidth: _min, maxWidth: _max, ...rest }: React.CSSProperties) => rest export function Modal({ title, onRequestClose, size, style, headerActions, className, isDismissible = true, shouldCloseOnClickOutside = true, shouldCloseOnEsc = true, __experimentalHideHeader = false, children, }: ModalProps) { const sheet = useOverlayPresentation() === "sheet" // WP modals are conditionally MOUNTED rather than toggled via an `open` prop, so if this component // is rendering at all it is open. Closing is therefore always delegated to onRequestClose, and the // parent unmounts us - we never hold open state of our own. const handleOpenChange = (open: boolean) => { if (!open) onRequestClose?.() } return ( { if (!shouldCloseOnClickOutside) event.preventDefault() }} onEscapeKeyDown={(event) => { if (!shouldCloseOnEsc) event.preventDefault() }} > {__experimentalHideHeader ? ( // Radix still requires an accessible name even with the visual header suppressed. {title ?? "Dialog"} ) : ( {/* A title is not optional for a11y even when the caller omits one, so fall back to a visually-hidden label rather than letting Radix render a nameless dialog. */} {title ? ( {title} ) : ( Dialog )} {headerActions && ( // pr-8 keeps the actions clear of the absolutely-positioned close X.
{headerActions}
)}
)} {children}
) } export default Modal