diff --git a/components/contacts/__tests__/contact-list.test.tsx b/components/contacts/__tests__/contact-list.test.tsx index 9a7754a..ca5fc60 100644 --- a/components/contacts/__tests__/contact-list.test.tsx +++ b/components/contacts/__tests__/contact-list.test.tsx @@ -89,8 +89,8 @@ describe('ContactList', () => { it('shows bulk action bar when contacts are selected', () => { render(); - 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', () => { diff --git a/components/contacts/contact-bulk-actions-menu.tsx b/components/contacts/contact-bulk-actions-menu.tsx new file mode 100644 index 0000000..ee3d3d1 --- /dev/null +++ b/components/contacts/contact-bulk-actions-menu.tsx @@ -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(null); + const triggerRef = useRef(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 ( +
+ + + setIsOpen(false)} + > + handleAction(onBulkAddToGroup)} + /> + handleAction(onBulkExport)} + /> + + handleAction(onBulkDelete)} + destructive + /> + +
+ ); +} diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index 4da1284..4065548 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -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({ {hasSelection && ( -
+
{t("bulk.selected", { count: selectedContactIds.size })}
- - - + diff --git a/locales/de/common.json b/locales/de/common.json index 0c11410..3f1d068 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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?", diff --git a/locales/en/common.json b/locales/en/common.json index 6e9afb8..e3c21a5 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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}}?", diff --git a/locales/es/common.json b/locales/es/common.json index 2b6006b..6d6aa09 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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}}?", diff --git a/locales/fr/common.json b/locales/fr/common.json index 43aeb10..d4e00f5 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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}} ?", diff --git a/locales/it/common.json b/locales/it/common.json index 0f3780a..c6170f2 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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}}?", diff --git a/locales/ja/common.json b/locales/ja/common.json index cbd2f96..e208548 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1189,6 +1189,7 @@ }, "bulk": { "selected": "{count, plural, other {#件選択中}}", + "actions": "一括操作", "select_all": "すべて選択", "delete": "削除", "delete_confirm": "{count, plural, other {#件の連絡先}}を削除しますか?", diff --git a/locales/nl/common.json b/locales/nl/common.json index 2346a5c..6f4db7e 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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?", diff --git a/locales/pt/common.json b/locales/pt/common.json index 03b6fe5..20d7af1 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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}}?",