Component reuse, Dialog component

This commit is contained in:
2026-06-12 04:11:21 +02:00
parent efa1f11840
commit 2dfd905e31
6 changed files with 226 additions and 40 deletions

View File

@@ -1,4 +1,5 @@
import Button from "./Button"; import Button from "./Button";
import Dialog from "./Dialog";
export type ConfirmDialogTone = "default" | "danger"; export type ConfirmDialogTone = "default" | "danger";
@@ -25,20 +26,28 @@ export default function ConfirmDialog({
onConfirm, onConfirm,
onCancel onCancel
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
if (!open) return null;
return ( return (
<div className="confirm-backdrop" role="presentation" onMouseDown={(event) => { <Dialog
if (event.target === event.currentTarget && !busy) onCancel(); open={open}
}}> title={title}
<section className="confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="confirm-dialog-title" aria-describedby="confirm-dialog-message"> role="alertdialog"
<h2 id="confirm-dialog-title">{title}</h2> className="confirm-dialog"
<p id="confirm-dialog-message">{message}</p> backdropClassName="confirm-backdrop"
<div className="button-row compact-actions confirm-dialog-actions"> headerClassName="confirm-dialog-header"
bodyClassName="confirm-dialog-body"
footerClassName="button-row compact-actions confirm-dialog-actions"
closeOnBackdrop={!busy}
closeDisabled={busy}
showCloseButton={false}
onClose={onCancel}
footer={(
<>
<Button onClick={onCancel} disabled={busy}>{cancelLabel}</Button> <Button onClick={onCancel} disabled={busy}>{cancelLabel}</Button>
<Button variant={tone === "danger" ? "danger" : "primary"} onClick={onConfirm} disabled={busy}>{busy ? "Working…" : confirmLabel}</Button> <Button variant={tone === "danger" ? "danger" : "primary"} onClick={onConfirm} disabled={busy}>{busy ? "Working…" : confirmLabel}</Button>
</div> </>
</section> )}
</div> >
<p>{message}</p>
</Dialog>
); );
} }

96
src/components/Dialog.tsx Normal file
View File

