Closed DaTrader closed 9 months ago
Here's the bug demo. I believe something's gone wrong between phx-debounce
and JS.focus
.
Start the demo then click on the upper input then on the lower -> infinite loop.
PS. Can't tell why it's not showing the list when first clicked. In the real app it does.
Application.put_env(:sample, Example.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 5001],
server: true,
live_view: [signing_salt: "aaaaaaaa"],
secret_key_base: String.duplicate("a", 64)
)
Mix.install([
{:plug_cowboy, "~> 2.5"},
{:jason, "~> 1.0"},
{:gettext, "~> 0.23"},
{:phoenix, "~> 1.7.0"},
{:phoenix_ecto, "~> 4.4"},
{:phoenix_html, "~> 3.3"},
{:phoenix_live_view, "0.20.4", override: true}
])
defmodule Repro3071.Gettext do
use Gettext, otp_app: :sample
end
defmodule Example.ErrorView do
def render(template, _), do: Phoenix.Controller.status_message_from_template(template)
end
defmodule Example.ConfigComponent do
use Phoenix.LiveComponent
alias Phoenix.LiveView.JS
def mount( socket) do
socket =
socket
|> assign_numbers_open( false)
|> assign_fruits_open( false)
{ :ok, socket}
end
def handle_event( event, _params, socket) do
socket =
case event do
"open_numbers" ->
socket
|> assign_fruits_open( false)
|> assign_numbers_open( true)
"close_numbers" ->
assign_numbers_open( socket, false)
"open_fruits" ->
socket
|> assign_numbers_open( false)
|> assign_fruits_open( true)
"close_fruits" ->
assign_fruits_open( socket, false)
end
{ :noreply, socket}
end
defp assign_numbers_open( socket, numbers_open?) do
assign( socket, :numbers_open?, numbers_open?)
end
defp assign_fruits_open( socket, fruits_open?) do
assign( socket, :fruits_open?, fruits_open?)
end
def render( assigns) do
~H"""
<div id={@id}>
<section class="w-[954px] flex flex-col gap-[32px]">
<div class="relative flex flex-col gap-[16px]">
<ul
:if={@numbers_open?}
id="PickNumber-config"
class="absolute bottom-[56px] w-full max-h-[264px] overflow-y-auto focus:outline-none bg-white border-1 border-black rounded-[8px] flex flex-col textL hidden [&_.option]:w-full [&_.option]:text-left [&_.option]:wp-outlined [&_.option]:px-[24px] [&_.option]:py-[12px]"
phx-mounted={JS.show( transition: { "transition ease-out duration-150", "opacity-0 scale-95", "opacity-100 scale-100"})}
phx-remove={JS.hide( transition: { "transition ease-in duration-75", "opacity-100 scale-100", "opacity-0 scale-95"})}
phx-click-away={JS.push( "close_numbers", target: @myself)}
phx-window-keydown={JS.push( "close_numbers", target: @myself) |> JS.focus( to: "#Input-SearchNumber-#{@id}")}
phx-key="escape"
>
<div class="relative py-[12px]">
<div id="Stream-PickNumber-config" class="flex flex-col">
<button id="Option-0c2ea4ce" type="button" class="option">One</button>
<button id="Option-cb2fcb05" type="button" class="option">Two</button>
<button id="Option-d12fb30f" type="button" class="option">Three</button>
</div>
</div>
</ul>
<span class="text-dark70">
By number
</span>
<div id="SearchNumber-config" class="flex-1">
<form method="post" autocomplete="off">
<div
style="min-width: 1px;"
class="group/search-field flex items-center border border-transparent bg-dark6 hover:bg-black/10 focus-within:bg-black/10 rounded-[8px] px-[16px] py-[6px] gap-[12px] [&_.child-search-ico]:w-[32px] [&_.child-search-ico]:h-[32px] [&_.child-input]:textL [&_.child-close-ico]:w-[24px] [&_.child-close-ico]:h-[24px]"
>
<div class="grow">
<div phx-feedback-for="search_input[search_term]">
<label for="Input-SearchNumber-config" class=""></label>
<input
type="text"
name="search_input[search_term]"
id="Input-SearchNumber-config"
value=""
class="child-input w-full border-none focus:ring-0 px-0 bg-transparent placeholder:text-black/30 group-hover/search-field:placeholder:text-black/50 group-focus-within/search-field:placeholder:text-black/50"
phx-click={unless @numbers_open?, do: JS.push( "open_numbers", target: @myself) |> JS.focus( to: "#Input-SearchNumber-config")}
phx-debounce="300"
placeholder="Search numbers"
>
</div>
</div>
</div>
</form>
</div>
</div>
<div class="relative flex flex-col gap-[16px]">
<ul
:if={@fruits_open?}
id="PickFruit-config"
class="absolute bottom-[56px] w-full max-h-[264px] overflow-y-auto focus:outline-none bg-white border-1 border-black rounded-[8px] flex flex-col textL hidden [&_.option]:w-full [&_.option]:text-left [&_.option]:wp-outlined [&_.option]:px-[24px] [&_.option]:py-[12px]"
phx-mounted={JS.show( transition: { "transition ease-out duration-150", "opacity-0 scale-95", "opacity-100 scale-100"})}
phx-remove={JS.hide( transition: { "transition ease-in duration-75", "opacity-100 scale-100", "opacity-0 scale-95"})}
phx-click-away={JS.push( "close_fruits", target: @myself)}
phx-window-keydown={JS.push( "close_fruits", target: @myself) |> JS.focus( to: "#Input-SearchFruit-#{@id}")}
phx-key="escape"
>
<div class="relative py-[12px]">
<div id="Stream-PickFruit-config" class="flex flex-col">
<button id="Option-4B20XWYO3OKU0EYHKSLT8U995" type="button" class="option">Apple</button>
<button id="Option-1UWA97LI1LTMC06KSSYC6Y2YM" type="button" class="option">Blueberry</button>
<button id="Option-3CFBL4N1V8NF3DCJE7JPX38H0" type="button" class="option">Citrus</button>
</div>
</div>
</ul>
<span class="text-dark70">
By fruit
</span>
<div id="SearchFruit-config" class="flex-1">
<form method="post" autocomplete="off">
<div
style="min-width: 1px;"
class="group/search-field flex items-center border border-transparent bg-dark6 hover:bg-black/10 focus-within:bg-black/10 rounded-[8px] px-[16px] py-[6px] gap-[12px] [&_.child-search-ico]:w-[32px] [&_.child-search-ico]:h-[32px] [&_.child-input]:textL [&_.child-close-ico]:w-[24px] [&_.child-close-ico]:h-[24px]"
>
<div class="grow">
<div phx-feedback-for="search_input[search_term]">
<label for="Input-SearchFruit-config" class=""></label>
<input
type="text"
name="search_input[search_term]"
id="Input-SearchFruit-config"
value=""
class="child-input w-full border-none focus:ring-0 px-0 bg-transparent placeholder:text-black/30 group-hover/search-field:placeholder:text-black/50 group-focus-within/search-field:placeholder:text-black/50"
phx-click={unless @fruits_open?, do: JS.push( "open_fruits", target: @myself) |> JS.focus( to: "#Input-SearchFruit-config")}
phx-debounce="300"
placeholder="Search fruits"
>
</div>
</div>
</div>
</form>
</div>
</div>
</section>
</div>
"""
end
end
defmodule Example.HomeLive do
use Phoenix.LiveView, layout: {__MODULE__, :live}
alias Example.FormComposition
defp phx_vsn, do: Application.spec(:phoenix, :vsn)
def render("live.html", assigns) do
~H"""
<script src={"https://cdn.jsdelivr.net/npm/phoenix@#{phx_vsn()}/priv/static/phoenix.min.js"}></script>
<script src={"https://cdn.jsdelivr.net/gh/phoenixframework/phoenix_live_view@main/priv/static/phoenix_live_view.min.js"}></script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket)
liveSocket.connect()
</script>
<style>
* { font-size: 16px; }
</style>
<%= @inner_content %>
"""
end
def render( assigns) do
~H"""
<div class="w-full h-screen flex flex-col items-center justify-center">
<.live_component
module={Example.ConfigComponent}
id="config-search"
/>
</div>
"""
end
end
defmodule Example.Router do
use Phoenix.Router
import Phoenix.LiveView.Router
pipeline :browser do
plug(:accepts, ["html"])
end
scope "/", Example do
pipe_through(:browser)
live("/", HomeLive, :index)
end
end
defmodule Example.Endpoint do
use Phoenix.Endpoint, otp_app: :sample
socket("/live", Phoenix.LiveView.Socket)
plug(Example.Router)
end
{:ok, _} = Supervisor.start_link([Example.Endpoint], strategy: :one_for_one)
Process.sleep(:infinity)
This one is fixed now. Thanks!
Environment
33f6780d72a066786610b776d4d5af94ddf465b2
to4a76bba5c200eb98ec59970f41445c60d582990b
Actual behavior
Context
A parent LiveComponent form has two text input fields that behave identically (each within its own LiveComponent itself). By clicking on any of the two the parent opens an option list (another live component) related to the clicked input and closes the option list of the other input so the two option list component instances are mutually exclusive (only one can be shown at the time). This is done via the following:
The purpose of the
JS.focus
is to focus the input field that was clicked on after the related option list live component is open (for otherwise, the focus stays within the open live component).The option lists are conditionally rendered and what the
"open_first_list"
handler does is assigns the:first_list_open?
to true and:second_list_open?
to false and the other way around for the "open_second_list".Each option list live component can be closed by either "phx-click-away" (which is the case here) or the
escape
key.Bug
This used to work perfectly well until one of the commits post v0.20.3.
It now behaves as follows:
Having the
JS.focus
removed, everything works smoothly again (except for the desired element not being focused obviously).Commit
The bug manifests first in the
4a76bba5c200eb98ec59970f41445c60d582990b
commit (a bump build), but my guess is it's probably55d50ef3b26494a43cd6489f3337205192fa0ad9
"always emit event on blur, even if throttled (#2318) (#3033)" or33f6780d72a066786610b776d4d5af94ddf465b2
"check for synthetic click event before dispatching click away (#3042)".