Files
commitea/apps/desktop/src/renderer/src/components/ui/dialog.tsx
Croissant Le Doux 635025113c feat(desktop): port the 15 design-system primitives (#14)
Verbatim port of the handoff primitives into components/ui/ — Icon,
Button, IconButton, Badge, Tag, Card, Tabs (core); Input, Select,
Checkbox, Radio, Switch (forms); Dialog, Toast, Tooltip (feedback).
Each keeps its injected token-referencing CSS byte-for-byte; the
handoff .d.ts contracts become the exported prop interfaces. Barrel at
components/ui/index.ts.

Adds a PrimitivesGallery (app root for now; real shell is P3-2) that
exercises every primitive with a light/dark toggle. Smoke suite asserts
the gallery, section coverage, theme flip, and dialog open/Escape;
screenshots captured for both themes. typecheck + 5 e2e green.

Closes P3-1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:12:00 -04:00

83 lines
3.0 KiB
TypeScript

import React from 'react'
import { IconButton } from './icon-button.js'
/**
* Modal dialog — Caslon title over a double stationery rule; used for propose-approve
* moments and destructive confirms.
*/
export interface DialogProps {
open: boolean;
onClose?: () => void;
title: React.ReactNode;
/** Right-aligned action row (Buttons) */
footer?: React.ReactNode;
children?: React.ReactNode;
style?: React.CSSProperties;
}
const CSS = `
.ct-dialog-scrim {
position: fixed; inset: 0;
background: rgba(32, 38, 29, 0.4);
display: flex; align-items: center; justify-content: center;
z-index: 100;
animation: ct-dialog-fade var(--duration-base) var(--ease-out);
}
.ct-dialog {
background: var(--surface-card);
border: 1px solid var(--border-hairline);
border-radius: var(--radius-3);
box-shadow: var(--shadow-3);
width: min(480px, calc(100vw - 48px));
max-height: calc(100vh - 96px);
display: flex; flex-direction: column;
animation: ct-dialog-rise var(--duration-base) var(--ease-out);
}
.ct-dialog__header {
display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
padding: 20px 20px 12px;
border-bottom: 3px double var(--border-strong);
margin: 0 20px; padding-left: 0; padding-right: 0;
}
.ct-dialog__title { font: var(--text-title); color: var(--ink-1); margin: 0; }
.ct-dialog__body { padding: 16px 20px; overflow-y: auto; font: var(--text-body); color: var(--ink-1); }
.ct-dialog__footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 12px 20px 20px;
}
@keyframes ct-dialog-fade { from { opacity: 0; } }
@keyframes ct-dialog-rise { from { opacity: 0; transform: translateY(8px); } }
@media (prefers-reduced-motion: reduce) {
.ct-dialog-scrim, .ct-dialog { animation: none; }
}
`;
(function inject() {
if (typeof document !== 'undefined' && !document.getElementById('ct-dialog-css')) {
const s = document.createElement('style'); s.id = 'ct-dialog-css'; s.textContent = CSS;
document.head.appendChild(s);
}
})();
export function Dialog({ open, onClose, title, footer, children, style }: DialogProps) {
React.useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && onClose) onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="ct-dialog-scrim" onClick={(e: React.MouseEvent) => { if (e.target === e.currentTarget && onClose) onClose(); }}>
<div className="ct-dialog" role="dialog" aria-modal="true" style={style}>
<header className="ct-dialog__header">
<h2 className="ct-dialog__title">{title}</h2>
{onClose ? <IconButton icon="x" label="Close" size="sm" onClick={onClose} /> : null}
</header>
<div className="ct-dialog__body">{children}</div>
{footer ? <footer className="ct-dialog__footer">{footer}</footer> : null}
</div>
</div>
);
}