The chrome every document card page wears: the control row, the count over the list, the loading veils and the pagination underneath.
MishkaDocumentWeb.Templates.IssueCard, MishkaDocumentWeb.Templates.DocumentSection and
MishkaBlogWeb.Templates.CollectionCard draw the same
furniture around different cards — only render_item/1 really differs between them — so the furniture
lives here once rather than being copied per template.
Most of what is here are the pieces, and a template that lays its page out differently takes only the
pieces it wants. But the six blog card pages had assembled those pieces into byte-identical pages —
six copies of the same ninety lines differing in a wrapper class, two empty labels and an icon — so
card_scaffold/1 is the assembled page as well, for the templates whose layout genuinely is the
common one. A template with its own layout (MishkaDocumentWeb.Templates.DocumentCard) still builds
it from the pieces.
Everything here is a plain Phoenix.Component, so a template calls it as
<Shared.filter_bar … />. The events (filter, clear_filters, archive_filter,
change_page_size, prev_page / next_page / go_to_page, toggle_select_all) and the input names
are Gervaz's contract — only the look is ours.
See MishkaGervaz.Table.Templates.Shared, which this leans on for the parts that stay generic
(render_filter/1, render_bulk_actions/1, relation filter state), and
MishkaCmsCoreWeb.UIAdapters.Admin, which draws the archive switch and the row actions.
Summary
Functions
A whole card page: the count, the order and the view switch, the filter row, the bulk bar, the stream and its pager.
Hides a card's extra edition chips again — the exact inverse of expand_editions/1.
The tooltip on an edition chip — "Open the FA edition".
What a stream shows when it holds nothing.
Reveals a card's remaining edition chips.
The control row — search, the archive switch, an Advanced panel holding every other filter — plus whatever the page wants on either side of it.
Every filter laid out at once, each under its own label, with the switches and the count beneath.
The spinner shown while the first page is still on its way.
A veil over the list while it reloads — the cards stay readable underneath, so the spinner rides on the page rather than on a panel of its own.
The scrolling body of an admin modal, capped so the head and footer stay put on a short screen.
The footer of an admin modal — a ruled band under the body for counts and controls.
The head of an admin modal: an icon tile, a title, and the thing the modal is about beneath it.
Which page buttons to draw: all of them while they fit, otherwise the ends, a window around where you are, and an ellipsis for the rest.
What sits under the list: numbered pages, or a Load More button over the page-size picker.
Which slice of the result set is under your eyes — Showing 1–20 of 48.
The Select-all checkbox on the left, the count on the right, immediately over the list.
What the select-all tick does when clicked, given whether it is currently on.
How many records the table holds in total — the 24 items over the grid.
Functions
A whole card page: the count, the order and the view switch, the filter row, the bulk bar, the stream and its pager.
This is the layout the blog card pages share — a list or a grid of records with the same furniture around it. What differs between them is only what a card is called and how its records are laid out, so those arrive as classes and labels rather than as six copies of this markup.
The records themselves stay with the caller, in the :records slot: they are that template's own
render_item/1 over the stream, and the stream loop has to be written where @stream is. This only
supplies the phx-update="stream" container it belongs in, and the empty state that shares it.
Pass the five show_* flags from MishkaGervaz.Table.Templates.Shared.assign_card_flags/1.
The caller keeps the page's outermost <div> rather than passing its class in: a template's
render/1 is the root of a stateful LiveComponent, and LiveView requires that root to be a literal
HTML tag — a component call there raises "Stateful components must have a single static HTML tag at
the root".
Attributes
static(:map) (required)state(:map) (required)myself(:any) (required)container_class(:string) (required) - the stream container: a column, or a grid.data_role(:string) (required) - test/debug handle on the stream container.empty_label(:string) (required) - the count's zero form, e.g.0 posts.empty_icon(:string) (required)empty_class(:string) -col-span-fullwhen the container is a grid. Defaults tonil.show_checkboxes(:boolean) (required)show_filters(:boolean) (required)show_pagination(:boolean) (required)show_bulk_actions(:boolean) (required)show_switcher(:boolean) (required)before_table(:any) - optional slot the page puts above the filter row. Defaults tonil.
Slots
records(required)
@spec collapse_editions(String.t()) :: Phoenix.LiveView.JS.t()
Hides a card's extra edition chips again — the exact inverse of expand_editions/1.
The tooltip on an edition chip — "Open the FA edition".
Every card that lists sibling editions words this the same way, so the string is translated once.
What a stream shows when it holds nothing.
Muted by class rather than dropped by :if — a stream container keeps children the server stops
rendering, and hidden only:flex is what makes this appear exactly while it is the sole child.
Attributes
id(:string) (required)static(:map) (required)icon(:string) - Defaults to"hero-document".class(:string) - Defaults tonil.
@spec expand_editions(String.t()) :: Phoenix.LiveView.JS.t()
Reveals a card's remaining edition chips.
Opening the row is a client-side class flip, not a round trip: the chips are already rendered, and
a stream row's expanded state must not depend on the server remembering it. Every id is scoped by
the row's own DOM id so one card's toggle cannot reach another's — the card supplies that id and
must name its three elements <id>-editions-rest, -more and -less.
hidden alone cannot hide a button that also carries inline-flex — both set display, and which
one wins is down to their order in the stylesheet, not the order they were added. So each toggle
takes the display class off as well as putting hidden on.
The control row — search, the archive switch, an Advanced panel holding every other filter — plus whatever the page wants on either side of it.
Nothing here names a filter: the one :text filter becomes the search box and the rest fall into the
panel, so the resource stays the single source of truth. accessible?/2 is what keeps a restricted
filter (a site or contributor picker) away from a tenant and a visible false one (a deep-link id)
out of the panel entirely.
The archive switch draws its own archive_filter form, so it stays a sibling of the filter form
rather than a child — a nested form is dropped by the parser, and its status radio would then be
serialized into filter, where it collides with a status filter of the same name and narrows every
search to nothing. The filter form is display: contents, so this row lays its children out and
order-* puts them back in the design's sequence.
Slots
:leading— sits before the search box (a primary action such as "Add Section"):trailing— sits after the Advanced button (a view switcher)
Attributes
static(:map) (required)state(:map) (required)myself(:any) (required)search_placeholder(:string) - Defaults tonil.
Slots
leadingtrailing
Every filter laid out at once, each under its own label, with the switches and the count beneath.
The alternative to filter_bar/1: a page with only a handful of filters shows them all rather than
hiding them behind a drawer. :boolean filters drop out of the grid and join the Select-all line as
switches, which is where a "Featured only" reads naturally.
All of it — including Select all — sits in one phx-change="filter" form, because Gervaz rebuilds
the whole filter set from the submitted params on every change: a filter left out of the form is a
filter cleared. The archive switch stays outside, since it draws a form of its own and a nested form
is dropped by the parser.
Attributes
static(:map) (required)state(:map) (required)myself(:any) (required)show_checkboxes(:boolean) (required)empty_label(:string) - Defaults tonil.
The spinner shown while the first page is still on its way.
Attributes
text(:string) - Defaults tonil.
A veil over the list while it reloads — the cards stay readable underneath, so the spinner rides on the page rather than on a panel of its own.
Attributes
text(:string) - Defaults tonil.
The scrolling body of an admin modal, capped so the head and footer stay put on a short screen.
Its scrollbar is hidden, as every scrolling surface in this admin is.
Attributes
class(:string) - Defaults tonil.
Slots
inner_block(required)
The head of an admin modal: an icon tile, a title, and the thing the modal is about beneath it.
Every modal on these pages wears the same head — version history, an edition's details, an edit form —
so they read as one surface with different contents rather than three unrelated dialogs. Pair it with
MishkaCmsCore.CoreComponents.modal/1 given padding_class="p-0", which is what lets the head sit
edge to edge above a scrolling body.
The right padding leaves room for the modal's own close button; anything in the :actions slot sits
to the left of it. When the icon, title and subtitle need the row, the actions move to a row of
their own beneath them, wrapping again if they still do not fit.
Attributes
icon(:string) (required)title(:string) (required)subtitle(:string) - Defaults tonil.tone(:string) - Defaults to"bg-[#f2f1fc] text-[#5b57d6]".
Slots
actions
@spec page_window(pos_integer(), pos_integer()) :: [pos_integer() | :gap]
Which page buttons to draw: all of them while they fit, otherwise the ends, a window around where you are, and an ellipsis for the rest.
What sits under the list: numbered pages, or a Load More button over the page-size picker.
Every event and figure here is Gervaz's — only the look is ours. A table that asked for :infinite
keeps the shared pagination, which is the only thing that knows how to draw it.
Attributes
static(:map) (required)state(:map) (required)myself(:any) (required)
@spec range_label(map(), pos_integer(), String.t() | nil) :: String.t()
Which slice of the result set is under your eyes — Showing 1–20 of 48.
The last page holds a remainder rather than a full page, so the upper bound is clamped by the total rather than by where the page would end.
The Select-all checkbox on the left, the count on the right, immediately over the list.
The checkbox keeps the gervaz-select-all-checkbox class because that is what
MishkaGervaz.Table.Templates.Shared.uncheck_all/2 and check_all_gallery/2 target — without it the
optimistic toggle silently no-ops and the box stays checked for a round trip after "Clear selection".
Attributes
static(:map) (required)state(:map) (required)myself(:any) (required)show_checkboxes(:boolean) (required)empty_label(:string) - Defaults tonil.
@spec toggle_all_js(boolean()) :: Phoenix.LiveView.JS.t()
What the select-all tick does when clicked, given whether it is currently on.
The event and the DOM flip travel together: the server is told to invert the selection, and the same click sets or clears every checkbox on the page without waiting for the round trip. Public because a card template that draws its own toolbar still needs this exact pair.
How many records the table holds in total — the 24 items over the grid.
Unlike range_label/3 this counts the whole result set rather than the page, because the grid's own
count sits beside the view switch where the page is not yet the subject.