fix: one kind of button in the Files selection bar, a closable details panel on tablets, and a share dialog that takes the focus

This commit is contained in:
Linus Rath
2026-10-28 05:29:23 +01:00
parent 15446aa0b6
commit f5f536f7c9
5 changed files with 45 additions and 46 deletions
@@ -462,4 +462,12 @@ describe('FileBrowser', () => {
expect(shared).toEqual(inList.filter((l) => inTree.includes(l)));
expect(shared).toEqual(expect.arrayContaining(['open', 'star', 'rename', 'move_to', 'download_zip', 'delete']));
});
it("closes the details panel over a narrow list with Escape", async () => {
renderBrowser({ props: { selectedResources: new Set(['notes.txt']) } });
fireEvent.click(screen.getByRole('button', { name: 'details' }));
expect(screen.getByRole('button', { name: 'details' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.getByRole('button', { name: 'details' })).toHaveAttribute('aria-pressed', 'false');
});
});
+8 -1
View File
@@ -745,7 +745,9 @@ export function FileBrowser({
return;
}
if (e.key === "Escape") {
if (searchQuery) setSearchQuery("");
// The details panel over the list closes first (innermost layer, R8).
if (showDetails && isNarrow) toggleDetails(false);
else if (searchQuery) setSearchQuery("");
else if (selected.length > 0) onClearSelection();
return;
}
@@ -796,6 +798,7 @@ export function FileBrowser({
return () => window.removeEventListener("keydown", handler);
}, [
showNewFolder, showNewTextFile, renameTarget, renameFolder, shareTargetId, moveTarget, accountPickerMode, selectedList, singleSelected, inTrash,
showDetails, isNarrow, toggleDetails,
selectAllVisible, onCopy, onCut, onPaste, clipboard, searchQuery, onClearSelection, deleteNames, openResource,
focusedName, onToggleSelect, currentPath, navigateUp, viewMode, gridColumns, moveFocus,
]);
@@ -1294,6 +1297,10 @@ export function FileBrowser({
</div>
)}
{/* Over the list on a narrow pane: a backdrop that closes it, as for the drawer. */}
{detailsPanel && isNarrow && (
<div className="absolute inset-0 z-20 bg-black/30" aria-hidden onClick={() => toggleDetails(false)} />
)}
{detailsPanel}
{isNarrow && canCreate && !selectionActions && (
+5 -4
View File
@@ -135,20 +135,21 @@ export function FilesHeader({
{selection.trash ? (
<>
{selection.onRestore && (
<ToolButton icon={RotateCcw} label={t("restore")} onClick={selection.onRestore} compact={compact} />
<ToolButton icon={RotateCcw} label={t("restore")} onClick={selection.onRestore} compact />
)}
<ToolButton icon={Trash2} label={t("delete_forever")} onClick={selection.onDelete} compact destructive />
</>
) : (
<>
{/* All icons with a tooltip, as in Mail's selection bar: one kind of button. */}
{selection.canOpen && (
<ToolButton icon={FolderOpen} label={t("open")} onClick={selection.onOpen} compact={compact} />
<ToolButton icon={FolderOpen} label={t("open")} onClick={selection.onOpen} compact />
)}
{selection.fileCount > 0 && (
<ToolButton icon={Download} label={t("download")} onClick={selection.onDownload} compact={compact} />
<ToolButton icon={Download} label={t("download")} onClick={selection.onDownload} compact />
)}
{selection.canShare && (
<ToolButton icon={Share2} label={t("share")} onClick={selection.onShare} compact={compact} />
<ToolButton icon={Share2} label={t("share")} onClick={selection.onShare} compact />
)}
{selection.canRename && (
<ToolButton icon={Pencil} label={t("rename")} onClick={selection.onRename} compact />
@@ -160,3 +160,12 @@ describe('ShareCollectionDialog directory', () => {
expect(screen.queryByText('no_principals')).toBeNull();
});
});
describe('ShareCollectionDialog focus', () => {
it('takes the focus when it opens and has a named close button', async () => {
renderDialog(fullDirectory());
const dialog = screen.getByRole('dialog');
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
expect(screen.getAllByRole('button', { name: 'close' }).length).toBeGreaterThan(0);
});
});
+15 -41
View File
@@ -1,10 +1,11 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "@/components/icons";
import { Loader2, UserPlus, Trash2, ChevronDown, Share2 } from "@/components/icons";
import { Dialog } from "@/components/ui/dialog";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { Principal, PrincipalDirectoryStatus, CalendarRights, AddressBookRights, FileNodeRights, MailboxRights } from "@/lib/jmap/types";
import { loadPrincipalDirectory } from "@/lib/jmap/principal-directory";
@@ -151,7 +152,6 @@ export function ShareCollectionDialog({
}: ShareCollectionDialogProps) {
const t = useTranslations("sharing");
const tCommon = useTranslations("common");
const modalRef = useRef<HTMLDivElement>(null);
const [allPrincipals, setAllPrincipals] = useState<Principal[]>([]);
const [directoryStatus, setDirectoryStatus] = useState<PrincipalDirectoryStatus>("ok");
const [directoryQueriesOff, setDirectoryQueriesOff] = useState(false);
@@ -189,15 +189,6 @@ export function ShareCollectionDialog({
return allPrincipals.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
}, [allPrincipals, ownAccountId, shareWith]);
// Close on Escape, focus trap, click outside
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
const handleSetRights = async (principalId: string, preset: RolePreset) => {
if (preset === "custom") return; // custom is read-only here
const rights = presetRights(kind, preset);
@@ -260,30 +251,17 @@ export function ShareCollectionDialog({
: ["read", "readWrite", "manager"] as const;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("title", { name: collectionName })}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 animate-in zoom-in-95 duration-200 max-h-[85vh] flex flex-col"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<Users className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t("title", { name: collectionName })}</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
aria-label={tCommon("close")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 space-y-4 overflow-y-auto">
// The shared dialog shell (R4): focus moves in and comes back, Escape
// and the close X work from anywhere in it.
<Dialog
isOpen
onClose={onClose}
title={t("title", { name: collectionName })}
icon={<Share2 className="w-5 h-5 text-primary shrink-0 mt-1" />}
size="lg"
footer={<Button onClick={onClose}>{tCommon("close")}</Button>}
>
<div className="pt-4 space-y-4">
<p className="text-sm text-muted-foreground">{t("description")}</p>
{sharedEntries.length === 0 && !showAdd && (
@@ -440,10 +418,6 @@ export function ShareCollectionDialog({
)}
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button onClick={onClose}>{tCommon("close")}</Button>
</div>
</div>
</div>
</Dialog>
);
}