mirror of
https://github.com/root-fr/jmap-webmail.git
synced 2026-09-26 08:01:20 +00:00
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:
committed by
Matthieu MALVACHE
parent
3c0f3400b4
commit
f6492bbeda
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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}}?",
|
||||
|
||||
@@ -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}}?",
|
||||
|
||||
@@ -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}} ?",
|
||||
|
||||
@@ -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}}?",
|
||||
|
||||
@@ -1189,6 +1189,7 @@
|
||||
},
|
||||
"bulk": {
|
||||
"selected": "{count, plural, other {#件選択中}}",
|
||||
"actions": "一括操作",
|
||||
"select_all": "すべて選択",
|
||||
"delete": "削除",
|
||||
"delete_confirm": "{count, plural, other {#件の連絡先}}を削除しますか?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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}}?",
|
||||
|
||||
Reference in New Issue
Block a user