From f4d4e2449a8b322bf657a9a77ed9c6a4ac90935a Mon Sep 17 00:00:00 2001 From: Harald Ringvold Date: Fri, 16 Aug 2024 19:40:36 +0200 Subject: [PATCH] Add tags_mode option and multiple_select variant The multiple select option for the new tags_mode makes it possible to select multiple options at once without needing to search or force the dropdown to appear. It is a kind of hybrid of the search variant and a normal multi select input input. --- lib/live_select.ex | 5 ++ lib/live_select/component.ex | 65 +++++++++++++++++-- lib/live_select/component.html.heex | 4 +- .../live_select_web/live/showcase_live.ex | 24 ++++++- .../live/showcase_live.html.heex | 6 ++ 5 files changed, 97 insertions(+), 7 deletions(-) diff --git a/lib/live_select.ex b/lib/live_select.ex index 51f0b0d..cca59d1 100644 --- a/lib/live_select.ex +++ b/lib/live_select.ex @@ -374,6 +374,11 @@ defmodule LiveSelect do default: Component.default_opts()[:mode], doc: "either `:single` (for single selection), or `:tags` (for multiple selection using tags)" + attr :tags_mode, :atom, + values: [:default, :multiple_select], + default: Component.default_opts()[:tags_mode], + doc: "whether to use default tags mode or multi-select tags mode. Multi-select mode enables selecting multiple options without the options dropdown being hidden" + attr :options, :list, doc: ~s(initial available options to select from. Note that, after the initial rendering of the component, options can only be updated using `Phoenix.LiveView.send_update/3` - See the "Options" section for details) diff --git a/lib/live_select/component.ex b/lib/live_select/component.ex index 86a715a..5e09027 100644 --- a/lib/live_select/component.ex +++ b/lib/live_select/component.ex @@ -41,7 +41,8 @@ defmodule LiveSelect.Component do text_input_extra_class: nil, text_input_selected_class: nil, update_min_len: 1, - value: nil + value: nil, + tags_mode: :default ] @styles [ @@ -427,6 +428,26 @@ defmodule LiveSelect.Component do defp maybe_select(%{assigns: %{active_option: -1}} = socket, _extra_params), do: socket + defp maybe_select( + %{assigns: %{active_option: active_option, options: options, selection: selection}} = + socket, + extra_params + ) + when active_option >= 0 do + option = Enum.at(socket.assigns.options, active_option) + + if already_selected?(option, selection) do + pos = selection_index(option, selection) + unselect(socket, pos) + else + select(socket, Enum.at(socket.assigns.options, socket.assigns.active_option), extra_params) + end + end + + defp selection_index(option, selection) do + Enum.find_index(selection, fn %{label: label} -> label == option.label end) + end + defp maybe_select(socket, extra_params) do select(socket, Enum.at(socket.assigns.options, socket.assigns.active_option), extra_params) end @@ -443,9 +464,10 @@ defmodule LiveSelect.Component do socket |> assign( - active_option: -1, + active_option: + if(multi_select_mode?(socket), do: socket.assigns.active_option, else: -1), selection: selection, - hide_dropdown: true + hide_dropdown: not multi_select_mode?(socket) ) |> maybe_save_selection() |> client_select(Map.merge(%{input_event: true}, extra_params)) @@ -662,10 +684,18 @@ defmodule LiveSelect.Component do defp encode(value), do: Jason.encode!(value) - defp already_selected?(option, selection) do + def already_selected?(idx, selection) when is_integer(idx) do + Enum.at(selection, idx) != nil + end + + def already_selected?(option, selection) do option.label in Enum.map(selection, & &1.label) end + defp multi_select_mode?(socket) do + socket.assigns.mode == :tags && socket.assigns.tags_mode == :multiple_select + end + defp next_selectable(%{ selection: selection, active_option: active_option, @@ -674,6 +704,19 @@ defmodule LiveSelect.Component do when max_selectable > 0 and length(selection) >= max_selectable, do: active_option + defp next_selectable(%{ + options: options, + active_option: active_option, + selection: selection, + tags_mode: :multiple_select + }) do + options + |> Enum.with_index() + |> Enum.reject(fn {opt, _} -> active_option == opt end) + |> Enum.map(fn {_, idx} -> idx end) + |> Enum.find(active_option, &(&1 > active_option)) + end + defp next_selectable(%{options: options, active_option: active_option, selection: selection}) do options |> Enum.with_index() @@ -690,6 +733,20 @@ defmodule LiveSelect.Component do when max_selectable > 0 and length(selection) >= max_selectable, do: active_option + defp prev_selectable(%{ + options: options, + active_option: active_option, + selection: selection, + tags_mode: :multiple_select + }) do + options + |> Enum.with_index() + |> Enum.reverse() + |> Enum.reject(fn {opt, _} -> active_option == opt end) + |> Enum.map(fn {_, idx} -> idx end) + |> Enum.find(active_option, &(&1 < active_option || active_option == -1)) + end + defp prev_selectable(%{options: options, active_option: active_option, selection: selection}) do options |> Enum.with_index() diff --git a/lib/live_select/component.html.heex b/lib/live_select/component.html.heex index a4ecd3a..d0841c7 100644 --- a/lib/live_select/component.html.heex +++ b/lib/live_select/component.html.heex @@ -122,12 +122,12 @@ ) ) } - data-idx={unless already_selected?(option, @selection), do: idx} + data-idx={idx} > <%= if @option == [] do %> <%= option.label %> <% else %> - <%= render_slot(@option, option) %> + <%= render_slot(@option, {option, already_selected?(option, @selection)}) %> <% end %> diff --git a/lib/support/live_select_web/live/showcase_live.ex b/lib/support/live_select_web/live/showcase_live.ex index 30a44c5..a4dc611 100644 --- a/lib/support/live_select_web/live/showcase_live.ex +++ b/lib/support/live_select_web/live/showcase_live.ex @@ -70,6 +70,7 @@ defmodule LiveSelectWeb.ShowcaseLive do field(:max_selectable, :integer, default: Component.default_opts()[:max_selectable]) field(:user_defined_options, :boolean) field(:mode, Ecto.Enum, values: [:single, :tags], default: Component.default_opts()[:mode]) + field(:tags_mode, Ecto.Enum, values: [:default, :multiple_select], default: Component.default_opts()[:tags_mode]) field(:new, :boolean, default: true) field(:placeholder, :string, default: "Search for a city") field(:search_delay, :integer, default: 10) @@ -96,6 +97,7 @@ defmodule LiveSelectWeb.ShowcaseLive do :max_selectable, :user_defined_options, :mode, + :tags_mode, :options, :selection, :placeholder, @@ -134,6 +136,15 @@ defmodule LiveSelectWeb.ShowcaseLive do (settings.mode != :single && option == :allow_clear) end) |> Keyword.new() + |> then(&maybe_set_classes_for_multiselect/1) + end + + defp maybe_set_classes_for_multiselect(opts) do + if LiveSelectWeb.ShowcaseLive.multiple_select?(opts) |> dbg do + Keyword.put(opts, :selected_option_class, "cursor-pointer font-bold hover:bg-gray-400 rounded") + else + opts + end end def has_style_errors?(%Ecto.Changeset{errors: errors}) do @@ -299,9 +310,15 @@ defmodule LiveSelectWeb.ShowcaseLive do {:ok, settings} -> socket.assigns + attrs = if settings.tags_mode == :multiple_select do + %{selected_option_class: "cursor-pointer font-bold hover:bg-gray-400 rounded"} + else + %{} + end + socket = socket - |> assign(:settings_form, Settings.changeset(settings, %{}) |> to_form) + |> assign(:settings_form, Settings.changeset(settings, attrs) |> to_form) |> update(:schema_module, fn _, %{settings_form: settings_form} -> if settings_form[:mode].value == :single, do: CitySearchSingle, else: CitySearchMany end) @@ -488,6 +505,11 @@ defmodule LiveSelectWeb.ShowcaseLive do {:noreply, socket} end + def multiple_select?(assigns) do + assigns[:mode] == :tags && Keyword.has_key?(assigns, :tags_mode) && + assigns[:tags_mode] == :multiple_select + end + defp value_mapper(%City{name: name} = value), do: %{label: name, value: Map.from_struct(value)} defp value_mapper(value), do: value diff --git a/lib/support/live_select_web/live/showcase_live.html.heex b/lib/support/live_select_web/live/showcase_live.html.heex index c0a36e8..52880d6 100644 --- a/lib/support/live_select_web/live/showcase_live.html.heex +++ b/lib/support/live_select_web/live/showcase_live.html.heex @@ -44,6 +44,12 @@ class: "select select-sm select-bordered text-xs" ) %> +
+ <%= label(@settings_form, :tags_mode, "Tags mode:", class: "label label-text font-semibold") %> + <%= select(@settings_form, :tags_mode, [:default, :multiple_select], + class: "select select-sm select-bordered text-xs" + ) %> +
<%= label(@settings_form, :max_selectable, "Max selectable:", class: "label label-text font-semibold"