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

Reusable UI components for the Catalogue module.

All components are designed to be opt-in — features are off by default and
enabled via attributes. Import into any LiveView with:

    import PhoenixKitCatalogue.Web.Components

## Components

  * `search_input/1` — search bar with debounce and clear button
  * `search_results_summary/1` — "N results for …" / "X of Y" summary line
  * `scope_selector/1` — disclosure with catalogue/category checkbox lists
    for narrowing a search (pairs with `Catalogue.search_items/2` filters)
  * `catalogue_rules_picker/1` — smart-catalogue rule editor (checkbox +
    value + unit per catalogue; pairs with `Catalogue.put_catalogue_rules/3`)
  * `view_mode_toggle/1` — table/card view toggle synced via localStorage
  * `item_table/1` — configurable item table with selectable columns
  * `item_picker/1` — combobox for picking a single item via server-side
    search; backed by `Components.ItemPicker` LiveComponent, fires
    `{:item_picker_select, id, item}` / `{:item_picker_clear, id}` upward
  * `featured_image_card/1` — the shared featured-image card used on
    catalogue / category / item forms (thumbnail or empty state + picker
    buttons). Expects `open_featured_image_picker` / `clear_featured_image`
    events wired up in the owning LV — see `Attachments`.
  * `metadata_editor/1` — the shared metadata tab body for catalogue and
    item forms (opt-in fields from `Metadata.definitions/1`). Expects
    `add_meta_field` and `remove_meta_field` events wired up in the LV;
    text edits are absorbed via the form's `validate`.
  * `empty_state/1` — centered empty state card with message and optional action

Several of these (`search_input`, `search_results_summary`,
`view_mode_toggle`, `empty_state`) are deliberately generic — no
catalogue-specific schema knowledge — and are candidates for
promotion to `phoenix_kit` core once a coordinated release lands.
Keeping them here for now avoids coupling catalogue's hex dep to
unpublished core features.

## Examples

    <%!-- Minimal item table: just name and SKU --%>
    <.item_table items={@items} columns={[:name, :sku]} />

    <%!-- Full-featured table with search, pricing, and actions --%>
    <.item_table
      items={@items}
      columns={[:name, :sku, :base_price, :price, :unit, :status, :category, :manufacturer]}
      markup_percentage={@catalogue.markup_percentage}
      edit_path={&Paths.item_edit/1}
      on_delete="delete_item"
    />

    <%!-- Search bar --%>
    <.search_input query={@search_query} placeholder={Gettext.gettext(PhoenixKitWeb.Gettext, "Search items...")} />

# `catalogue_rules_picker`

Renders the smart-catalogue rule editor: one row per candidate
catalogue with a checkbox, a numeric value input, and a unit dropdown.

Pairs with `PhoenixKitCatalogue.Catalogue.put_catalogue_rules/3`. The
component is thin — the caller (usually `ItemFormLive`) owns the
working-rules state in a map `%{referenced_catalogue_uuid => %{value, unit}}`
and calls `put_catalogue_rules/3` on save.

**Event flow:**

  * `on_toggle` — `%{"uuid" => uuid}` when the checkbox is clicked.
    Caller toggles membership in its rules map.
  * `on_set_value` — `%{"uuid" => uuid, "value" => string}` when the
    user edits the amount input.
  * `on_set_unit` — `%{"uuid" => uuid, "unit" => string}` when the
    user picks a different unit.
  * `on_clear` — no params; clear every checked row. Shown only when
    at least one rule is active.

Rows for an unchecked catalogue render disabled inputs but stay
visible so the user always sees the full picker. When `value` is blank
and `item_default_value` is given, the input's placeholder previews
the inherited default (e.g. `"Inherit: 5"`). The unit dropdown is
self-contained per row — it does not inherit from any item-level
default, so changing the item's `default_unit` never flips a rule
row's visible unit.

