# `PhoenixKitCatalogue.Web.Components.ItemSelectorModal`
[🔗](https://github.com/BeamLabEU/phoenix_kit_catalogue/blob/v0.31.3/lib/phoenix_kit_catalogue/web/components/item_selector_modal.ex#L1)

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}
    />

## Other attrs the host may pass

  * `mode` — `:multi` (default) or `:single`.
  * `immediate` — with `mode: :single`, confirm on the first pick: the
    `{:items_selected, …}` message fires at once and the Cancel/Confirm
    row is not rendered. Default `false`.
  * `per_page` — page size for the listing and load-more. Default `50`.
  * `locale` — forces the display language; omitted, the process gettext
    locale applies (the fallback the browse widget and picker share).

## 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),
    `: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` + a `fee_note` means "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 `:if` assign
    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 core
    `phoenix_kit.js` from core > 2.13.17; on older bundles both
    dialogs close visually and this message still fires once.)

## Live while open

The popup follows the catalogue for as long as it is open, with no
host wiring: a relay process (`PhoenixKitCatalogue.Web.ComponentRelay`)
holds the PubSub subscription for the component and pushes one
debounced refresh per burst of writes through `send_update/3`. A price
corrected on the admin side, an item trashed by another user, a
reordered or renamed category or catalogue — the tiles, the listing
(every page loaded so far), the tray and the stacked details re-read
in place, while the user's search, level, scroll and selection stay
exactly where they were. The relay stops with the popup; a host that
unmounts the component without a close is detected by the relay's
ack timeout.

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
