PhoenixKitCatalogue.Web.Components.ItemPicker (PhoenixKitCatalogue v0.6.0)

Copy Markdown View Source

Combobox LiveComponent for picking a single item from the catalogue via server-side search.

Drop one into any LiveView — typically many, one per row in a picker table. Each instance owns its own search state; the parent LV only reacts to two messages:

{:item_picker_select, id, %Item{}}  # user chose an item
{:item_picker_clear,  id}           # user cleared the selection

API

<.item_picker
  id="row-42-picker"
  category_uuids={[category.uuid]}
  selected_item={@chosen_item}
  excluded_uuids={@already_used_uuids}
  locale="en"
/>

Attrs:

  • :id (required) — unique DOM/component id. The :item_picker_* messages echo this back so a parent with N pickers knows which fired.
  • :category_uuids — scope search to these categories. nil or [] means "all categories + uncategorized" (matches Catalogue.search_items/2).
  • :catalogue_uuids — scope search to these catalogues. Composes with :category_uuids (AND).
  • :include_descendants — when true (default), :category_uuids is expanded through the V103 tree; pass false for literal set semantics.
  • :only:uncategorized_only restricts results to items without a category; :categorized_only restricts to items in some category; nil (default) is unrestricted. Forwards to Catalogue.search_items/2's :only opt.
  • :selected_item — the %Item{} currently chosen (or nil). Drives the input text and the aria-selected / primary-border styling in the dropdown.
  • :excluded_uuids — items in this list are rendered dim + aria-disabled and cannot be clicked. Use for "already picked in another row" state.
  • :locale (required) — locale string for translated display names ("en", "es", etc.). Resolved via Catalogue.get_translation/2.
  • :placeholder — input placeholder. Defaults to "Search items…".
  • :empty_query_limit — how many items to show when the query is empty (the "just focused" state). Defaults to 10.
  • :page_size — max results fetched per query. Defaults to 20. When the unbounded count exceeds this the dropdown shows a "Type to refine…" sentinel row so the user knows there's more.
  • :disabled — disables the input and hides the clear button.
  • :format_price — 1-arity function taking an %Item{} (with :catalogue preloaded — the search always does this) and returning a display string or nil. Defaults to a Decimal stringifier of item_pricing(item).final_price. Return nil to omit the price column entirely.
  • :show_unit — when true, renders the item's measurement unit (via :format_unit) as a small muted label next to the price in each dropdown row. Defaults to false (no unit) so existing consumers are unaffected.
  • :format_unit — 1-arity function taking the item's unit string and returning a display label ("" to omit). Only used when :show_unit is true. Defaults to a built-in mapping of common abbreviations (piecepc, setset, pairpair, sheetsheet, m2, running_meterrm; unknown strings pass through). Supply your own to use a different unit vocabulary.
  • :highlight_selected — when true (default), the input gets the input-primary border while an item is selected. Pass false to suppress that highlight. Default preserves existing behaviour.
  • :initial_query — optional seed string for the search input. When provided (and nothing is selected and the user hasn't typed), the input is prefilled with this string and the dropdown opens with matching results on first render. Fires once; subsequent updates leave the query alone. Defaults to nil (no seeding).

Keyboard / a11y

Handled client-side by the colocated ItemPicker hook:

  • ArrowDown / ArrowUp cycle through enabled options (announced via aria-activedescendant; DOM focus stays on the input).
  • Home / End jump to first / last enabled option.
  • Enter activates the focused option (simulates a click so the normal select event fires).
  • Escape closes the dropdown and keeps focus on the input.
  • Clicking outside the picker closes it (phx-click-away).

The dropdown is absolutely positioned and elevated with z-50; the parent container must allow overflow (overflow: visible or just don't set overflow: hidden on an ancestor that clips it).