PhoenixKitCatalogue.Web.Components.Browse (PhoenixKitCatalogue v0.25.0)

Copy Markdown View Source

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

category_chips(assigns)

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 to nil.
  • target (:any) - Defaults to nil.
  • 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 to false.

chip_categories(scope, locale)

@spec chip_categories(map(), String.t() | nil) :: [
  %{uuid: String.t(), name: String.t() | nil}
]

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.

column_toggle(assigns)

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 to nil.

default_table_columns()

@spec default_table_columns() :: [atom()]

The default column set — selling price with inline unit, no raw base price.

expand_scope(scope)

@spec expand_scope(map() | keyword()) :: map()

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.

format_price(d)

@spec format_price(term()) :: String.t() | nil

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.

grid_skeleton(assigns)

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 to 8.

item_card(assigns)

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 to false.
  • clickable (:boolean) - Defaults to true.
  • show_price (:boolean) - Defaults to true.
  • show_sku (:boolean) - Defaults to true.
  • 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 to true.
  • 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 to nil.
  • target (:any) - Defaults to nil.

Slots

  • footer

item_grid(assigns)

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 to nil.

Slots

  • inner_block (required)

item_row(assigns)

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 to false.
  • clickable (:boolean) - Defaults to true.
  • 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 to false.
  • 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 to nil.
  • 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 to true.
  • target (:any) - Defaults to nil.

Slots

  • qty - rendered in the :qty cell when that column is present.

item_table(assigns)

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 to false.

Slots

  • inner_block (required)

normalize_uuid(bin)

@spec normalize_uuid(term()) :: String.t() | nil

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.

present_items(items, locale)

@spec present_items([map() | struct()], String.t() | nil) :: [map()]

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.

qty_stepper(assigns)

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 (form phx-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 the id you 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 stable id leaves 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 to nil.

  • precision (:integer) - Defaults to 0.

  • min (:string) - min attr for the control (arrows stop here). Defaults to nil.

  • max (:string) - Defaults to nil.

  • select_floor (:string) - the smallest value the SERVER accepts as a selection (the host's qty_min) — may differ from min, 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 to min.

    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 to nil.

  • size (:string) - Defaults to "sm". Must be one of "xs", or "sm".

resolve_columns!(columns, display)

@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.

resolve_view!(view, default)

@spec resolve_view!(term(), String.t()) :: String.t()

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.

smart_fee(_)

@spec smart_fee(map() | struct()) :: {:price, Decimal.t()} | {:note, String.t()} | 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_unit set) 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).

table_columns()

@spec table_columns() :: [atom()]

The legal item_table/item_row column atoms, in canonical order.

view_toggle(assigns)

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 to nil.