## Attributes

  * `catalogues` — list of `%Catalogue{}` the user can pick (required).
    Typically `Catalogue.list_catalogues()` filtered to active/archived
    and excluding the parent smart catalogue itself.
  * `rules` — map `%{referenced_catalogue_uuid => %{value, unit}}`
    (or `%CatalogueRule{}` values; only `:value` / `:unit` are read).
    Unchecked catalogues simply don't appear in the map (default `%{}`).
  * `item_default_value` — item's `default_value`, used as the value
    input's placeholder (default `nil`)
  * `units` — list of unit options for the dropdown
    (default `["percent", "flat"]`). The first entry is the fallback
    shown when a rule has no unit set yet.
  * `on_toggle` — event name (default `"toggle_catalogue_rule"`)
  * `on_set_value` — event name (default `"set_catalogue_rule_value"`)
  * `on_set_unit` — event name (default `"set_catalogue_rule_unit"`)
  * `on_clear` — event name (default `"clear_catalogue_rules"`)
  * `id` — DOM id (default `"catalogue-rules-picker"`)
  * `class` — extra wrapper classes

## Example

    <.catalogue_rules_picker
      catalogues={@candidate_catalogues}
      rules={@working_rules}
      item_default_value={@item_default_value}
    />

## Attributes

* `catalogues` (`:list`) (required)
* `rules` (`:map`) - Defaults to `%{}`.
* `item_default_value` (`:any`) - Defaults to `nil`.
* `units` (`:list`) - Defaults to `["percent", "flat"]`.
* `on_toggle` (`:string`) - Defaults to `"toggle_catalogue_rule"`.
* `on_set_value` (`:string`) - Defaults to `"set_catalogue_rule_value"`.
* `on_set_unit` (`:string`) - Defaults to `"set_catalogue_rule_unit"`.
* `on_clear` (`:string`) - Defaults to `"clear_catalogue_rules"`.
* `id` (`:string`) - Defaults to `"catalogue-rules-picker"`.
* `class` (`:string`) - Defaults to `""`.

# `empty_state`

Renders an empty state card with a message and optional action slot.

## Attributes

  * `message` — the text to display (required)

## Slots

  * `inner_block` — optional action content (buttons, links)

## Attributes

* `message` (`:string`) (required)
## Slots

* `inner_block`

# `featured_image_card`

Renders the featured-image card used on catalogue, category, and item forms.

Shown on the form in a self-contained card: a thumbnail + file name + size
when an image is set, or a dashed empty-state with a primary button when
not. Owning LV must handle the three events wired up by this component:

  * `open_featured_image_picker` — opens the `MediaSelectorModal`
  * `clear_featured_image` — nulls the pointer
  * (change — same `open_featured_image_picker` event)

Each of those has a one-liner delegator to `Attachments`; see the
reference wiring in `catalogue_form_live.ex`, `category_form_live.ex`,
or `item_form_live.ex`.

## Attributes

  * `featured_image_uuid` — uuid string or nil; drives which branch renders
  * `featured_image_file` — the `%Storage.File{}` struct (for name/size) or nil
  * `subtitle` — override the default caption text (optional)
  * `class` — extra classes merged onto the outer card

## Examples

    <.featured_image_card
      featured_image_uuid={@featured_image_uuid}
      featured_image_file={@featured_image_file}
    />

    <.featured_image_card
      featured_image_uuid={@featured_image_uuid}
      featured_image_file={@featured_image_file}
      subtitle={gettext("Shown on category landing pages.")}
    />

## Attributes

* `featured_image_uuid` (`:string`) - Defaults to `nil`.
* `featured_image_file` (`:any`) - Defaults to `nil`.
* `subtitle` (`:string`) - Defaults to `nil`.
* `class` (`:string`) - Defaults to `""`.

# `item_picker`

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

Thin wrapper around the `ItemPicker` LiveComponent — it's the
LiveComponent that owns search state, events, and the colocated JS
hook. This wrapper exists so consumers have an attr-declared call
site and don't have to remember `<.live_component module={...}>`.

