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 ## 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(:phoenix_multi_select, :class_module) || __MODULE__ ## Customize the class name shared by the outer div @class_prefix Application.compile_env(:phoenix_multi_select, :class_prefix) || "phx-msel" ## When true, the component will use Alpinejs. Otherwise - Phoenix.LiveView.JS @use_alpinejs Application.compile_env(:phoenix_multi_select, :use_alpinejs) || false ## 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: "-mt-[4px] w-96 p-2 ml-0 z-5 outline-none flex flex-col border-x border-b rounded-b-lg shadow-md" <> (@use_alpinejs && "" || " hidden"), filter: "mb-2 block w-full pl-2 pr-12 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" <> (@use_alpinejs && " mb-2" || ""), 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 = unquote(key) |> unquote(__MODULE__).css_fetch(unquote(add_color_class)) @class_callback.apply_css(unquote(key), value) end end @doc false defmacro init_rest(assigns, from_mount) when is_boolean(from_mount) do quote do if @use_alpinejs do unquote(from_mount) && add_alpinejs_assigns(unquote(assigns)) || unquote(assigns) else unquote(from_mount) && unquote(assigns) || add_js_assigns(unquote(assigns)) end end end @doc false def css_fetch(k, true), do: [@css[k], @css[:colors]] |> build_class() def css_fetch(k, false), do: [@css[k]] |> build_class() @doc false def apply_css(_key, value), do: value @doc false defp add_alpinejs_assigns(assigns) do assigns |> assign_new(:top_rest, fn -> [{"x-data", "{open: false}"}] end) |> assign_new(:main_rest, fn -> [{"x-bind:class", "{'rounded-b-lg': !open}"}, {"@click", "open=!open"}] end) |> assign_new(:tags_rest, fn -> [] end) |> assign_new(:ddown_events, fn -> [{"@click.outside", "open=!open"}, {"x-show", "open"}] end) |> assign_new(:updown_rest, fn -> [{"x-bind:class", "{'rotate-180': open}"}] end) end @doc false defp add_js_assigns(assigns) do assigns |> assign_new(:top_rest, fn -> [] end) |> assign_new(:main_rest, fn -> [{"phx-click", toggle_open(assigns[:id])}] end) |> assign_new(:tags_rest, fn -> [] end) |> assign_new(:ddown_events, fn -> [{"phx-click-away", toggle_open(assigns[:id])}] end) |> assign_new(:updown_rest, fn -> [] end) 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) |> init_rest(true) {:ok, Map.put(socket, :assigns, assigns)} end @doc false def update(%{options: options} = params, socket) do socket = assign(socket, params) assigns = socket.assigns assigns = assigns |> assign_new(:filter_id, fn -> "#{assigns.id}-filter" end) |> assign(:checked_options, filter_checked_options(options)) |> init_rest(false) {:ok, Map.put(socket, :assigns, assigns)} 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 @doc false def render(assigns) do ~H"""