PhoenixKitWeb.Components.Core.BulkSelect (phoenix_kit v2.37.1)

Copy Markdown View Source

Bulk-select toolkit for admin tables, client-side. The selection lives in the browser; the server only learns about it at action time (when the user clicks an action button) via the BulkSelectScope JS hook. This makes per-checkbox toggles feel instant — no LV round-trip on every click.

Three function components compose with <.table_default>, plus a wrapper element with the hook attached.

  • <.bulk_select_scope> — opens an inline-styled wrapper with phx-hook="BulkSelectScope". Everything inside (header cell, row cells, toolbar buttons) participates in the same selection set. Pass total_count so the hook knows when "all" is checked.

  • <.bulk_select_header_cell> — the header checkbox. Tri-state (unchecked / indeterminate / checked) is managed by the hook.

  • <.bulk_select_cell> — per-row checkbox bound to a UUID value.

  • <.bulk_actions_toolbar> — the toolbar above the table. Buttons with data-bulk-action dispatch LV events with the selected UUIDs in the {uuids: [...]} payload.

Example

<.bulk_select_scope id="projects-bulk" total_count={length(@projects)}>
  <.bulk_actions_toolbar
    on_open_reorder="open_reorder_modal"
    on_clear_selection="clear"
    noun_singular={gettext("project")}
    noun_plural={gettext("projects")}
  />

  <.table_default id="projects-list" size="sm">
    <.table_default_header>
      <.table_default_row>
        <.bulk_select_header_cell
          id="projects-select-all"
          aria_label={gettext("Select all projects")}
        />
        <.table_default_header_cell>Name</.table_default_header_cell>
        ...
      </.table_default_row>
    </.table_default_header>
    <tbody>
      <.table_default_row :for={p <- @projects}>
        <.bulk_select_cell value={p.uuid} />
        <.table_default_cell>{p.name}</.table_default_cell>
        ...
      </.table_default_row>
    </tbody>
  </.table_default>
</.bulk_select_scope>

The consumer LV handles on_open_reorder (etc.) with a payload of %{"uuids" => uuids}:

def handle_event("open_reorder_modal", %{"uuids" => uuids}, socket) do
  {:noreply, assign(socket, show_reorder_modal: true, captured_uuids: uuids)}
end

Why client-side

Server-side selection (each click is a phx-click round-trip) forces a re-render with every toggle, which feels laggy at any meaningful network latency. Bulk-select is a high-frequency UI interaction where the server only needs to know the selection at the moment it acts on it — making the client the natural owner.

Summary

Functions

Floating toolbar above the table. Built-in actions: Reorder, Delete, Clear. Each button is opt-in via flags / event-name attrs. Toolbar always renders; the count text + button labels + visibility update live as the user toggles checkboxes.

Per-row checkbox cell. The value is captured into the selection set when checked; it's the identifier the server receives in {uuids: [...]} when an action fires.

Header checkbox cell — drop-in replacement for <.table_default_header_cell> in the bulk-select column. The BulkSelectScope hook drives its checked / indeterminate state based on the current selection.

Opens a bulk-select scope. Everything inside this wrapper participates in the same selection set.

Functions

bulk_actions_toolbar(assigns)

Floating toolbar above the table. Built-in actions: Reorder, Delete, Clear. Each button is opt-in via flags / event-name attrs. Toolbar always renders; the count text + button labels + visibility update live as the user toggles checkboxes.

Reorder is mandatory (on_open_reorder is required). Delete and Clear are optional.

Attributes

  • on_open_reorder (:string) (required) - Event pushed when the Reorder button is clicked. Receives %{"uuids" => uuids}.
  • on_bulk_delete (:string) - Event pushed when Delete is clicked. Required if allow_delete is true. Defaults to nil.
  • noun_singular (:string) - Defaults to "item".
  • noun_plural (:string) - Defaults to "items".
  • allow_delete (:boolean) - Defaults to true.
  • reorder_gate (:atom) - When :always, the Reorder button is always visible — label is 'Reorder all' when 0–1 rows are selected, 'Reorder N selected' when 2+. (A one-row reorder is a no-op, so we keep the 'Reorder all' label there; the consumer LV normalises 1-uuid scopes to :all when applying.) When :multi, hidden unless count > 1 — useful when the surrounding context has no meaningful 'reorder all' interpretation (e.g. the list is currently sorted by name, not the manual position field). Defaults to :always. Must be one of :always, or :multi.
  • reorder_dialog_id (:string) - Optional id of a kept-in-DOM reorder modal (e.g. "reorder-modal"). When set, the Reorder button opens that dialog locally via the BulkSelectScope hook BEFORE pushing on_open_reorder, so the modal appears instantly without waiting for the LV round-trip. Pair with <.reorder_modal> (which sets keep_in_dom={true} on its inner <.modal>). Omit to fall back to the round-trip-open flow used by conditionally-rendered modals. Defaults to nil.

Slots

  • leading - Content rendered on the left of the toolbar before the action buttons. Common use: tuck a sort selector in here so the toolbar reads as one widget.
  • trailing - Content rendered at the far right of the toolbar, after the contextual Reorder/Delete/Clear buttons. Common use: a view-mode switcher that should sit visually apart from the filter/sort controls on the left.

bulk_select_cell(assigns)

Per-row checkbox cell. The value is captured into the selection set when checked; it's the identifier the server receives in {uuids: [...]} when an action fires.

Attributes

  • value (:string) (required)
  • class (:string) - Defaults to "w-8".

bulk_select_header_cell(assigns)

Header checkbox cell — drop-in replacement for <.table_default_header_cell> in the bulk-select column. The BulkSelectScope hook drives its checked / indeterminate state based on the current selection.

Attributes

  • id (:string) (required)
  • aria_label (:string) - Defaults to "Toggle select all".
  • class (:string) - Defaults to "w-8".

bulk_select_scope(assigns)

Opens a bulk-select scope. Everything inside this wrapper participates in the same selection set.

Attributes

  • id (:string) (required)

  • total_count (:integer) (required)

  • class (:string) - Defaults to "".

  • swap (:string) - CSS selector for a toolbar OUTSIDE this scope to hide while the scope holds a selection — the action bar takes its place instead of being added above the rows.

    Revealing the bar without hiding anything pushes every row down, and the next click then lands on the wrong checkbox (boss via Max, 2026-09-20; measured at 52px on the catalogue's category list, more than one row). The controls it replaces — sort, status tabs, columns, the view toggle — are not usable mid-selection anyway.

    Several scopes on a page may name the same toolbar; it stays hidden while any of them has a selection.

    ⚠️ The target must hand style to the clientphx-mounted={JS.ignore_attributes(["style"])}. The hook hides it with an inline display: none, and a target that renders no style from the server has that one stripped by the patcher on its next re-render (a PubSub refresh changing a tab count is enough), putting the toolbar back UNDER the action bar and restoring the very shift this exists to prevent. The hook re-applies on any patch of the SCOPE, which is why the hole only shows when the target is patched alone.

    Defaults to nil.

Slots

  • inner_block (required)