The parent LiveView reacts to two messages in its `handle_info/2`:

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

where `id` is the `:id` you passed in — handy for multiple pickers on
one page.

## Examples

    <.item_picker
      id={"row-#{@row.id}-picker"}
      category_uuids={[@category_uuid]}
      selected_item={@row.item}
      excluded_uuids={@used_uuids}
      locale="en"
    />

See `PhoenixKitCatalogue.Web.Components.ItemPicker` for the full attr
reference and the keyboard / a11y contract.

## Attributes

* `id` (`:string`) (required)
* `category_uuids` (`:list`) - Defaults to `nil`.
* `catalogue_uuids` (`:list`) - Defaults to `nil`.
* `include_descendants` (`:boolean`) - Defaults to `true`.
* `only` (`:atom`) - Restrict results to uncategorised or categorised items only. Defaults to `nil`. Must be one of `nil`, `:uncategorized_only`, or `:categorized_only`.
* `selected_item` (`:any`) - Defaults to `nil`.
* `excluded_uuids` (`:list`) - Defaults to `[]`.
* `locale` (`:string`) (required)
* `placeholder` (`:string`) - Defaults to `nil`.
* `empty_query_limit` (`:integer`) - Defaults to `10`.
* `page_size` (`:integer`) - Defaults to `20`.
* `disabled` (`:boolean`) - Defaults to `false`.
* `format_price` (`:any`) - Defaults to `nil`.

# `item_table`

Renders a configurable item table with optional card view toggle.

Columns are opt-in — only the columns you list are shown. Actions (edit, delete,
restore) are opt-in via their respective attributes.

## Attributes

  * `items` — list of items to display (required)
  * `columns` — list of column atoms to show (default: `[:name, :sku, :base_price, :status]`)
    Available: [:name, :sku, :base_price, :price, :discount, :final_price, :unit, :status, :category, :catalogue, :manufacturer]
  * `cards` — enable card view toggle (default: `false`). When enabled, renders a
    table/card toggle button and shows items as cards on mobile. The card view
    shows the item name as the title, selected columns as key-value fields,
    and action buttons in the card footer.
  * `id` — unique ID for the component (required when `cards` is true, used by
    the JS hook to persist view preference)
  * `markup_percentage` — catalogue markup for `:price` and `:final_price` columns
    (required when either is listed; ignored otherwise)
  * `discount_percentage` — catalogue discount for `:discount` and `:final_price`
    columns (required when either is listed; ignored otherwise). The `:discount`
    column honors per-item overrides via `Item.effective_discount/2`.
  * `edit_path` — 1-arity function `(uuid -> path)` to enable edit links
  * `on_delete` — event name for soft-delete button (e.g. `"delete_item"`)
  * `on_restore` — event name for restore button (e.g. `"restore_item"`)
  * `on_permanent_delete` — event name for permanent delete (e.g. `"show_delete_confirm"`)
  * `permanent_delete_type` — type string passed as `phx-value-type` (e.g. `"item"`)
  * `catalogue_path` — 1-arity function `(uuid -> path)` for catalogue links in `:catalogue` column
  * `variant` — table variant: `"default"` or `"zebra"` (default: `"default"`)
  * `size` — table size: `"xs"`, `"sm"`, `"md"`, `"lg"` (default: `"sm"`)
  * `wrapper_class` — override wrapper CSS class

## Examples

    <%!-- Table only --%>
    <.item_table items={@items} columns={[:name, :sku, :base_price]} />

    <%!-- With card view toggle --%>
    <.item_table
      items={@items}
      columns={[:name, :sku, :base_price, :price, :status]}
      cards={true}
      id="catalogue-items"
      markup_percentage={@catalogue.markup_percentage}
      edit_path={&Paths.item_edit/1}
      on_delete="delete_item"
    />

## Attributes

