feat(workspace): deleted folders go to the trash as a whole, after a confirmation
A deleted personal folder is kept in trashed_folders with its subfolders; its notes are trashed and tied to it. The trash lists the folder as one entry with its note count; restoring brings back the folder tree (empty subfolders too) and all notes, deleting for good, emptying the trash and the notes trash delay remove it with its notes. Folders in shared folders keep the old way. Deleting a folder now asks first, in a small popover at the click (ConfirmPopover, documented in ui-patterns). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9dcfa6c27e
commit
b26b07cfea
18 files changed
+564
-25
No files matched your search
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface ConfirmPopoverProps {
|
||||
// Where the click was (clientX/clientY); the popover opens right there, kept inside the window
|
||||
x: number;
|
||||
y: number;
|
||||
message: React.ReactNode;
|
||||
confirmLabel: string;
|
||||
danger?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const MARGIN = 8;
|
||||
|
||||
// Small confirmation right at the mouse (Doku/ui-patterns.md): Enter or the button confirms, Escape, a
|
||||
// click outside or "Abbrechen" closes it.
|
||||
export const ConfirmPopover: React.FC<ConfirmPopoverProps> = ({ x, y, message, confirmLabel, danger, onConfirm, onCancel }) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||
const [pos, setPos] = useState({ left: x, top: y, ready: false });
|
||||
|
||||
// Below and right of the pointer; flipped above or to the left when it would leave the window
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const { width, height } = el.getBoundingClientRect();
|
||||
let left = x - 12;
|
||||
let top = y + MARGIN;
|
||||
if (left + width > window.innerWidth - MARGIN) left = window.innerWidth - width - MARGIN;
|
||||
if (top + height > window.innerHeight - MARGIN) top = y - height - MARGIN;
|
||||
setPos({ left: Math.max(MARGIN, left), top: Math.max(MARGIN, top), ready: true });
|
||||
confirmRef.current?.focus();
|
||||
}, [x, y]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: Event) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onCancel();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
};
|
||||
document.addEventListener('pointerdown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [onCancel]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role="alertdialog"
|
||||
aria-modal="false"
|
||||
style={{ left: pos.left, top: pos.top, visibility: pos.ready ? 'visible' : 'hidden' }}
|
||||
className="fixed z-[70] w-72 max-w-[calc(100vw-1rem)] p-3 bg-panel border border-border rounded-card shadow-panel text-xs text-main space-y-3"
|
||||
>
|
||||
<div className="leading-snug">{message}</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onCancel} className="btn-action h-11 sm:h-8 px-3 text-xs">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`h-11 sm:h-8 px-3 text-xs font-semibold rounded-btn border transition-colors ${
|
||||
danger
|
||||
? 'bg-status-err/15 text-status-err border-status-err/50 hover:bg-status-err/25'
|
||||
: 'btn-primary'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ConfirmPopover } from '../ConfirmPopover';
|
||||
|
||||
describe('ConfirmPopover', () => {
|
||||
it('confirms with its button, which has the focus (Enter)', () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(<ConfirmPopover x={100} y={100} message="Ordner löschen?" confirmLabel="In den Papierkorb" onConfirm={onConfirm} onCancel={vi.fn()} />);
|
||||
const button = screen.getByRole('button', { name: 'In den Papierkorb' });
|
||||
expect(document.activeElement).toBe(button);
|
||||
fireEvent.click(button);
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('closes without confirming on Abbrechen, Escape or a click outside', () => {
|
||||
const onConfirm = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
render(<ConfirmPopover x={100} y={100} message="Ordner löschen?" confirmLabel="Löschen" onConfirm={onConfirm} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Abbrechen' }));
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(onCancel).toHaveBeenCalledTimes(3);
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user