From 9ae5755d052c2f08517f431b94f0a4004c826dc0 Mon Sep 17 00:00:00 2001 From: RisPNG Date: Thu, 19 Feb 2026 15:34:53 +0800 Subject: [PATCH 01/10] implement restore on focus --- assets/js/live_select.js | 7 +- lib/live_select.ex | 5 + lib/live_select/component.ex | 19 +++- .../live_select_web/live/showcase_live.ex | 3 + test/live_select_test.exs | 91 +++++++++++++++++++ 5 files changed, 123 insertions(+), 2 deletions(-) diff --git a/assets/js/live_select.js b/assets/js/live_select.js index d105f35..fb531b4 100644 --- a/assets/js/live_select.js +++ b/assets/js/live_select.js @@ -89,7 +89,7 @@ export default { }, mounted() { this.maybeStyleClearButtons() - this.handleEvent("select", ({ id, selection, mode, current_text, input_event, parent_event }) => { + this.handleEvent("select", ({ id, selection, mode, current_text, input_event, parent_event, trigger_change }) => { if (this.el.id === id) { this.selection = selection if (current_text != null) { @@ -101,6 +101,11 @@ export default { if (parent_event) { this.pushEventToParent(parent_event, { id }) } + if (trigger_change) { + const field = this.el.dataset['field'] + this.pushEventTo(this.el, "change", { text: current_text }) + this.pushEventToParent("live_select_change", { id: this.el.id, field, text: current_text }) + } } }) this.handleEvent("scroll_to_option", ({ id, idx }) => { diff --git a/lib/live_select.ex b/lib/live_select.ex index 4385fcc..9c3676c 100644 --- a/lib/live_select.ex +++ b/lib/live_select.ex @@ -418,6 +418,11 @@ defmodule LiveSelect do doc: ~s(if `true`, the current list of selectable options and the content of the input text field are preserved upon selection) + attr :restore_on_focus, :boolean, + default: Component.default_opts()[:restore_on_focus], + doc: + ~s(if `true`, when in single mode, the input text field is preserved to the active option label upon selection.) + 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." diff --git a/lib/live_select/component.ex b/lib/live_select/component.ex index c08e452..2272b90 100644 --- a/lib/live_select/component.ex +++ b/lib/live_select/component.ex @@ -20,6 +20,7 @@ defmodule LiveSelect.Component do clear_tag_button_class: nil, clear_tag_button_extra_class: nil, keep_options_on_select: false, + restore_on_focus: false, current_text: "", user_defined_options: false, container_class: nil, @@ -218,12 +219,28 @@ defmodule LiveSelect.Component do @impl true def handle_event(event, _params, socket) when event in ~w(focus click) do + restore = + socket.assigns.restore_on_focus && + socket.assigns.mode == :single && + socket.assigns.selection != [] + + current_text = + if restore do + List.first(socket.assigns.selection).label + else + socket.assigns.current_text + end + + trigger_change = restore && Enum.empty?(socket.assigns.options) + socket = socket + |> assign(current_text: current_text) |> client_select(%{ input_event: false, parent_event: socket.assigns[:"phx-focus"], - current_text: socket.assigns.current_text + current_text: current_text, + trigger_change: trigger_change }) |> assign(hide_dropdown: false) diff --git a/lib/support/live_select_web/live/showcase_live.ex b/lib/support/live_select_web/live/showcase_live.ex index cf49fa7..d44ee78 100644 --- a/lib/support/live_select_web/live/showcase_live.ex +++ b/lib/support/live_select_web/live/showcase_live.ex @@ -76,6 +76,8 @@ defmodule LiveSelectWeb.ShowcaseLive do default: Component.default_opts()[:keep_options_on_select] ) + field(:restore_on_focus, :boolean, default: Component.default_opts()[:restore_on_focus]) + field(:mode, Ecto.Enum, values: [:single, :tags, :quick_tags], default: Component.default_opts()[:mode] @@ -106,6 +108,7 @@ defmodule LiveSelectWeb.ShowcaseLive do :disabled, :options_styled_as_checkboxes, :keep_options_on_select, + :restore_on_focus, :max_selectable, :user_defined_options, :mode, diff --git a/test/live_select_test.exs b/test/live_select_test.exs index ab55024..cbe3479 100644 --- a/test/live_select_test.exs +++ b/test/live_select_test.exs @@ -1213,4 +1213,95 @@ defmodule LiveSelectTest do refute_selected(live) end + + describe "when restore_on_focus = true" do + setup %{conn: conn} do + stub_options(A: 1, B: 2, C: 3) + + {:ok, live, _html} = live(conn, "/?restore_on_focus=true") + + type(live, "ABC") + select_nth_option(live, 2) + assert_selected(live, :B, 2) + + %{live: live} + end + + test "on focus, the text input is set to the selected label", %{live: live} do + element(live, selectors()[:text_input]) + |> render_focus() + + assert_set_text_field(live, :B) + end + + test "on click, the text input is set to the selected label", %{live: live} do + element(live, selectors()[:text_input]) + |> render_click() + + assert_set_text_field(live, :B) + end + + test "on focus with cleared options, trigger_change is sent", %{live: live} do + element(live, selectors()[:text_input]) + |> render_focus() + + assert_push_event(live, "select", %{ + id: "my_form_city_search_live_select_component", + current_text: :B, + trigger_change: true + }) + end + + test "on blur, the text input is restored to the selected label", %{live: live} do + element(live, selectors()[:text_input]) + |> render_focus() + + element(live, selectors()[:text_input]) + |> render_blur() + + assert_set_text_field(live, :B) + end + + test "blur then focus cycle preserves the selected label", %{live: live} do + element(live, selectors()[:text_input]) + |> render_blur() + + element(live, selectors()[:text_input]) + |> render_focus() + + assert_set_text_field(live, :B) + + element(live, selectors()[:text_input]) + |> render_blur() + + assert_selected_static(live, :B, 2) + end + + test "does not trigger_change when options are present", %{live: live} do + stub_options(A: 1, B: 2, C: 3) + type(live, "ABC") + + element(live, selectors()[:text_input]) + |> render_focus() + + assert_push_event(live, "select", %{ + id: "my_form_city_search_live_select_component", + current_text: :B, + trigger_change: false + }) + end + + test "without a selection, focus behaves as default", %{conn: conn} do + stub_options(A: 1, B: 2, C: 3) + + {:ok, live, _html} = live(conn, "/?restore_on_focus=true") + + type(live, "ABC") + + element(live, selectors()[:text_input]) + |> render_focus() + + assert_set_text_field(live, "ABC") + end + end end From dd8fd73b0c68d5188458ca9638b59bbbdce885cf Mon Sep 17 00:00:00 2001 From: RisPNG Date: Thu, 19 Feb 2026 15:36:42 +0800 Subject: [PATCH 02/10] implement demo in showcase app --- lib/support/live_select_web/live/showcase_live.ex | 3 ++- lib/support/live_select_web/live/showcase_live.html.heex | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/support/live_select_web/live/showcase_live.ex b/lib/support/live_select_web/live/showcase_live.ex index d44ee78..4fa8da1 100644 --- a/lib/support/live_select_web/live/showcase_live.ex +++ b/lib/support/live_select_web/live/showcase_live.ex @@ -148,7 +148,8 @@ defmodule LiveSelectWeb.ShowcaseLive do (remove_defaults && value == Keyword.get(default_opts, option)) || (settings.mode == :single && option == :max_selectable) || (settings.mode != :single && option == :allow_clear) || - (settings.mode == :quick_tags && option == :keep_options_on_select) + (settings.mode == :quick_tags && option == :keep_options_on_select) || + (settings.mode != :single && option == :restore_on_focus) end) |> Keyword.new() 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 d1f9ef7..6e92b15 100644 --- a/lib/support/live_select_web/live/showcase_live.html.heex +++ b/lib/support/live_select_web/live/showcase_live.html.heex @@ -74,6 +74,13 @@ disabled: to_string(@settings_form[:mode].value) == "quick_tags" )} + <%= label class: "label cursor-pointer" do %> Disabled:  {checkbox(@settings_form, :disabled, class: "toggle")} From 2877b0f8d4268206eb6f8b91cb9b5c922cb23627 Mon Sep 17 00:00:00 2001 From: RisPNG Date: Thu, 19 Feb 2026 15:38:49 +0800 Subject: [PATCH 03/10] refactor option name to "keep_label_on_select" to be more align with the rest of the options. --- lib/live_select.ex | 6 +++--- lib/live_select/component.ex | 4 ++-- lib/support/live_select_web/live/showcase_live.ex | 8 +++++--- lib/support/live_select_web/live/showcase_live.html.heex | 4 ++-- test/live_select_test.exs | 6 +++--- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/lib/live_select.ex b/lib/live_select.ex index 9c3676c..75d19ae 100644 --- a/lib/live_select.ex +++ b/lib/live_select.ex @@ -418,10 +418,10 @@ defmodule LiveSelect do doc: ~s(if `true`, the current list of selectable options and the content of the input text field are preserved upon selection) - attr :restore_on_focus, :boolean, - default: Component.default_opts()[:restore_on_focus], + attr :keep_label_on_select, :boolean, + default: Component.default_opts()[:keep_label_on_select], doc: - ~s(if `true`, when in single mode, the input text field is preserved to the active option label upon selection.) + ~s(if `true`, when in single mode, the input text field is preserved to the active label upon selection.) 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." diff --git a/lib/live_select/component.ex b/lib/live_select/component.ex index 2272b90..1f7e916 100644 --- a/lib/live_select/component.ex +++ b/lib/live_select/component.ex @@ -20,7 +20,7 @@ defmodule LiveSelect.Component do clear_tag_button_class: nil, clear_tag_button_extra_class: nil, keep_options_on_select: false, - restore_on_focus: false, + keep_label_on_select: false, current_text: "", user_defined_options: false, container_class: nil, @@ -220,7 +220,7 @@ defmodule LiveSelect.Component do @impl true def handle_event(event, _params, socket) when event in ~w(focus click) do restore = - socket.assigns.restore_on_focus && + socket.assigns.keep_label_on_select && socket.assigns.mode == :single && socket.assigns.selection != [] diff --git a/lib/support/live_select_web/live/showcase_live.ex b/lib/support/live_select_web/live/showcase_live.ex index 4fa8da1..c5571e0 100644 --- a/lib/support/live_select_web/live/showcase_live.ex +++ b/lib/support/live_select_web/live/showcase_live.ex @@ -76,7 +76,9 @@ defmodule LiveSelectWeb.ShowcaseLive do default: Component.default_opts()[:keep_options_on_select] ) - field(:restore_on_focus, :boolean, default: Component.default_opts()[:restore_on_focus]) + field(:keep_label_on_select, :boolean, + default: Component.default_opts()[:keep_label_on_select] + ) field(:mode, Ecto.Enum, values: [:single, :tags, :quick_tags], @@ -108,7 +110,7 @@ defmodule LiveSelectWeb.ShowcaseLive do :disabled, :options_styled_as_checkboxes, :keep_options_on_select, - :restore_on_focus, + :keep_label_on_select, :max_selectable, :user_defined_options, :mode, @@ -149,7 +151,7 @@ defmodule LiveSelectWeb.ShowcaseLive do (settings.mode == :single && option == :max_selectable) || (settings.mode != :single && option == :allow_clear) || (settings.mode == :quick_tags && option == :keep_options_on_select) || - (settings.mode != :single && option == :restore_on_focus) + (settings.mode != :single && option == :keep_label_on_select) end) |> Keyword.new() 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 6e92b15..25a9581 100644 --- a/lib/support/live_select_web/live/showcase_live.html.heex +++ b/lib/support/live_select_web/live/showcase_live.html.heex @@ -75,8 +75,8 @@ )}