defmodule Phoenix.LiveView.Components.MultiSelect do @moduledoc """ Multi-select component for Phoenix LiveView. Use in your HEEX templates with: ``` <.multi_select id="multi" options={ %{id: 1, label: "One"}, %{id: 2, label: "Two"}, } > ``` See `multi_select/1` for details. """ use Phoenix.LiveComponent import Phoenix.HTML alias Phoenix.LiveView.JS def __using__(_) do quote do import Phoenix.LiveView.Components.MultiSelect, only: [multi_select: 1] end end defmodule Option do @doc """ The option struct can be used for passing a list of option values to the `multi_select` component. """ defstruct \ id: nil, label: nil, selected: false @type t :: %__MODULE__{ id: integer, label: String.t, selected: boolean } def new(%{} = map) do %__MODULE__{ id: Map.get(map, :id), label: Map.get(map, :label), selected: Map.get(map, :selected) || false, } end end @doc """ MultiSelect LiveView stateful component. The component implements a number of configuration options: * `:id` - the required unique ID of the HTML element for this component * `:debounce` - the integer controlling a `phx-debounce` value for the search input * `:options` - a required list of `%{id: any(), label: string()}` options to select from * `:form` - the required form name owning this component * `:on_change` - a lambda `(options :: [%Multiselect.Option{}]) -> ok` called on change of selected items * `:class` - class added to the main `div` of the component * `:max_selected` - max number of selected items * `:wrap` - allow to wrap selected tags to multiple lines * `:title` - component's title to use as the tooltip * `:placeholder` - component's placeholder text * `:search_placeholder` - placeholder for the search input box * `:search_cbox_titles` - titles `on|off` of the checked icon in the search checkbox (default: "Clear filter of selected items|Filter selected items") * `:filter_side` - apply item filtering on client or server (default: client) """ attr :id, :string, required: true attr :debounce, :integer, default: 350 attr :options, :list, default: [], doc: "List of `%{id: String.t, label: String.t}` maps" attr :form, :any, required: true attr :on_change, :any, doc: "Lambda `(options) -> ok` to be called on selecting items" attr :class, :string, default: nil attr :max_selected, :integer, default: nil, doc: "Max number of items selected" attr :max_shown, :integer, default: 100000,doc: "Max number of shown selected tags" attr :wrap, :boolean, default: false, doc: "Permit multiline wrapping of selected items" attr :title, :string, default: nil, doc: "Component tooltip title" attr :placeholder, :string, default: "Select...", doc: "Placeholder shown on empty input" attr :search_placeholder, :string, default: "Search...", doc: "Placeholder for the search input" attr :search_cbox_titles, :string, default: "Clear filter of selected items|Filter selected items", doc: "Titles `on|off` of the checked icon in the search checkbox" attr :filter_side, :atom, default: :client, values: [:client, :server] def multi_select(assigns) do assigns = assign(assigns, :options, (for o <- assigns.options, do: Option.new(o))) rest = Phoenix.Component.assigns_to_attributes(assigns, []) assigns = assign(assigns, :rest, rest) ~H""" <.live_component module={__MODULE__} {@rest}/> """ end @doc """ Call this function to notify the LiveView to update the settings of the multi_select component identified by the `id`. """ def update_settings(id, attrs) when is_list(attrs) do send_update(__MODULE__, [{:id, id} | attrs]) end @doc false def mount(%{assigns: assigns} = socket) do assigns = assigns |> assign(:filter, "") |> assign(:cur_shown, 10000) |> assign(:filter_checked, false) |> assign(:option_count, 0) |> assign(:selected_count, 0) {:ok, Map.put(socket, :assigns, assigns)} end @doc false def update(%{options: options} = assigns, socket) do socket = socket |> assign(:checked_options, filter_checked_options(options)) |> assign(assigns) {:ok, socket} end def update(%{id: id} = params, %{assigns: %{id: id}} = socket) do {:ok, update2(socket, Map.delete(params, :id))} end defp update2(socket, attrs) do Enum.reduce(attrs, socket, fn ({:wrap = k, v}, s) when is_boolean(v) -> assign(s, k, v) ({:max_selected = k, v}, s) when is_integer(v) -> assign(s, k, v) end) end ## This setting allows to customize CSS classes. It supposed to return the ## module that has `apply_css(key, css_classes) -> css_classes :: String.t` function. @class_callback Application.compile_env(:live_view, :phoenix_multi_select, %{})[:class_module] || __MODULE__ ## Customize the class name shared by the outer div @class_prefix Application.compile_env(:live_view, :phoenix_multi_select, %{})[:class_prefix] || "phx-msel" ## Metadata with all CSS attributes for the MultiSelect component @css %{ component: @class_prefix <> " h-12 flex flex-col w-96 py-[7px] gap-1 relative sm:text-sm", main: " p-2 flex w-full gap-1 min-h-fit border rounded-t-lg rounded-b-lg", tags: " flex flex-wrap gap-1 w-full", placeholder: " select-none opacity-50 self-center", tag: " bg-primary-600 rounded-md p-1 gap-1 select-none text-white flex place-items-center", main_icons: " right-2 self-center py-1 pl-1 z-10 flex place-items-center", body: " hidden -mt-[4px] w-96 p-2 ml-0 z-5 outline-none flex flex-col border-x border-b rounded-b-lg shadow-md", filter: " mb-2 block w-full pl-2 pr-12 px-[11px] rounded-lg focus:outline-none focus:ring-1 sm:text-sm sm:leading-6 phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 phx-no-feedback:focus:ring-zinc-800/5", filter_icons: " absolute inset-y-0 right-2 flex items-center", icon_color: " fill-zinc-400 hover:fill-zinc-500", icon_check_color: " fill-zinc-400 hover:fill-zinc-500|fill-primary-600 hover:fill-primary-700", # Two sets of colors `on|off` options: " overflow-auto max-h-48 pt-1 pl-1 scrollbar scrollbar-thumb-zinc-400 scrollbar-track-zinc-200 dark:scrollbar-thumb-gray-700 dark:scrollbar-track-gray-900", option_label: " flex text-sm font-medium text-gray-900 dark:text-gray-300 place-items-center", option_input: " rounded w-4 h-4 mr-2 dark:checked:bg-primary-500 border border-gray-300 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-800 focus:ring-1 dark:bg-gray-700 dark:border-gray-600 transition duration-200", colors: " bg-white border-gray-300 dark:border-gray-600 disabled:bg-gray-100 disabled:cursor-not-allowed shadow-sm dark:bg-gray-800 dark:text-gray-300 dark:disabled:bg-gray-700", } @doc false defmacro css(key, add_color_class \\ false) do quote do value = if unquote(add_color_class), do: (@css[unquote(key)] <> @css[:colors]), else: @css[unquote(key)] @class_callback.apply_css(unquote(key), value) end end @doc false def apply_css(_key, value), do: value @doc false def render(assigns) do assigns = assigns |> assign(:filter_id, "#{assigns.id}-filter") ~H"""