From 11240a6e00ddba8515a020518afa880575a10097 Mon Sep 17 00:00:00 2001 From: Harald Ringvold Date: Fri, 16 Aug 2024 19:40:36 +0200 Subject: [PATCH 01/11] 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 3ab370e..90c6883 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 8ff2cc0..e095fd7 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 [ @@ -429,6 +430,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 @@ -445,9 +466,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)) @@ -664,10 +686,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, @@ -676,6 +706,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() @@ -692,6 +735,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 c31b186..1bcada5 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 @@ -300,9 +311,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) @@ -489,6 +506,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 bbeb267..33ddf93 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" From cded2de885ef35f2cd8fdc8eabd286903ecf7c03 Mon Sep 17 00:00:00 2001 From: Harald Ringvold Date: Tue, 3 Sep 2024 14:37:42 +0200 Subject: [PATCH 02/11] Fix focus issue on first select Do not move focus back to search box on first select. Also refactor to use more efficient way of checking for already selected. --- lib/live_select/component.ex | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/live_select/component.ex b/lib/live_select/component.ex index e095fd7..62e7d71 100644 --- a/lib/live_select/component.ex +++ b/lib/live_select/component.ex @@ -201,7 +201,11 @@ defmodule LiveSelect.Component do &if &1.assigns.mode == :single do clear(&1, %{input_event: false, parent_event: &1.assigns[:"phx-focus"]}) else - parent_event(&1, &1.assigns[:"phx-focus"], %{id: &1.assigns.id}) + if &1.assigns.tags_mode == :multiple_select do + &1 + else + parent_event(&1, &1.assigns[:"phx-focus"], %{id: &1.assigns.id}) + end end ) |> assign(hide_dropdown: false) @@ -466,8 +470,7 @@ defmodule LiveSelect.Component do socket |> assign( - active_option: - if(multi_select_mode?(socket), do: socket.assigns.active_option, else: -1), + active_option: if(multi_select_mode?(socket), do: socket.assigns.active_option, else: -1), selection: selection, hide_dropdown: not multi_select_mode?(socket) ) @@ -691,7 +694,7 @@ defmodule LiveSelect.Component do end def already_selected?(option, selection) do - option.label in Enum.map(selection, & &1.label) + Enum.any?(selection, fn item -> item.label == option.label end) end defp multi_select_mode?(socket) do From 3db95ff66b93daf1de9fa5cff1e3be046122132e Mon Sep 17 00:00:00 2001 From: Harald Ringvold Date: Thu, 3 Oct 2024 16:56:46 +0200 Subject: [PATCH 03/11] Change multi-select variant to quick_tags mode Adds toggle to showcase app to view example of custom html for options list. Also fixes issue with dropdown disappearing when selecting first time in quick_tags mode. --- lib/live_select.ex | 10 +- lib/live_select/component.ex | 38 ++++---- lib/live_select/component.html.heex | 8 +- .../live_select_web/live/showcase_live.ex | 94 ++++++++++++++----- .../live/showcase_live.html.heex | 33 +++++-- test/live_select/component_test.exs | 7 +- 6 files changed, 124 insertions(+), 66 deletions(-) diff --git a/lib/live_select.ex b/lib/live_select.ex index 90c6883..67db91b 100644 --- a/lib/live_select.ex +++ b/lib/live_select.ex @@ -370,14 +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)" - - 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" + 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 62e7d71..b3f1a38 100644 --- a/lib/live_select/component.ex +++ b/lib/live_select/component.ex @@ -41,8 +41,7 @@ defmodule LiveSelect.Component do text_input_extra_class: nil, text_input_selected_class: nil, update_min_len: 1, - value: nil, - tags_mode: :default + value: nil ] @styles [ @@ -79,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 @@ -201,11 +200,7 @@ defmodule LiveSelect.Component do &if &1.assigns.mode == :single do clear(&1, %{input_event: false, parent_event: &1.assigns[:"phx-focus"]}) else - if &1.assigns.tags_mode == :multiple_select do - &1 - else - parent_event(&1, &1.assigns[:"phx-focus"], %{id: &1.assigns.id}) - end + parent_event(&1, &1.assigns[:"phx-focus"], %{id: &1.assigns.id}) end ) |> assign(hide_dropdown: false) @@ -443,36 +438,39 @@ defmodule LiveSelect.Component do option = Enum.at(socket.assigns.options, active_option) if already_selected?(option, selection) do - pos = selection_index(option, selection) + 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 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 + 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: if(multi_select_mode?(socket), do: socket.assigns.active_option, else: -1), + active_option: if(quick_tags_mode?(socket), do: socket.assigns.active_option, else: -1), selection: selection, - hide_dropdown: not multi_select_mode?(socket) + hide_dropdown: not quick_tags_mode?(socket) ) |> maybe_save_selection() |> client_select(Map.merge(%{input_event: true}, extra_params)) @@ -546,7 +544,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)) @@ -697,8 +695,8 @@ defmodule LiveSelect.Component do Enum.any?(selection, fn item -> item.label == option.label end) end - defp multi_select_mode?(socket) do - socket.assigns.mode == :tags && socket.assigns.tags_mode == :multiple_select + defp quick_tags_mode?(socket) do + socket.assigns.mode == :quick_tags end defp next_selectable(%{ @@ -713,7 +711,7 @@ defmodule LiveSelect.Component do options: options, active_option: active_option, selection: selection, - tags_mode: :multiple_select + mode: :quick_tags }) do options |> Enum.with_index() @@ -742,7 +740,7 @@ defmodule LiveSelect.Component do options: options, active_option: active_option, selection: selection, - tags_mode: :multiple_select + mode: :quick_tags }) do options |> Enum.with_index() diff --git a/lib/live_select/component.html.heex b/lib/live_select/component.html.heex index d0841c7..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: @@ -127,7 +129,7 @@ <%= if @option == [] do %> <%= option.label %> <% else %> - <%= render_slot(@option, {option, already_selected?(option, @selection)}) %> + <%= 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 1bcada5..24210ad 100644 --- a/lib/support/live_select_web/live/showcase_live.ex +++ b/lib/support/live_select_web/live/showcase_live.ex @@ -67,10 +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(:tags_mode, Ecto.Enum, values: [:default, :multiple_select], default: Component.default_opts()[:tags_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) @@ -94,10 +99,10 @@ defmodule LiveSelectWeb.ShowcaseLive do :allow_clear, :debounce, :disabled, + :custom_option_html, :max_selectable, :user_defined_options, :mode, - :tags_mode, :options, :selection, :placeholder, @@ -121,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 @@ -140,8 +145,12 @@ defmodule LiveSelectWeb.ShowcaseLive do 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") + 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 @@ -251,20 +260,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 %> +
> + +
   + <: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 %> """ end + + defp indent(amount) do + raw(for _ <- 1..amount, do: "   ") + end end @impl true @@ -311,11 +355,12 @@ 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 + attrs = + if settings.mode == :quick_select do + %{selected_option_class: "cursor-pointer font-bold hover:bg-gray-400 rounded"} + else + %{} + end socket = socket @@ -506,9 +551,8 @@ 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 + def quick_tags?(mode) do + mode == :quick_tags end defp value_mapper(%City{name: name} = value), do: %{label: name, value: Map.from_struct(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 33ddf93..6a46ad5 100644 --- a/lib/support/live_select_web/live/showcase_live.html.heex +++ b/lib/support/live_select_web/live/showcase_live.html.heex @@ -40,16 +40,10 @@
{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" )}
-
- <%= 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" @@ -77,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:", @@ -292,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", @@ -316,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 From 995b45cd315e81f346c0b48b592715c42e829f97 Mon Sep 17 00:00:00 2001 From: Harald Ringvold Date: Thu, 12 Dec 2024 16:56:04 +0100 Subject: [PATCH 04/11] Add tests for quick_tags and fix failing tags tests --- lib/live_select/component.ex | 6 +- lib/live_select/component.html.heex | 18 +- .../live/showcase_live.html.heex | 35 +- test/live_select_quick_tags_test.exs | 534 ++++++++++++++++++ 4 files changed, 565 insertions(+), 28 deletions(-) create mode 100644 test/live_select_quick_tags_test.exs diff --git a/lib/live_select/component.ex b/lib/live_select/component.ex index b3f1a38..6a5eb4c 100644 --- a/lib/live_select/component.ex +++ b/lib/live_select/component.ex @@ -435,13 +435,13 @@ defmodule LiveSelect.Component do extra_params ) when active_option >= 0 do - option = Enum.at(socket.assigns.options, active_option) + option = Enum.at(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) + select(socket, Enum.at(options, active_option), extra_params) end end @@ -710,7 +710,6 @@ defmodule LiveSelect.Component do defp next_selectable(%{ options: options, active_option: active_option, - selection: selection, mode: :quick_tags }) do options @@ -739,7 +738,6 @@ defmodule LiveSelect.Component do defp prev_selectable(%{ options: options, active_option: active_option, - selection: selection, mode: :quick_tags }) do options diff --git a/lib/live_select/component.html.heex b/lib/live_select/component.html.heex index a55681a..855614d 100644 --- a/lib/live_select/component.html.heex +++ b/lib/live_select/component.html.heex @@ -8,10 +8,7 @@ data-field={@field.id} data-debounce={@debounce} > - -
+
<%= if (@mode == :tags || @mode == :quick_tags) && Enum.any?(@selection) do %> <%= for {option, idx} <- Enum.with_index(@selection) do %>
@@ -41,8 +38,8 @@
<% end %> - <% end %> -
+ <% end %> +
<%= text_input(@field.form, @text_input_field, @@ -124,12 +121,17 @@ ) ) } - data-idx={idx} + data-idx={ + if @mode == :quick_tags or not already_selected?(option, @selection), do: idx + } > <%= if @option == [] do %> <%= option.label %> <% else %> - <%= render_slot(@option, Map.merge(option, %{selected: already_selected?(option, @selection)})) %> + <%= render_slot( + @option, + Map.merge(option, %{selected: already_selected?(option, @selection)}) + ) %> <% end %>
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 6a46ad5..32a7a92 100644 --- a/lib/support/live_select_web/live/showcase_live.html.heex +++ b/lib/support/live_select_web/live/showcase_live.html.heex @@ -291,21 +291,21 @@ 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 %> - - + <:option :let={%{label: label, value: _value, selected: selected}}> + <%= if @settings_form[:custom_option_html].value do %> +
+ + <%= label %> +
+ <% else %> + <%= label %> + <% end %> + +
{submit("Submit", @@ -345,7 +345,10 @@ <.copy_to_clipboard_icon />
- + diff --git a/test/live_select_quick_tags_test.exs b/test/live_select_quick_tags_test.exs new file mode 100644 index 0000000..1e7b71e --- /dev/null +++ b/test/live_select_quick_tags_test.exs @@ -0,0 +1,534 @@ +defmodule LiveSelectQuickTagsTest do + @moduledoc false + + use LiveSelectWeb.ConnCase, async: true + + import LiveSelect.TestHelpers + + setup %{conn: conn} do + {:ok, live, _html} = live(conn, "/?mode=quick_tags") + + %{live: live} + end + + test "can select multiple options", %{live: live} do + stub_options(~w(A B C D)) + + type(live, "ABC") + + select_nth_option(live, 2, method: :key) + + type(live, "ABC") + + select_nth_option(live, 4, method: :click) + + assert_selected_multiple(live, ~w(B D)) + end + + test "already selected options are selectable in the dropdown using keyboard", %{live: live} do + stub_options(~w(A B C D)) + + type(live, "ABC") + + select_nth_option(live, 2) + + type(live, "ABC") + navigate(live, 2, :down) + keydown(live, "Enter") + + assert_selected_multiple(live, ~w()) + + type(live, "ABC") + navigate(live, 10, :down) + navigate(live, 10, :up) + keydown(live, "Enter") + end + + test "already selected options are selectable in the dropdown using mouseclick", %{ + live: live + } do + select_and_open_dropdown(live, 2) + + assert_selected_multiple(live, ~w(B)) + + select_nth_option(live, 2, method: :click) + + assert_selected_multiple(live, ~w()) + end + + test "hitting enter with only one option selects it", %{live: live} do + stub_options(~w(A)) + + type(live, "ABC") + + keydown(live, "Enter") + + assert_selected_multiple(live, ~w(A)) + end + + test "hitting enter with more than one option does not select", %{live: live} do + stub_options(~w(A B)) + + type(live, "ABC") + + keydown(live, "Enter") + + assert_selected_multiple_static(live, []) + end + + test "hitting enter with only one option does not select it if already selected", %{live: live} do + stub_options(~w(A)) + + type(live, "ABC") + + select_nth_option(live, 1) + + assert_selected_multiple(live, ~w(A)) + + type(live, "ABC") + + keydown(live, "Enter") + + assert_selected_multiple_static(live, ~w(A)) + end + + describe "when user_defined_options = true" do + setup %{conn: conn} do + {:ok, live, _html} = live(conn, "/?mode=tags&user_defined_options=true&update_min_len=3") + %{live: live} + end + + test "hitting enter adds entered text to selection", %{live: live} do + stub_options(["A", "B"]) + + type(live, "ABC") + + keydown(live, "Enter") + + assert_selected_multiple(live, ["ABC"]) + end + + test "hitting enter does not add text to selection if element with same label is already selected", + %{live: live} do + stub_options(["ABC", "DEF"]) + + type(live, "ABC") + + select_nth_option(live, 1, method: :key) + + assert_selected_multiple(live, ["ABC"]) + + type(live, "ABC") + + assert_options(live, ["ABC", "DEF"]) + + keydown(live, "Enter") + + assert_selected_multiple_static(live, ["ABC"]) + end + + test "hitting enter adds text to selection even if there is only one available option", %{ + live: live + } do + stub_options(["A"]) + + type(live, "ABC") + + keydown(live, "Enter") + + assert_selected_multiple(live, ["ABC"]) + end + + test "text added to selection should be trimmed", %{live: live} do + stub_options([]) + + type(live, " ABC ") + + keydown(live, "Enter") + + assert_selected_multiple_static(live, ["ABC"]) + end + + test "text with only whitespace is ignored and not added to selection", %{live: live} do + stub_options(["ABC"]) + + type(live, " ") + + keydown(live, "Enter") + + assert_selected_multiple_static(live, []) + end + + test "text shorter than update_min_len is ignored and not added to selection", %{live: live} do + stub_options([{"ABC", 1}, {"DEF", 2}]) + + type(live, "AB") + + keydown(live, "Enter") + + assert_selected_multiple_static(live, []) + end + + test "hitting enter while options are awaiting update does not select", %{live: live} do + stub_options(~w(A B C), delay_forever: true) + + type(live, "ABC") + + keydown(live, "Enter") + + assert_selected_multiple_static(live, []) + end + + test "one can still select options from the dropdown", %{live: live} do + stub_options(~w(A B C)) + + type(live, "ABC") + + select_nth_option(live, 1, method: :key) + + type(live, "ABC") + + select_nth_option(live, 2, method: :click) + + assert_selected_multiple(live, ~w(A B)) + end + end + + describe "when max_selectable option is set" do + setup %{conn: conn} do + {:ok, live, _html} = live(conn, "/?mode=tags&max_selectable=2") + + %{live: live} + end + + test "prevents selection of more than max_selectable options", %{live: live} do + stub_options(~w(A B C D)) + + type(live, "ABC") + + select_nth_option(live, 2, method: :key) + + type(live, "ABC") + + select_nth_option(live, 4, method: :click) + + assert_selected_multiple(live, ~w(B D)) + + type(live, "ABC") + + select_nth_option(live, 3, method: :click) + + assert_selected_multiple_static(live, ~w(B D)) + end + end + + test "can remove selected options by clicking on tag", %{live: live} do + stub_options(~w(A B C D)) + + type(live, "ABC") + + select_nth_option(live, 2) + + type(live, "ABC") + + select_nth_option(live, 3) + + type(live, "ABC") + + select_nth_option(live, 1) + + assert_selected_multiple(live, ~w(B C A)) + + unselect_nth_option(live, 2) + + assert_selected_multiple(live, ~w(B A)) + end + + test "can set an option as sticky so it can't be removed", %{live: live} do + options = + [ + %{tag_label: "R", value: "Rome", sticky: true}, + %{tag_label: "NY", value: "New York"} + ] + |> Enum.sort() + + sticky_pos = + Enum.find_index(options, & &1[:sticky]) + 1 + + stub_options(options) + + type(live, "ABC") + + select_nth_option(live, 1) + + type(live, "ABC") + + select_nth_option(live, 2) + + refute_option_removable(live, sticky_pos) + + assert_option_removable(live, 3 - sticky_pos) + end + + test "can specify alternative labels for tags using maps", %{live: live} do + options = + [%{tag_label: "R", value: "Rome"}, %{tag_label: "NY", value: "New York"}] |> Enum.sort() + + stub_options(options) + + type(live, "ABC") + + select_nth_option(live, 1) + + type(live, "ABC") + + select_nth_option(live, 2) + + assert_selected_multiple(live, options |> Enum.map(&Map.put(&1, :label, &1.value))) + end + + test "can specify alternative labels for tags using keywords", %{live: live} do + options = + [[tag_label: "R", value: "Rome"], [tag_label: "NY", value: "New York"]] |> Enum.sort() + + stub_options(options) + + type(live, "ABC") + + select_nth_option(live, 1) + + type(live, "ABC") + + select_nth_option(live, 2) + + selection = + options + |> Enum.map(&Map.new/1) + |> Enum.map(&Map.put(&1, :label, &1[:value])) + + assert_selected_multiple(live, selection) + end + + test "can be disabled", %{conn: conn} do + {:ok, live, _html} = live(conn, "/?disabled=true&mode=tags") + + assert element(live, selectors()[:text_input]) + |> render() + |> Floki.parse_fragment!() + |> Floki.attribute("disabled") == ["disabled"] + end + + test "can clear the selection", %{conn: conn} do + {:ok, live, _html} = live(conn, "/?mode=tags") + + stub_options(~w(A B C D)) + + type(live, "ABC") + + select_nth_option(live, 1) + + type(live, "ABC") + + select_nth_option(live, 2, method: :click) + + assert_selected_multiple(live, ~w(A B)) + + send_update(live, value: nil) + + assert_selected_multiple(live, []) + end + + test "can force the selection", %{conn: conn} do + {:ok, live, _html} = live(conn, "/?mode=tags") + + stub_options(~w(A B C D)) + + type(live, "ABC") + + select_nth_option(live, 1) + + type(live, "ABC") + + select_nth_option(live, 2, method: :click) + + assert_selected_multiple(live, ~w(A B)) + + send_update(live, value: ~w(B C)) + + assert_selected_multiple(live, ~w(B C)) + end + + test "can force the selection and options", %{conn: conn} do + {:ok, live, _html} = live(conn, "/?mode=tags") + + stub_options(~w(A B C D)) + + type(live, "ABC") + + select_nth_option(live, 1) + + type(live, "ABC") + + select_nth_option(live, 2, method: :click) + + assert_selected_multiple(live, ~w(A B)) + + send_update(live, value: [3, 5], options: [{"C", 3}, {"D", 4}, {"E", 5}]) + + assert_selected_multiple(live, [%{label: "C", value: 3}, %{label: "E", value: 5}]) + end + + test "can render custom clear button", %{conn: conn} do + {:ok, live, _html} = live(conn, "/live_component_test") + + type(live, "Ber", + component: "#my_form_city_search_custom_clear_tags_live_select_component", + parent: "#form_component" + ) + + select_nth_option(live, 1, + component: "#my_form_city_search_custom_clear_tags_live_select_component" + ) + + assert element( + live, + "#my_form_city_search_custom_clear_tags_live_select_component button[data-idx=0]", + "custom clear button" + ) + |> has_element? + end + + defp select_and_open_dropdown(live, pos) do + if pos < 1 || pos > 4, do: raise("pos must be between 1 and 4") + + stub_options(~w(A B C D)) + + type(live, "ABC") + + select_nth_option(live, 2) + + type(live, "ABC") + + :ok + end + + describe "when focus and blur events are set" do + setup %{conn: conn} do + {:ok, live, _html} = + live(conn, "/?phx-focus=focus-event-for-parent&phx-blur=blur-event-for-parent&mode=tags") + + %{live: live} + end + + test "focusing on the input field sends a focus event to the parent", %{live: live} do + element(live, selectors()[:text_input]) + |> render_focus() + + assert_push_event(live, "parent_event", %{ + id: "my_form_city_search_live_select_component", + event: "focus-event-for-parent", + payload: %{id: "my_form_city_search_live_select_component"} + }) + end + + test "blurring the input field sends a blur event to the parent", %{live: live} do + element(live, selectors()[:text_input]) + |> render_blur() + + assert_push_event(live, "select", %{ + id: "my_form_city_search_live_select_component", + parent_event: "blur-event-for-parent" + }) + end + + test "selecting option with enter doesn't send blur event to parent", %{conn: conn} do + stub_options([{"A", 1}, {"B", 2}, {"C", 3}]) + + {:ok, live, _html} = live(conn, "/?phx-blur=blur-event-for-parent&mode=tags") + + type(live, "ABC") + + assert_options(live, ["A", "B", "C"]) + + select_nth_option(live, 2, method: :key) + + refute_push_event(live, "select", %{ + id: "my_form_city_search_live_select_component", + parent_event: "blur-event-for-parent" + }) + end + + test "selecting option with click doesn't send blur event to parent", %{conn: conn} do + stub_options([{"A", 1}, {"B", 2}, {"C", 3}]) + + {:ok, live, _html} = live(conn, "/?phx-blur=blur-event-for-parent&mode=tags") + + type(live, "ABC") + + assert_options(live, ["A", "B", "C"]) + + select_nth_option(live, 2, method: :click) + + refute_push_event(live, "select", %{ + id: "my_form_city_search_live_select_component", + parent_event: "blur-event-for-parent" + }) + end + end + + test "selection can be updated from the form", %{conn: conn} do + stub_options(%{ + "A" => 1, + "B" => 2, + "C" => 3 + }) + + {:ok, live, _html} = live(conn, "/?mode=tags") + + type(live, "ABC") + + select_nth_option(live, 1) + + type(live, "ABC") + + select_nth_option(live, 2, method: :click) + + stub_options(%{"D" => 4, "E" => 5}) + + type(live, "DEE") + + select_nth_option(live, 1) + + render_change(live, "change", %{"my_form" => %{"city_search" => [1, 2, 4]}}) + + assert_selected_multiple(live, [ + %{value: 1, label: "A"}, + %{value: 2, label: "B"}, + %{value: 4, label: "D"} + ]) + end + + test "selection recovery", %{conn: conn} do + {:ok, live, _html} = live(conn, "/?mode=tags") + + values = [ + value1 = %{"name" => "A", "pos" => [10.0, 20.0]}, + value2 = %{"name" => "B", "pos" => [30.0, 40.0]}, + value3 = %{"name" => "C", "pos" => [50.0, 60.0]} + ] + + render_change(live, "change", %{"my_form" => %{"city_search" => Jason.encode!(values)}}) + + render_hook(element(live, selectors()[:container]), "selection_recovery", [ + %{label: "A", value: value1}, + %{label: "B", value: value2}, + %{label: "C", value: value3} + ]) + + assert_selected_multiple_static(live, [ + %{label: "A", value: value1}, + %{label: "B", value: value2}, + %{label: "C", value: value3} + ]) + end +end From d877febfc76098f4f3fa0f5fa71c003c5b0cbda2 Mon Sep 17 00:00:00 2001 From: Max Marcon Date: Thu, 26 Dec 2024 12:21:54 +0100 Subject: [PATCH 05/11] update docs --- lib/live_select.ex | 106 ++++++++++++++++++++++----------------------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/lib/live_select.ex b/lib/live_select.ex index 67db91b..29fadfd 100644 --- a/lib/live_select.ex +++ b/lib/live_select.ex @@ -6,12 +6,12 @@ defmodule LiveSelect do @moduledoc ~S""" The `LiveSelect` component is rendered by calling the `live_select/1` function and passing it a form field. LiveSelect creates a text input field in which the user can type text, and hidden input field(s) that will contain the value of the selected option(s). - + Whenever the user types something in the text input, LiveSelect triggers a `live_select_change` event for your LiveView or LiveComponent. - The message has a `text` parameter containing the current text entered by the user, as well as `id` and `field` parameters with the id of the + The message has a `text` parameter containing the current text entered by the user, as well as `id` and `field` parameters with the id of the LiveSelect component and the name of the LiveSelect form field, respectively. Your job is to handle the event, retrieve the list of selectable options and then call `Phoenix.LiveView.send_update/3` - to send the list of options to LiveSelect. See the "Examples" section below for details, and check out the + to send the list of options to LiveSelect. See the "Examples" section below for details, and check out the [cheatsheet](https://hexdocs.pm/live_select/cheatsheet.html) for some useful tips. Selection can happen either using the keyboard, by navigating the options with the arrow keys and then pressing enter, or by @@ -23,12 +23,12 @@ defmodule LiveSelect do In single mode, if the configuration option `allow_clear` is set, the user can manually clear the selection by clicking on the `x` button on the input field. In tags mode, single tags can be removed by clicking on them. - ## Single mode + ## Single mode demo ## Tags mode - + demo When `:tags` mode is enabled `LiveSelect` allows the user to select multiple entries. The entries will be visible above the text input field as removable tags. @@ -46,7 +46,7 @@ defmodule LiveSelect do * _atoms, strings or numbers_: In this case, each element will be both label and value for the option * _tuples_: `{label, value}` corresponding to label and value for the option - * _maps_: `%{label: label, value: value}` or `%{value: value}` + * _maps_: `%{label: label, value: value}` or `%{value: value}` * _keywords_: `[label: label, value: value]` or `[value: value]` In the case of maps and keywords, if only `value` is specified, it will be used as both value and label for the option. @@ -61,23 +61,23 @@ defmodule LiveSelect do Note that the option values, if they are not strings, will be JSON-encoded. Your LiveView will receive this JSON-encoded version in the `phx-change` and `phx-submit` events. - ## Styling - + ## Styling + `LiveSelect` supports 3 styling modes: * `tailwind`: uses standard tailwind utility classes (the default) * `daisyui`: uses [daisyUI](https://daisyui.com/) classes. * `none`: no styling at all. - Please see [the styling section](styling.md) for details + Please see [the styling section](styling.md) for details ## Alternative tag labels - - Sometimes, in `:tags` mode, you might want to use alternative labels for the tags. For example, you might want the labels in the tags to be shorter + + Sometimes, in `:tags` mode, you might want to use alternative labels for the tags. For example, you might want the labels in the tags to be shorter in order to save space. You can do this by specifying an additional `tag_label` key when passing options as map or keywords. For example, passing these options: ``` - [%{label: "New York", value: "NY", tag_label: "NY"}, %{label: "Barcelona", value: "BCN", tag_label: "BCN"}] + [%{label: "New York", value: "NY", tag_label: "NY"}, %{label: "Barcelona", value: "BCN", tag_label: "BCN"}] ``` will result in "New York" and "Barcelona" being used for the options in the dropdown, while "NY" and "BCN" will be used for the tags (and the values). @@ -93,11 +93,11 @@ defmodule LiveSelect do Now, whenever the selection contains "New York", the option will stick and the user won't be able to remove it. ## Slots - + You can control how your options and tags are rendered by using the `:option` and `:tag` slots. Let's say you want to show some fancy icons next to each option in the dropdown and the tags: - ```elixir + ```elixir <.live_select field={@form[:city_search]} phx-target={@myself} @@ -115,7 +115,7 @@ defmodule LiveSelect do ``` Here's the result: - + slots ## Controlling the selection programmatically @@ -127,8 +127,8 @@ defmodule LiveSelect do send_update(LiveSelect.Component, id: live_select_id, value: new_selection) ``` - `new_selection` must be a single element in `:single` mode, a list in `:tags` mode. If it's `nil`, the selection will be cleared. - After updating the selection, `LiveSelect` will trigger a change event in the form. + `new_selection` must be a single element in `:single` mode, a list in `:tags` mode. If it's `nil`, the selection will be cleared. + After updating the selection, `LiveSelect` will trigger a change event in the form. To set a custom id for the component to use with `Phoenix.LiveView.send_update/3`, you can pass the `id` assign to `live_select/1`. @@ -148,23 +148,23 @@ defmodule LiveSelect do _Template:_ ``` <.form for={@form} phx-change="change"> - <.live_select field={@form[:city_search]} /> + <.live_select field={@form[:city_search]} /> ``` - + > #### Forms implemented in LiveComponents {: .warning} - > + > > If your form is implemented in a LiveComponent and not in a LiveView, you have to add the `phx-target` attribute > when rendering LiveSelect: > > ```elixir > <.live_select field={@form[:city_search]} phx-target={@myself} /> - > ``` - + > ``` + _LiveView or LiveComponent that is the target of the form's events:_ ``` @impl true - def handle_event("live_select_change", %{"text" => text, "id" => live_select_id}, socket) do + def handle_event("live_select_change", %{"text" => text, "id" => live_select_id}, socket) do cities = City.search(text) # cities could be: # [ {"city name 1", [lat_1, long_1]}, {"city name 2", [lat_2, long_2]}, ... ] @@ -173,13 +173,13 @@ defmodule LiveSelect do # [ "city name 1", "city name 2", ... ] # # or: - # [ [label: "city name 1", value: [lat_1, long_1]], [label: "city name 2", value: [lat_2, long_2]], ... ] + # [ [label: "city name 1", value: [lat_1, long_1]], [label: "city name 2", value: [lat_2, long_2]], ... ] # # or even: # ["city name 1": [lat_1, long_1], "city name 2": [lat_2, long_2]] send_update(LiveSelect.Component, id: live_select_id, options: cities) - + {:noreply, socket} end @@ -192,7 +192,7 @@ defmodule LiveSelect do IO.puts("You selected city #{city_name} located at: #{city_coords}") {:noreply, socket} - end + end ``` ### Tags mode @@ -203,7 +203,7 @@ defmodule LiveSelect do _Template:_ ``` <.form for={@form} phx-change="change"> - <.live_select field={@form[:city_search]} mode={:tags} /> + <.live_select field={@form[:city_search]} mode={:tags} /> ``` @@ -216,17 +216,17 @@ defmodule LiveSelect do socket ) do # list_of_coords will contain the list of the JSON-encoded coordinates of the selected cities, for example: - # ["[-46.565,-23.69389]", "[-48.27722,-18.91861]"] + # ["[-46.565,-23.69389]", "[-48.27722,-18.91861]"] IO.puts("You selected cities located at: #{list_of_coords}") {:noreply, socket} - end + end ``` - ### Multiple LiveSelect inputs in the same LiveView + ### Multiple LiveSelect inputs in the same LiveView - If you have multiple LiveSelect inputs in the same LiveView, you can distinguish them based on the field id. + If you have multiple LiveSelect inputs in the same LiveView, you can distinguish them based on the field id. For example: _Template:_ @@ -255,10 +255,10 @@ defmodule LiveSelect do ## Using LiveSelect with associations and embeds - LiveSelect can also be used to display and select associations or embeds without too much effort. - Let's say you have the following schemas: - - ``` + LiveSelect can also be used to display and select associations or embeds without too much effort. + Let's say you have the following schemas: + + ``` defmodule City do @moduledoc false @@ -292,7 +292,7 @@ defmodule LiveSelect do end end ``` - + Each city has a name and an array with coordinates - we want `LiveSelect` to display the name as label in the dropdown and in the tags, but we want the entire data structure (name + coordinates) to be sent to the server when the user selects. @@ -329,20 +329,20 @@ defmodule LiveSelect do {:noreply, socket} end ``` - + > #### IMPORTANT: the output of the `value_mapper/1` function should be JSON-encodable {: .warning} - Finally, in order to take care of (2) you need to decode the JSON-encoded list of options that's coming from the client before you can + Finally, in order to take care of (2) you need to decode the JSON-encoded list of options that's coming from the client before you can cast them to create a changeset. To do so, `LiveSelect` offers a convenience function called `LiveSelect.decode/1`: ``` def handle_event("change", params, socket) do - # decode will JSON-decode the value in city_search, handling the type of selection + # decode will JSON-decode the value in city_search, handling the type of selection # and taking care of special values such as "" and nil params = update_in(params, ~w(city_search_form city_search), &LiveSelect.decode/1) - + # now we can cast the params: changeset = CitySearchForm.changeset(params) - + {:noreply, assign(socket, form: to_form(changeset))} end ``` @@ -352,12 +352,12 @@ defmodule LiveSelect do @doc ~S""" Renders a `LiveSelect` input in a form. - + [INSERT LVATTRDOCS] ## Styling attributes - * See [the styling section](styling.md) for details + * See [the styling section](styling.md) for details """ @doc type: :component @@ -373,13 +373,13 @@ defmodule LiveSelect do values: [:single, :tags, :quick_tags], default: Component.default_opts()[:mode], doc: - "either `:single` (for single selection), `:tags` (for multiple selection using tags), or :quick_tags (tags mode but can select multiple at a time)" + "either `:single` (for single selection), `:tags` (for multiple selection using tags), or :quick_tags (like tags but that dropdown stays open after selection)" 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) - attr :value, :any, doc: "used to manually set a selection - overrides any values from the form. + attr :value, :any, doc: "used to manually set a selection - overrides any values from the form. Must be a single element in `:single` mode, or a list of elements in `:tags` mode." attr :max_selectable, :integer, @@ -480,27 +480,27 @@ defmodule LiveSelect do @doc ~S""" Decodes the selection from the client. This has to be used when the values in the selection aren't simple integers or strings. - - Let's say you receive your params in the variable `params`, and your `LiveSelect` field is called `my_field` and belongs to the form `my_form`. Then you should + + Let's say you receive your params in the variable `params`, and your `LiveSelect` field is called `my_field` and belongs to the form `my_form`. Then you should decode like this: ``` params = update_in(params, ~w(my_form my_field), &LiveSelect.decode/1) ``` - - ## Examples: + + ## Examples: iex> decode(nil) [] - + iex> decode("") nil - + iex> decode("{\"name\":\"Berlin\",\"pos\":[13.41053,52.52437]}") %{"name" => "Berlin","pos" => [13.41053,52.52437]} - + iex> decode(["{\"name\":\"New York City\",\"pos\":[-74.00597,40.71427]}","{\"name\":\"Stockholm\",\"pos\":[18.06871,59.32938]}"]) - [%{"name" => "New York City","pos" => [-74.00597,40.71427]}, %{"name" => "Stockholm","pos" => [18.06871,59.32938]}] + [%{"name" => "New York City","pos" => [-74.00597,40.71427]}, %{"name" => "Stockholm","pos" => [18.06871,59.32938]}] """ def decode(selection) do case selection do From 4d66dced01ab32e9fa93526460991f3121fb63b2 Mon Sep 17 00:00:00 2001 From: Max Marcon Date: Thu, 26 Dec 2024 12:26:17 +0100 Subject: [PATCH 06/11] update docs --- lib/live_select.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/live_select.ex b/lib/live_select.ex index 29fadfd..5e5df0c 100644 --- a/lib/live_select.ex +++ b/lib/live_select.ex @@ -373,7 +373,7 @@ defmodule LiveSelect do values: [:single, :tags, :quick_tags], default: Component.default_opts()[:mode], doc: - "either `:single` (for single selection), `:tags` (for multiple selection using tags), or :quick_tags (like tags but that dropdown stays open after selection)" + "either `:single` (for single selection), `:tags` (for multiple selection using tags), or :quick_tags (like tags can be selected/deselected in quick succession)" attr :options, :list, doc: From 08ec29f774d4b1d0ea832d6163c118be20f58137 Mon Sep 17 00:00:00 2001 From: Max Marcon Date: Thu, 26 Dec 2024 12:28:33 +0100 Subject: [PATCH 07/11] update docs again (test) --- lib/live_select.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/live_select.ex b/lib/live_select.ex index 5e5df0c..6cbc148 100644 --- a/lib/live_select.ex +++ b/lib/live_select.ex @@ -373,7 +373,7 @@ defmodule LiveSelect do values: [:single, :tags, :quick_tags], default: Component.default_opts()[:mode], doc: - "either `:single` (for single selection), `:tags` (for multiple selection using tags), or :quick_tags (like tags can be selected/deselected in quick succession)" + "either `:single` (for single selection), `:tags` (for multiple selection using tags), or :quick_tags (multiple selection but tags can be selected/deselected in quick succession)" attr :options, :list, doc: From d8007572d41bd825196a52abdcf467bd30b4630d Mon Sep 17 00:00:00 2001 From: Max Marcon Date: Thu, 26 Dec 2024 14:52:41 +0100 Subject: [PATCH 08/11] also cover navigating options upwards in quick_tags mode --- test/live_select_quick_tags_test.exs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/test/live_select_quick_tags_test.exs b/test/live_select_quick_tags_test.exs index 1e7b71e..d3b8157 100644 --- a/test/live_select_quick_tags_test.exs +++ b/test/live_select_quick_tags_test.exs @@ -25,26 +25,23 @@ defmodule LiveSelectQuickTagsTest do assert_selected_multiple(live, ~w(B D)) end - test "already selected options are selectable in the dropdown using keyboard", %{live: live} do + test "already selected options can be deselected in the dropdown using keyboard", %{live: live} do stub_options(~w(A B C D)) type(live, "ABC") select_nth_option(live, 2) + assert_selected_multiple(live, ~w(B)) type(live, "ABC") - navigate(live, 2, :down) - keydown(live, "Enter") - - assert_selected_multiple(live, ~w()) + navigate(live, 3, :down) + navigate(live, 1, :up) - type(live, "ABC") - navigate(live, 10, :down) - navigate(live, 10, :up) keydown(live, "Enter") + assert_selected_multiple(live, ~w()) end - test "already selected options are selectable in the dropdown using mouseclick", %{ + test "already selected options can be deselected in the dropdown using mouseclick", %{ live: live } do select_and_open_dropdown(live, 2) From 792ee34539f08c3d886dfddaeac4836f0981a86e Mon Sep 17 00:00:00 2001 From: Max Marcon Date: Thu, 26 Dec 2024 16:03:04 +0100 Subject: [PATCH 09/11] document selected field --- lib/live_select.ex | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/live_select.ex b/lib/live_select.ex index 6cbc148..780dd0b 100644 --- a/lib/live_select.ex +++ b/lib/live_select.ex @@ -95,6 +95,9 @@ defmodule LiveSelect do ## Slots You can control how your options and tags are rendered by using the `:option` and `:tag` slots. + Both slots will be passed an option as argument. In the case of the `:option` slot, the option will have an + extra boolean field `:selected`, which will be set to `true` if the option has been selected by the user. + Let's say you want to show some fancy icons next to each option in the dropdown and the tags: ```elixir From f9607d0d6f52e22ab11b81ea58d24f96a3081c78 Mon Sep 17 00:00:00 2001 From: Harald Ringvold Date: Fri, 27 Dec 2024 12:49:05 +0100 Subject: [PATCH 10/11] Changes based on review comments --- lib/live_select/component.ex | 52 ++++++------------- lib/live_select/component.html.heex | 2 +- .../live_select_web/live/showcase_live.ex | 41 ++++++--------- .../live/showcase_live.html.heex | 8 +-- test/live_select/component_test.exs | 5 -- 5 files changed, 37 insertions(+), 71 deletions(-) diff --git a/lib/live_select/component.ex b/lib/live_select/component.ex index 6a5eb4c..a8dbc1d 100644 --- a/lib/live_select/component.ex +++ b/lib/live_select/component.ex @@ -441,7 +441,7 @@ defmodule LiveSelect.Component do pos = get_selection_index(option, selection) unselect(socket, pos) else - select(socket, Enum.at(options, active_option), extra_params) + select(socket, option, extra_params) end end @@ -455,15 +455,10 @@ defmodule LiveSelect.Component do 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] + if socket.assigns.mode in [:tags, :quick_tags] do + socket.assigns.selection ++ [selected] + else + [selected] end socket @@ -687,11 +682,7 @@ defmodule LiveSelect.Component do defp encode(value), do: Jason.encode!(value) - def already_selected?(idx, selection) when is_integer(idx) do - Enum.at(selection, idx) != nil - end - - def already_selected?(option, selection) do + defp already_selected?(option, selection) do Enum.any?(selection, fn item -> item.label == option.label end) end @@ -710,19 +701,14 @@ defmodule LiveSelect.Component do defp next_selectable(%{ options: options, active_option: active_option, - mode: :quick_tags + selection: selection, + mode: mode }) 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() - |> Enum.reject(fn {opt, _} -> active_option == opt || already_selected?(opt, selection) end) + |> Enum.reject(fn {opt, _} -> + active_option == opt || (mode != :quick_tags && already_selected?(opt, selection)) + end) |> Enum.map(fn {_, idx} -> idx end) |> Enum.find(active_option, &(&1 > active_option)) end @@ -738,21 +724,15 @@ defmodule LiveSelect.Component do defp prev_selectable(%{ options: options, active_option: active_option, - mode: :quick_tags + selection: selection, + mode: mode }) 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() - |> Enum.reverse() - |> Enum.reject(fn {opt, _} -> active_option == opt || already_selected?(opt, selection) end) + |> Enum.reject(fn {opt, _} -> + active_option == opt || (mode != :quick_tags && already_selected?(opt, selection)) + end) |> Enum.map(fn {_, idx} -> idx end) |> Enum.find(active_option, &(&1 < active_option || active_option == -1)) end diff --git a/lib/live_select/component.html.heex b/lib/live_select/component.html.heex index 855614d..319fafc 100644 --- a/lib/live_select/component.html.heex +++ b/lib/live_select/component.html.heex @@ -9,7 +9,7 @@ data-debounce={@debounce} >
- <%= if (@mode == :tags || @mode == :quick_tags) && Enum.any?(@selection) do %> + <%= if (@mode in [:tags, :quick_tags]) && Enum.any?(@selection) do %> <%= for {option, idx} <- Enum.with_index(@selection) do %>
<%= if @tag == [] do %> diff --git a/lib/support/live_select_web/live/showcase_live.ex b/lib/support/live_select_web/live/showcase_live.ex index 24210ad..67dfc28 100644 --- a/lib/support/live_select_web/live/showcase_live.ex +++ b/lib/support/live_select_web/live/showcase_live.ex @@ -67,7 +67,7 @@ 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(:options_styled_as_checkboxes, :boolean) field(:max_selectable, :integer, default: Component.default_opts()[:max_selectable]) field(:user_defined_options, :boolean) @@ -99,7 +99,7 @@ defmodule LiveSelectWeb.ShowcaseLive do :allow_clear, :debounce, :disabled, - :custom_option_html, + :options_styled_as_checkboxes, :max_selectable, :user_defined_options, :mode, @@ -126,7 +126,7 @@ defmodule LiveSelectWeb.ShowcaseLive do default_opts = Component.default_opts() settings - |> Map.drop([:search_delay, :new, :selection, :custom_option_html]) + |> Map.drop([:search_delay, :new, :selection, :options_styled_as_checkboxes]) |> Map.from_struct() |> then( &if is_nil(&1.style) do @@ -141,19 +141,6 @@ 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 @@ -260,7 +247,7 @@ defmodule LiveSelectWeb.ShowcaseLive do assigns = assign(assigns, opts: opts, format_value: format_value) ~H""" - <%= if @custom_option_html do %> + <%= if @options_styled_as_checkboxes do %>
<.live_select
   field={my_form[:city_search]} @@ -341,6 +328,17 @@ defmodule LiveSelectWeb.ShowcaseLive do socket end + params = + if params["mode"] == "quick_tags" do + Map.put_new( + params, + "selected_option_class", + "cursor-pointer font-bold hover:bg-gray-400 rounded" + ) + else + params + end + changeset = Settings.changeset(params) |> then( @@ -355,16 +353,9 @@ 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, attrs) |> to_form) + |> assign(:settings_form, Settings.changeset(settings, %{}) |> to_form) |> update(:schema_module, fn _, %{settings_form: settings_form} -> if settings_form[:mode].value == :single, do: CitySearchSingle, else: CitySearchMany end) 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 32a7a92..1e9b1cf 100644 --- a/lib/support/live_select_web/live/showcase_live.html.heex +++ b/lib/support/live_select_web/live/showcase_live.html.heex @@ -72,8 +72,8 @@ {checkbox(@settings_form, :disabled, class: "toggle")} <% end %> <%= label class: "label cursor-pointer" do %> - Custom option HTML:  - <%= checkbox(@settings_form, :custom_option_html, class: "toggle") %> + Options styles as checkboxes:  + <%= checkbox(@settings_form, :options_styled_as_checkboxes, class: "toggle") %> <% end %>
@@ -292,7 +292,7 @@ {live_select_assigns(@settings_form.source)} > <:option :let={%{label: label, value: _value, selected: selected}}> - <%= if @settings_form[:custom_option_html].value do %> + <%= if @settings_form[:options_styled_as_checkboxes].value do %>
diff --git a/test/live_select/component_test.exs b/test/live_select/component_test.exs index c36a49e..463e7d4 100644 --- a/test/live_select/component_test.exs +++ b/test/live_select/component_test.exs @@ -539,11 +539,6 @@ defmodule LiveSelect.ComponentTest do ] end - describe "in quick_tags mode" do - test "" do - end - end - for style <- [:daisyui, :tailwind, :none, nil] do @style style From d1feae9fcbe1ef24de584f35d9786c5576cd1f72 Mon Sep 17 00:00:00 2001 From: Max Marcon Date: Fri, 27 Dec 2024 14:07:02 +0100 Subject: [PATCH 11/11] fix typo --- lib/support/live_select_web/live/showcase_live.html.heex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1e9b1cf..62b6b8a 100644 --- a/lib/support/live_select_web/live/showcase_live.html.heex +++ b/lib/support/live_select_web/live/showcase_live.html.heex @@ -72,7 +72,7 @@ {checkbox(@settings_form, :disabled, class: "toggle")} <% end %> <%= label class: "label cursor-pointer" do %> - Options styles as checkboxes:  + Options styled as checkboxes:  <%= checkbox(@settings_form, :options_styled_as_checkboxes, class: "toggle") %> <% end %>