feat(ui): add Modal chrome primitives, migrate file dialogs (#896)

Introduces shared <Modal>, <ModalHeader>, <ModalBody>, <ModalFooter>,
and <ModalDestructiveHeader> primitives that wrap shadcn Dialog and
encapsulate the canonical modal chrome: cyan rail at the left edge,
mono uppercase kicker, italic serif title, footer hint, standardized
button order (secondary outline, primary cyan / destructive).

Migrates NewFolderDialog and DeleteFileConfirm onto the new primitives
as the first proof points. The destructive variant flips the rail and
kicker color to the destructive token without changing the chrome
recipe.
This commit is contained in:
Anso
2026-05-03 13:37:02 -04:00
committed by GitHub
parent 71b7a52def
commit 898ef1a0e8
3 changed files with 178 additions and 59 deletions
@@ -1,13 +1,6 @@
import { useState, useEffect } from 'react';
import { AlertTriangle, Loader2, Trash2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { AlertTriangle, Loader2 } from 'lucide-react';
import { Modal, ModalDestructiveHeader, ModalBody, ModalFooter } from '@/components/ui/modal';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -75,25 +68,30 @@ export function DeleteFileConfirm({
const deleteLabel = notEmpty ? 'Delete all' : 'Delete';
const titleNode = entryName ? (
<>
Delete <em className="font-display italic text-destructive">{entryName}</em>?
</>
) : (
'Delete item?'
);
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="w-4 h-4 text-destructive" strokeWidth={1.5} />
Delete {entryName ? `"${entryName}"` : 'item'}?
</DialogTitle>
<DialogDescription>
{notEmpty ? (
<span className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" strokeWidth={1.5} />
This folder is not empty. Delete everything inside?
</span>
) : (
'This action cannot be undone.'
)}
</DialogDescription>
</DialogHeader>
<Modal open={open} onOpenChange={handleClose} size="sm">
<ModalDestructiveHeader
kicker={`${stackName.toUpperCase()} · DELETE · IRREVERSIBLE`}
title={titleNode}
description={`Confirm deletion of ${entryName || 'item'} from ${stackName}.`}
/>
<ModalBody>
{notEmpty ? (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" strokeWidth={1.5} />
<span>This folder is not empty. Delete everything inside?</span>
</div>
) : (
<p className="text-sm text-muted-foreground">This action cannot be undone.</p>
)}
{isProtected && (
<div className="space-y-2">
@@ -116,8 +114,10 @@ export function DeleteFileConfirm({
</div>
</div>
)}
<DialogFooter>
</ModalBody>
<ModalFooter
hint={isProtected ? 'PROTECTED FILE' : notEmpty ? 'NON-EMPTY FOLDER' : undefined}
secondary={
<Button
variant="outline"
size="sm"
@@ -126,10 +126,12 @@ export function DeleteFileConfirm({
>
Cancel
</Button>
}
primary={
<Button
variant="destructive"
size="sm"
data-testid="delete-confirm-btn"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
disabled={deleting || !protectedOk}
>
@@ -138,8 +140,8 @@ export function DeleteFileConfirm({
)}
{deleteLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
}
/>
</Modal>
);
}
@@ -1,13 +1,6 @@
import { useState } from 'react';
import { FolderPlus, Loader2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Loader2 } from 'lucide-react';
import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/ui/modal';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -73,19 +66,16 @@ export function NewFolderDialog({
if (e.key === 'Enter') void handleCreate();
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FolderPlus className="w-4 h-4" strokeWidth={1.5} />
New Folder
</DialogTitle>
<DialogDescription className="sr-only">
Enter a name for the new folder.
</DialogDescription>
</DialogHeader>
const parentLabel = currentDir || stackName;
return (
<Modal open={open} onOpenChange={handleClose} size="sm">
<ModalHeader
kicker={`${stackName.toUpperCase()} · NEW FOLDER`}
title="New folder"
description="Enter a name for the new folder."
/>
<ModalBody>
<div className="space-y-1.5">
<Label htmlFor="folder-name">Folder name</Label>
<Input
@@ -104,8 +94,11 @@ export function NewFolderDialog({
<p className="text-xs text-destructive">{validationError}</p>
)}
</div>
<DialogFooter>
</ModalBody>
<ModalFooter
hint="PARENT"
hintAccent={parentLabel}
secondary={
<Button
variant="outline"
size="sm"
@@ -114,6 +107,8 @@ export function NewFolderDialog({
>
Cancel
</Button>
}
primary={
<Button
size="sm"
onClick={() => void handleCreate()}
@@ -124,8 +119,8 @@ export function NewFolderDialog({
)}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
}
/>
</Modal>
);
}
+122
View File
@@ -0,0 +1,122 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
const KICKER_CLASS = 'font-mono text-[10px] uppercase tracking-[0.22em]';
type ModalSize = 'sm' | 'md' | 'lg' | 'xl';
const SIZE_CLASS: Record<ModalSize, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl 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',
SIZE_CLASS[size],
className,
)}
>
{children}
</DialogContent>
</Dialog>
);
}
interface ModalHeaderBaseProps {
kicker: string;
title: React.ReactNode;
description?: string;
}
function HeaderShell({
kicker,
title,
description,
railClassName,
kickerClassName,
}: ModalHeaderBaseProps & { railClassName: string; kickerClassName: string }) {
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]', railClassName)} />
<div className={cn(KICKER_CLASS, kickerClassName)}>
{kicker}
</div>
<DialogTitle className="mt-1 font-display text-[1.75rem] italic leading-tight text-stat-value">
{title}
</DialogTitle>
<DialogDescription className="sr-only">
{description ?? (typeof title === 'string' ? title : kicker)}
</DialogDescription>
</div>
);
}
export function ModalHeader(props: ModalHeaderBaseProps) {
return (
<HeaderShell
{...props}
railClassName="bg-brand"
kickerClassName="text-stat-subtitle"
/>
);
}
export function ModalDestructiveHeader(props: ModalHeaderBaseProps) {
return (
<HeaderShell
{...props}
railClassName="bg-destructive"
kickerClassName="text-destructive"
/>
);
}
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>
);
}