defmodule MvWeb.Components.BulkActionsDropdown do @moduledoc """ Single "Aktionen" dropdown bundling the four member bulk actions, flattened to one level: open in email program (mailto), copy email addresses, export to CSV, export to PDF. It keeps the CSRF-protected `
` POST export items unchanged (CSV/PDF) and adds the mailto and copy items that previously lived as standalone header buttons next to a separate export dropdown. ## Scope and trigger badge The trigger reads `Aktionen` followed by a scope badge: an emphasized (`primary`) count `N` when `N` members are selected, and a muted (`neutral`) badge otherwise — `gefiltert` when a search term or filter narrows the list, `alle` when nothing is selected and no search/filter is active. Only an actual selection is emphasized. The badge sits inside the shared `dropdown_menu/1` trigger via its `trigger_badge` slot, matching the member-filter dropdown's count badge. The `scope`, `selected_count`, `mailto_bcc`, `recipient_count` and `mailto_disabled?` are computed by the parent LiveView and passed in. ## Recipient handling (mailto / copy) The parent already excludes members without an email when building `mailto_bcc` and `recipient_count` (defensive filter preserved verbatim from the previous behaviour). Export, by contrast, still includes every member in scope regardless of email — its payload is unchanged. ## Mailto recipient cap A mailto URI carries every recipient in its BCC; browsers cannot reliably hand a very long mailto over to the mail program. When `mailto_disabled?` is true (recipient count at or above `Mv.Constants.max_mailto_bulk_recipients/0`) the mailto item is rendered disabled (`aria-disabled`, `tabindex="-1"`, href dropped) with an explanatory tooltip. Copy and Export have no such cap. ## Event routing `dropdown_menu/1` sends `toggle_dropdown`/`close_dropdown` to `@myself`, so the component owns its own `:open` state. The copy item carries an *un-targeted* `phx-click="copy_emails"`, which therefore reaches the parent LiveView's `handle_event/3` (which keeps access to `@members`), plus the `CopyToClipboard` hook. """ use MvWeb, :live_component use Gettext, backend: MvWeb.Gettext # Same focus ring as CoreComponents button/dropdown (WCAG 2.4.7) defp dropdown_item_class do focus = MvWeb.CoreComponents.button_focus_classes() |> Kernel.++(["focus-visible:ring-inset"]) |> Enum.join(" ") "flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left whitespace-nowrap #{focus}" end @impl true def mount(socket) do {:ok, assign(socket, :open, false)} end @impl true def update(assigns, socket) do socket = socket |> assign(:id, assigns.id) |> assign(:export_payload_json, assigns[:export_payload_json] || "") |> assign(:selected_count, assigns[:selected_count] || 0) |> assign(:scope, assigns[:scope] || :all) |> assign(:mailto_bcc, assigns[:mailto_bcc] || "") |> assign(:recipient_count, assigns[:recipient_count] || 0) |> assign(:mailto_disabled?, assigns[:mailto_disabled?] || false) # The parent never sets :open (the component owns it via toggle/close). # Honouring an explicit :open assign keeps the component renderable in # isolation (render_component/2) for structural tests. socket = case Map.fetch(assigns, :open) do {:ok, open} -> assign(socket, :open, open) :error -> socket end {:ok, socket} end @impl true def render(assigns) do assigns = assigns |> assign(:scope_label, scope_label(assigns)) |> assign(:scope_variant, scope_variant(assigns)) ~H"""
<.dropdown_menu id={"#{@id}-menu"} button_label={gettext("Actions")} icon="hero-bolt" open={@open} phx_target={@myself} menu_width="w-70" menu_align="left" button_class="btn-secondary gap-2" testid="bulk-actions-dropdown" button_testid="bulk-actions-button" menu_testid="bulk-actions-menu" > <:trigger_badge> <.badge variant={@scope_variant} size="sm" data-testid="bulk-actions-scope-badge"> {@scope_label}
  • <.mailto_item mailto_bcc={@mailto_bcc} disabled={@mailto_disabled?} />
  • """ end # The mailto item is an anchor menu item. When over the recipient cap it is # rendered disabled following the same a11y pattern as a disabled CoreComponents # link button (href dropped, tabindex=-1, aria-disabled=true) and exposes the # explanatory tooltip via title. attr :mailto_bcc, :string, required: true attr :disabled, :boolean, required: true defp mailto_item(%{disabled: true} = assigns) do assigns = assign(assigns, :item_class, dropdown_item_class()) ~H""" <.icon name="hero-envelope" class="h-4 w-4" /> {gettext("Open in email program")} """ end defp mailto_item(%{disabled: false} = assigns) do assigns = assign(assigns, :item_class, dropdown_item_class()) ~H""" @mailto_bcc} class={@item_class} aria-label={gettext("Open in email program")} data-testid="bulk-actions-mailto" > <.icon name="hero-envelope" class="h-4 w-4" /> {gettext("Open in email program")} """ end defp over_threshold_tooltip do gettext("Too many recipients for this function. Copy the addresses or export the list.") end # The trigger scope is shown as a badge after the "Aktionen" label. Only an # actual selection is emphasized (primary); both the "filtered" and "all" # scopes are muted (neutral), since neither means members are selected. defp scope_label(assigns) do case assigns.scope do :selection -> to_string(assigns.selected_count) :filtered -> gettext("filtered") _ -> gettext("all") end end defp scope_variant(assigns) do case assigns.scope do :selection -> "primary" _ -> "neutral" end end @impl true def handle_event("toggle_dropdown", _params, socket) do {:noreply, assign(socket, :open, !socket.assigns.open)} end def handle_event("close_dropdown", _params, socket) do {:noreply, assign(socket, :open, false)} end end