* `items` (`:list`) (required)
* `columns` (`:list`) - Defaults to `[:name, :sku, :base_price, :status]`.
* `cards` (`:boolean`) - Defaults to `false`.
* `show_toggle` (`:boolean`) - Defaults to `true`.
* `id` (`:string`) - Defaults to `nil`.
* `storage_key` (`:string`) - Defaults to `nil`.
* `markup_percentage` (`:any`) - Defaults to `nil`.
* `discount_percentage` (`:any`) - Defaults to `nil`.
* `edit_path` (`:any`) - Defaults to `nil`.
* `on_delete` (`:string`) - Defaults to `nil`.
* `on_restore` (`:string`) - Defaults to `nil`.
* `on_permanent_delete` (`:string`) - Defaults to `nil`.
* `permanent_delete_type` (`:string`) - Defaults to `"item"`.
* `catalogue_path` (`:any`) - Defaults to `nil`.
* `variant` (`:string`) - Defaults to `"default"`.
* `size` (`:string`) - Defaults to `"sm"`.
* `wrapper_class` (`:string`) - Defaults to `nil`.

# `metadata_editor`

Renders the metadata editor used inside the Metadata tab on the item
and catalogue forms — heading + empty-state alert + one text input
per attached key + add-picker dropdown.

Owner LV must handle the three events wired up by this component:

  * `add_meta_field` (from the add-picker `<.select>`'s `phx-change`)
  * `remove_meta_field` (per-row × button)
  * (text edits are absorbed by the form's `phx-change="validate"`
    via `Metadata.absorb_params/2`)

## Attributes

  * `resource_type` — `:item` or `:catalogue`; drives which
    `Metadata.definitions/1` list is consumed for the add-picker and
    for legacy-key detection
  * `state` — the `%{attached: [key], values: %{key => string}}` map
    produced by `Metadata.build_state/2` and kept on the socket
  * `id_prefix` — DOM-id prefix for inputs and the add-picker (so the
    same Metadata editor can render twice on a page without colliding)
  * `title` — heading text (optional, defaults to "Metadata")
  * `description` — the grey subtitle under the heading (optional)

## Examples

    <.metadata_editor
      resource_type={:catalogue}
      state={@meta_state}
      id_prefix="catalogue"
    />

## Attributes

* `resource_type` (`:atom`) (required)
* `state` (`:map`) (required)
* `id_prefix` (`:string`) (required)
* `title` (`:string`) - Defaults to `nil`.
* `description` (`:string`) - Defaults to `nil`.

# `scope_selector`

Renders a compact scope selector for narrowing a search to a subset of
catalogues and/or categories.

Designed to pair with `Catalogue.search_items/2`'s `:catalogue_uuids`
and `:category_uuids` options. The component is thin — the parent
LiveView owns the selection state and decides which catalogues and
categories are pickable. Typical flow:

    # LV loads the pickable set (e.g. via list_catalogues_by_name_prefix/2)
    socket
    |> assign(:scope_catalogues, Catalogue.list_catalogues_by_name_prefix("Kit"))
    |> assign(:scope_categories, [])
    |> assign(:selected_catalogue_uuids, [])
    |> assign(:selected_category_uuids, [])

Renders as a disclosure with a summary ("2 catalogues · all categories")
and two checkbox lists inside. Each section is only rendered when its
list is non-empty, so callers can use it for catalogue-only or
category-only scoping.

## Events

Emits four events (all names customizable via attrs):

  * `on_toggle_catalogue` — `%{"uuid" => uuid}` when a catalogue is clicked
  * `on_toggle_category` — `%{"uuid" => uuid}` when a category is clicked
  * `on_clear_catalogues` — no params; clear all catalogue selections
  * `on_clear_categories` — no params; clear all category selections

The LV toggles membership in its own selection lists, then re-runs
the search with the updated scope.

## Attributes

  * `catalogues` — list of `%Catalogue{}` the user can pick from (default `[]`)
  * `categories` — list of `%Category{}` the user can pick from (default `[]`)
  * `selected_catalogue_uuids` — currently selected catalogue UUIDs (default `[]`)
  * `selected_category_uuids` — currently selected category UUIDs (default `[]`)
  * `on_toggle_catalogue` — event name (default `"toggle_catalogue_scope"`)
  * `on_toggle_category` — event name (default `"toggle_category_scope"`)
  * `on_clear_catalogues` — event name (default `"clear_catalogue_scope"`)
  * `on_clear_categories` — event name (default `"clear_category_scope"`)
  * `id` — DOM id (default `"scope-selector"`)
  * `open` — force the disclosure open (default `false` — collapsed until clicked)
  * `class` — extra CSS classes on the wrapper

## Example

    <.scope_selector
      catalogues={@scope_catalogues}
      categories={@scope_categories}
      selected_catalogue_uuids={@selected_catalogue_uuids}
      selected_category_uuids={@selected_category_uuids}
    />

## Attributes

* `catalogues` (`:list`) - Defaults to `[]`.
* `categories` (`:list`) - Defaults to `[]`.
* `selected_catalogue_uuids` (`:list`) - Defaults to `[]`.
* `selected_category_uuids` (`:list`) - Defaults to `[]`.
* `on_toggle_catalogue` (`:string`) - Defaults to `"toggle_catalogue_scope"`.
* `on_toggle_category` (`:string`) - Defaults to `"toggle_category_scope"`.
* `on_clear_catalogues` (`:string`) - Defaults to `"clear_catalogue_scope"`.
* `on_clear_categories` (`:string`) - Defaults to `"clear_category_scope"`.
* `id` (`:string`) - Defaults to `"scope-selector"`.
* `open` (`:boolean`) - Defaults to `false`.
* `class` (`:string`) - Defaults to `""`.

# `search_input`

Renders a search input with debounce and clear button.

Emits `search` event with `%{"query" => value}` on change/submit,
and `clear_search` on clear button click. Override event names via attrs.

## Attributes

  * `query` — current search query string (required)
  * `placeholder` — input placeholder text. `nil` (default) resolves
    to a translated `gettext("Search...")` inside the component body.
    Pass an explicit string to override (e.g.
    `gettext("Search items...")`).
  * `on_search` — event name for search (default: "search")
  * `on_clear` — event name for clear (default: "clear_search")
  * `debounce` — debounce ms (default: 300)
  * `class` — additional CSS classes on the wrapper div

## Attributes

* `query` (`:string`) (required)
* `placeholder` (`:string`) - Defaults to `nil`.
* `on_search` (`:string`) - Defaults to `"search"`.
* `on_clear` (`:string`) - Defaults to `"clear_search"`.
* `debounce` (`:integer`) - Defaults to `300`.
* `class` (`:string`) - Defaults to `""`.

# `search_results_summary`

Renders a search results count summary line.

## Attributes

  * `count` — total number of matching results (required)
  * `query` — the search query string (required)
  * `loaded` — optional count of results currently rendered. When given
    and less than `count`, the summary shows "X of Y" so users know the
    list is paging. Omit or pass `nil` for a plain "N results" line.

## Attributes

* `count` (`:integer`) (required)
* `query` (`:string`) (required)
* `loaded` (`:integer`) - Defaults to `nil`.

# `view_mode_toggle`

Renders a table/card view toggle that syncs all tables sharing the same storage key.

Place this once at the top of a page, and set `show_toggle={false}` +
matching `storage_key` on the individual `item_table` components.

Uses the same localStorage mechanism as `table_default`'s built-in toggle,
so all tables reading the same key will respect the user's choice.

## Attributes

  * `storage_key` — the localStorage key to sync (required, must match the tables)
  * `class` — additional CSS classes

## Examples

    <.view_mode_toggle storage_key="catalogue-items" />
    <.item_table cards={true} show_toggle={false} storage_key="catalogue-items" ... />

## Attributes

* `storage_key` (`:string`) (required)
* `class` (`:string`) - Defaults to `""`.

---

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