Release v0.1.3

This commit is contained in:
2026-06-26 01:39:19 +02:00
parent 02564047e9
commit df701fddd2
29 changed files with 600 additions and 154 deletions

View File

@@ -0,0 +1,113 @@
import { useRef, useState, type CSSProperties, type ReactNode } from "react";
import { UploadCloud } from "lucide-react";
export type FileDropZoneProps = {
accept?: string;
multiple?: boolean;
disabled?: boolean;
busy?: boolean;
progress?: number | null;
label?: ReactNode;
actionLabel?: ReactNode;
busyLabel?: ReactNode;
progressLabel?: ReactNode;
note?: ReactNode;
className?: string;
inputLabel?: string;
onFiles: (files: File[]) => void | Promise<void>;
};
export default function FileDropZone({
accept,
multiple = true,
disabled = false,
busy = false,
progress = null,
label = "Drop files here",
actionLabel = "or click to select files",
busyLabel = "Uploading files",
progressLabel,
note,
className = "",
inputLabel = "Drop files here or click to select files",
onFiles
}: FileDropZoneProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [dragActive, setDragActive] = useState(false);
const progressValue = typeof progress === "number" && Number.isFinite(progress) ? Math.max(0, Math.min(100, progress)) : null;
const showProgress = busy || progressValue !== null;
const interactionDisabled = disabled || busy;
const zoneClassName = `file-drop-zone ${dragActive ? "is-active" : ""} ${showProgress ? "is-busy" : ""} ${className}`.trim();
const roundedProgress = progressValue === null ? null : Math.round(progressValue);
const progressStyle = progressValue === null ? undefined : { "--file-drop-progress": `${progressValue}%` } as CSSProperties;
async function handleFiles(fileList: FileList | File[]) {
if (interactionDisabled) return;
const files = Array.from(fileList);
if (files.length === 0) return;
try {
await onFiles(files);
} finally {
if (inputRef.current) inputRef.current.value = "";
}
}
return (
<>
<div
className={zoneClassName}
role="button"
tabIndex={interactionDisabled ? -1 : 0}
aria-label={inputLabel}
aria-disabled={interactionDisabled}
aria-busy={showProgress || undefined}
onClick={() => {
if (!interactionDisabled) inputRef.current?.click();
}}
onKeyDown={(event) => {
if ((event.key === "Enter" || event.key === " ") && !interactionDisabled) {
event.preventDefault();
inputRef.current?.click();
}
}}
onDragOver={(event) => {
event.preventDefault();
if (!interactionDisabled) setDragActive(true);
}}
onDragLeave={() => setDragActive(false)}
onDrop={(event) => {
event.preventDefault();
setDragActive(false);
if (!interactionDisabled) void handleFiles(event.dataTransfer.files);
}}
>
{showProgress ? (
<span
className={`file-drop-progress ${progressValue === null ? "is-indeterminate" : ""}`.trim()}
role="progressbar"
aria-label="Upload progress"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={roundedProgress ?? undefined}
style={progressStyle}
>
<span>{roundedProgress === null ? "" : `${roundedProgress}%`}</span>
</span>
) : (
<UploadCloud size={28} aria-hidden="true" />
)}
<strong>{showProgress ? busyLabel : label}</strong>
<span>{showProgress ? progressLabel ?? (roundedProgress === null ? "Uploading…" : `${roundedProgress}% uploaded`) : actionLabel}</span>
{note && <span className="muted small-text">{note}</span>}
</div>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
hidden
onChange={(event) => event.target.files && void handleFiles(event.target.files)}
/>
</>
);
}

View File

@@ -115,9 +115,13 @@ export default function MessageDisplayPanel({
<strong><Archive size={15} aria-hidden="true" /> {archive.label}</strong>
<span>{archive.items.length} file{archive.items.length === 1 ? "" : "s"} inside ZIP</span>
</div>
{archive.protected && <small><LockKeyhole size={13} aria-hidden="true" /> Password protected</small>}
{archive.protected && (
<div className="message-display-attachment-protection">
<small><LockKeyhole size={13} aria-hidden="true" /> Password protected</small>
{archive.protectionNote && <small className="message-display-attachment-protection-note">{formatProtectionNote(archive.protectionNote)}</small>}
</div>
)}
</header>
{archive.protectionNote && <p className="muted small-note">{formatProtectionNote(archive.protectionNote)}</p>}
<div className="message-display-attachment-list">
{archive.items.map((attachment, index) => <AttachmentRow key={attachmentKey(attachment, index)} attachment={attachment} index={index} />)}
</div>

View File

@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import Button from "./Button";
import Dialog from "./Dialog";
import DismissibleAlert from "./DismissibleAlert";
export type UnsavedNavigationAction = () => void;
@@ -139,23 +140,26 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
<UnsavedChangesContext.Provider value={value}>
{children}
{pendingAction && registration && (
<div className="overlay-backdrop" role="dialog" aria-modal="true">
<div className="modal-panel unsaved-changes-dialog">
<header className="modal-header">
<h2>{registration.title ?? "Unsaved changes"}</h2>
<button className="modal-close" onClick={() => setPendingAction(null)} disabled={saving}>x</button>
</header>
<div className="modal-body">
<p>{registration.message ?? "This page has unsaved changes. Save them before leaving, or discard the changes and continue."}</p>
{saveError && <DismissibleAlert tone="danger" resetKey={saveError}>{saveError}</DismissibleAlert>}
</div>
<footer className="modal-footer unsaved-changes-actions">
<Dialog
open
role="alertdialog"
title={registration.title ?? "Unsaved changes"}
className="unsaved-changes-dialog"
footerClassName="unsaved-changes-actions"
closeOnBackdrop={!saving}
closeDisabled={saving}
onClose={() => setPendingAction(null)}
footer={(
<>
<Button onClick={() => setPendingAction(null)} disabled={saving}>Cancel</Button>
<Button onClick={handleDiscardAndLeave} disabled={saving}>Discard</Button>
<Button variant="primary" onClick={() => void handleSaveAndLeave()} disabled={saving}>{saving ? "Saving..." : "Save and leave"}</Button>
</footer>
</div>
</div>
</>
)}
>
<p>{registration.message ?? "This page has unsaved changes. Save them before leaving, or discard the changes and continue."}</p>
{saveError && <DismissibleAlert tone="danger" resetKey={saveError}>{saveError}</DismissibleAlert>}
</Dialog>
)}
</UnsavedChangesContext.Provider>
);

View File

@@ -762,7 +762,7 @@ export default function DataGrid<T>({
pageSize={paginationPageSize}
totalRows={paginationTotal}
pageCount={paginationPageCount}
pageSizeOptions={pagination.pageSizeOptions ?? [25, 50, 100]}
pageSizeOptions={pagination.pageSizeOptions ?? [10, 25, 50, 100]}
disabled={pagination.disabled}
onPageChange={pagination.onPageChange}
onPageSizeChange={pagination.onPageSizeChange}