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 withphx-hook="BulkSelectScope". Everything inside (header cell, row cells, toolbar buttons) participates in the same selection set. Passtotal_countso 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 withdata-bulk-actiondispatch 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)}
endWhy 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
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 ifallow_deleteis true. Defaults tonil.noun_singular(:string) - Defaults to"item".noun_plural(:string) - Defaults to"items".allow_delete(:boolean) - Defaults totrue.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 pushingon_open_reorder, so the modal appears instantly without waiting for the LV round-trip. Pair with<.reorder_modal>(which setskeep_in_dom={true}on its inner<.modal>). Omit to fall back to the round-trip-open flow used by conditionally-rendered modals. Defaults tonil.
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.
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".
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".
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
styleto the client —phx-mounted={JS.ignore_attributes(["style"])}. The hook hides it with an inlinedisplay: 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)