Phoenix LiveView + HeadlessUI

I wrote a short description of how to use HeadlessUI components with LiveView:

Tailored to HeadlessUI, but in fact not HeadlesUI specific, but applicable to React components in general.

Approach:

  • React Component
  • Wrapped in Web Component that handles server->client (attribute changes) and client-server (events) communication
  • Rendered to light DOM (not shadow DOM, due to lacking HeadlessUI support)
  • micro LiveView Hook with glue code push LiveView events to server

Pros:

  • integrates well with minimal esbuild assets pipeline
  • web component is a good interface boundary for LiveView / “custom” Javascript
  • sprinkle minimal amounts of Javascript where necessary

Cons:

  • use with caution, prefer LiveView Phoenix.LiveView.JS bindings where sufficient

Do you use similar techniques? What are your experiences?
Are there better ways of implementing this approach?

3 Likes

While I really like the idea and effort put into those components, I’ll never understand why I’d use them, when they cannot replace native inputs. It’s nice to have a fancy combobox (especially for multi select), but not if it means all my phx-change / phx-submit form bindings break, because the input doesn’t integrate with a form.

Form integration is a good point. But quite easily and cleanly done using a hidden input as a “bridge”.
What do you think?

// ComboboxFormWebcomponent.jsx
export default class ComboboxFormWebcomponent extends HTMLElement {
  connectedCallback() {
    // ...
    const inputId = this.getAttribute("input-id")
    this.inputEl = document.getElementById(inputId)
    this.inputEl.addEventListener("change", this.onValueChange)
    this.render()
  }

  disconnectedCallback() {
    this.inputEl.removeEventListener("change", this.onValueChange)
  }

  render() {
     // ...
    const value = this.inputEl.getAttribute('value')
    const onSelect = ({ value }) => {
      this.inputEl.value = value
      this.inputEl.dispatchEvent(new Event("input", {bubbles: true}))
    }

    this.__reactRoot.render(
      <Combobox options={options} value={value} onSelect={onSelect} />
    )
  }

  // ...

  onValueChange = () => this.render()
}
# home_live.ex
defmodule PhoenixHeadlessuiWeb.HomeLive do
  # ...

  @impl true
  def render(assigns) do
    ~H"""
    <.form let={f} for={@changeset} phx-change="validate" phx-submit="save">
      <%= label f, :assignee %>
      <%= hidden_input f, :assignee, id: "assignee-input" %>
      <x-combobox-form input-id="assignee-input" phx-update="ignore" id="react-form-combobox" options={Jason.encode!(@options)} />
      <%= error_tag f, :assignee %>

      <%= submit "Save" %>
    </.form>
    """
  end

  @impl true
  def mount(_params, _session, socket) do
    {:ok, socket
    |> assign(:options, @options)    |> assign(:changeset, Form.changeset(%Form{}))}
  end
end

Generally I find the combination of LV and web-components interesting.
But it seems like these “headless-UI” components are just there to make input-elements nicer. Or is there some other benefit. Do you know if tailwind team plans to build some more involved components?

There had been an interesting talk at last years elixir conf: https://www.youtube.com/watch?v=xXWyOy9XdA8

I don’t think they’re just useful for input fields, but more anything interactive. They’re useful wherever you want to abstract some behaviour without enforcing how the UI around said behaviours looks like.

2 Likes

Sure, I was talking about tailwind’s https://headlessui.com/ in particular. These seem very limited for now.

Nope.

Great talk, wasn’t aware of that. I would look at my post as a port of Chris’s ideas to react/JSX world, where he focuses more on lit.
I especially like his call for simplicity by avoiding JS rendering, shadow DOM etc. if not needed.