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.ComponentsComponents
search_input/1— search bar with debounce and clear buttonsearch_results_summary/1— "N results for …" / "X of Y" summary linescope_selector/1— disclosure with catalogue/category checkbox lists for narrowing a search (pairs withCatalogue.search_items/2filters)catalogue_rules_picker/1— smart-catalogue rule editor (checkbox + value + unit per catalogue; pairs withCatalogue.put_catalogue_rules/3)view_mode_toggle/1— table/card view toggle synced via localStorageitem_table/1— configurable item table with selectable columnsitem_picker/1— combobox for picking a single item via server-side search; backed byComponents.ItemPickerLiveComponent, fires{:item_picker_select, id, item}/{:item_picker_clear, id}upwardfeatured_image_card/1— the shared featured-image card used on catalogue / category / item forms (thumbnail or empty state + picker buttons). Expectsopen_featured_image_picker/clear_featured_imageevents wired up in the owning LV — seeAttachments.metadata_editor/1— the shared metadata tab body for catalogue and item forms (opt-in fields fromMetadata.definitions/1). Expectsadd_meta_fieldandremove_meta_fieldevents wired up in the LV; text edits are absorbed via the form'svalidate.
Embeddable browse surfaces (separate modules)
Components.ItemSelectorModal— LiveComponent: the client-facing "pick items + quantities from the catalogue" modal. Scoped viasearch_items/2opts, reports{:items_selected, %{picks: …}}/{:item_selector_closed, _}to the host LV.Components.CatalogueBrowse— LiveComponent: the same browse surface (search + category chips + card grid or admin-look table, toggleable since 2026-08-30) without selection chrome, for embedding a catalogue view on any logged-in page. Reports{:catalogue_browse, %{event: :item_clicked, …}}.Components.Browse— the pure function components both are built from (item_card/1,item_grid/1,item_table/1,item_row/1,category_chips/1,qty_stepper/1,view_toggle/1,column_toggle/1,grid_skeleton/1,present_items/2, plus the shared scope/column resolvers) for hosts that want to compose their own surface withCatalogue.BrowseState. ⚠️Browsehas its ownitem_table/1andview_toggle/1, colliding with THIS module's same-named admin components — don't import both wholesale; import Browse withonly:/except:or call it qualified.
Several of these (search_input, search_results_summary,
view_mode_toggle) 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(PhoenixKitCatalogue.Gettext, "Search items...")} />
Summary
Functions
Whether any row in a list carries a featured image — gates the photo column in table views, so a table with no images at all doesn't spend a permanently empty column on them.
Like any_featured_thumb?/1, but also counts the paperclip indicator:
the media column earns its place when some row has an image OR attached
documents (per the counts map from Catalogue.attached_file_counts/1).
The shared "Photos and Files" tab panel: featured-image card plus the attached-files manager (dropzone, in-flight uploads, file grid with signed links and a confirm-guarded remove). One implementation for the catalogue / category / item forms so the three tabs cannot drift.
The attribute filter: ONE button opening every set and its values.
The attribute filter as a slug list.
The module's card media band: the picture a card leads with.
Classes for the :card_media frame every catalogue card uses. Kept in
one place so the bands cannot drift apart page by page.
The band as a DYNAMIC attribute for table_default.
Renders the smart-catalogue rule editor: one row per candidate catalogue with a checkbox, a numeric value input, and a unit dropdown.
One category row's configurable data cells — the body twin of
category_header_cells/1, keyed off the same columns list.
One category as the admin-look tile: the shared media band (featured image or folder glyph), the linked name, a columns-driven facts grid and the badge row — extracted from the catalogue detail page (2026-08-31) so the item-selector popup presents subcategories exactly the way the admin pages do, from ONE definition.
The category tables' configurable header cells — one per entry of the
admin Columns modal's vocabulary, same order. Extracted with
category_body_cells/1 from the catalogue detail page (2026-08-31) so
its flat table, its tree table and the item-selector popup's level
table all draw the same columns from one definition.
Renders the featured-image card used on catalogue, category, and item forms.
Featured-image thumbnail for list rows, rendered to the left of the name.
Renders nothing when the resource carries no attached image. Works on
anything with a data map holding "featured_image_uuid" — catalogue /
category / item structs and the index's Map.from_struct/1 row maps alike.
The "Supplier price" cell text for one item's cost ranges (see
Catalogue.supplier_cost_ranges/1): one supplier → 5.69, several →
5.69–9.99 (min–max of the current rows). Rows priced in different
currencies are shown as separate ranges with their code — 5.69–9.99 EUR, 4.00 USD — so two currencies are never collapsed into one span.
Nothing priced → —.
The same Edit / Search PDFs / Delete menu WITHOUT the table cell — for card footers, where the boss standard is the ⋮ menu, not a row of icon buttons.
Combobox for picking a single catalogue item via server-side search.
The name + SKU + sale-price cells for an item, rendered as standalone
<td>s for a core-toolkit <.table_default> row. Pricing uses
Catalogue.item_pricing/1 so the figures match the rest of the
module. Pass edit_path (a 1-arity uuid -> path fn) to make the
name a link.
The per-row action menu for an active item (Edit / Search PDFs /
Delete), rendered as a standalone <td> for a core-toolkit row.
Mirrors item_table's item_actions action set for the active list.
Renders a configurable item table with optional card view toggle.
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.
The configurable columns of party_items_table/1, in the shape core's
column_settings_modal/1 expects. name is deliberately absent: it is
always shown, so it is not the picker's to remove.
Default shown columns for party_items_table/1.
An item list for another module to embed — the CRM company page's Catalogue tab today.
Renders a compact scope selector for narrowing a search to a subset of catalogues and/or categories.
Renders a search input with debounce and clear button.
Renders a search results count summary line.
The Uncategorized bucket in the shared category card's clothes — the
catalogue's loose items presented like any subcategory (Max,
2026-08-31: "show them, just like if we were inside a category and
there were sub categories"). No Category record backs it, so it takes
the items count directly; navigation comes in like category_card/1's
(patch for the admin pages, phx_click/phx_target for the popup —
the trigger then carries phx-value-uuid="__uncategorized__").
Renders a table/card view toggle that syncs all tables sharing the same storage key.
The one localStorage key every catalogue surface shares, so a view chosen on one page is the view the next page opens with.
The module's view switcher: card / comfortable / compact.
The INSTANT view switcher, for surfaces that render both faces and let CSS choose between them.
Functions
Whether any row in a list carries a featured image — gates the photo column in table views, so a table with no images at all doesn't spend a permanently empty column on them.
Like any_featured_thumb?/1, but also counts the paperclip indicator:
the media column earns its place when some row has an image OR attached
documents (per the counts map from Catalogue.attached_file_counts/1).
The shared "Photos and Files" tab panel: featured-image card plus the attached-files manager (dropzone, in-flight uploads, file grid with signed links and a confirm-guarded remove). One implementation for the catalogue / category / item forms so the three tabs cannot drift.
Consumer contract: the LiveView uses PhoenixKitCatalogue.Attachments
(mount_attachments + allow_attachment_upload) and delegates the
cancel_upload and remove_file events; this component only renders.
Attributes
uploads(:any) (required)files_state(:map) (required)featured_image_uuid(:any) - Defaults tonil.featured_image_file(:any) - Defaults tonil.featured_subtitle(:string) (required)files_hint(:string) (required)remove_confirm(:string) (required)remove_title(:string) (required)
The attribute filter: ONE button opening every set and its values.
A set per button put six dropdowns in the catalogues index toolbar and wrapped it onto a second row; one button keeps the toolbar the shape it was whatever a catalogue's attributes grow into (Max, 2026-08-28).
Values are toggles, and picking Blue and Oak narrows to the items carrying BOTH — which is what "blue oak doors" means. Shared by the detail page and the index's items search mode, both narrowing items directly, so it means the same thing wherever it appears (the index's old "catalogues CONTAINING such items" reading left with the folder- level filter, 2026-08-29).
Each value carries what it would still match and is DISABLED at zero, so the filter cannot be walked into an empty list (Max, 2026-08-28). Because the counts are conditioned on the current selection, a dead combination greys out the moment its first half is picked.
Attributes
options(:list) (required)selected(:list) (required)class(:string) - Defaults tonil.id(:string) - Defaults to"attribute-filter".always_visible(:boolean) - Skip the every-value-is-dead hiding. In items mode the filter is a primary control (Max, 2026-08-29): a search that currently kills every value should show them greyed out, not vanish the button. Defaults tofalse.counts(:map) -%{slug => count}of what each value would still match given the current selection (Catalogue.attribute_value_match_counts/1). A value missing from the map matches nothing and is offered disabled. Defaults to%{}.
The attribute filter as a slug list.
Stored in the URL as one comma-joined string, so a filtered view is a link you can send someone.
The module's card media band: the picture a card leads with.
One definition for every card in the module. The categories grid had a band like this from the start and the boss likes it (via Max, 2026-08-28) — item and catalogue cards showed a small inline thumbnail beside the title instead, or nothing at all, so the same data looked like two different products depending on the page.
Pass it to table_default's :card_media slot together with
card_media_class={card_media_band()}. Renders the resource's featured
photo, or a muted icon when there is none, so a card without a picture
is the same shape as one with.
Attributes
resource(:map) (required)has_files(:boolean) - Defaults tofalse.on_click(:string) - Defaults tonil.variant(:string) - Storage variant — cards are card-width, so the 800px "medium" by default (the 150px thumbnail stretched here was the blur the boss reported, 2026-08-29). Defaults to"medium".navigate(:any) - When set, the picture links here — the same place the card's title goes. Defaults tonil.patch(:any) - patch variant ofnavigate. Defaults tonil.placeholder_icon(:string) - Shown when the resource has no picture — "hero-folder" for containers. Defaults to"hero-photo".
Slots
overlay- Corner controls (a checkbox, a drag handle) — absolutely positioned.
Classes for the :card_media frame every catalogue card uses. Kept in
one place so the bands cannot drift apart page by page.
The band as a DYNAMIC attribute for table_default.
card_media_class only exists in core after this module's released pin,
and a literal attribute would fail the compile gate until that lands. An
older core ignores the extra assign and renders the media unframed —
the picture is still there, it just isn't held to a fixed height yet.
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). TypicallyCatalogue.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/:unitare read). Unchecked catalogues simply don't appear in the map (default%{}).item_default_value— item'sdefault_value, used as the value input's placeholder (defaultnil)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 tonil.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".on_reorder(:string) - When set, rule rows are draggable. Defaults tonil.id(:string) - Defaults to"catalogue-rules-picker".class(:string) - Defaults to"".
One category row's configurable data cells — the body twin of
category_header_cells/1, keyed off the same columns list.
Attributes
columns(:list) (required)cat(:map) (required)child_counts(:map) (required)child_subcat_counts(:map) - Defaults to%{}.file_counts(:map) - Defaults to%{}.
One category as the admin-look tile: the shared media band (featured image or folder glyph), the linked name, a columns-driven facts grid and the badge row — extracted from the catalogue detail page (2026-08-31) so the item-selector popup presents subcategories exactly the way the admin pages do, from ONE definition.
The tile is presentation only. Navigation comes in from the caller:
patch (the admin pages) or phx_click/phx_target (the popup's
drill event — the trigger then carries phx-value-uuid). Admin-only
chrome stays with the admin: the bulk checkbox and drag handle render
through the :overlay slot (inside the figure), the row menu through
:menu (end of the badge row), and the tree-DnD data attributes ride
:rest on the root.
Attributes
category(:map) (required)name(:string) - Display name override (viewer-locale translation); falls back to category.name. Defaults tonil.columns(:list) - Which facts the grid shows, in order — the admin Columns modal's vocabulary. Defaults to["items"].count(:integer) - Defaults to0.subcat_count(:integer) - Defaults to0.file_count(:integer) - Defaults to0.has_subs(:boolean) - Defaults tofalse.has_files(:boolean) - Defaults tofalse.patch(:string) - Defaults tonil.phx_click(:string) - Defaults tonil.phx_target(:any) - Defaults tonil.- Global attributes are accepted.
Slots
overlaymenu
The category tables' configurable header cells — one per entry of the
admin Columns modal's vocabulary, same order. Extracted with
category_body_cells/1 from the catalogue detail page (2026-08-31) so
its flat table, its tree table and the item-selector popup's level
table all draw the same columns from one definition.
Attributes
columns(:list) (required)
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 theMediaSelectorModalclear_featured_image— nulls the pointer- (change — same
open_featured_image_pickerevent)
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 rendersfeatured_image_file— the%Storage.File{}struct (for name/size) or nilsubtitle— 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 tonil.featured_image_file(:any) - Defaults tonil.subtitle(:string) - Defaults tonil.class(:string) - Defaults to"".
Featured-image thumbnail for list rows, rendered to the left of the name.
Renders nothing when the resource carries no attached image. Works on
anything with a data map holding "featured_image_uuid" — catalogue /
category / item structs and the index's Map.from_struct/1 row maps alike.
The URL is signed straight off the stored uuid (no per-row file lookup, so
lists stay query-free); a dangling pointer — the file was deleted after
being attached — 404s and removes itself via onerror instead of showing
the browser's broken-image glyph.
Attributes
resource(:any) (required)class(:any) - Defaults to"w-10 h-10".variant(:string) - Storage variant to load. "thumbnail" (150px) fits the 40px list cells this was built for; pass "medium" (800px) for card-width slots — a 150px asset stretched across a card is the blur the boss reported (2026-08-29). Never "original" in lists. Defaults to"thumbnail".comfy_scale(:boolean) - Whether the comfy-density row override ([.pk-comfy_&]:w-18) applies. True for table cells; FALSE for fill slots — inside a comfy card the override beat w-full and shrank the band image to a 72px square (the not-full-width report, 2026-08-29). Defaults totrue.has_files(:boolean) - The file-attached indicator: with an image, a small paperclip emblem in the thumb's top-right corner; with no image, a muted paperclip tile in the same slot. Feed it fromCatalogue.attached_file_counts/1— it means "has attached documents" (the non-image files the product card's Files section lists). Defaults tofalse.on_click(:string) - When set, the thumb becomes a button pushing this event with the resource's uuid — the product-view hook ("pressing on the featured image"). nil keeps the thumb inert. Defaults tonil.
The "Supplier price" cell text for one item's cost ranges (see
Catalogue.supplier_cost_ranges/1): one supplier → 5.69, several →
5.69–9.99 (min–max of the current rows). Rows priced in different
currencies are shown as separate ranges with their code — 5.69–9.99 EUR, 4.00 USD — so two currencies are never collapsed into one span.
Nothing priced → —.
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 selectionwhere 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(:any) - Defaults tonil.catalogue_uuids(:any) - Defaults tonil.include_descendants(:boolean) - Defaults totrue.only(:atom) - Restrict results to uncategorised or categorised items only. Defaults tonil. Must be one ofnil,:uncategorized_only, or:categorized_only.statuses(:any) - Item statuses to include (nil/[]= all non-deleted) — search_items/2's :statuses. Defaults tonil.selected_item(:any) - Defaults tonil.excluded_uuids(:list) - Defaults to[].locale(:string) (required)placeholder(:string) - Defaults tonil.empty_query_limit(:integer) - Defaults to10.page_size(:integer) - Defaults to20.disabled(:boolean) - Defaults tofalse.format_price(:any) - Defaults tonil.format_unit(:any) - Defaults tonil.show_unit(:boolean) - Defaults tofalse.show_sku(:boolean) - Defaults tofalse.highlight_selected(:boolean) - Defaults totrue.initial_query(:string) - Defaults tonil.photo_clickable(:boolean) - Defaults tofalse.photo_placeholder(:boolean) - Defaults tofalse.photo_size(:string) - Defaults to"w-8 h-8".
The name + SKU + sale-price cells for an item, rendered as standalone
<td>s for a core-toolkit <.table_default> row. Pricing uses
Catalogue.item_pricing/1 so the figures match the rest of the
module. Pass edit_path (a 1-arity uuid -> path fn) to make the
name a link.
Renders the name cell (link), then one cell per entry in columns —
"sku" / "price" / "unit" / "status" — in the given order, so
a Columns configuration controls both visibility and sequence.
Attributes
item(:any) (required)edit_path(:any) - Defaults tonil.has_attributes(:boolean) - Defaults tofalse.file_count(:integer) - Defaults to0.columns(:list) - Defaults to["sku", "price", "unit", "status"].supplier_costs(:list) - This item's entry fromCatalogue.supplier_cost_ranges/1(drives"supplier_price"). Defaults to[].
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 whencardsis true, used by the JS hook to persist view preference)markup_percentage— catalogue markup for:priceand:final_pricecolumns (required when either is listed; ignored otherwise)discount_percentage— catalogue discount for:discountand:final_pricecolumns (required when either is listed; ignored otherwise). The:discountcolumn honors per-item overrides viaItem.effective_discount/2.edit_path— 1-arity function(uuid -> path)to enable edit linkson_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 asphx-value-type(e.g."item")catalogue_path— 1-arity function(uuid -> path)for catalogue links in:cataloguecolumnvariant— 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 totrue.photo_click(:string) - Event pushed (with the item's uuid) when a featured-image thumb is clicked — the host renders the ProductCard modal and handles its events. nil keeps thumbs inert. Defaults tonil.file_counts(:map) - %{item_uuid => attached-document count} from Catalogue.attached_file_counts/1 — drives the paperclip indicator in the photo column. Computed by the caller (function components must not query). Defaults to%{}.attribute_map(:map) - %{item_uuid => attribute_group_uuid} from Catalogue.item_attribute_group_map/1 — drives the swatch indicator beside the name. Computed by the caller. Defaults to%{}.show_toggle(:boolean) - Defaults totrue.id(:string) - Defaults tonil.storage_key(:string) - Defaults tonil.view_mode(:string) - Controlled view (card/comfy/table). Set it to follow the user's module-wide preference (ViewConfig.load_view/1) instead of the per-browser localStorage key — seeview_toggle/1. Setting it also hides the table's built-in toggle: in controlled mode that toggle posts to the server, and the module answers onset_view(seeview_event). Defaults tonil.view_event(:string) - Event the CONTROLLED toggle posts. Core's default isswitch_view, which no catalogue LiveView answers — and an unhandled event crashes the view. Defaults to"set_view".markup_percentage(:any) - Defaults tonil.discount_percentage(:any) - Defaults tonil.edit_path(:any) - Defaults tonil.name_path(:any) - Where an item's NAME links, as a 1-arity function of the item. Defaults toedit_path(the catalogue's own convention). An embedded, read-only list can point it somewhere else — landing on an edit form from a list you are only reading is a surprise.Defaults to
nil.on_delete(:string) - Defaults tonil.on_restore(:string) - Defaults tonil.on_permanent_delete(:string) - Defaults tonil.permanent_delete_type(:string) - Defaults to"item".catalogue_path(:any) - Defaults tonil.variant(:string) - Defaults to"default".size(:string) - Defaults to"sm".wrapper_class(:string) - Defaults tonil.pdf_search_event(:string) - When set, action menu gets a 'Search PDFs' entry that pushes this event with phx-value-uuid. Defaults tonil.on_reorder(:string) - When set, rows become draggable and emit this event. Defaults tonil.reorder_scope(:map) - Map of extra scope values (e.g. %{catalogue_uuid: "...", category_uuid: "..."}) — exposed to the SortableGrid hook as data-sortable-scope-* attrs. Defaults to%{}.reorder_group(:string) - SortableJS group name; tables sharing a group can exchange items via cross-container drag (e.g. items moving between categories). Defaults tonil.selectable(:boolean) - When true, each row gets a checkbox in the leftmost column (combined with the drag handle when reorderable). The drag handle is hidden until the row is hovered. Defaults tofalse.selected_uuids(:any) - MapSet of selected item UUIDs. Defaults tonil.on_toggle_select(:string) - Event name fired when the user toggles a row's checkbox. The LV handler receivesphx-value-uuid. Defaults tonil.
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>'sphx-change)remove_meta_field(per-row × button)- (text edits are absorbed by the form's
phx-change="validate"viaMetadata.absorb_params/2)
Attributes
resource_type—:itemor:catalogue; drives whichMetadata.definitions/1list is consumed for the add-picker and for legacy-key detectionstate— the%{attached: [key], values: %{key => string}}map produced byMetadata.build_state/2and kept on the socketid_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 tonil.description(:string) - Defaults tonil.
The configurable columns of party_items_table/1, in the shape core's
column_settings_modal/1 expects. name is deliberately absent: it is
always shown, so it is not the picker's to remove.
Default shown columns for party_items_table/1.
An item list for another module to embed — the CRM company page's Catalogue tab today.
This is a deliberately narrow wrapper around item_table/1 rather than a
second table. A caller outside this package cannot invoke item_table/1
itself: HEEx injects attr defaults at the CALL SITE, so reaching it
through apply/3 would mean the caller supplying every attribute by hand
and re-supplying each new one we add. Here the defaults are applied inside
the catalogue, and the contract with the caller is two keys.
Takes a plain map (no attr defaults are available through apply/3):
:items—%Item{}structs, ideally hydrated byManufacturers.hydrate/1so the manufacturer column has a name:id— unique DOM id for the table:columns— optional; which columns to show, in order. Defaults to a sensible set. Pair it with core'scolumn_settings_modal/1andmanaged_columns/0below to give the embed a working column picker.
Presentation — the image column, the card/table toggle, price and status
formatting — stays owned by the catalogue, so an embedded list keeps
matching the catalogue's own. That includes the convention that an item's
name opens its edit form; the catalogue column is the way back to where
the item lives.
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 clickedon_toggle_category—%{"uuid" => uuid}when a category is clickedon_clear_catalogues— no params; clear all catalogue selectionson_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 (defaultfalse— 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 tofalse.class(:string) - Defaults to"".
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 translatedgettext("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 tonil.on_search(:string) - Defaults to"search".on_clear(:string) - Defaults to"clear_search".debounce(:integer) - Defaults to300.class(:string) - Defaults to"".id(:string) - Form id — LiveView warns without one and cannot recover the form after a disconnect. Defaults to"catalogue-search-input".
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 thancount, the summary shows "X of Y" so users know the list is paging. Omit or passnilfor a plain "N results" line.
Attributes
count(:integer) (required)query(:string) (required)loaded(:integer) - Defaults tonil.
The Uncategorized bucket in the shared category card's clothes — the
catalogue's loose items presented like any subcategory (Max,
2026-08-31: "show them, just like if we were inside a category and
there were sub categories"). No Category record backs it, so it takes
the items count directly; navigation comes in like category_card/1's
(patch for the admin pages, phx_click/phx_target for the popup —
the trigger then carries phx-value-uuid="__uncategorized__").
Attributes
count(:integer) - Items count; nil hides the facts grid. Defaults tonil.patch(:string) - Defaults tonil.phx_click(:string) - Defaults tonil.phx_target(:any) - Defaults tonil.
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"".
The one localStorage key every catalogue surface shares, so a view chosen on one page is the view the next page opens with.
The module's view switcher: card / comfortable / compact.
Server-driven on purpose. The localStorage-backed view_mode_toggle/1
remembers a mode per surface and per browser, which is why a choice made
on one page never reached the next one; this posts set_view and the
answer is stored per USER, module-wide (ViewConfig.load_view/1), so
every catalogue page opens the way you left the last one.
Attributes
view(:string) (required)class(:any) - Defaults tonil.
The INSTANT view switcher, for surfaces that render both faces and let CSS choose between them.
Core's view_mode_toggle/1 does the switching (no server involved, so
it is immediate) and this adds the remembering: the page's stored
choice seeds the browser on mount, and a change is pushed to
set_view afterwards, once the view has already moved.
Use view_toggle/1 instead where the server picks the layout — the
catalogues index renders a folder tree or a card grid, which is not
something CSS can swap.
Attributes
view(:string) (required)id(:string) - Defaults to"catalogue-view-pref".class(:any) - Defaults tonil.