Embeddable, selection-agnostic building blocks for browsing catalogue
items — the pieces ItemSelectorModal is assembled from, exposed so a
host LiveView can compose its own browse surface (a storefront section,
a picker, a read-only category wall) without copying markup.
Everything here is a pure function component: state in, events out. Each
interactive component takes a target (phx-target) so it works inside
a LiveComponent as well as straight in a LiveView — leave it nil and
events go to the host LV. Event names are fixed (documented per
component) so one handle_event/3 vocabulary serves every embedding.
The data these render is a presented item — a plain map produced by
present_items/2, which resolves translations and the featured-photo URL
once per fetch rather than on every render:
items
|> Browse.present_items(locale)
# => [%{uuid: "…", name: "…", sku: "…", price: %Decimal{}|nil,
# unit: "piece", photo_url: "/…/medium/…"|nil,
# manufacturer: "…"|nil, default_qty: %Decimal{1}}]Pair them with PhoenixKitCatalogue.Catalogue.BrowseState for the
fetch/paging state machine; the moduledoc there shows the loop.
Summary
Functions
Horizontally scrollable category filter chips: "All" plus one per
category. Dispatches browse_category with phx-value-uuid ("" for All).
A columns-visibility dropdown: one checkbox row per TOGGLEABLE column,
pushing event (default "toggle_column") with %{"col" => col} to
target on each row click. Presentation only — the caller owns which
columns are toggleable at all (its pre-approved set minus pinned ones)
and what is currently visible. Focus-based dropdown, so several columns
can be flipped before it closes on blur.
The default column set — selling price with inline unit, no raw base price.
Signed URL for an item's featured photo (medium variant), or
nil. Signing is pure computation — no Storage roundtrip — so this is safe
per item; it lives here so every surface resolves photos one way.
Formats a Decimal price for the card/tray. Bare number, no currency
symbol — the same convention as the module's item table (format_price
in Web.Components): which currency a price is in is host business the
catalogue has never decided.
Placeholder cards with the exact geometry of item_card/1, so the grid
does not reflow when real items arrive.
One product card: photo-forward (square, object-cover, lazy), then
name / sku / price. Selection chrome is NOT built in — the picker layers
it through the :footer slot and the selected ring, so a plain browse
embedding renders the same card with neither.
The responsive card grid. Cards come in through the default slot so the caller decides what a card is — this component owns only the layout.
One selectable row for item_table. Every cell except :qty carries the
same card_click toggle the card face uses — one event vocabulary, two
views — while the :qty cell (the :qty slot, typically a
qty_stepper) is deliberately not click-bound so stepping a quantity
can never toggle the row underneath it.
The table twin of item_grid: an admin-look list for the same presented
maps. Hosts configure which columns render (and their order) via
columns — the popup is potentially client-facing, so nothing is shown
that the host didn't ask for. Rows go inside via item_row/1 with the
SAME columns value.
Denormalizes schema items into presented maps: translated name, signed
featured-photo URL, selling price (Catalogue.item_pricing/1's
final_price, matching ItemPicker), and a starting quantity of 1.
Quantity stepper: minus / text input / plus. The input commits on blur or
Enter (qty_commit with %{"uuid" =>, "value" =>}) — never on keystroke,
so typing "2." on the way to "2.5" is not fought. The buttons dispatch
qty_dec / qty_inc immediately.
The legal item_table/item_row column atoms, in canonical order.
A server-driven segmented view toggle: one button per mode, the active
one highlighted, pushing event (default "set_view") with
%{"mode" => mode} to target. Presentation only — the caller owns the
state and any persistence. (The admin pages' view_mode_toggle is the
localStorage/client-side sibling; this one is for LiveComponents that
hold their view in assigns.)
Functions
Horizontally scrollable category filter chips: "All" plus one per
category. Dispatches browse_category with phx-value-uuid ("" for All).
Attributes
id(:string) (required)categories(:list) (required) - [%{uuid:, name:}].active_uuid(:any) - Defaults tonil.target(:any) - Defaults tonil.show_uncategorized(:boolean) - Adds an Uncategorized chip (value "uncategorized") after the category chips — for scopes where items without a category exist and the chips would otherwise never add up. Defaults tofalse.
A columns-visibility dropdown: one checkbox row per TOGGLEABLE column,
pushing event (default "toggle_column") with %{"col" => col} to
target on each row click. Presentation only — the caller owns which
columns are toggleable at all (its pre-approved set minus pinned ones)
and what is currently visible. Focus-based dropdown, so several columns
can be flipped before it closes on blur.
Attributes
id(:string) (required)columns(:list) (required) - toggleable columns, display order.visible(:list) (required)event(:string) - Defaults to"toggle_column".target(:any) - Defaults tonil.
The default column set — selling price with inline unit, no raw base price.
Signed URL for an item's featured photo (medium variant), or
nil. Signing is pure computation — no Storage roundtrip — so this is safe
per item; it lives here so every surface resolves photos one way.
Formats a Decimal price for the card/tray. Bare number, no currency
symbol — the same convention as the module's item table (format_price
in Web.Components): which currency a price is in is host business the
catalogue has never decided.
Placeholder cards with the exact geometry of item_card/1, so the grid
does not reflow when real items arrive.
Attributes
id(:string) (required)count(:integer) - Defaults to8.
One product card: photo-forward (square, object-cover, lazy), then
name / sku / price. Selection chrome is NOT built in — the picker layers
it through the :footer slot and the selected ring, so a plain browse
embedding renders the same card with neither.
Dispatches card_click with phx-value-uuid when clickable.
Attributes
id(:string) (required)item(:map) (required) - a presented item (see present_items/2).selected(:boolean) - Defaults tofalse.clickable(:boolean) - Defaults totrue.show_price(:boolean) - Defaults totrue.show_sku(:boolean) - Defaults totrue.target(:any) - Defaults tonil.
Slots
footer
The responsive card grid. Cards come in through the default slot so the caller decides what a card is — this component owns only the layout.
Attributes
id(:string) (required)class(:string) - Defaults tonil.
Slots
inner_block(required)
One selectable row for item_table. Every cell except :qty carries the
same card_click toggle the card face uses — one event vocabulary, two
views — while the :qty cell (the :qty slot, typically a
qty_stepper) is deliberately not click-bound so stepping a quantity
can never toggle the row underneath it.
Attributes
id(:string) (required)item(:map) (required) - a presented item (see present_items/2).columns(:list) (required) - the same list the item_table got.selected(:boolean) - Defaults tofalse.clickable(:boolean) - Defaults totrue.target(:any) - Defaults tonil.
Slots
qty- rendered in the :qty cell when that column is present.
The table twin of item_grid: an admin-look list for the same presented
maps. Hosts configure which columns render (and their order) via
columns — the popup is potentially client-facing, so nothing is shown
that the host didn't ask for. Rows go inside via item_row/1 with the
SAME columns value.
Attributes
id(:string) (required)columns(:list) (required) - subset of table_columns/0, display order.
Slots
inner_block(required)
Denormalizes schema items into presented maps: translated name, signed
featured-photo URL, selling price (Catalogue.item_pricing/1's
final_price, matching ItemPicker), and a starting quantity of 1.
Item.default_value is the smart-catalogue fee fallback (percent/flat),
not a pick quantity — do not use it as a stepper default.
Runs once per fetched page — never call translation or URL helpers from a template; a quantity keystroke re-renders every card.
Quantity stepper: minus / text input / plus. The input commits on blur or
Enter (qty_commit with %{"uuid" =>, "value" =>}) — never on keystroke,
so typing "2." on the way to "2.5" is not fought. The buttons dispatch
qty_dec / qty_inc immediately.
Integer mode is precision: 0 (the default); a decimal item is the same
component with precision > 0 and a unit suffix — no redesign, which
is the point. All limits are re-enforced server-side; these attrs only
shape the keyboard.
Attributes
id(:string) (required)uuid(:string) (required)qty(:string) (required) - display string, already formatted.unit(:string) - Defaults tonil.precision(:integer) - Defaults to0.target(:any) - Defaults tonil.size(:string) - Defaults to"sm". Must be one of"xs", or"sm".
The legal item_table/item_row column atoms, in canonical order.
A server-driven segmented view toggle: one button per mode, the active
one highlighted, pushing event (default "set_view") with
%{"mode" => mode} to target. Presentation only — the caller owns the
state and any persistence. (The admin pages' view_mode_toggle is the
localStorage/client-side sibling; this one is for LiveComponents that
hold their view in assigns.)
Attributes
id(:string) (required)modes(:list) (required) - [%{mode:, icon:, label:}] in display order.current(:string) (required)event(:string) - Defaults to"set_view".target(:any) - Defaults tonil.