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.
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"".
Slots
inner_block(required)