fix: show the same actions in the same order in every menu of a contact, group or folder

This commit is contained in:
Linus Rath
2026-10-28 04:57:25 +01:00
parent 567880d57f
commit 26aef32786
9 changed files with 172 additions and 152 deletions
@@ -0,0 +1,52 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ContactDetail } from '../contact-detail';
import { ContactGroupDetail } from '../contact-group-detail';
import { ContactList } from '../contact-list';
import type { ContactCard } from '@/lib/jmap/types';
vi.mock('@/components/contacts/contact-activity', () => ({ ContactActivity: () => null }));
const alice: ContactCard = {
id: '1',
addressBookIds: {},
name: { components: [{ kind: 'given', value: 'Alice' }], isOrdered: true },
emails: { e0: { address: 'alice@example.com' } },
};
const menuLabels = () => within(screen.getByRole('menu')).getAllByRole('menuitem').map((i) => i.textContent);
describe('one action list per contact (R6)', () => {
it('offers Duplicate in the right-click menu as in the detail ⋮', () => {
const noop = vi.fn();
render(
<ContactList
contacts={[alice]} selectedContactId={null} searchQuery="" onSearchChange={noop} onSelectContact={noop}
onCreateNew={noop} selectedContactIds={new Set()} onToggleSelection={noop} onSelectRangeContacts={noop}
onSelectAll={noop} onClearSelection={noop} onBulkDelete={noop} onBulkAddToGroup={noop} onBulkExport={noop}
onEditContact={noop} onDeleteContact={noop} onAddContactToGroup={noop} onDuplicateContact={noop} onComposeContact={noop}
/>,
);
fireEvent.contextMenu(screen.getByText('Alice'));
const rightClick = menuLabels();
expect(rightClick).toContain('context_menu.duplicate');
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' });
render(<ContactDetail contact={alice} onEdit={noop} onDelete={noop} onDuplicate={noop} onAddToGroup={noop} onCompose={noop} />);
fireEvent.click(screen.getByRole('button', { name: 'detail.more_actions' }));
const detail = menuLabels();
// The detail's ⋮ is the same list, without what the page has as buttons.
expect(detail).toEqual(rightClick.filter((l) => !['context_menu.open', 'context_menu.send_email', 'context_menu.edit'].includes(l ?? '')));
});
it('says "Edit group" on a group page and keeps Delete in a ⋮ like a contact page', () => {
const group = { id: 'g', kind: 'group', addressBookIds: {}, name: { full: 'Team' } } as ContactCard;
const onDelete = vi.fn();
render(<ContactGroupDetail group={group} members={[]} onEdit={vi.fn()} onDelete={onDelete} onRemoveMember={vi.fn()} onSelectMember={vi.fn()} />);
expect(screen.getByRole('button', { name: /groups.edit/ })).toBeInTheDocument();
expect(screen.queryByText('form.edit_title')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'detail.more_actions' }));
fireEvent.click(screen.getByText('groups.delete'));
expect(onDelete).toHaveBeenCalled();
});
});
+32 -24
View File
@@ -45,12 +45,16 @@ interface ContactContextMenuProps {
menuRef: React.RefObject<HTMLDivElement | null>;
isMultiSelect?: boolean;
selectedCount?: number;
onOpen: () => void;
/** Left out where the contact is already open (the detail's ⋮). */
onOpen?: () => void;
/** Touch: start selecting with this contact (there is no Ctrl+click). */
onSelect?: () => void;
onEdit: () => void;
/** Left out where Edit is a button next to the menu. */
onEdit?: () => void;
onDelete: () => void;
onAddToGroup: () => void;
onAddToGroup?: () => void;
/** Leave out what the detail shows as buttons of its own. */
inDetail?: boolean;
/** Writes to the contact in the app's composer. */
onCompose?: () => void;
onDuplicate?: () => void;
@@ -79,6 +83,7 @@ export function ContactContextMenu({
onDuplicate,
onMoveTo,
onBatchMoveTo,
inDetail = false,
onBatchExport,
onBatchAddToGroup,
onBatchDelete,
@@ -155,24 +160,43 @@ export function ContactContextMenu({
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />
{/* R6 order: Open, write, Edit, then the rest; the detail's ⋮ leaves
out what it has as buttons (Edit, Send email, Call). */}
{onOpen && <ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />}
{onSelect && <ContextMenuItem icon={CheckSquare} label={t("filters.select")} onClick={handle(onSelect)} />}
<ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />
{(email || phone) && <ContextMenuSeparator />}
{email && onCompose && (
{email && onCompose && !inDetail && (
<ContextMenuItem
icon={Mail}
label={t("context_menu.send_email")}
onClick={handle(onCompose)}
/>
)}
{phone && (
{phone && !inDetail && (
<ContextMenuItem
icon={Phone}
label={t("context_menu.call")}
onClick={handle(handleCall)}
/>
)}
{onEdit && <ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />}
{!inDetail && <ContextMenuSeparator />}
{onAddToGroup && (
<ContextMenuItem
icon={Users}
label={t("context_menu.add_to_group")}
onClick={handle(onAddToGroup)}
/>
)}
{onDuplicate && (
<ContextMenuItem
icon={Copy}
label={t("context_menu.duplicate")}
onClick={handle(onDuplicate)}
/>
)}
{onMoveTo && (
<ContextMenuItem icon={FolderInput} label={t("address_books.move_to")} onClick={handle(onMoveTo)} />
)}
{email && (
<ContextMenuItem
icon={ClipboardCopy}
@@ -187,22 +211,6 @@ export function ContactContextMenu({
onClick={handle(() => handleCopy(phone))}
/>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={Users}
label={t("context_menu.add_to_group")}
onClick={handle(onAddToGroup)}
/>
{onDuplicate && (
<ContextMenuItem
icon={Copy}
label={t("context_menu.duplicate")}
onClick={handle(onDuplicate)}
/>
)}
{onMoveTo && (
<ContextMenuItem icon={FolderInput} label={t("address_books.move_to")} onClick={handle(onMoveTo)} />
)}
<ContextMenuItem
icon={Download}
label={t("context_menu.export_vcard")}
+33 -105
View File
@@ -1,36 +1,25 @@
"use client";
import { useState, useEffect, useRef, useMemo } from "react";
import { useEffect, useRef, useMemo } from "react";
import { useLocale, useTranslations } from "next-intl";
import { contactDateParts, formatContactDate } from "@/lib/contact-dates";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreVertical, Printer, FolderInput } from "@/components/icons";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, MoreVertical } from "@/components/icons";
import { Avatar } from "@/components/ui/avatar";
import { MailtoLink } from "@/components/ui/mailto-link";
import { Button } from "@/components/ui/button";
import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { cn } from "@/lib/utils";
import type { ContactCard, AnniversaryDate } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoRef } from "@/stores/contact-store";
import { ContactActivity } from "./contact-activity";
import { toast } from "@/stores/toast-store";
import { exportContact, useContactExportBusy } from "./contact-export";
import { printContact, printLabels } from "./contact-print";
import { ContactContextMenu } from "./contact-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { IconButton } from "@/components/ui/icon-button";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { contactHooks } from "@/lib/plugin-hooks";
import { contactToView } from "@/lib/plugin-projection";
type MoreItem =
| {
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
destructive?: boolean;
disabled?: boolean;
separator?: false;
}
| { separator: true };
interface ContactDetailProps {
contact: ContactCard | null;
onEdit: () => void;
@@ -85,12 +74,13 @@ function getCompletedYears(dateInput: AnniversaryDate): number | null {
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, isMobile, className }: ContactDetailProps) {
const t = useTranslations("contacts");
const locale = useLocale();
// The ⋮ shows the contact's menu, the one right-click shows in the list (R6).
const moreMenu = useContextMenu<ContactCard>();
const contextLabel = (key: string) => (CONTEXT_LABEL_KEYS[key] ? t(CONTEXT_LABEL_KEYS[key]) : key);
const featureLabel = (key: string) => (PHONE_FEATURE_LABEL_KEYS[key] ? t(PHONE_FEATURE_LABEL_KEYS[key]) : key);
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
const hasCryptoKeysSlotOffers = usePluginSlotOffers('contact-cryptokeys').length > 0;
const exporting = useContactExportBusy();
const pluginContactProps = useMemo(() => (contact ? { contact: contactToView(contact) } : null), [contact]);
// Tell plugins once per contact shown here, not on every edit of that card.
@@ -117,28 +107,6 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
const photo = getContactPhotoRef(contact) ?? null;
const phone = contact.phones ? Object.values(contact.phones)[0]?.number : undefined;
const handleExport = async () => {
if (await exportContact(contact)) toast.success(t("export.success", { count: 1 }));
};
const handlePrint = () => {
void printContact(contact, printLabels(t, locale), name);
};
const moreItems: MoreItem[] = [];
if (onAddToGroup) {
moreItems.push({ icon: Users, label: t("context_menu.add_to_group"), onClick: onAddToGroup });
}
if (onDuplicate) {
moreItems.push({ icon: Copy, label: t("context_menu.duplicate"), onClick: onDuplicate });
}
if (onMoveTo) {
moreItems.push({ icon: FolderInput, label: t("address_books.move_to"), onClick: onMoveTo });
}
moreItems.push({ icon: Download, label: t("context_menu.export_vcard"), onClick: handleExport, disabled: exporting });
moreItems.push({ icon: Printer, label: t("context_menu.print"), onClick: handlePrint });
moreItems.push({ separator: true });
moreItems.push({ icon: Trash2, label: t("context_menu.delete"), onClick: onDelete, destructive: true });
const emails = contact.emails ? Object.values(contact.emails) : [];
const phones = contact.phones ? Object.values(contact.phones) : [];
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
@@ -206,7 +174,32 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
<Pencil className="w-4 h-4 me-1" />
{t("form.edit_title")}
</Button>
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
<IconButton
variant="outline"
size="sm"
label={t("detail.more_actions")}
aria-haspopup="menu"
aria-expanded={moreMenu.contextMenu.isOpen}
onClick={(e) => moreMenu.openContextMenuFrom(e.currentTarget, contact, "end")}
className="touch-manipulation"
>
<MoreVertical />
</IconButton>
{moreMenu.contextMenu.data && (
<ContactContextMenu
contact={moreMenu.contextMenu.data}
position={moreMenu.contextMenu.position}
isOpen={moreMenu.contextMenu.isOpen}
onClose={moreMenu.closeContextMenu}
menuRef={moreMenu.menuRef}
inDetail
onCompose={onCompose}
onDelete={onDelete}
onAddToGroup={onAddToGroup}
onDuplicate={onDuplicate}
onMoveTo={onMoveTo}
/>
)}
</div>
</div>
</div>
@@ -529,71 +522,6 @@ function RowActions({ children }: { children: React.ReactNode }) {
);
}
function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDocMouseDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDocMouseDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDocMouseDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
if (items.length === 0) return null;
return (
<div ref={ref} className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setOpen((o) => !o)}
title={label}
aria-label={label}
aria-haspopup="menu"
aria-expanded={open}
className="touch-manipulation"
>
<MoreVertical className="w-4 h-4" />
</Button>
{open && (
<div
role="menu"
className="absolute end-0 top-full mt-1 z-30 min-w-[200px] bg-background rounded-md shadow-lg border border-border py-1"
>
{items.map((item, i) => {
if (item.separator) {
return <ContextMenuSeparator key={i} />;
}
return (
<ContextMenuItem
key={i}
icon={item.icon}
label={item.label}
destructive={item.destructive}
disabled={item.disabled}
onClick={() => {
item.onClick();
setOpen(false);
}}
/>
);
})}
</div>
)}
</div>
);
}
function CopyButton({ value, label, successMsg, failMsg, className }: { value: string; label: string; successMsg: string; failMsg: string; className?: string }) {
return (
<button
+19 -9
View File
@@ -1,7 +1,9 @@
"use client";
import { useTranslations } from "next-intl";
import { Users, Pencil, Trash2, UserMinus, Mail } from "@/components/icons";
import { Users, Pencil, Trash2, UserMinus, Mail, MoreVertical } from "@/components/icons";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
@@ -35,6 +37,8 @@ export function ContactGroupDetail({
}: ContactGroupDetailProps) {
const t = useTranslations("contacts");
const groupName = getContactDisplayName(group);
// Delete sits in a ⋮, as on a contact's page (R6).
const menu = useContextMenu<null>();
const hasEmailMembers = members.some((m) => getContactPrimaryEmail(m).trim());
return (
@@ -55,18 +59,24 @@ export function ContactGroupDetail({
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
<Pencil className="w-4 h-4 me-1" />
{t("form.edit_title")}
{t("groups.edit")}
</Button>
<Button
<IconButton
variant="outline"
size="sm"
onClick={onDelete}
aria-label={t("groups.delete")}
title={t("groups.delete")}
className="text-destructive hover:text-destructive hover:bg-destructive/10 touch-manipulation"
label={t("detail.more_actions")}
aria-haspopup="menu"
aria-expanded={menu.contextMenu.isOpen}
onClick={(e) => menu.openContextMenuFrom(e.currentTarget, null, "end")}
className="touch-manipulation"
>
<Trash2 className="w-4 h-4" />
</Button>
<MoreVertical />
</IconButton>
<ContextMenu ref={menu.menuRef} isOpen={menu.contextMenu.isOpen} position={menu.contextMenu.position} onClose={menu.closeContextMenu}>
<ContextMenuItem icon={Pencil} label={t("groups.edit")} onClick={() => { menu.closeContextMenu(); onEdit(); }} />
<ContextMenuSeparator />
<ContextMenuItem icon={Trash2} label={t("groups.delete")} destructive onClick={() => { menu.closeContextMenu(); onDelete(); }} />
</ContextMenu>
</div>
</div>
{onComposeGroup && hasEmailMembers && (
+3
View File
@@ -117,6 +117,7 @@ interface ContactListProps {
onAddContactToGroup: (id: string) => void;
/** Opens the app's composer to the contact. */
onComposeContact?: (contact: ContactCard) => void;
onDuplicateContact?: (contact: ContactCard) => void;
/** Move contacts to another address book; undefined where there is none. */
onMoveContacts?: (ids: string[]) => void;
/** Show a burger button at the start that opens the (overlay) categories sidebar. */
@@ -145,6 +146,7 @@ export function ContactList({
onDeleteContact,
onAddContactToGroup,
onComposeContact,
onDuplicateContact,
onMoveContacts,
onMenuClick,
isLoading = false,
@@ -750,6 +752,7 @@ export function ContactList({
onDelete={() => onDeleteContact(contextMenu.data!)}
onAddToGroup={() => onAddContactToGroup(contextMenu.data!.id)}
onCompose={onComposeContact ? () => onComposeContact(contextMenu.data!) : undefined}
onDuplicate={onDuplicateContact ? () => onDuplicateContact(contextMenu.data!) : undefined}
onMoveTo={onMoveContacts ? () => onMoveContacts([contextMenu.data!.id]) : undefined}
onBatchMoveTo={onMoveContacts ? () => onMoveContacts([...selectedContactIds]) : undefined}
onBatchExport={onBulkExport}
+1
View File
@@ -1290,6 +1290,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
onDeleteContact={handleDeleteContact}
onAddContactToGroup={guard(handleAddContactToGroup)}
onComposeContact={handleComposeContact}
onDuplicateContact={(contact) => void handleDuplicateContact(contact)}
onMoveContacts={canMoveContacts ? handleMoveContacts : undefined}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
isLoading={supportsSync && !contactsLoaded}
@@ -449,4 +449,17 @@ describe('FileBrowser', () => {
fireEvent.keyDown(document.body, { key: '/' });
expect(document.activeElement).toHaveAttribute('role', 'searchbox');
});
it("lists a folder's actions in the same order in the list and in the tree", () => {
renderBrowser({ props: { onDownloadZip: vi.fn(async () => {}), onMoveNode: vi.fn(async () => {}) } });
const labels = () => within(screen.getByRole('menu')).getAllByRole('menuitem').map((i) => (i.textContent ?? '').replace(/(F2|Del|Ctrl\+[A-Z]|⌘[A-Z])$/, ''));
fireEvent.contextMenu(within(list()).getByText('Documents'));
const inList = labels();
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' });
fireEvent.contextMenu(within(tree()).getByText('Documents'));
const inTree = labels();
const shared = inTree.filter((l) => inList.includes(l));
expect(shared).toEqual(inList.filter((l) => inTree.includes(l)));
expect(shared).toEqual(expect.arrayContaining(['open', 'star', 'rename', 'move_to', 'download_zip', 'delete']));
});
});
+12 -10
View File
@@ -876,22 +876,18 @@ export function FileBrowser({
const starred = starredPaths.has(path);
return (
<>
{/* R6 order, the same as the folder tree's menu: Open, Edit, Share,
Star, Rename, Duplicate, Move to…, Cut, Copy, Download, Details, Delete. */}
{resource.isDirectory ? (
<>
<ContextMenuItem icon={FolderOpen} label={t("open")} onClick={() => { closeMenu(); openResource(resource); }} />
{onDownloadZip && (
<ContextMenuItem icon={Download} label={t("download_zip")} onClick={() => { closeMenu(); downloadNames([resource.name]); }} />
)}
</>
<ContextMenuItem icon={FolderOpen} label={t("open")} onClick={() => { closeMenu(); openResource(resource); }} />
) : resource.isSymlink ? null : (
<>
{isOfficeEditable?.(resource.name) && onEditFile && (
<ContextMenuItem icon={SquarePen} label={t("office_edit")} onClick={() => { closeMenu(); onEditFile(resource.name); }} />
)}
{isPreviewable(resource.name) && (
<ContextMenuItem icon={Eye} label={t("preview")} onClick={() => { closeMenu(); previewResource(resource); }} />
)}
<ContextMenuItem icon={Download} label={t("download")} onClick={() => { closeMenu(); void onDownload(resource.name); }} />
{isOfficeEditable?.(resource.name) && onEditFile && (
<ContextMenuItem icon={SquarePen} label={t("office_edit")} onClick={() => { closeMenu(); onEditFile(resource.name); }} />
)}
</>
)}
{(canShare(resource) || (resource.isDirectory && !resource.isShared)) && <ContextMenuSeparator />}
@@ -917,6 +913,12 @@ export function FileBrowser({
{!resource.isSymlink && (
<ContextMenuItem icon={Copy} label={t("copy")} shortcut={formatShortcutCombo("Mod + C")} onClick={() => { closeMenu(); onCopy([resource.name]); }} />
)}
{resource.isDirectory && onDownloadZip && (
<ContextMenuItem icon={Download} label={t("download_zip")} onClick={() => { closeMenu(); downloadNames([resource.name]); }} />
)}
{!resource.isDirectory && !resource.isSymlink && (
<ContextMenuItem icon={Download} label={t("download")} onClick={() => { closeMenu(); void onDownload(resource.name); }} />
)}
<ContextMenuItem icon={Info} label={t("details")} onClick={() => { closeMenu(); toggleDetails(true); }} />
<ContextMenuSeparator />
<ContextMenuItem icon={Trash2} label={t("delete")} shortcut="Del" destructive onClick={() => { closeMenu(); deleteNames([resource.name]); }} />
+7 -4
View File
@@ -464,7 +464,12 @@ export function FilesSidebar({
<>
<ContextMenuItem icon={FolderOpen} label={t("open")} onClick={() => run(() => onNavigate(path, folder.id))} />
{writable && createItems(path, folder.id)}
{/* The list's folder menu in the same order; Cut, Copy and
Details act on the list and are left out here (R6). */}
{(!folder.isShared || canShare(folder)) && <ContextMenuSeparator />}
{canShare(folder) && (
<ContextMenuItem icon={Share2} label={t("share")} onClick={() => run(() => onShare(folder))} />
)}
{!folder.isShared && (
<ContextMenuItem
icon={starred ? StarOff : Star}
@@ -472,10 +477,7 @@ export function FilesSidebar({
onClick={() => run(() => onToggleFavorite(path))}
/>
)}
{canShare(folder) && (
<ContextMenuItem icon={Share2} label={t("share")} onClick={() => run(() => onShare(folder))} />
)}
{(mayRename || mayDelete) && <ContextMenuSeparator />}
<ContextMenuSeparator />
{mayRename && (
<ContextMenuItem icon={Pencil} label={t("rename")} onClick={() => run(() => onRenameFolder(folder))} />
)}
@@ -485,6 +487,7 @@ export function FilesSidebar({
{onDownloadFolder && (
<ContextMenuItem icon={Download} label={t("download_zip")} onClick={() => run(() => onDownloadFolder(folder))} />
)}
{mayDelete && <ContextMenuSeparator />}
{mayDelete && (
<ContextMenuItem icon={Trash2} label={t("delete")} destructive onClick={() => run(() => onDeleteFolder(folder))} />
)}