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
co-authored by Claude Opus 4.8
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>
);
};