mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 23:06:49 +00:00
46b3d53274
* refactor(frontend): extend Modal size system with wide variant Adds 'wide' (max-w-5xl w-[95vw]) to ModalSize for dialogs that render Monaco DiffEditor side-by-side, which need ~1024px to display both panels clearly. The existing 'xl' cap at max-w-xl (576px) is too narrow for that use case. * refactor(frontend): migrate ComposeDiffPreviewDialog to Modal chrome (D-5) Replaces raw Dialog/DialogHeader/DialogFooter with Modal size="wide", ModalHeader, and ModalFooter. Kicker carries the stack name and file type context; title is the file name (accessible dialog name for tests). Footer hint reuses the existing "ON DISK → UNSAVED" label via the hint prop. * refactor(frontend): migrate GitSourceDiffDialog to Modal chrome (D-5) Replaces Dialog + nested AlertDialog with Modal size="wide", ModalHeader, ModalFooter, and ConfirmModal. Kicker is "GIT · PULL PREVIEW"; title is the stack name. Short SHA moves to the sr-only description. The "Deploy after apply" checkbox in the footer hint slot uses normal-case and tracking-normal on the Label to prevent KICKER_CLASS uppercase/tracking from being inherited. The overwrite confirmation uses ConfirmModal with variant="destructive" (rose rail) since it replaces local file content.
254 lines
6.6 KiB
TypeScript
254 lines
6.6 KiB
TypeScript
import * as React from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
import { buttonVariants } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog';
|
|
|
|
const KICKER_CLASS = 'font-mono text-[10px] uppercase tracking-[0.22em]';
|
|
|
|
type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'wide';
|
|
|
|
const SIZE_CLASS: Record<ModalSize, string> = {
|
|
sm: 'max-w-sm',
|
|
md: 'max-w-md',
|
|
lg: 'max-w-lg',
|
|
xl: 'max-w-xl w-[95vw]',
|
|
wide: 'max-w-5xl w-[95vw]',
|
|
};
|
|
|
|
interface ModalProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
children: React.ReactNode;
|
|
size?: ModalSize;
|
|
className?: string;
|
|
}
|
|
|
|
export function Modal({ open, onOpenChange, children, size = 'md', className }: ModalProps) {
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
className={cn(
|
|
'p-0 gap-0 overflow-hidden grid-cols-1',
|
|
SIZE_CLASS[size],
|
|
className,
|
|
)}
|
|
>
|
|
{children}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
interface ModalHeaderBaseProps {
|
|
kicker: string;
|
|
title: React.ReactNode;
|
|
description?: string;
|
|
}
|
|
|
|
type HeaderVariant = 'default' | 'destructive';
|
|
|
|
const HEADER_VARIANT: Record<HeaderVariant, { rail: string; kicker: string }> = {
|
|
default: { rail: 'bg-brand', kicker: 'text-stat-subtitle' },
|
|
destructive: { rail: 'bg-destructive', kicker: 'text-destructive' },
|
|
};
|
|
|
|
interface HeaderShellProps extends ModalHeaderBaseProps {
|
|
variant: HeaderVariant;
|
|
TitleComponent: React.ElementType;
|
|
DescriptionComponent: React.ElementType;
|
|
}
|
|
|
|
function HeaderShell({
|
|
kicker,
|
|
title,
|
|
description,
|
|
variant,
|
|
TitleComponent,
|
|
DescriptionComponent,
|
|
}: HeaderShellProps) {
|
|
const v = HEADER_VARIANT[variant];
|
|
return (
|
|
<div className="relative border-b border-card-border/60 px-6 pt-6 pb-4 pr-12">
|
|
<span aria-hidden className={cn('absolute inset-y-0 left-0 w-[3px]', v.rail)} />
|
|
<div className={cn(KICKER_CLASS, v.kicker)}>
|
|
{kicker}
|
|
</div>
|
|
<TitleComponent className="mt-1 font-display text-[1.75rem] italic leading-tight text-stat-value">
|
|
{title}
|
|
</TitleComponent>
|
|
<DescriptionComponent className="sr-only">
|
|
{description ?? (typeof title === 'string' ? title : kicker)}
|
|
</DescriptionComponent>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ModalHeader(props: ModalHeaderBaseProps) {
|
|
return (
|
|
<HeaderShell
|
|
{...props}
|
|
variant="default"
|
|
TitleComponent={DialogTitle}
|
|
DescriptionComponent={DialogDescription}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function ModalDestructiveHeader(props: ModalHeaderBaseProps) {
|
|
return (
|
|
<HeaderShell
|
|
{...props}
|
|
variant="destructive"
|
|
TitleComponent={DialogTitle}
|
|
DescriptionComponent={DialogDescription}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ConfirmHeader(props: ModalHeaderBaseProps) {
|
|
return (
|
|
<HeaderShell
|
|
{...props}
|
|
variant="default"
|
|
TitleComponent={AlertDialogTitle}
|
|
DescriptionComponent={AlertDialogDescription}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ConfirmDestructiveHeader(props: ModalHeaderBaseProps) {
|
|
return (
|
|
<HeaderShell
|
|
{...props}
|
|
variant="destructive"
|
|
TitleComponent={AlertDialogTitle}
|
|
DescriptionComponent={AlertDialogDescription}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function ModalBody({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
return <div className={cn('px-6 py-5 space-y-4', className)} {...props} />;
|
|
}
|
|
|
|
interface ModalFooterProps {
|
|
primary: React.ReactNode;
|
|
secondary?: React.ReactNode;
|
|
hint?: React.ReactNode;
|
|
hintAccent?: React.ReactNode;
|
|
}
|
|
|
|
export function ModalFooter({ primary, secondary, hint, hintAccent }: ModalFooterProps) {
|
|
return (
|
|
<div className="flex items-center justify-between gap-4 border-t border-card-border/60 px-6 py-4">
|
|
<div className={cn(KICKER_CLASS, 'text-stat-subtitle')}>
|
|
{hint}
|
|
{hintAccent !== undefined && (
|
|
<span className="ml-1.5 rounded-sm border border-card-border bg-card px-1.5 py-0.5 text-stat-value">
|
|
{hintAccent}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{secondary}
|
|
{primary}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type ConfirmSize = 'sm' | 'md';
|
|
|
|
interface ConfirmModalProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
variant?: 'default' | 'destructive';
|
|
size?: ConfirmSize;
|
|
kicker: string;
|
|
title: React.ReactNode;
|
|
description?: string;
|
|
hint?: React.ReactNode;
|
|
confirmLabel: React.ReactNode;
|
|
cancelLabel?: React.ReactNode;
|
|
confirming?: boolean;
|
|
onConfirm: () => void | Promise<void>;
|
|
onCancel?: () => void;
|
|
children?: React.ReactNode;
|
|
}
|
|
|
|
export function ConfirmModal({
|
|
open,
|
|
onOpenChange,
|
|
variant = 'default',
|
|
size = 'sm',
|
|
kicker,
|
|
title,
|
|
description,
|
|
hint,
|
|
confirmLabel,
|
|
cancelLabel = 'Cancel',
|
|
confirming = false,
|
|
onConfirm,
|
|
onCancel,
|
|
children,
|
|
}: ConfirmModalProps) {
|
|
const Header = variant === 'destructive' ? ConfirmDestructiveHeader : ConfirmHeader;
|
|
const cancelClass = buttonVariants({ variant: 'outline', size: 'sm' });
|
|
const actionClass = buttonVariants({
|
|
variant: variant === 'destructive' ? 'destructive' : 'default',
|
|
size: 'sm',
|
|
});
|
|
|
|
return (
|
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
|
<AlertDialogContent className={cn('p-0 gap-0 overflow-hidden border-card-border/60', SIZE_CLASS[size])}>
|
|
<Header kicker={kicker} title={title} description={description} />
|
|
{children !== undefined && <ModalBody>{children}</ModalBody>}
|
|
<ModalFooter
|
|
hint={hint}
|
|
secondary={
|
|
<AlertDialogCancel
|
|
className={cancelClass}
|
|
disabled={confirming}
|
|
onClick={onCancel}
|
|
>
|
|
{cancelLabel}
|
|
</AlertDialogCancel>
|
|
}
|
|
primary={
|
|
<AlertDialogAction
|
|
className={actionClass}
|
|
disabled={confirming}
|
|
onClick={(e) => {
|
|
const result = onConfirm();
|
|
// Async confirms keep the dialog open so the caller can render
|
|
// `confirming` state and close via onOpenChange when work completes.
|
|
// Sync confirms let Radix auto-close.
|
|
if (result instanceof Promise) {
|
|
e.preventDefault();
|
|
void result;
|
|
}
|
|
}}
|
|
>
|
|
{confirmLabel}
|
|
</AlertDialogAction>
|
|
}
|
|
/>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
);
|
|
}
|