refactor(builder): extract shared Modal component for the three editor modals

New src/ui/Modal.tsx ({ open, onClose, title?, children, width?,
closeOnEscape?, closeOnBackdropClick?, backdropStyle?, backdropProps? })
owns the backdrop, Escape-to-close, backdrop-click-to-close, and a new
body-scroll-lock while open. Adopted by TemplateModal, HeadCodeModal, and
SitesmithModal; each keeps its own panel styling/header/footer as children
since those differ per modal.

- TemplateModal: Escape/backdrop-click still close the confirm-template
  sub-dialog first via a wrapped onClose passed to Modal; the header's X
  button keeps using the raw onClose prop (always fully closes), matching
  prior asymmetric behavior.
- HeadCodeModal: straightforward adoption, no prior custom close logic.
- SitesmithModal: previously had no `open` prop, no Escape-to-close, and no
  backdrop-click-to-close. Preserved via open (always mounted-open by its
  parent already), closeOnEscape={false}, closeOnBackdropClick={false}; role
  and aria-modal are passed through via backdropProps to keep them on the
  same backdrop element as before.

Body scroll-lock while a modal is open is a small new addition (requested by
the task) applied uniformly; it has no visible effect since each modal's
opaque fixed-position backdrop already fully covers the viewport.

Added a light Modal.test.tsx (renders children, Escape/backdrop-click close
behavior, scroll-lock) using the existing react-dom/client + act harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 15:22:05 -07:00
parent 8f51b5144a
commit b88b242b6a
5 changed files with 216 additions and 73 deletions
+10 -3
View File
@@ -11,6 +11,7 @@ import { MessageList } from './MessageList';
import { ChatInput } from './ChatInput';
import { WorkingIndicator } from './WorkingIndicator';
import { SitesmithResponse } from '../../types/sitesmith';
import { Modal } from '../../ui/Modal';
interface Props {
onClose: () => void;
@@ -39,7 +40,6 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
const canChat = summary && (summary.status === 'OK_BONUS' || summary.status === 'OK_MONTHLY');
const overlay: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.8)', zIndex: 9000, display: 'flex', alignItems: 'center', justifyContent: 'center' };
const panel: React.CSSProperties = { background: '#0f0f17', border: '1px solid #27272a', borderRadius: 12, width: 'min(720px, 90vw)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' };
const header: React.CSSProperties = { display: 'flex', alignItems: 'center', padding: '14px 18px', borderBottom: '1px solid #27272a', gap: 8 };
const body: React.CSSProperties = { flex: 1, padding: '14px 18px', overflowY: 'auto', display: 'flex', flexDirection: 'column' };
@@ -93,7 +93,14 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
};
return (
<div role="dialog" aria-modal="true" style={overlay}>
<Modal
open
onClose={onClose}
closeOnEscape={false}
closeOnBackdropClick={false}
backdropStyle={{ backgroundColor: 'rgba(0,0,0,0.8)', zIndex: 9000 }}
backdropProps={{ role: 'dialog', 'aria-modal': true }}
>
<div style={panel}>
<div style={header}>
<div style={{ fontWeight: 600, color: '#fff' }}> Sitesmith</div>
@@ -166,6 +173,6 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
onCancel={() => setPendingReplace(null)}
/>
</div>
</div>
</Modal>
);
};
+4 -27
View File
@@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
import React from 'react';
import { useSiteDesign } from '../../state/SiteDesignContext';
import { Modal } from '../../ui/Modal';
interface HeadCodeModalProps {
open: boolean;
@@ -9,19 +10,8 @@ interface HeadCodeModalProps {
export const HeadCodeModal: React.FC<HeadCodeModalProps> = ({ open, onClose }) => {
const { design, updateDesign } = useSiteDesign();
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open, onClose]);
if (!open) return null;
return (
<div style={backdropStyle} onClick={onClose}>
<Modal open={open} onClose={onClose}>
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div style={modalHeaderStyle}>
@@ -92,25 +82,12 @@ export const HeadCodeModal: React.FC<HeadCodeModalProps> = ({ open, onClose }) =
</button>
</div>
</div>
</div>
</Modal>
);
};
/* ---------- Styles ---------- */
const backdropStyle: React.CSSProperties = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.65)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000,
};
const modalStyle: React.CSSProperties = {
width: '90vw',
maxWidth: 700,
+12 -43
View File
@@ -10,6 +10,7 @@ import {
} from '../../templates';
import { componentResolver } from '../../components/resolver';
import { clickableProps } from '../../utils/a11y';
import { Modal } from '../../ui/Modal';
// ---------------------------------------------------------------------------
// Types
@@ -60,18 +61,15 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
};
}, []);
// Close on Escape key
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (confirmTemplate) setConfirmTemplate(null);
else onClose();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open, confirmTemplate, onClose]);
// Escape / backdrop-click close the confirmation dialog first if it's open,
// otherwise close the modal. Passed to <Modal> below, which handles the
// Escape listener and the backdrop-click detection itself; the header's own
// close button (X) below still uses the raw `onClose` prop directly, so it
// always fully closes the modal even mid-confirmation (unchanged behavior).
const handleModalClose = useCallback(() => {
if (confirmTemplate) setConfirmTemplate(null);
else onClose();
}, [confirmTemplate, onClose]);
// Filter templates by category
const filtered = useMemo(() => {
@@ -236,24 +234,8 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
}
}, [confirmTemplate, applyDesign, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, wait, onClose]);
// Close on backdrop click
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
if (confirmTemplate) {
setConfirmTemplate(null);
} else {
onClose();
}
}
},
[confirmTemplate, onClose],
);
if (!open) return null;
return (
<div style={backdropStyle} onClick={handleBackdropClick}>
<Modal open={open} onClose={handleModalClose}>
<div style={modalStyle}>
{/* Header */}
<div style={modalHeaderStyle}>
@@ -377,7 +359,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
</div>
)}
</div>
</div>
</Modal>
);
};
@@ -473,19 +455,6 @@ const TemplateCard: React.FC<{
// Styles
// ---------------------------------------------------------------------------
const backdropStyle: React.CSSProperties = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.65)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000,
};
const modalStyle: React.CSSProperties = {
width: '90vw',
maxWidth: 900,