@@ -0,0 +1,96 @@
import { useEffect, useId, type ReactNode } from "react";
export type DialogProps = {
open: boolean;
title: ReactNode;
children: ReactNode;
footer?: ReactNode;
role?: "dialog" | "alertdialog";
ariaDescribedBy?: string;
closeLabel?: string;
closeOnBackdrop?: boolean;
showCloseButton?: boolean;
closeDisabled?: boolean;
onClose?: () => void;
className?: string;
backdropClassName?: string;
headerClassName?: string;
titleClassName?: string;
bodyClassName?: string;
footerClassName?: string;
};
function joinClasses(...classes: Array<string | undefined | false>) {
return classes.filter(Boolean).join(" ");
}
export default function Dialog({
open,
title,
children,
footer,
role = "dialog",
ariaDescribedBy,
closeLabel = "Close",
closeOnBackdrop = true,
showCloseButton = true,
closeDisabled = false,
onClose,
className = "",
backdropClassName = "",
headerClassName = "",
titleClassName = "",
bodyClassName = "",
footerClassName = ""
}: DialogProps) {
const titleId = useId();
const canClose = Boolean(onClose) && !closeDisabled;
useEffect(() => {
if (!open || !canClose) return undefined;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose?.();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [canClose, onClose, open]);
if (!open) return null;
return (
<div
className={joinClasses("dialog-backdrop", backdropClassName)}
role="presentation"
onMouseDown={(event) => {
if (closeOnBackdrop && canClose && event.target === event.currentTarget) onClose?.();
}}
>
<section
className={joinClasses("dialog-panel", className)}
role={role}
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={ariaDescribedBy}
>
<div className={joinClasses("dialog-header", headerClassName)}>
<h2 id={titleId} className={joinClasses("dialog-title", titleClassName)}>{title}</h2>
{showCloseButton && onClose && (
<button
type="button"
className="dialog-close"
onClick={onClose}
aria-label={closeLabel}
disabled={closeDisabled}
>
×
</button>
)}
</div>
<div className={joinClasses("dialog-body", bodyClassName)}>{children}</div>
{footer && <div className={joinClasses("dialog-footer", footerClassName)}>{footer}</div>}
</section>
</div>
);
}

View File

@@ -4,6 +4,8 @@ import type { ApiSettings } from "../../types";
import Button from "../../components/Button"; import Button from "../../components/Button";
import ConfirmDialog from "../../components/ConfirmDialog"; import ConfirmDialog from "../../components/ConfirmDialog";
import DismissibleAlert from "../../components/DismissibleAlert"; import DismissibleAlert from "../../components/DismissibleAlert";
import FormField from "../../components/FormField";
import FieldLabel from "../../components/help/FieldLabel";
import ToggleSwitch from "../../components/ToggleSwitch"; import ToggleSwitch from "../../components/ToggleSwitch";
import LoadingIndicator from "../../components/LoadingIndicator"; import LoadingIndicator from "../../components/LoadingIndicator";
import { import {
@@ -1326,12 +1328,11 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) {
{dialog === "create-folder" && ( {dialog === "create-folder" && (
<FileDialog title="Create folder" onClose={closeDialog}> <FileDialog title="Create folder" onClose={closeDialog}>
<label className="field-block"> <FormField label="Folder name" help="Create a folder below the selected destination.">
<span>Folder name</span>
<input autoFocus value={newFolderName} placeholder="e.g. invoices" onChange={(event) => { setNewFolderName(event.target.value); setNewFolderError(""); }} onKeyDown={(event) => { if (event.key === "Enter") void handleCreateFolder(); }} /> <input autoFocus value={newFolderName} placeholder="e.g. invoices" onChange={(event) => { setNewFolderName(event.target.value); setNewFolderError(""); }} onKeyDown={(event) => { if (event.key === "Enter") void handleCreateFolder(); }} />
<small>Created inside {activeDialogTarget?.folderPath || "Root"}.</small> <small>Created inside {activeDialogTarget?.folderPath || "Root"}.</small>
{newFolderError && <small className="field-error">{newFolderError}</small>} {newFolderError && <small className="field-error">{newFolderError}</small>}
</label> </FormField>
<div className="button-row compact-actions align-end"> <div className="button-row compact-actions align-end">
<Button onClick={closeDialog} disabled={busy}>Cancel</Button> <Button onClick={closeDialog} disabled={busy}>Cancel</Button>
<Button variant="primary" onClick={() => void handleCreateFolder()} disabled={busy || !newFolderName.trim()}>Create Folder</Button> <Button variant="primary" onClick={() => void handleCreateFolder()} disabled={busy || !newFolderName.trim()}>Create Folder</Button>
@@ -1343,14 +1344,13 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) {
<FileDialog title={`${transferMode === "copy" ? "Copy" : "Move"} selection`} onClose={closeDialog}> <FileDialog title={`${transferMode === "copy" ? "Copy" : "Move"} selection`} onClose={closeDialog}>
<p className="muted">Choose a destination space and folder. Folders are transferred recursively.</p> <p className="muted">Choose a destination space and folder. Folders are transferred recursively.</p>
<div className="form-grid two-column-form-grid"> <div className="form-grid two-column-form-grid">
<label className="field-block"> <FormField label="Destination space" help="Select the space that will receive the selected file(s) or folder(s).">
<span>Destination space</span>
<select value={transferDialogState.targetSpaceId} onChange={(event) => setTransferDialogState((current) => current ? { ...current, targetSpaceId: event.target.value, targetFolder: "" } : current)}> <select value={transferDialogState.targetSpaceId} onChange={(event) => setTransferDialogState((current) => current ? { ...current, targetSpaceId: event.target.value, targetFolder: "" } : current)}>
{spaces.map((space) => <option key={space.id} value={space.id}>{space.label}</option>)} {spaces.map((space) => <option key={space.id} value={space.id}>{space.label}</option>)}
</select> </select>
</label> </FormField>
<div className="field-block"> <div className="field-block">
<span>Destination folder</span> <FieldLabel className="form-label" help="Choose the target folder. Folders are transferred recursively.">Destination folder</FieldLabel>
<TransferFolderSelector <TransferFolderSelector
space={findSpace(transferDialogState.targetSpaceId)} space={findSpace(transferDialogState.targetSpaceId)}
nodes={buildFolderTree(filesBySpace[transferDialogState.targetSpaceId] ?? EMPTY_FILES, foldersBySpace[transferDialogState.targetSpaceId] ?? EMPTY_FOLDERS)} nodes={buildFolderTree(filesBySpace[transferDialogState.targetSpaceId] ?? EMPTY_FILES, foldersBySpace[transferDialogState.targetSpaceId] ?? EMPTY_FOLDERS)}
@@ -1369,11 +1369,9 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) {
{dialog === "single-rename" && ( {dialog === "single-rename" && (
<FileDialog title="Rename item" onClose={closeDialog}> <FileDialog title="Rename item" onClose={closeDialog}>
<label className="field-block"> <FormField label="New name" help="Only the visible name changes. Immutable blobs stay stable for audit.">
<span>New name</span>
<input autoFocus value={singleRenameName} onChange={(event) => setSingleRenameName(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") void runSingleRename(); }} /> <input autoFocus value={singleRenameName} onChange={(event) => setSingleRenameName(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") void runSingleRename(); }} />
<small>Only the visible name changes. Immutable blobs stay stable for audit.</small> </FormField>
</label>
<div className="button-row compact-actions align-end"> <div className="button-row compact-actions align-end">
<Button onClick={closeDialog} disabled={busy}>Cancel</Button> <Button onClick={closeDialog} disabled={busy}>Cancel</Button>
<Button variant="primary" onClick={() => void runSingleRename()} disabled={busy || !singleRenameName.trim()}>Rename</Button> <Button variant="primary" onClick={() => void runSingleRename()} disabled={busy || !singleRenameName.trim()}>Rename</Button>
@@ -1385,17 +1383,16 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) {
<FileDialog title="Bulk rename selected items" onClose={closeDialog}> <FileDialog title="Bulk rename selected items" onClose={closeDialog}>
<p className="muted">Bulk rename changes managed display paths only. Immutable blobs stay stable for audit.</p> <p className="muted">Bulk rename changes managed display paths only. Immutable blobs stay stable for audit.</p>
<div className="form-grid two-column-form-grid"> <div className="form-grid two-column-form-grid">
<label className="field-block"> <FormField label="Mode" help="Choose how the selected names should be changed.">
<span>Mode</span>
<select value={renameMode} onChange={(event) => setRenameMode(event.target.value as RenameMode)}> <select value={renameMode} onChange={(event) => setRenameMode(event.target.value as RenameMode)}>
<option value="prefix">Add prefix</option> <option value="prefix">Add prefix</option>
<option value="suffix">Add suffix before extension</option> <option value="suffix">Add suffix before extension</option>
<option value="replace">Find and replace</option> <option value="replace">Find and replace</option>
</select> </select>
</label> </FormField>
{renameMode === "prefix" && <label className="field-block"><span>Prefix</span><input value={renamePrefix} onChange={(event) => setRenamePrefix(event.target.value)} /></label>} {renameMode === "prefix" && <FormField label="Prefix"><input value={renamePrefix} onChange={(event) => setRenamePrefix(event.target.value)} /></FormField>}
{renameMode === "suffix" && <label className="field-block"><span>Suffix</span><input value={renameSuffix} onChange={(event) => setRenameSuffix(event.target.value)} /></label>} {renameMode === "suffix" && <FormField label="Suffix"><input value={renameSuffix} onChange={(event) => setRenameSuffix(event.target.value)} /></FormField>}
{renameMode === "replace" && <><label className="field-block"><span>Find</span><input value={renameFind} onChange={(event) => setRenameFind(event.target.value)} /></label><label className="field-block"><span>Replacement</span><input value={renameReplacement} onChange={(event) => setRenameReplacement(event.target.value)} /></label></>} {renameMode === "replace" && <><FormField label="Find"><input value={renameFind} onChange={(event) => setRenameFind(event.target.value)} /></FormField><FormField label="Replacement"><input value={renameReplacement} onChange={(event) => setRenameReplacement(event.target.value)} /></FormField></>}
</div> </div>
{selectedFolderPaths.size > 0 && ( {selectedFolderPaths.size > 0 && (
<ToggleSwitch label="Apply recursively to folder contents" checked={renameRecursive} onChange={setRenameRecursive} disabled={busy} /> <ToggleSwitch label="Apply recursively to folder contents" checked={renameRecursive} onChange={setRenameRecursive} disabled={busy} />

View File

@@ -1,6 +1,7 @@
import type { DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react"; import type { DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
import { Copy, Download, Folder, FolderOpen, Home, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react"; import { Copy, Download, Folder, FolderOpen, Home, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react";
import Button from "../../../components/Button"; import Button from "../../../components/Button";
import Dialog from "../../../components/Dialog";
import type { ConflictAction, FileSpace, RenameResponse } from "../../../api/files"; import type { ConflictAction, FileSpace, RenameResponse } from "../../../api/files";
import type { ConflictDialogState, FileActionTarget, FileConflictItem, FolderNode, ContextMenuState } from "../types"; import type { ConflictDialogState, FileActionTarget, FileConflictItem, FolderNode, ContextMenuState } from "../types";
import { isPathUnderOrSame, normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils"; import { isPathUnderOrSame, normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils";
@@ -227,15 +228,18 @@ export function RenamePreviewList({
export function FileDialog({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) { export function FileDialog({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
return ( return (
<div className="file-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}> <Dialog
<div className="file-dialog" role="dialog" aria-modal="true" aria-label={title}> open
<div className="file-dialog-header"> title={title}
<h3>{title}</h3> onClose={onClose}
<button type="button" onClick={onClose} aria-label="Close">×</button> backdropClassName="file-dialog-backdrop"
</div> className="file-dialog"
<div className="file-dialog-body">{children}</div> headerClassName="file-dialog-header"
</div> titleClassName="file-dialog-title"
</div> bodyClassName="file-dialog-body"
>
{children}
</Dialog>
); );
} }

View File

@@ -1701,7 +1701,8 @@
background: var(--panel-soft); background: var(--panel-soft);
} }
.file-dialog-header h3 { .file-dialog-header h3,
.file-dialog-header .file-dialog-title {
margin: 0; margin: 0;
color: var(--text-strong); color: var(--text-strong);
font-size: 18px; font-size: 18px;

View File

@@ -512,6 +512,80 @@
font-weight: 600; font-weight: 600;
} }
/* Reusable dialog -------------------------------------------------------- */
.dialog-backdrop {
position: fixed;
inset: 0;
z-index: 12000;
display: grid;
place-items: center;
padding: 1.5rem;
background: rgba(15, 23, 42, 0.36);
backdrop-filter: blur(2px);
}
.dialog-panel {
width: min(560px, 100%);
max-height: min(760px, calc(100vh - 3rem));
overflow: auto;
border: 1px solid var(--line, rgba(15, 23, 42, 0.14));
border-radius: var(--radius-lg, 18px);
background: var(--surface, #fff);
color: var(--text, #172033);
box-shadow: var(--shadow-strong, 0 24px 64px rgba(15, 23, 42, 0.25));
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.dialog-title {
margin: 0;
color: var(--text-strong, #111827);
font-size: 1.05rem;
}
.dialog-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--muted, #5f6b7a);
cursor: pointer;
font-size: 1.5rem;
line-height: 1;
}
.dialog-close:hover,
.dialog-close:focus-visible {
background: rgba(15, 23, 42, 0.07);
color: var(--text-strong, #111827);
outline: none;
}
.dialog-close:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.dialog-body {
color: var(--text, #172033);
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
}
/* Reusable confirm dialog ------------------------------------------------ */ /* Reusable confirm dialog ------------------------------------------------ */
.confirm-backdrop { .confirm-backdrop {
position: fixed; position: fixed;
@@ -534,8 +608,13 @@
box-shadow: var(--shadow-strong, 0 24px 64px rgba(15, 23, 42, 0.25)); box-shadow: var(--shadow-strong, 0 24px 64px rgba(15, 23, 42, 0.25));
} }
.confirm-dialog-header {
margin-bottom: 0.55rem;
}
.confirm-dialog-header .dialog-title,
.confirm-dialog h2 { .confirm-dialog h2 {
margin: 0 0 0.55rem; margin: 0;
color: var(--text-strong, #111827); color: var(--text-strong, #111827);
font-size: 1.05rem; font-size: 1.05rem;
} }