PhoenixKitCatalogue.Web.Components.ItemSelectorModal (PhoenixKitCatalogue v0.24.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 that has categories carries the admin's Categories | Items switcher: Categories (the default) is the pure category outline — top-level categories plus an Uncategorized entry where the scope allows it — and Items is the flat list of everything in scope. 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 has no switcher and simply lists the items.

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 root's 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, so picks is never []. Each pick is a map with :uuid, :qty (always a Decimal, 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 :if assign here.

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 (forcing "click" with a visible :qty keeps the legacy behaviour: control appears once selected, no checkbox column). 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

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.