Packages
keen_web_multiselect
1.0.0-rc.4
Phoenix LiveView wrapper for the @keenmate/web-multiselect custom element (bundled JS+CSS, typed attrs for every documented option, optional LV hook).
Current section
Files
Jump to
Current section
Files
lib/keenmate/web_multiselect/components.ex
defmodule Keenmate.WebMultiselect.Components do
@moduledoc """
Phoenix function component wrapping `<web-multiselect>`.
"""
use Phoenix.Component
alias Keenmate.WebMultiselect.{FormHelpers, OptionHelpers}
alias Phoenix.HTML.FormField
@doc """
Renders a `<web-multiselect>` custom element.
## Examples
<.web_multiselect
id="languages"
placeholder="Pick a language"
options={[%{value: "js", label: "JavaScript"}, %{value: "py", label: "Python"}]} />
<.web_multiselect field={@form[:tags]} options={@tag_options} />
## LiveView events
When `hook={true}` is set (see the README), the underlying `select`, `deselect`, and
`change` events are forwarded to the server. Listen for them with `handle_event/3`:
def handle_event("web_multiselect:change", %{"id" => id, "values" => values}, socket) do
...
end
To push option or selection changes back from the server, use
`Keenmate.WebMultiselect.push_update/3`.
## Attribute defaults
Every attribute defaults to `nil`, which means "don't emit it" — the underlying
component's own default then applies. The docs below note the upstream default where
it's useful to know. See the [upstream usage docs](https://github.com/keenmate/web-multiselect/blob/main/docs/usage.md)
for the full behavior of each option.
"""
# -- Form integration ------------------------------------------------------
attr :id, :string, default: nil, doc: "DOM id; required when used with a LV hook."
attr :field, FormField, default: nil, doc: "A `Phoenix.HTML.FormField` to bind to."
attr :name, :string, default: nil, doc: "Form field name (overridden by `:field` if set)."
attr :value, :any,
default: nil,
doc: "Initial selected value(s). Strings, numbers, list, or nil."
attr :options, :any,
default: nil,
doc: """
Option list. Accepts:
* a list of maps with `:value`/`:label` (and optional `:icon`, `:subtitle`, `:group`, `:disabled`)
* a list of `{value, label}` tuples
* a list of arbitrary maps when paired with `*_member` or `get_*_callback` props (JS side)
"""
attr :hook, :any,
default: nil,
doc: """
Forwards `select`/`deselect`/`change` events to LiveView. Pass `hook={true}` for
the bundled `"KeenWebMultiselectHook"`, or a string to name a custom hook. `false`
or `nil` renders no hook. Requires `:id`.
"""
attr :search_event, :string,
default: nil,
doc: """
Server-side search hook. When set, the LV hook installs a `searchCallback` on the
element that pushes this event name to the LV with `%{"query" => q, "id" => id}` and
awaits a `{:reply, %{results: [...]}, socket}` response. The results populate the
dropdown asynchronously. Requires `hook={true}`.
"""
# -- Behaviour -------------------------------------------------------------
attr :multiple, :boolean,
default: nil,
doc:
"Allow selecting more than one option. Defaults to `true` upstream — set `false` for a single-select."
attr :search_placeholder, :string,
default: nil,
doc: "Placeholder text for the search/filter input inside the dropdown."
attr :search_hint, :string,
default: nil,
doc: "Hint line shown near the search input to guide the user (e.g. \"Type to filter…\")."
attr :allow_groups, :boolean,
default: nil,
doc: "Render options grouped by their `group_member` (optgroup-style headers). Off by default."
attr :show_checkboxes, :boolean,
default: nil,
doc: "Render a checkbox in each option row. Off by default."
attr :show_select_all, :boolean,
default: nil,
doc: """
Shows upstream's built-in "Select All" action button above the option list
(internal: `isSelectAllShown`). Pairs naturally with `show_checkboxes`. For
finer control over the button row use the `action_buttons` API instead.
"""
attr :checkbox_align, :string,
default: nil,
values: [nil, "top", "center", "bottom"],
doc: "Vertical alignment of each option's checkbox within its row."
attr :close_on_select, :boolean,
default: nil,
doc: "Close the dropdown after a selection is made (typical for single-select)."
attr :dropdown_min_width, :string,
default: nil,
doc: "Minimum width of the dropdown panel, as a CSS length (e.g. `\"320px\"`)."
attr :dropdown_max_width, :string,
default: nil,
doc: "Maximum width of the dropdown panel, as a CSS length."
attr :max_height, :string,
default: nil,
doc: "Maximum height of the option list before it scrolls, as a CSS length."
attr :empty_message, :string,
default: nil,
doc: "Message shown when there are no options to display."
attr :loading_message, :string,
default: nil,
doc: "Message shown while async options are loading."
attr :select_placeholder, :string,
default: nil,
doc: """
Placeholder shown when the input isn't a usable search box (`enable_search={false}`,
or `search_input_mode` is `"readonly"`/`"hidden"`). Defaults to `"Pick an option..."`
upstream — set this to override.
"""
attr :no_data_placeholder, :string,
default: nil,
doc: """
Placeholder shown when the option list is empty. Useful for cascade multiselects
whose parent isn't resolved yet. Takes priority over `placeholder` and `search_placeholder`
when the list is empty. Unset by default so async-loaded selects don't flash an
empty-state message before their data arrives.
"""
# -- Badges ---------------------------------------------------------------
attr :badges_display_mode, :string,
default: nil,
values: [nil, "badges", "count", "compact", "partial", "none"],
doc:
"How selected items appear in the control: `badges` (default), `count`, `compact`, `partial`, or `none`."
attr :badges_threshold, :integer,
default: nil,
doc: "Selected-count at which the display collapses to the `badges_threshold_mode` summary."
attr :badges_threshold_mode, :string,
default: nil,
values: [nil, "count", "partial"],
doc: "How to summarize once `badges_threshold` is exceeded — `count` or `partial`."
attr :badges_max_visible, :integer,
default: nil,
doc: "Maximum number of badges rendered before the remainder collapse into a summary."
attr :badges_position, :string,
default: nil,
values: [nil, "top", "bottom", "left", "right"],
doc: "Where the badges render relative to the input."
attr :show_counter, :boolean,
default: nil,
doc: "Show a count of the selected items."
attr :show_badge_full_title, :boolean,
default: nil,
doc: """
Make badges display each option's `full_title_member` value instead of its display value
(falling back to the display value for options without one). Off by default.
"""
attr :enable_badge_tooltips, :boolean,
default: nil,
doc: "Show a tooltip with the full label when hovering a badge. Off by default."
attr :badge_tooltip_placement, :string,
default: nil,
values: [
nil,
"top",
"top-start",
"top-end",
"bottom",
"bottom-start",
"bottom-end",
"left",
"left-start",
"left-end",
"right",
"right-start",
"right-end"
],
doc: "Placement of the badge tooltip (a Floating-UI placement)."
attr :badge_tooltip_delay, :integer,
default: nil,
doc: "Show/hide delay for badge tooltips, in milliseconds."
attr :badge_tooltip_offset, :integer,
default: nil,
doc: "Distance of the badge tooltip from the badge, in pixels."
attr :remove_button_tooltip_text, :string,
default: nil,
doc:
~s(Format string for the remove-button tooltip. `{0}` interpolates the item name. Defaults to `"Remove {0}"` upstream.)
# -- Option tooltips ------------------------------------------------------
attr :enable_option_tooltips, :boolean,
default: nil,
doc: """
Show a hover tooltip on each dropdown option row. Default content is the option's
display value, plus its subtitle on a second line when present. Override per option
with the JS-side `getOptionTooltipCallback`. Off by default upstream.
"""
attr :option_tooltip_placement, :string,
default: nil,
values: [
nil,
"top",
"top-start",
"top-end",
"bottom",
"bottom-start",
"bottom-end",
"left",
"left-start",
"left-end",
"right",
"right-start",
"right-end"
],
doc:
~s(Placement relative to the option row. Defaults to `"top-start"` upstream \(anchored to the row's start edge\). Use `left`/`right` for a narrow control's side.)
attr :option_tooltip_follow_cursor, :boolean,
default: nil,
doc:
"Anchor the option tooltip to the mouse pointer and follow it across the row. Useful for very wide rows. Defaults to `false` upstream."
attr :option_tooltip_delay, :integer,
default: nil,
doc: "Show/hide delay in ms. Falls back to `badge_tooltip_delay`, then `100` upstream."
attr :option_tooltip_offset, :integer,
default: nil,
doc: "Tooltip offset in px from the row. Falls back to `badge_tooltip_offset`, then `8` upstream."
# -- Search ---------------------------------------------------------------
attr :enable_search, :boolean,
default: nil,
doc: "Show the search/filter input inside the dropdown. On by default upstream."
attr :search_input_mode, :string,
default: nil,
values: [nil, "normal", "readonly", "hidden"],
doc:
"Search input behavior: `normal`, `readonly`, or `hidden`. When not usable, `select_placeholder` shows."
attr :search_mode, :string,
default: nil,
values: [nil, "filter", "navigate"],
doc:
"What typing does: `filter` narrows the list, `navigate` jumps to matching rows without hiding others."
attr :min_search_length, :integer,
default: nil,
doc: "Minimum number of characters before searching/filtering kicks in."
attr :keep_options_on_search, :boolean,
default: nil,
doc: "Keep the full option list visible while searching instead of filtering it down."
attr :should_keep_search_on_close, :boolean,
default: nil,
doc: "Preserve the search text when the dropdown closes instead of clearing it."
attr :allow_add_new, :boolean,
default: nil,
doc: "Allow creating a new option from the search text when nothing matches."
attr :search_debounce, :integer,
default: nil,
doc: """
Debounce delay in milliseconds before the async `searchCallback` fires. A burst of
keystrokes collapses into a single request instead of one per character. Applies to
the async path only — local in-memory filtering stays instant. Defaults to `0`
(no debounce) upstream.
"""
# -- Actions / placement --------------------------------------------------
attr :sticky_actions, :boolean,
default: nil,
doc: "Keep the action-buttons row pinned while the option list scrolls."
attr :actions_layout, :string,
default: nil,
values: [nil, "nowrap", "wrap"],
doc: "Whether action buttons stay on a single row (`nowrap`) or wrap onto multiple rows (`wrap`)."
attr :actions_position, :string,
default: nil,
values: [nil, "top", "bottom"],
doc: ~s(Render the action-buttons block as a sticky header \(`"top"`, default\) or footer \(`"bottom"`\).)
attr :actions_align, :string,
default: nil,
values: [nil, "stretch", "left", "right", "center", "space-between"],
doc:
~s(Horizontal arrangement of buttons within a row. `"stretch"` \(default\) keeps full-width; the others size buttons to content and distribute them.)
attr :lock_placement, :boolean,
default: nil,
doc: "Lock the dropdown's placement instead of auto-flipping/shifting to fit the viewport."
# -- Data extraction members ----------------------------------------------
attr :value_member, :string,
default: nil,
doc: ~s(Object key to read each option's value from. The wrapper defaults this to `"value"`.)
attr :display_value_member, :string,
default: nil,
doc: ~s(Object key for each option's display label. The wrapper defaults this to `"label"`.)
attr :search_value_member, :string,
default: nil,
doc:
"Object key for the text search matches against. Not defaulted — falls back to the display value upstream."
attr :icon_member, :string,
default: nil,
doc: ~s(Object key for an option's icon. The wrapper defaults this to `"icon"`.)
attr :subtitle_member, :string,
default: nil,
doc: ~s(Object key for an option's secondary line. The wrapper defaults this to `"subtitle"`.)
attr :full_title_member, :string,
default: nil,
doc: """
Object key for an option's **full title** — a fully-qualified label that ships with the
data (e.g. a breadcrumb like `"Fruit / Pome fruit / Apple"`); never computed by the
component. Pair with `show_badge_full_title` to render it on badges. Not defaulted.
"""
attr :group_member, :string,
default: nil,
doc:
~s(Object key for an option's group name \(used when `allow_groups`\). The wrapper defaults this to `"group"`.)
attr :disabled_member, :string,
default: nil,
doc: ~s(Object key marking an option disabled. The wrapper defaults this to `"disabled"`.)
# -- Tree of options ------------------------------------------------------
attr :path_member, :string,
default: nil,
doc: """
Object key holding each option's **materialized dot-path** (e.g. `"1"`, `"1.1"`,
`"1.1.1"`). Setting this turns on **tree mode**: options render as an always-expanded
hierarchy, indented by depth. Parent and level are derived from the path. There is no
collapse — reach for `@keenmate/web-treeview` when you need expand/collapse.
"""
attr :parent_path_member, :string,
default: nil,
doc: "Object key holding an option's parent path. Optional — derived from `path_member` when unset."
attr :level_member, :string,
default: nil,
doc: "Object key holding an option's depth/level. Optional — derived from `path_member` when unset."
attr :has_children_member, :string,
default: nil,
doc: "Object key holding a precomputed `hasChildren` flag. Optional — derived from the tree when unset."
attr :is_selectable_member, :string,
default: nil,
doc: """
Object key holding a per-option **selectability** flag for tree mode. A node with a falsy
value renders *normally* — **not** greyed out like `disabled` — but has no checkbox, is
skipped by keyboard focus, and cannot be toggled or picked by Select All. Options
default to selectable. Use it for structural branch rows (e.g. a leaves-only tree); use
`disabled_member` for genuinely unavailable options. The JS-only `getIsSelectableCallback`
(predicate over the built node, so it can read `hasChildren`) has no HEEx attribute.
"""
attr :tree_path_separator, :string,
default: nil,
doc: ~s(Separator used in tree paths. Defaults to `"."` upstream.)
attr :checkbox_mode, :string,
default: nil,
values: [nil, "independent", "cascade"],
doc: """
Tree checkbox interaction. `"independent"` (default) toggles only the clicked node.
`"cascade"` checks a node's whole subtree and shows a **tristate** (checked /
indeterminate / unchecked) box on partially-selected branches. Tree + multiple only.
Pair with `cascade_select_policy` to control which values a cascade selection emits.
"""
attr :cascade_select_policy, :string,
default: nil,
values: [nil, "rolled-up", "leaves", "all"],
doc: """
In `checkbox_mode="cascade"`, which values a selection emits (badges / form / change):
`"rolled-up"` (default) — the **minimal cover**: a fully-selected subtree collapses to
its root ("complete node"); a partially-selected branch emits its individually-checked
descendants, rolling to the nearest selectable descendant when the complete node itself
is non-selectable. `"leaves"` — only the checked leaf nodes. `"all"` — every fully-checked
node (branches and leaves), like `@keenmate/web-treeview`.
"""
# -- Form value serialization ---------------------------------------------
attr :value_format, :string,
default: nil,
values: [nil, "json", "csv", "array"],
doc: "How selected values are serialized into the hidden form input: `json`, `csv`, or `array`."
attr :initial_values, :any,
default: nil,
doc: "Pre-selected values. List/term; encoded as JSON for the underlying attribute."
# -- Virtual scroll -------------------------------------------------------
attr :enable_virtual_scroll, :boolean,
default: nil,
doc: "Enable windowed rendering of the option list so large datasets stay fast."
attr :virtual_scroll_threshold, :integer,
default: nil,
doc: "Option count above which virtual scrolling activates automatically."
attr :option_height, :integer,
default: nil,
doc: "Fixed pixel height of each option row — required for the virtual-scroll offset math."
attr :badge_height, :integer,
default: nil,
doc: "Pixel height per badge row in the popover virtual-scroll list. Defaults to `36` upstream."
attr :virtual_scroll_buffer, :integer,
default: nil,
doc: "Extra rows rendered above and below the viewport while virtual-scrolling."
# -- Passthrough ----------------------------------------------------------
attr :placeholder, :string,
default: nil,
doc: "Placeholder shown when no item is selected (passthrough)."
attr :class, :string, default: nil, doc: "CSS class(es) applied to the `<web-multiselect>` element."
attr :style, :string, default: nil, doc: "Inline `style` applied to the `<web-multiselect>` element."
attr :show_debug_info, :boolean,
default: nil,
doc: """
Toggles upstream's `.ms__debug-info` stats panel inside the shadow DOM. Reactive — flipping
between renders adds/removes the panel without reinitializing the component. Useful while
diagnosing virtual-scroll thresholds, search timing, and option-list growth.
"""
attr :rest, :global, doc: "Any extra HTML / phx-* attribute; passed through verbatim."
slot :inner_block,
doc: "Optional declarative `<option>` / `<optgroup>` markup."
@spec web_multiselect(map()) :: Phoenix.LiveView.Rendered.t()
def web_multiselect(assigns) do
assigns = FormHelpers.assign_from_field(assigns)
assigns = assign(assigns, :hook, resolve_hook(assigns.hook))
assigns = assign(assigns, :attributes, OptionHelpers.to_html_attributes(assigns))
~H"""
<web-multiselect
id={@id}
name={@name}
phx-hook={@hook}
phx-update={@id && "ignore"}
class={@class}
style={@style}
data-ready=""
data-search-event={@search_event}
{@attributes}
{@rest}
>
{render_slot(@inner_block)}
</web-multiselect>
"""
end
# `hook={true}` is sugar for the bundled hook name; a string names a custom hook.
defp resolve_hook(true), do: "KeenWebMultiselectHook"
defp resolve_hook(hook) when is_binary(hook), do: hook
defp resolve_hook(_), do: nil
end