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).
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.
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.
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(:string) - Defaults tonil.target(:any) - Defaults tonil.
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)
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".