'use client'; import { useId, type ReactNode } from 'react'; import { Button } from './button'; import { ModalV2, ModalV2Content, ModalV2Footer, ModalV2Header, ModalV2Title, ModalV2TwoColumn, type ModalV2Size, } from './modal-v2'; import { Skeleton } from './skeleton'; import { UnsavedChangesChip, useGuardedClose } from './modal-guarded-close'; /** * Label widths for the loading placeholder. Uneven on purpose — a column of * identical bars reads as a rendering artifact rather than a form arriving. */ const SKELETON_FIELD_WIDTHS = ['w-24', 'w-32', 'w-20', 'w-40', 'w-28', 'w-36']; /** Layout: pass `children` for one column, or `leftColumn`+`rightColumn` for two. */ type AdminFormModalLayout = | { children: ReactNode; leftColumn?: never; rightColumn?: never } | { leftColumn: ReactNode; rightColumn: ReactNode; children?: never }; type AdminFormModalBaseProps = { title: ReactNode; /** Secondary line under the title (what this record is, why it matters). */ subtitle?: ReactNode; isOpen: boolean; onClose: () => void; onSave: () => void; saveLabel: string; canSave?: boolean; saving?: boolean; /** * The entity is still being fetched — the modal was opened from a URL on a * cold load, so there is no list row to borrow from. Content is hidden behind * a placeholder and Save is held until the form has real values to submit. */ loading?: boolean; error?: string | null; /** * Placeholder to show while `loading`, for modals whose body is NOT a field * grid. The default bars imply a form; a modal that resolves into a list or a * table would visibly change shape when the data lands. */ loadingContent?: ReactNode; /** Secondary actions pinned to the footer's LEFT edge (Delete, Preview…). */ footerExtras?: ReactNode; /** * Additional PRIMARY actions, rendered next to Save (Publish, Mark * Complete…). A second primary action put in `footerExtras` lands at the far * left, a whole modal-width away from the button it belongs beside. */ footerActions?: ReactNode; /** Status text between the extras and the buttons (e.g. "3 rules selected"). */ footerStatus?: ReactNode; /** Unsaved edits exist: shows the footer chip and guards close with a confirm. */ dirty?: boolean; /** Names WHAT is dirty (chip tooltip) — turns "why is this dirty?!" into a hover. */ dirtyDetail?: string; /** * Panel width. Two-column layouts are always `wide` — passing * `leftColumn`/`rightColumn` upgrades this automatically. */ size?: ModalV2Size; contentClassName?: string; }; /** * THE admin form-modal shell: header, scrolling content (one column or two), an * error line, and a Cancel/Save footer with optional extra actions. * * Every admin form modal renders this rather than reassembling `ModalV2` + * header + footer by hand. Three copies of the same shell had already been * written in one feature (repo form, rule form, bindings), which is how the * Save button ends up saying "Saving…" in one place and spinning silently in * another. * * Width is a `size` variant, never a call-site `max-w-*` — the widths had * drifted across seven values (`max-w-md` through `max-w-[1400px]`) for modals * doing the same job. */ export function AdminFormModal({ title, subtitle, isOpen, onClose, onSave, saveLabel, canSave = true, saving = false, loading = false, error = null, loadingContent, footerExtras, footerActions, footerStatus, dirty = false, dirtyDetail, size, contentClassName = 'space-y-[var(--spacing-system-lf)]', children, leftColumn, rightColumn, }: AdminFormModalBaseProps & AdminFormModalLayout) { const twoColumn = leftColumn !== undefined && rightColumn !== undefined; const effectiveSize: ModalV2Size = twoColumn ? 'wide' : (size ?? 'medium'); /** Placeholder column count follows the panel width so the skeleton occupies * the same shape the real content will. */ const skeletonColumns = effectiveSize === 'wide' ? 2 : 1; const formId = useId(); // The lib's unified dirty-state contract: closing a dirty settings form must // never silently discard the edits — `useGuardedClose` asks the house // confirm (ModalV2, never window.confirm) before running `onClose`. const { guardedClose, dialog } = useGuardedClose(dirty, onClose, { title: 'Discard unsaved changes?', body: 'Your edits have not been saved. Close and discard them?', }); // NO field autofocus here. `ModalV2` owns focus now (focus-in on open, focus // containment while topmost, Tab trap, restore to the opener), so the shell // reaching in to focus "the first field" only fought it — and picked widgets // that ACT on focus: the author selector's combobox opened its popover on // every `?edit=` load, and on a cold URL load the programmatic focus had // no preceding pointer event, so the UA painted a focus ring too. Focus lands // on the dialog itself, which is the correct dialog behavior; the first Tab // reaches the first field. if (!isOpen) return null; return ( {title} {/* A
, not a

. `subtitle` is a ReactNode and callers legitimately pass badges and chips — `Badge` renders a

, which is invalid inside

. The browser then re-parents it during hydration and React throws a mismatch (seen on role-baselines, whose subtitle carries a weight Badge). Typography is unchanged. */} {subtitle && (

{subtitle}
)} {/* A real
: Enter in a text input previously did nothing in every admin modal routed through this shell. */} { e.preventDefault(); if (canSave && !saving) onSave(); }} // Some modals contain a FILTER input, not a form field. Enter there must // not ship the save — in the bindings modal that would commit an // absolute-state rule-set replacement mid-search. onKeyDown={(e) => { if ( e.key === 'Enter' && (e.target as HTMLElement)?.dataset?.noSubmitOnEnter === 'true' ) { e.preventDefault(); } }} > {loading ? ( // NOT `contentClassName` — that describes the real content's layout // (master-detail modals pass `flex`, which turns the placeholder into // a shrink-to-fit flex item pinned to the left of a 1400px panel). // The placeholder owns its own layout and mirrors the column count so // the modal doesn't visibly re-flow when the entity lands. {loadingContent ? (
{loadingContent}
) : (
Loading… {Array.from({ length: skeletonColumns }, (_, col) => ( ))}
)}
) : twoColumn ? ( ) : ( {children} )} {/* Outside the scrolling region(s): in a two-column modal an error belongs to the save, not to either column, and must stay visible wherever each column happens to be scrolled to. */} {error && (

{error}

)}
{footerExtras}
{footerStatus} {dirty && } {footerActions}
{dialog} ); }