LiveView pattern - how a live component rendered inside a form should handle its own internal form inputs

Hi! I’m wondering how the community has solved the problem as stated in the topic title: how a live component rendered inside a form should handle its own internal form inputs.

Allow me to explain. Let’s say, for example, we have a LiveView that contains a form, with various input types. In addition to the form elements, we also render a live component inside that form. For example maybe a “searchable dropdown” or a “autocomplete” search input component or something like that. Here’s a simple example:

Parent LiveView with a form:

defmodule MyAppWeb.TestLive do
  use MyAppWeb, :live_view
  
  alias MyAppWeb.NestedTestComponent

  def mount(_params, _session, socket) do
    {:ok, socket}
  end

  def render(assigns) do
    ~H"""
      <div>
        <h1>My LiveView</h1>
        <.form let={f} for={:test} phx-change="test">
            <%= label f, :parent_liveview_input %>
            <%= text_input f, :parent_liveview_input, class: "border-2" %>

            <.live_component
              id={:nested}
              module={NestedTestComponent}
            />
        </.form>
      </div>
    """
  end
end

Nested LiveComponent rendered inside the form:

defmodule MyAppWeb.NestedTestComponent do
  use MyAppWeb, :live_component

  def mount(_params, _session, socket) do
    {:ok, socket}
  end

  def handle_event("nested_test", _, socket) do
    IO.inspect("this event never runs")
    {:noreply, socket}
  end

  def render(assigns) do
    ~H"""
      <div>
        <h1>My Nested LiveView Component</h1>
        <form id="wow"></form>
        <form phx-change="nested_test" phx-target={@myself}>
          <input type="text" name="nested_component_input" class="border-2" />
        </form>
      </div>
    """
  end
end

This seems like it would work. The problem arises, however, in the nature of html and most browsers disallowing nested <form /> elements. The nested element is removed completely from the DOM. I know this at least happens in Chrome and Firefox.

My personal use case for such a pattern is an “autocomplete” component, where I would pass in the Phoenix form into my nested component, which would then render a hidden input that would be registered with the passed in form coming from the parent liveview. I would have an internal text input bound to a phx-change event inside my component where the user could “search” for a certain item in a collection, and on select, I would update the hidden input with that selection.

This way, state and business concerns are encapsulated in the live component, and the parent liveview does not need to “know” about how an item in my nested live component is selected, only that it will be selected, and will apply to the changeset given to the parent form.

In summary, I would love to hear how the community has solved the problem of a nested live component rendered inside a form handles its own phx-change etc… events, given that the nested live component cannot itself contain a form, due to browser/html restrictions. Thank you in advance.

1 Like

I’ve never used it beyond quick prototypes, but to get around nested forms one can use the form attributes on inputs.

<form id="form-1"></form>
<form id="form-2"></form>
<input type="text" form="form-1" />
<input type="text" form="form-2" />
3 Likes

Very true, I’ve actually thought about using this method. However, this forces every parent to “know” about the implementation details of the child component; something I was hoping to avoid. This may be the only way to go, though.

My personal practice is to not fight the html spec. I’d rethink the approach.

Do you need to use a form in the inner component to achieve what you want? Can you send or send_update?

A bit late to the party, but i had to solve this exact issue to create a dynamic real time search interface for forms.

Basically I bind everything to the input and handle it inside of the live component, however the input is part of the parent form and is setup to only provide the ID of the specific item that was selected in a search.

A few explanations as the code is rather hairy:

  • I bind a JS hook so I can explicitly capture and see Tab and Enter on the server
  • I use list indexes to facilitate being able to arrow down and up in the displayed list of results (but you can still click on a specific result if that’s your style)
  • There is massive voodoo involved with dynamically querying any schema I want, as I have to support various query options (active: true/false, draft: true/false, or none of those).
  • The displayed search items support display of one field, two separate fields comma separated, and even nested associations with dynamic preload and select), as well as second line descriptions, font-awesome icons etc.
  • I also support affecting other fields in the parent form (in my case copy down of values that are commonly the same, but not if that field already has data in it) - this has been a pain point due to how liveview inconsistently handles (or rather deciding not to) re-render of assigns, but still works okay most of the time.
<%= live_component Search, f: f, id: Nanoid.generate(), assoc: :subrecord, search: :name, tabindex: 4 %>
def render(assigns) do
    ~H"""
    <span>
      <%= get_label(assigns) %>
      <input
        id={@id}
        class={iclass(assigns)}
        name={name(@name)}
        autocomplete="off"
        phx-target={@myself}
        phx-keyup="input"
        phx-hook="SearchInput"
        autofocus={Map.get(assigns, :autofocus)}
        tabindex={Map.get(assigns, :tabindex)}
        value={@value}/>
        <div class="sc-container nueue"><%= for {item, i} <- Enum.with_index(@items) do %>
          <div class={class(i, @index)}>
            <div class="sc-tit" phx-click="fin" phx-value-id={item.id} phx-target={@myself}>
              <%= alt(assigns, item, Map.get(item, @search)) %>
            </div>
          </div>
        <% end %></div>
        <span>
          <%= hidden_input @f, @name, value: @vid %>
          <%= ShipWeb.ErrorHelpers.error_tag @f, @name %>
        </span>
    </span>
    """
  end

This seems to be super interesting.

I have a similar implementation but with a problem:
When I press enter to choose an option, the form is submitted.

How do you solve this problem?

Speak about major Voodoo.
I’m currently working on a typeahead for tags.
I have an index LiveView with a FormComponent (LiveComponent) containing a TagInput (LiveComponent)

    <.live_component module={MyappWeb.Helpers.TagInput}, f={f}, attribute={:tags_list}, tags={@article.tags} id={@article.id} />

and the TagInput’s renders

def render(assigns) do
    # Avoid submit on Enter
    #   onkeydown="return event.key != 'Enter';"  
    # use `list` attributes to associate a datalist
    #   list="tags"
    # target this component on key-up event
    #   phx-target={@myself} phx-keyup="key-up"
    # don't get the browser's history in the field
    #   autocomplete="off" 
    # the hook resets the text input when 'tag' is empty
    ~H"""
 <div class="tag_input" >      
      <input name="tag" type="text" list="tags"
        placeholder="start typing..." 
        id={"tag_input_#{@id}"} phx-hook="FieldReset" data-tag={@tag}
        phx-target={@myself} phx-keyup="key-up"
        autocomplete="off" 
        onkeydown="return event.key != 'Enter';"  
        />
      <%= for tag <- @tags do %>
        <%= tag(:span, tag.title) %>
      <% end %>
</div>
"""
end

Still WIP though.
Now I have to set a hidden input and handle my tags on the parent component submit event

2 Likes

Noticed this also, eventually found a fix for the form submitting on button click, i understood it’s a html thing, so for any buttons on the form apart from the submit one, adding <button type="button"/> fixes it.