PhoenixKitCatalogue. Web. Components. ItemSelectorModal
(PhoenixKitCatalogue v0.20.0)
Copy Markdown
View Source
Catalogue item selector modal: the catalogue's analogue of core's
MediaSelectorModal. A logged-in user browses the catalogue inside a
modal — search, category chips, photo-forward card grid — picks items,
sets a quantity per item, reviews the selection in a tray, and confirms.
Usage
# In the parent LiveView's template. Mount it with :if — unmounting
# on close is what gives clean reopen semantics (fresh search, fresh
# scroll, preselects re-read).
<.live_component
:if={@show_item_selector}
module={PhoenixKitCatalogue.Web.Components.ItemSelectorModal}
id="order-item-selector"
scope={%{catalogue_uuids: [@catalogue.uuid]}}
selected={@order_lines_by_uuid}
/>Required host wiring (do not skip — silent failure otherwise)
This is a LiveComponent; it reports through process messages to the
host LiveView, exactly like MediaSelectorModal. The host MUST handle
both, or a confirmed selection is silently dropped:
handle_info({:items_selected, %{id: id, mode: mode, picks: picks}}, socket)— fired on Confirm, and only when at least one available pick exists: with nothing confirmable the event is refused server-side, exactly as the button is disabled client-side, sopicksis never[]. Each pick is a map with:uuid,:qty(always aDecimal, integers included — hosts write one clause),:unit, and a display snapshot::name,:sku,:price,:line_total(price × qty, nil when the item has no price) and:photo_url(a signed URL — it expires, render it, never persist it). The snapshot is for rendering the host's own summary without a re-query; it is NOT an order record — re-read and re-price items server-side when the selection becomes something real.handle_info({:item_selector_closed, %{id: id}}, socket)— fired on cancel/ESC/backdrop, AND after a confirm. Reset the:ifassign here.
Views and columns
Two presentations over the same fetch: view: "table" (the default — a
compact admin-look list) and view: "card" (the photo-forward grid). A
toggle beside the search box switches them; the choice is transient and
the host attr only sets the STARTING view — like scope, it is read at
init and not refreshed by later parent renders.
Table columns are a host contract, because the popup can be
client-facing: pass columns as a non-empty list from
Browse.table_columns/0 (:thumb :breadcrumb :name :sku :manufacturer :category :unit :price :base_price :qty) in display order, and only
those render. :breadcrumb is a headerless muted "Category /" prefix
column beside Name (granted and hidden by default, like :sku). Unknown entries raise. :price is the customer-facing selling
price (markup and discounts applied) shown as "6.40 / piece" — the
default set carries it and NOT :base_price, the raw internal number,
which an embed must ask for explicitly; :unit is the standalone
column for price-free lists.
Omitted, the full set applies minus what show_sku: false /
show_prices: false already opt out of. Omitting :qty hides the
inline stepper — quantities are then edited in the tray only.
Granted columns are additionally staged by viewport so the modal never
scrolls sideways: identity and the pick-driving numbers (thumb, name,
price, qty) hold down to phone width; unit returns at sm, SKU at
md, manufacturer and category at lg. The modal box itself widens on
large viewports (xl/2xl) beyond core Modal's 4xl cap. On phones the
card grid remains the roomier alternative, one toggle away.
Selection modes
selection_mode: "click" (default) is the classic picker: clicking a
row/card toggles it, and the stepper appears once selected.
selection_mode: "quantity" is the order-sheet flavour: EVERY rendered
row shows its stepper at 0, entering a positive quantity (plus button
or typing) IS the selection, stepping back to 0 removes it, and
rows/cards are not click-targets at all — no separate "select" step.
The tray, Confirm, and every guard behave identically in both modes.
Scope
scope fixes what the user may browse: any of :catalogue_uuids,
:category_uuids, :only, :statuses, :include_descendants (the
Catalogue.search_items/2 vocabulary). It is enforced in BrowseState
— every fetch re-derives from it, and client events can only narrow
within it, so a crafted event cannot browse or select outside what the
host allowed. Selection events are additionally accepted only for uuids
the component itself has rendered (or that arrived preselected).
Preselection
selected is %{uuid => qty} (string uuids, Decimal or integer
quantities). Preselected items are hydrated at mount ignoring scope —
the tray must be able to render what the host handed it. A hydrated item
that falls OUTSIDE the scope renders in the tray marked unavailable and
is excluded from the confirm picks, never silently dropped. A uuid that
no longer resolves at all (deleted or unknown) IS dropped — there is
nothing to render. Hydrated quantities are clamped like typed ones
(min/max/precision and the absolute ceiling), and in :single mode at
most one preselected entry survives (first by uuid).
Quantities
qty_precision: 0 (default) is whole numbers; a positive precision
turns the same stepper decimal-capable ("2.5" of unit "L"). The input
commits on blur/Enter; decimal commas are accepted ("2,5" — ru/et
keyboards); all limits re-clamped server-side.