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. Most event names are fixed (documented per
component) so one handle_event/3 vocabulary serves every embedding;
view_toggle/column_toggle take an event attr and
item_card/item_row let the host name the details event
(photo_click/thumb_click). The search box and the load-more button
are NOT components here — the two shipped surfaces hand-roll that
markup (see their templates for the copyable shape).
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,
# fee_note: "12%"|"Computed"|nil,
# base_price: %Decimal{}|nil, unit: "piece",
# photo_url: "/…/medium/…"|nil, thumb_url: "/…/thumbnail/…"|nil,
# manufacturer: "…"|nil, category: "…"|nil,
# default_qty: %Decimal{1}}]item_row/1's default columns read thumb_url, category and
base_price too — a host hand-building presented maps needs the full
shape above, not a subset (fee_note is the one key read
Map.get-safely, so legacy hand-built maps merely lose the smart-fee
display rather than crash).
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).
The chip row's categories for a scope, translated for the viewer.
Only meaningful when the scope names exactly one catalogue — with
several (or all), a flat chip row of every category across catalogues
is noise, and search does the narrowing instead. An
:uncategorized_only scope contradicts every category narrowing
(search_items/2 raises on the combination), so it gets no chips.
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.
Expands a scope's :category_uuids through the category tree, once, at
init — so chips, BrowseState.category_allowed?/2 and preselect checks
all compare the same literal list the fetch layer queries. A
parent-category scope means "that category and its subtree"
(include_descendants defaults to true across the search vocabulary),
but every consumer compares literally — without this, descendant chips
vanish and narrowing to one is rejected as out of scope (2026-08-25
quorum review, finding 4; shared here 2026-08-30 so CatalogueBrowse
stops missing the fix the modal got).
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.
Signed URL for the 150px thumbnail variant, or nil — for the 32-48px
row cells that were shipping the 800px medium into a thumb-sized img
(bandwidth, not quality; 2026-08-29 image sweep). Same shape check as
featured_photo_url/1.
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.
Normalizes a uuid to its canonical string form. Tree.subtree_uuids_for/1
returns Postgres' raw 16-byte binaries; chips render and client events
carry strings, and comparing the two shapes silently never matches.
Denormalizes schema items into presented maps: translated name, signed
featured-photo URL, selling price (Catalogue.item_pricing/1's
final_price, matching ItemPicker), a :fee_note (smart_fee/1's
display text for fee items with no numeric price — nil otherwise, and
optional in hand-built maps, hence the Map.get reads downstream),
and a starting quantity of 1.
Quantity input: a native <input type="number"> — the browser's own
spinner arrows, the same control the rest of the kit uses for numbers
(2026-08-30, replacing the custom −/+ join stepper).
Resolves the granted column list — the host contract both browse
surfaces enforce. nil yields the default set minus what the
show_sku/show_prices display flags already opt out of; an explicit
non-empty list is taken verbatim, in order, and unknown entries raise —
a silently-dropped column is how a price ends up shown to the wrong
audience's sibling.
Validates a host-supplied view attr — "table" | "card" (atoms accepted),
raising on anything else. Both browse surfaces call this at init; each
passes its own default for nil.
What a surface can DISPLAY for a smart-catalogue fee item that has no
intrinsic price (the guide: a standalone default_value + "flat" IS
the price; percent fees and rule-priced items get their number at
order time, host-side)
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.
The chip row's categories for a scope, translated for the viewer.
Only meaningful when the scope names exactly one catalogue — with
several (or all), a flat chip row of every category across catalogues
is noise, and search does the narrowing instead. An
:uncategorized_only scope contradicts every category narrowing
(search_items/2 raises on the combination), so it gets no chips.
Metadata-only read (list_categories_metadata_for_catalogue/1) — the
full listing preloads every item just to render chips. Any failure
degrades to []: chips are navigation, not data.
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.
@spec default_table_columns() :: [atom()]
The default column set — selling price with inline unit, no raw base price.
Expands a scope's :category_uuids through the category tree, once, at
init — so chips, BrowseState.category_allowed?/2 and preselect checks
all compare the same literal list the fetch layer queries. A
parent-category scope means "that category and its subtree"
(include_descendants defaults to true across the search vocabulary),
but every consumer compares literally — without this, descendant chips
vanish and narrowing to one is rejected as out of scope (2026-08-25
quorum review, finding 4; shared here 2026-08-30 so CatalogueBrowse
stops missing the fix the modal got).
Anything not shaped like an expandable scope passes through untouched
for BrowseState.init/1 to validate loudly. Re-expanding is
idempotent, and a member's subtree cannot escape the root's subtree, so
narrowing stays inside the allow-list.
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.
The pointer comes from free-form JSONB, so it is shape-checked before it reaches a URL path: a non-UUID value (import garbage, a crafted admin write) renders no image instead of sending every viewer's browser a GET to an attacker-shaped path (2026-08-31 sweep).
Signed URL for the 150px thumbnail variant, or nil — for the 32-48px
row cells that were shipping the 800px medium into a thumb-sized img
(bandwidth, not quality; 2026-08-29 image sweep). Same shape check as
featured_photo_url/1.
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.selected_badge(:boolean) - the corner check badge on a selected card. Off in quantity mode (2026-08-31): a number above zero already says selected, and the extra check chrome read as "checkboxes are still there". Defaults totrue.photo_click(:string) - event name for a click on the photo area OR the title — the "view details" affordance (2026-08-30; the title joined the photo 2026-08-31: clicking it means the same as clicking the image). When set, figure and name become their own buttons dispatching this with the uuid, and only the REST of the body carries the select toggle. Nil keeps the whole face one target. Defaults tonil.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.checkbox(:boolean) - leftmost selection checkbox (2026-08-30): unchecked on every selectable row so the affordance is visible before the first pick. Display-only — while the row is clickable the CELL carries the same card_click toggle as the rest of the row (no second selection pathway to guard); with clickable={false} it is inert chrome. Pass the same value to item_table's checkbox attr or the header and body column counts skew. Defaults tofalse.thumb_click(:string) - event name for a click on the :thumb OR :name cell — the "view details" affordance (2026-08-30; the name joined the thumb 2026-08-31: clicking the title means the same as clicking the image). When set, those two cells stop carrying the row's select toggle and dispatch this instead, with the uuid. Nil keeps them plain select cells like every other. Defaults tonil.selected_icon(:boolean) - the name-cell check icon on a selected row (already absent when a checkbox column shows it instead). Off in quantity mode (2026-08-31): the number above zero is the selected signal. 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.checkbox(:boolean) - renders a leftmost selection-checkbox column — pair with item_row's. Defaults tofalse.
Slots
inner_block(required)
Normalizes a uuid to its canonical string form. Tree.subtree_uuids_for/1
returns Postgres' raw 16-byte binaries; chips render and client events
carry strings, and comparing the two shapes silently never matches.
Denormalizes schema items into presented maps: translated name, signed
featured-photo URL, selling price (Catalogue.item_pricing/1's
final_price, matching ItemPicker), a :fee_note (smart_fee/1's
display text for fee items with no numeric price — nil otherwise, and
optional in hand-built maps, hence the Map.get reads downstream),
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 input: a native <input type="number"> — the browser's own
spinner arrows, the same control the rest of the kit uses for numbers
(2026-08-30, replacing the custom −/+ join stepper).
Three event paths, one server vocabulary:
qty_change(formphx-change, debounced) — fires for spinner clicks and settled typing. Consumers should treat it as a LIVE update: apply valid values, silently ignore incomplete ones ("2." on the way to "2.5") — never reset the input from here, the user may still be typing.qty_commit(blur / Enter) — the authoritative commit; a consumer may reset rejected garbage here via the revision-bump pattern: put a per-row revision counter in theidyou pass (the shipped surfaces use"...-qty-#{uuid}-r#{rev}") and bump it on commit — the id change makes morphdom recreate the input with the server value, which a plain re-assign cannot do when the value attr is unchanged. A stableidleaves typed garbage stuck in the field.- Both carry
%{"uuid" =>, "value" =>}.
Integer mode is precision: 0 (the default; step 1); a decimal item is
the same control with precision > 0 (step 0.1 / 0.01 / …) and a unit
suffix. min/max/step shape the arrows and keyboard ONLY — the
form is novalidate, so they never gate the submit (a browser
validation failure would leave Enter silently dead), and every limit
is re-enforced server-side, exactly as before.
Attributes
id(:string) (required)uuid(:string) (required)qty(:string) (required) - display string, already formatted.unit(:string) - Defaults tonil.precision(:integer) - Defaults to0.min(:string) - min attr for the control (arrows stop here). Defaults tonil.max(:string) - Defaults tonil.select_floor(:string) - the smallest value the SERVER accepts as a selection (the host's qty_min) — may differ frommin, which is "0" in quantity mode so the down arrow can reach the deselect state. The instant-highlight hook flips to selected only at/above it; a value the server will reject changes nothing, because a rejection produces no diff to undo a premature flip (external review, 2026-08-31). Nil falls back tomin.Defaults to
nil.zero_deselects(:boolean) - whether a typed 0 previews as DESELECTED (quantity mode's contract). In click+inline_qty mode the server clamps 0 back to the minimum and keeps the row selected, so the preview must not un-highlight.Defaults to
false.target(:any) - Defaults tonil.size(:string) - Defaults to"sm". Must be one of"xs", or"sm".
@spec resolve_columns!(term(), %{ :show_sku => boolean(), :show_prices => boolean(), optional(atom()) => term() }) :: [atom()]
Resolves the granted column list — the host contract both browse
surfaces enforce. nil yields the default set minus what the
show_sku/show_prices display flags already opt out of; an explicit
non-empty list is taken verbatim, in order, and unknown entries raise —
a silently-dropped column is how a price ends up shown to the wrong
audience's sibling.
Validates a host-supplied view attr — "table" | "card" (atoms accepted),
raising on anything else. Both browse surfaces call this at init; each
passes its own default for nil.
What a surface can DISPLAY for a smart-catalogue fee item that has no
intrinsic price (the guide: a standalone default_value + "flat" IS
the price; percent fees and rule-priced items get their number at
order time, host-side):
{:price, %Decimal{}}— a flat standalone fee; safe to use as the price (line totals included).{:note, text}— display-only:"12%"for a percent fee, a localized "Computed" for a fee item (default_unitset) whose number is missing.nil— a plain item (priced or simply price-less). An item priced purely by catalogue RULES with no fee fields of its own lands here too: the rules live on the catalogue, not the row, so the two are indistinguishable at presentation time.
Before this, smart items rendered a BLANK price everywhere (2026-08-31 — tim-dev's rule-priced services, Nordic Line's fees).
@spec table_columns() :: [atom()]
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.