feat: simplify contact bulk selection actions menu (#39)

* feat(contacts): simplify bulk selection actions menu

* refactor(contacts): reuse shared context menu for bulk actions
This commit is contained in:
Rens Reinders
2026-03-18 11:09:35 +01:00
committed by Matthieu MALVACHE
parent 3c0f3400b4
commit f6492bbeda
11 changed files with 153 additions and 21 deletions
@@ -89,8 +89,8 @@ describe('ContactList', () => {
it('shows bulk action bar when contacts are selected', () => {
render(<ContactList {...defaultProps} selectedContactIds={new Set(['1'])} />);
expect(screen.getByText('bulk.delete')).toBeInTheDocument();
expect(screen.getByText('bulk.export')).toBeInTheDocument();
expect(screen.getByText('bulk.selected')).toBeInTheDocument();
expect(screen.getByTestId('contact-bulk-actions-trigger')).toBeInTheDocument();
});
it('excludes groups from the list', () => {
@@ -0,0 +1,135 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { MoreVertical, Users, Download, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuItem,
ContextMenuSeparator,
} from "@/components/ui/context-menu";
interface ContactBulkActionsMenuProps {
onBulkAddToGroup: () => void;
onBulkExport: () => void;
onBulkDelete: () => void;
}
export function ContactBulkActionsMenu({
onBulkAddToGroup,
onBulkExport,
onBulkDelete,
}: ContactBulkActionsMenuProps) {
const t = useTranslations("contacts");
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const menuRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const updateMenuPosition = () => {
const triggerRect = triggerRef.current?.getBoundingClientRect();
if (!triggerRect) return;
const menuWidth = 200;
const viewportPadding = 8;
const x = Math.max(
viewportPadding,
Math.min(triggerRect.right - menuWidth, window.innerWidth - menuWidth - viewportPadding)
);
const y = Math.min(triggerRect.bottom + 4, window.innerHeight - viewportPadding);
setPosition({ x, y });
};
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (menuRef.current?.contains(target)) return;
if (triggerRef.current?.contains(target)) return;
setIsOpen(false);
};
const handleResize = () => {
if (!isOpen) return;
if (!triggerRef.current) {
setIsOpen(false);
return;
}
updateMenuPosition();
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setIsOpen(false);
};
updateMenuPosition();
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
window.addEventListener("resize", handleResize);
window.addEventListener("scroll", handleResize, true);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
window.removeEventListener("resize", handleResize);
window.removeEventListener("scroll", handleResize, true);
};
}, [isOpen]);
const handleAction = (action: () => void) => {
action();
setIsOpen(false);
};
const handleToggle = () => {
if (isOpen) {
setIsOpen(false);
return;
}
updateMenuPosition();
setIsOpen(true);
};
return (
<div>
<Button
ref={triggerRef}
variant="outline"
size="sm"
onClick={handleToggle}
className="h-7 text-xs px-2"
aria-expanded={isOpen}
aria-haspopup="menu"
data-testid="contact-bulk-actions-trigger"
>
{t("bulk.actions")}
<MoreVertical className="w-3.5 h-3.5 ml-1.5" />
</Button>
<ContextMenu
ref={menuRef}
isOpen={isOpen}
position={position}
onClose={() => setIsOpen(false)}
>
<ContextMenuItem
icon={Users}
label={t("bulk.add_to_group")}
onClick={() => handleAction(onBulkAddToGroup)}
/>
<ContextMenuItem
icon={Download}
label={t("bulk.export")}
onClick={() => handleAction(onBulkExport)}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("bulk.delete")}
onClick={() => handleAction(onBulkDelete)}
destructive
/>
</ContextMenu>
</div>
);
}
+8 -19
View File
@@ -2,10 +2,11 @@
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { Search, Plus, BookUser, Info, Check, Trash2, Users, Download, X, UserPlus, Upload } from "lucide-react";
import { Search, Plus, BookUser, Info, Check, X, UserPlus, Upload } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item";
import { ContactBulkActionsMenu } from "./contact-bulk-actions-menu";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store";
@@ -105,28 +106,16 @@ export function ContactList({
</div>
{hasSelection && (
<div className="px-3 py-2 border-b border-border bg-muted/50 flex items-center gap-2 flex-wrap">
<div className="px-3 py-2 border-b border-border bg-muted/50 flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">
{t("bulk.selected", { count: selectedContactIds.size })}
</span>
<div className="flex-1" />
<Button variant="ghost" size="sm" onClick={onBulkAddToGroup} className="h-7 text-xs">
<Users className="w-3.5 h-3.5 mr-1" />
{t("bulk.add_to_group")}
</Button>
<Button variant="ghost" size="sm" onClick={onBulkExport} className="h-7 text-xs">
<Download className="w-3.5 h-3.5 mr-1" />
{t("bulk.export")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={onBulkDelete}
className="h-7 text-xs text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
>
<Trash2 className="w-3.5 h-3.5 mr-1" />
{t("bulk.delete")}
</Button>
<ContactBulkActionsMenu
onBulkAddToGroup={onBulkAddToGroup}
onBulkExport={onBulkExport}
onBulkDelete={onBulkDelete}
/>
<Button variant="ghost" size="icon" onClick={onClearSelection} className="h-7 w-7">
<X className="w-3.5 h-3.5" />
</Button>
+1
View File
@@ -1189,6 +1189,7 @@
},
"bulk": {
"selected": "{count, plural, one {1 ausgewählt} other {# ausgewählt}}",
"actions": "Massenaktionen",
"select_all": "Alle auswählen",
"delete": "Löschen",
"delete_confirm": "{count, plural, one {1 Kontakt} other {# Kontakte}} löschen?",
+1
View File
@@ -1198,6 +1198,7 @@
},
"bulk": {
"selected": "{count, plural, one {1 selected} other {# selected}}",
"actions": "Bulk actions",
"select_all": "Select all",
"delete": "Delete",
"delete_confirm": "Delete {count, plural, one {1 contact} other {# contacts}}?",
+1
View File
@@ -1189,6 +1189,7 @@
},
"bulk": {
"selected": "{count, plural, one {1 seleccionado} other {# seleccionados}}",
"actions": "Acciones masivas",
"select_all": "Seleccionar todo",
"delete": "Eliminar",
"delete_confirm": "¿Eliminar {count, plural, one {1 contacto} other {# contactos}}?",
+1
View File
@@ -1189,6 +1189,7 @@
},
"bulk": {
"selected": "{count, plural, one {1 sélectionné} other {# sélectionnés}}",
"actions": "Actions groupées",
"select_all": "Tout sélectionner",
"delete": "Supprimer",
"delete_confirm": "Supprimer {count, plural, one {1 contact} other {# contacts}} ?",
+1
View File
@@ -1189,6 +1189,7 @@
},
"bulk": {
"selected": "{count, plural, one {1 selezionato} other {# selezionati}}",
"actions": "Azioni di massa",
"select_all": "Seleziona tutto",
"delete": "Elimina",
"delete_confirm": "Eliminare {count, plural, one {1 contatto} other {# contatti}}?",
+1
View File
@@ -1189,6 +1189,7 @@
},
"bulk": {
"selected": "{count, plural, other {#件選択中}}",
"actions": "一括操作",
"select_all": "すべて選択",
"delete": "削除",
"delete_confirm": "{count, plural, other {#件の連絡先}}を削除しますか?",
+1
View File
@@ -1189,6 +1189,7 @@
},
"bulk": {
"selected": "{count, plural, one {1 geselecteerd} other {# geselecteerd}}",
"actions": "Bulk acties",
"select_all": "Alles selecteren",
"delete": "Verwijderen",
"delete_confirm": "{count, plural, one {1 contact} other {# contacten}} verwijderen?",
+1
View File
@@ -1189,6 +1189,7 @@
},
"bulk": {
"selected": "{count, plural, one {1 selecionado} other {# selecionados}}",
"actions": "Ações em massa",
"select_all": "Selecionar tudo",
"delete": "Excluir",
"delete_confirm": "Excluir {count, plural, one {1 contato} other {# contatos}}?",