Improve Support for Web Components in Forms with "Element Adapters"

Background

I work at a hedge fund and our traders need highly dynamic UIs (think splitters, tabbed panels, tree lists, enormous data grids, pop-outs, etc.) and we don’t want to have to build it all from scratch nor do we want to switch to React.

I had thought we found an 80% solution in Shoelace (https://shoelace.style) – a really well done web component library – and I got very far with using it in our main Phoenix app (just a few tricks to make reflected attributes work), but I hit the wall hard when it came to forms. I found that there are several (perfectly reasonable) assumptions baked into the LiveView JavaScript that don’t support web components (and, really, how could they? Web components are custom by nature).

Proposal

Phoenix LV already works well with web components, but I think we can take it even further. If we get it right, then the chances that someone will have to eject and switch to React are much, much lower. Since web components are markup-first and generally use attributes as their api they could be a nearly perfect fit for advanced, server-managed UI. I’m familiar with phx-update="ignore", but I think that is an escape hatch that we don’t need to use as often as we might think AND it doesn’t help for web component-based form controls at all.

My proposal is to introduce an element adapter and adapter registration mechanism which will allow Phoenix LV JavaScript to code to a standard interface for interacting with an element and will also allow Phoenix users to provide adapters for web components.

Phoenix LV would ship with adapters for all standard html elements and they would implement the logic that is in place today. Adapters would be assigned by tagName since this is an element-level responsibility. Element adapters would most likely be passed to the LiveSocket constructor.

The gist of the proposal is “When it comes to html elements, ask instead of inspect/assume.”

Some Examples

In DOM.js there are a few good candidates: hasSelectionRange, isTextualInput, isFormInput. There are other examples elsewhere, but these seemed reasonably illustrative.

Example: hasSelectionRange

Here’s the definition for hasSelectionRange in DOM.js

hasSelectionRange(el) {
  return el.setSelectionChange && (el.type === "text" || el.type === "textarea)
}

Here’s a function in DOM.js that calls hasSelectionRange.

restoreFocus(focused, selectionStart, selectionEnd){
  if(focused instanceof HTMLSelectElement){ focused.focus() }
  if(!DOM.isTextualInput(focused)){ return }

  let wasFocused = focused.matches(":focus")
  if(focused.readOnly){ focused.blur() }
  if(!wasFocused){ focused.focus() }
  if(this.hasSelectionRange(focused)){
    focused.setSelectionRange(selectionStart, selectionEnd)
  }
}

Here’s how adding an element adapter might look for the Shoelace input element <sl-input> which does have its own setSelectionRange, but without this we’d have no way to tell Phoenix LV about that. (A better example would have been isFormInput, but you get the idea I hope.)

// Defining/assigning an adapter
const liveSocket = new LiveSocket("/live", Socket, {
  elementAdapters: {
    // If these adapters are the same for a group of elements
    // the adapter function can be defined outside and assigned
    // to multiple tagNames here.
    //
    // The default adapters for native html elements would obviously live
    // inside phoenix liveview js
    "SL-INPUT": (el) => {
      // NOTE: I think doing this extraction work might tighten up this interface a bit.
      return {
        el: el,
        isTextualInput: true,
        hasSelectionRange: true,
        // ... Use sl-input's setSelectionRange function
        setSelectionRange: el.setSelectionRange,
        isFocusable: true,
        // sl-inputs can get focus by calling focus
        focus: el.focus,
        // isFormControl: true
        // value: () => {}
        // ... etc.
      }
    }
  }
})

Usage within the same example code above, would then become…

restoreFocus(focused, selectionStart, selectionEnd){
  // Maybe we just pass the wrapped el around instead...
  const ela = elAdapter(focused);

  if (ela.isFocusable) { ela.focus() }
  if (!ela.isTextualInput) { return }

  let wasFocused = focused.matches(":focus")
  if(ela.readOnly){ ela.blur() }
  if(!wasFocused){ ela.focus() }
  if(ela.hasSelectionRange()){
    ela.setSelectionRange(selectionStart, selectionEnd)
  }
}
// The elAdapter function is just a lookup on the registered adapters by tagName
elAdapter(el) {
  return someAdapterRegistry[el.tagName](el);
}

Benefits

  • Expose and standardize phx operations against html elements by coding to an interface
  • Should make phoenix element interactions more testable
  • Allow web components to better participate in the finer parts of PHX DOM management (has focus, merge attrs, phx private, etc).

Thoughts?

If there’s any interest in this then I’d be happy to do a proof of concept. I’d start by writing adapters for the native html elements and moving the current html inspection/interaction into the adapters.

If there’s no interest OR if there’s an obvious reason why this could never work, then let me know. I’ve poked around quite a lot in the existing JS and it seems possible to me. I’ve seen much of the record-keeping via phxPrivate, phx-has-focused, and so on and I don’t think any of that is a blocker.

10 Likes

Sorry for the edits. I’m done now. ;^D

We are currently exploring more direct WC integration in the form of a callback that well invoke for any custom element that implements the interface. Made up name at the moment, but imagine if any WC instance implemented liveViewMounted/1 and received the hook object to do whatever they needed like pushing events to the server, listening for server events, etc. You’re talking about lower level integration where the WC couples do our internal DOM patching stuff, but I’m not yet sure about that. We can start at the top level and work down if needed.

14 Likes

If I’m understanding the proposal correctly, I’m imagining this as a set of protocols or behaviours for high level element concepts.

That would remind me of React’s approach to allowing libraries to implement custom Input elements while still benefiting from all the compatibility. Obviously they did that with class inheritance and later extends but the idea feels similar to me. A contract for different fundamental concepts, treated like an interface, and a pattern for implementing new interface when it’s determined that those fundamental concepts weren’t expressive enough.

All this feels really similar to the struggles the frontend js space has had to deal with and adapt to.

I could imagine MySillyCombobox as a module combined a web component inside the render powering it that then implements some protocol called Select.

Then if you are a maintainer of a headless container library equivalent to Radix, you just have to come up with the new protocol surface the WAI ARIA concepts that aren’t directly supported in HTML.

1 Like

Cool. Glad to hear that it’s on your radar and thanks for the quick response!! This is helping us to determine whether we can stay in LV or if we have to mix in React and it’s good to hear that there are official Phoenix eyeballs on it.

I think our proposals are ultimately the same plan started from different ends.

The example I used was definitely low-level, but to fully embrace WC-based form controls I think we’d have to go that low. There’s lots of stuff happening in bindForms on LiveSocket that I don’t think will be able to stick around in its current form. It’s a long thread to pull, so I understand the hesitation.

At a minimum, WC-based form control support would require a way for a WC to indicate to phoenix: that it’s a form control so the value is serialized with the form, how to get its value, and how to know when it has changed, focused, or blurred.

Some thoughts on what I think you’re describing on your side…

I love the idea of having a hook or hook-like object available for WCs.

It seems like a stretch to expect WC’s to implement liveViewMounted/1 or for users to have to wrap 3rd party WCs in other WCs to implement liveViewMounted/1 (not sure if that was the actual suggestion) so it still feels a bit like an adapter/adapter-registry scenario but possibly at a higher level. Maybe a phoenix user or phoenix library author can register hooks by tagName and they’re automatically wired up?

As you’re exploring the space I think Shoelace would be a really great library to use for testing your ideas on WC integration. And I think form controls/form integration are very important for any solution.

One thing we learned when using Shoelace (but really any WC with reflected attributes) is to provide the default values on initial render so that when reflected attributes are actually set by the component on init that phoenix lv doesn’t immediately clobber them.

<sl-button>
  I'm a button. 
  I reflect my default attributes meaning i will add attributes to myself, 
  but then phoenix will clobber them and then I'll get weird.
</sl-button>

<sl-button variant="default" size="small">
  I'm a button and I'm ok. 
  You'd probably actually want to wrap me as <.sl_button> where the 
  defaults are set automatically and where you could handle a phoenix field as an attr.
  Many WC manifests list the default values so these aren't hard to find.
</sl-button>

The other bit to watch out for is when triggering an event on a WC that modifies one of these attributes (at least as of now) you’ll need to make the corresponding change through phx js. The close button in a shoelace dialog will remove the open attribute, but Phoenix will put it back. Our fix was to make sure that the corresponding JS action was fired: JS.remove_attribute("open") and JS.set_attribute({"open", true}).

You’ve probably got better ways to accomplish this.

Our sl_dialog looked like this, for example…

  ##
  ## Shoelace Dialog
  ## https://shoelace.style/components/dialog
  ##

  # Attributes
  attr :open, :boolean, default: false
  attr :label, :string, default: nil
  attr :"no-header", :boolean, default: false
  attr :rest, :global

  # Events
  attr :"sl-show", JS, default: %JS{}
  attr :"sl-after-show", JS, default: %JS{}
  attr :"sl-hide", JS, default: %JS{}
  attr :"sl-after-hide", JS, default: %JS{}
  attr :"sl-initial-focus", JS, default: %JS{}
  attr :"sl-request-close", JS, default: %JS{}

  # LV Slots
  slot :inner_block, required: true

  # Shoelace Slots
  # - label
  # - header-actions
  # - footer

  # NOTE: Shoelace events that modify reflected attributes require us to "tell Phoenix"
  #       about them by performing the equivalent LV JS function to update the LV
  #       template state.

  def sl_dialog(assigns) do
    ~H"""
    <sl-dialog
      open={@open}
      label={@label}
      no-header={assigns[:"no-header"]}
      sl-show={assigns[:"sl-show"]}
      sl-after-show={JS.set_attribute(assigns[:"sl-after-show"], {"open", true})}
      sl-hide={assigns[:"sl-hide"]}
      sl-after-hide={JS.remove_attribute(assigns[:"sl-after-hide"], "open")}
      sl-initial-focus={assigns[:"sl-initial-focus"]}
      sl-request-close={assigns[:"sl-request-close"]}
      {@rest}
    >
      <%= render_slot(@inner_block) %>
    </sl-dialog>
    """
  end

Whew. Long one…

Let me know if there’s any way I can help, but otherwise I’ll defer to Phoenix team and see what shakes out from your exploration. I think a lot of people would appreciate work in this area so I hope it works out.

2 Likes

That’s a good description of what I’m thinking might happen if we start pulling the thread on LiveSocket js interactions with elements. I’m not sure that it would even make it all the way up to the Elixir code, though, but the general idea of what you’re saying here is what I was going for.

1 Like

Isn’t that what ElementInternals is about? By my understanding web components can already act as inputs in regards to the js form APIs that way and that seemed to work the last time I tried that with LV.

What would need to change however is LV javascript hardcoding element names as it does in various places atm.

Yeah, great point. I think there are just a couple things that aren’t addressed by ElementInternals:

  • AFAIK (correct me if I’m wrong) the WC author can assign internals to any property, internals_, internals, mySpecialInternals, so there might still be some small mapping step if we need to access the internals object directly.
  • Only other thing is handling WC change/input events and triggering phx form events with them.

Yeah, I think that’s going to have to happen at some point.

In my opinion a WC trying to act as a proper input should trigger the events relevant to form handling anyways.

That’s exactly my point: We shouldn’t need to reach into the web component. We should just be able to work with it through various form related apis and events.

I don’t disagree, but I think forcing that could severely limit the WC frameworks that phoenix could use. Although, if a WC form control can pass through Phoenix w/o issue then one could always just do…

window.addEventListener("sl-input", (evt) => {
  evt.target.dispatchEvent("input");
});

No disagreement here either, but I worry about edge cases. ElementInternals provides a form property which may or may not be useful to LV js. Mostly phoenix could find the form by looking at the ancestors, but a form control can also specify its form by using form="some_id" at which point it might be nice for ElementInternals to handle that if it can.

Maybe a naive question but why is mixing in React (or something lighter perhaps like Alpine or Vue) considered such an undesirable outcome? I’ve been chained to React for the last 5 years, unfortunately before I got acquainted with LV and had the political capital to veto React adoption, but I always imagined I’d be happy to be using LV for 90% of the UI and just drop in Vue components where necessary. If the needs of an app are really that dynamic can LV ever really be a good fit? At some point you are going to be maintaining such a complex model of your state on the FE it seems like a split app just makes more sense than trying to tie everything to the BE through a socket, no?

Personally my frustration with React has never been so much that it’s a bad technology per se (at least for the JS world :sweat_smile:) but that hype took it places where it (well, in particular Redux) had no places being, i.e. basically static pages with maybe a sprinkling of async sugar but now they are maintaining a whole (cached!) copy of the data model. I don’t think I’d be as unsatisfied if we were building a really fluid/dynamic UX instead of just HTML+

2 Likes

It’s a fair question, but it probably has as many answers as there are developers. For me, specifically, my team is very comfortable with Elixir/Phoenix/LV/etc. and not with JS/React. I’m quite comfortable in both, but if we can stay in Phoenix I’d rather do so for the sake of the team, maintenance, build pipelines, testing, etc. I’m not a JS/React hater by any means, but I do prefer Phoenix LV.

I think it can. It’s pretty close already. we should try at least! ;^D

I think that might actually be more likely if we were to start doing one-offs in react. What’s up for discussion now is still almost entirely server-side state. WCs have some internal state, but it is generally exposed through attributes which should be perfect for LV.

4 Likes

Thanks for the discussion, everyone.

Looking forward to seeing what the Phoenix team comes up with.

3 Likes

I’m just playing around with shoelace and LV, and hit exactly the points you address here.

So thanks for the pointer, works like a charm now!

What’s going on with the attributes I understood, but did not know how to address
correctly, I’ll try out your solution. Question about <sl-dialog> (and other stuff that @opens) - how do you open() on the client (an open thats not baked in like in <sl-details> but has to be wired)? Do you sth like the shoelace docs:

<script>
  const dialog = document.querySelector('.dialog-overview');
  const openButton = dialog.nextElementSibling;
  openButton.addEventListener('click', () => dialog.show());
  ...
</script>

Or do you always go over the LV-server?

Really looking forward to a solutions for forms, but I think its not super critical to just use tailwind-forms for now.

1 Like

We were doing it over the server, so if it was triggered by user interaction there would be a phx-click={JS.set_attribute({"open", true}, to: "some_selector")}. There’s an important trick with the close, though. You’ll need to listen for sl-after-hide and then trigger a JS.remove_attribute("open") to “tell phoenix” about it. Otherwise, LV will pop it open again on re-render.

If you check out the example wrapper for sl-dialog above we were using attributes like sl-show, sl-after-show, and so on. Those represent Shoelace events, but they aren’t part of Shoelace as attributes. Those sl-* attributes were how we were keeping it phoenix-like and getting to use LV js. In app.js there were listeners that would basically call execJS on the corresponding attribute when Shoelace fired one of the events. And you can see that we wrapped the sl-after-close and sl-after-show to automatically trigger corresponding attribute operations through LV (purely so LV would know about them.)

// WARNING: Lazy example. there are more checks and stuff and you wouldn't 
// want to type this out for every single event.
window.addEventListener("sl-after-show", (evt) => {
  if (evt.target.matches("[sl-after-show]") {
    liveSocket.execJS(evt.target, evt.target.getAttribute("sl-after-show"));
  }
};

Hope that helps!!

1 Like

Hope that helps!!

it does. And also answers the next question I had (how to wire JS{} in the attribs to the actual events! Great I’ll try it out. Is it OK if I open-source the solution? (nothing fancy just for people to have a starting point)

Go for it!

Name proposal: Shoelive

I wonder why it is necessary to tell LV that @open is set again (with reacting to sl-after-show) if you set it with JS.set_attr in the first place.

Makes sense for hard-wired open/close like in sl-detail.

Hm. Good point. That one may not have been strictly necessary.

One other gotcha…

The tailwind forms plugin seems to interfere with Shoelace when used with its global strategy. We had to switch it to the class strategy which wasn’t a big deal considering we were planning to use Shoelace forms.

Always updating the server on-event will make it work with hard-wired components and allow using JS on the client. So I think its just easier to always do it.

tailwind-forms does not play nice some times, I’m used to that.