PhoenixKitCatalogue. Web. Components. ItemSelectorModal
(PhoenixKitCatalogue v0.25.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, admin-style subcategory tiles, an admin-look table or
photo-forward card grid — picks items, sets a quantity per item, and
confirms.
Browsing levels (admin-page semantics, 2026-08-31)
Categories present exactly the way the admin detail page presents
them, from the same shared definitions (Components.category_card/1
tiles in card view, the category_header_cells/1 columns as a compact
table in table view). A root is just a level (Max, 2026-08-31): with
categories it lists the pure outline — top-level categories plus an
Uncategorized entry where the scope allows it — and items come from
entering a folder or searching; page 1 is not even fetched there.
Drilling into a category shows both sections, headed like the
admin's: its child categories, an Up button, and the level's OWN
items — while a non-empty search always covers the subtree of
wherever you stand (BrowseState's drill: :direct) and hides the
level navigation. A category-less root simply lists the items. A
MULTI-catalogue scope drills CATALOGUE-FIRST: the root lists the
offered catalogues as tiles, choosing one lands on that catalogue's
own root — its top categories, its Uncategorized bucket — and Up
climbs back through the same chain. The catalogue choice narrows
every fetch within the scope's offered list (BrowseState's
{:set_catalogue, _}, membership-checked like every narrowing).
root_switcher: true restores the admin-style Categories | Items |
| either-or at such roots (Catalogues | Items at a multi-catalogue |
root): Items is the flat no-query list of everything at that level. Off by default — the control read as a search-mode toggle while flipping the browse listing (both Max and the external UX review tripped over it), and search finds items better than the flat list browses them.
Search is the admin's two-list surface: item results are the primary, default list, and categories whose name matches (in any language) render above them as navigation — a hit opens that category's page with the search cleared. Hits are filtered to the scoped category tree, cover only the drilled subtree when drilled, and stay away from the opt-in root Items mode (the admin's items-type search) and the Uncategorized drill.
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),:fee_note(Browse.smart_fee/1's display text —"12%"or a localized "Computed" — for a smart-catalogue fee with no numeric price; nil otherwise.price: nil+ afee_notemeans "fee, compute it order-side", not "free") 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. One exception: with the item-details popup stacked open, the first cancel closes only the details — no message fires — and the next one closes the selector as usual. (Keeping Esc to the TOP popup client-side also needs the host's vendored corephoenix_kit.jsfrom core > 2.13.17; on older bundles both dialogs close visually and this message still fires once.)
Item details page
show_item_details (default true since 2026-08-31) makes the
photo/thumbnail a "look closer" affordance — the same gesture
ItemPicker ships — opening the item's full details as its OWN
ProductCard popup stacked over the selector (photo/file carousel,
SKU, price, unit, description, metadata, attributes). The selector
stays mounted underneath — search, tiles, scroll and selection are
exactly where the user left them. A mode-aware selection control sits
in the detail footer (Add/Remove in the checkbox flavour, the quantity
input in quantity mode); opening never auto-selects.
Pass false for embeds that must not expose the detail body —
description, metadata and attached files go beyond what the columns
contract granted, the same opt-out story as show_prices/show_sku
(both of which the page honours). With columns omitting :thumb the
table view has no entry point (cards keep theirs). Videos/FAQ sections
are a planned extension once the item data model carries them.
Header and tray
With context_header (default true) the modal's title area shows WHAT
is being browsed: when the scope names exactly one category (or,
failing that, one catalogue), its featured image, translated name and
description render as the header — the category wins as the more
specific. An explicit title attr still names the modal (the context
adds image/description around it); context_header: false, a
multi-entry scope, or any resolution failure falls back to the plain
title. Chrome, not data: nothing here widens what can be browsed.
show_tray (default FALSE since 2026-08-31) controls the cart-count
button and the expandable review list at the bottom. The quantity-first
default already shows a number above 0 on every picked row, so the
cart is opt-in chrome for hosts that want a review list. Cancel and
Confirm always stay.
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 host attr only sets the
STARTING view — like scope, it is read at init and not refreshed by
later parent renders — and a user's own toggle wins over it on the next
open when current_user is passed (see Per-user persistence).
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 (and the
popup derives the checkbox flavour; see Selection modes).
hidden_columns sets which GRANTED columns start hidden (the viewer
re-shows them from the Columns dropdown): default [:breadcrumb] —
SKU is visible by default since 2026-08-31 (the boss: the article
number belongs on the list) — and [] starts everything visible.
Unknown or ungranted entries are ignored — hiding less than asked
never widens anything. The detail page follows the GRANT (plus the
flags), not the visibility: hiding a granted :price doesn't strip it
from details, un-granting it does.
Per-user persistence
Pass current_user (the phoenix_kit_users struct the host's
live_session already assigns) and the selector remembers the view and
column visibility each user last chose — stored beside the admin
tables' preferences in custom_fields (ViewConfig.load_selector/1),
one set per user across every selector embed. The saved choice beats
the host's STARTING attrs (view, hidden_columns), never the grant:
saved names outside columns are ignored, and quantity mode still
forces :qty visible. No user, no persistence — every choice simply
lives for the session.
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 — either quantities or checkboxes
The two flavours are mutually exclusive, and the DEFAULT derives from
the columns (2026-08-31): a visible :qty column makes the popup
quantity-first — EVERY rendered row shows its quantity control at 0,
entering a positive quantity (spinner arrows or typing) IS the
selection, zero removes it, and rows/cards are not click-targets —
while a popup without :qty is the checkbox flavour: the table leads
with a checkbox column (unchecked on every selectable row, so the
"you can pick these" affordance is visible before the first pick),
clicking a row/card toggles it, and quantities are edited in the tray.
A checked box and a quantity input never share a row — one selected
signal, not two.
selection_mode: "click" | "quantity" still forces a flavour
explicitly, and the either-or holds even then (Max, 2026-08-31: a
checkmark means no number entry, and the check sits in the leftmost
column): forcing "click" with a visible :qty leads with the checkbox
column and the qty cell shows the picked amount READ-ONLY — meaningful
for preselects a host hands in at other quantities; user picks land at
the minimum. A host that wants users EDITING quantities in this
flavour must open a surface for it: inline_qty: true (below) or
show_tray: true (off by default). inline_qty: true is the
deliberate opt-in for hosts that really want both — it restores the
legacy pairing (no checkbox column, check icon/badge plus a stepper
once selected).
Quantity mode ignores it. The tray, Confirm, and every guard behave
identically in all flavours.
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
The control is a native <input type="number"> (browser spinner
arrows — 2026-08-30). qty_precision: 0 (default) is whole numbers
(step 1); a positive precision turns it decimal-capable ("2.5" of unit
"L", step 0.1). Arrow clicks and settled typing apply live (debounced
qty_change, which never resets in-progress text); blur/Enter is the
authoritative commit that discards garbage. Decimal commas are accepted
("2,5" — ru/et keyboards); all limits re-clamped server-side.