Component reuse, Dialog component
This commit is contained in:
@@ -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
96
src/components/Dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user