diff --git a/lib/live_select.ex b/lib/live_select.ex index 51f0b0d..dc47185 100644 --- a/lib/live_select.ex +++ b/lib/live_select.ex @@ -370,9 +370,10 @@ defmodule LiveSelect do ~S(an id to assign to the component. If none is provided, `#{form_name}_#{field}_live_select_component` will be used) attr :mode, :atom, - values: [:single, :tags], + values: [:single, :tags, :quick_tags], default: Component.default_opts()[:mode], - doc: "either `:single` (for single selection), or `:tags` (for multiple selection using tags)" + doc: + "either `:single` (for single selection), `:tags` (for multiple selection using tags), or :quick_tags (tags mode but can select multiple at a time)" attr :options, :list, doc: diff --git a/lib/live_select/component.ex b/lib/live_select/component.ex index 86a715a..3e93c32 100644 --- a/lib/live_select/component.ex +++ b/lib/live_select/component.ex @@ -78,7 +78,7 @@ defmodule LiveSelect.Component do none: [] ] - @modes ~w(single tags)a + @modes ~w(single tags quick_tags)a @impl true def mount(socket) do @@ -427,25 +427,48 @@ 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 = get_selection_index(option, selection) + unselect(socket, pos) + else + select(socket, Enum.at(socket.assigns.options, socket.assigns.active_option), extra_params) + end + end + defp maybe_select(socket, extra_params) do select(socket, Enum.at(socket.assigns.options, socket.assigns.active_option), extra_params) end + defp get_selection_index(option, selection) do + Enum.find_index(selection, fn %{label: label} -> label == option.label end) + end + defp select(socket, selected, extra_params) do selection = case socket.assigns.mode do :tags -> socket.assigns.selection ++ [selected] + :quick_tags -> + socket.assigns.selection ++ [selected] + _ -> [selected] end socket |> assign( - active_option: -1, + active_option: if(quick_tags_mode?(socket), do: socket.assigns.active_option, else: -1), selection: selection, - hide_dropdown: true + hide_dropdown: not quick_tags_mode?(socket) ) |> maybe_save_selection() |> client_select(Map.merge(%{input_event: true}, extra_params)) @@ -519,7 +542,7 @@ defmodule LiveSelect.Component do List.wrap(normalize_selection_value(value, options ++ current_selection, value_mapper)) end - defp update_selection(value, current_selection, options, :tags, value_mapper) do + defp update_selection(value, current_selection, options, _mode, value_mapper) do value = if Enumerable.impl_for(value), do: value, else: [value] Enum.map(value, &normalize_selection_value(&1, options ++ current_selection, value_mapper)) @@ -662,8 +685,16 @@ defmodule LiveSelect.Component do defp encode(value), do: Jason.encode!(value) - defp already_selected?(option, selection) do - option.label in Enum.map(selection, & &1.label) + def already_selected?(idx, selection) when is_integer(idx) do + Enum.at(selection, idx) != nil + end + + def already_selected?(option, selection) do + Enum.any?(selection, fn item -> item.label == option.label end) + end + + defp quick_tags_mode?(socket) do + socket.assigns.mode == :quick_tags end defp next_selectable(%{ @@ -674,6 +705,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, + mode: :quick_tags + }) 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 +734,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, + mode: :quick_tags + }) 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..a55681a 100644 --- a/lib/live_select/component.html.heex +++ b/lib/live_select/component.html.heex @@ -8,10 +8,11 @@ data-field={@field.id} data-debounce={@debounce} > - <%= if @mode == :tags && Enum.any?(@selection) do %> +
+ <%= if (@mode == :tags || @mode == :quick_tags) && Enum.any?(@selection) do %> <%= for {option, idx} <- Enum.with_index(@selection) do %>
<%= if @tag == [] do %> @@ -40,8 +41,9 @@
<% end %> + <% end %>
- <% end %> +
<%= text_input(@field.form, @text_input_field, class: @@ -122,12 +124,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, Map.merge(option, %{selected: 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..2ccc710 100644 --- a/lib/support/live_select_web/live/showcase_live.ex +++ b/lib/support/live_select_web/live/showcase_live.ex @@ -67,9 +67,15 @@ defmodule LiveSelectWeb.ShowcaseLive do field(:allow_clear, :boolean) field(:debounce, :integer, default: Component.default_opts()[:debounce]) field(:disabled, :boolean) + field(:custom_option_html, :boolean) 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(:mode, Ecto.Enum, + values: [:single, :tags, :quick_tags], + default: Component.default_opts()[:mode] + ) + field(:new, :boolean, default: true) field(:placeholder, :string, default: "Search for a city") field(:search_delay, :integer, default: 10) @@ -93,6 +99,7 @@ defmodule LiveSelectWeb.ShowcaseLive do :allow_clear, :debounce, :disabled, + :custom_option_html, :max_selectable, :user_defined_options, :mode, @@ -119,7 +126,7 @@ defmodule LiveSelectWeb.ShowcaseLive do default_opts = Component.default_opts() settings - |> Map.drop([:search_delay, :new, :selection]) + |> Map.drop([:search_delay, :new, :selection, :custom_option_html]) |> Map.from_struct() |> then( &if is_nil(&1.style) do @@ -134,6 +141,19 @@ 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.quick_tags?(opts[:mode]) 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 @@ -239,20 +259,55 @@ defmodule LiveSelectWeb.ShowcaseLive do assigns = assign(assigns, opts: opts, format_value: format_value) ~H""" -
- <.live_select -
   field={my_form[:city_search]} - <%= for {key, value} <- @opts, !is_nil(value) do %> - <%= if value == true do %> -
   <%= key %> - <% else %> -
   <%= key %>=<%= @format_value.(value) %> + <%= if @custom_option_html do %> +
+ <.live_select +
   field={my_form[:city_search]} + <%= for {key, value} <- @opts, !is_nil(value) do %> + <%= if value == true do %> +
   <%= key %> + <% else %> +
   <%= key %>=<%= @format_value.(value) %> + <% end %> <% end %> - <% end %> - /> -
+
> + +
   + <:option :let={%{label: label, value: value, selected: selected}} + + > + +
<%= indent(2) %> <div class="flex justify-content items-center"> +
<%= indent(3) %> <input +
<%= indent(4) %> class="rounded w-4 h-4 mr-3 border border-border" +
<%= indent(4) %> type="checkbox" +
<%= indent(4) %> checked={selected} +
<%= indent(3) %> /> +
<%= indent(4) %> <span class="text-sm"><%= label %> +
<%= indent(2) %> </div> +
<%= indent(1) %> </:option> +
<.live_select/> +
+ <% else %> +
+ <.live_select +
   field={my_form[:city_search]} + <%= for {key, value} <- @opts, !is_nil(value) do %> + <%= if value == true do %> +
   <%= key %> + <% else %> +
   <%= key %>=<%= @format_value.(value) %> + <% end %> + <% end %> + /> +
+ <% end %> """ end + + defp indent(amount) do + raw(for _ <- 1..amount, do: "   ") + end end @impl true @@ -299,9 +354,16 @@ defmodule LiveSelectWeb.ShowcaseLive do {:ok, settings} -> socket.assigns + attrs = + if settings.mode == :quick_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 +550,10 @@ defmodule LiveSelectWeb.ShowcaseLive do {:noreply, socket} end + def quick_tags?(mode) do + mode == :quick_tags + 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..9b20cde 100644 --- a/lib/support/live_select_web/live/showcase_live.html.heex +++ b/lib/support/live_select_web/live/showcase_live.html.heex @@ -40,7 +40,7 @@
<%= label(@settings_form, :mode, "Mode:", class: "label label-text font-semibold") %> - <%= select(@settings_form, :mode, [:single, :tags], + <%= select(@settings_form, :mode, [:single, :tags, :quick_tags], class: "select select-sm select-bordered text-xs" ) %>
@@ -71,6 +71,10 @@ Disabled:  <%= checkbox(@settings_form, :disabled, class: "toggle") %> <% end %> + <%= label class: "label cursor-pointer" do %> + Custom option HTML:  + <%= checkbox(@settings_form, :custom_option_html, class: "toggle") %> + <% end %>
<%= label(@settings_form, :search_delay, "Search delay in ms:", @@ -286,7 +290,22 @@ field={@live_select_form[:city_search]} value_mapper={&value_mapper/1} {live_select_assigns(@settings_form.source)} - /> + > + <:option :let={%{label: label, value: value, selected: selected}}> + <%= if @settings_form[:custom_option_html].value do %> +
+ + <%= label %> +
+ <% else %> + <%= label %> + <% end %> + +
<%= submit("Submit", @@ -310,7 +329,7 @@
- +
diff --git a/test/live_select/component_test.exs b/test/live_select/component_test.exs index b6a8090..c36a49e 100644 --- a/test/live_select/component_test.exs +++ b/test/live_select/component_test.exs @@ -427,7 +427,7 @@ defmodule LiveSelect.ComponentTest do test "raises if unknown mode is given", %{form: form} do assert_raise( RuntimeError, - ~s(Invalid mode: "not_a_valid_mode". Mode must be one of: [:single, :tags]), + ~s(Invalid mode: "not_a_valid_mode". Mode must be one of: [:single, :tags, :quick_tags]), fn -> render_component(&LiveSelect.live_select/1, field: form[:input], @@ -539,6 +539,11 @@ defmodule LiveSelect.ComponentTest do ] end + describe "in quick_tags mode" do + test "" do + end + end + for style <- [:daisyui, :tailwind, :none, nil] do @style style