Let's make shoelace work with liveview

Motivated by the success @benkimpel had with shoelace (see: https://elixirforum.com/t/improve-support-for-web-components-in-forms-with-element-adapters/61175) and the thread by @adw632 (see: Adobe Spectrum 2 web components with LiveView) I tried it myself.

I had some success, but there are also some problems I do not know how to solve and if they are solvable.

Solved? Problem 1 - LV removes attributes set by shoelace

If you do not set all relevant attributes on an element, shoelace sets defaults, LV removes them. This can be easily solved by either wrapping all shoelace elements into function components (see Ben’s thread) or just:


SL_DEFAULTS = {
    "SL-BUTTON": { "variant": "default", "size": "medium" },
    "SL-AVATAR": { "shape": "circle" },
    "SL-BADGE": { "variant": "neutral" },
    "SL-ICON": { "library": "default" },
   ...
}

let liveSocket = new LiveSocket("/live", Socket, {
    params: { _csrf_token: csrfToken },
    dom: {
        onBeforeElUpdated(_from, to) {
            const sl_defaults_for_current = SL_DEFAULTS[to.tagName];
            if (sl_defaults_for_current) {
                Object.entries(sl_defaults_for_current).forEach(([key, value]) => {
                    if (!to.hasAttribute(key)) {
                        to.setAttribute(key, value);
                    }
                });
            }
        }
    }
})

this seems to work fine.

Solved? Problem 2 - LV removes state like in @open

Multiple components store their state in an @open, eg <sl-details>

<sl-details summary="Toggle Me">
      Lorem ipsum  ...
</sl-details>

So when you

  1. render → closed (@open not set)
  2. toggle → opens (@open set)
  3. rerender → closes (@open removed by LV)

This can be fixed by sth like

window.addEventListener("sl-after-show", (evt) => {
    liveSocket.execJS(evt.target, '[["set_attr", {"attr": ["open", true]}]]');
})

window.addEventListener("sl-after-hide", (evt) => {
    liveSocket.execJS(evt.target, '[["remove_attr", {"attr": "open"}]]');
})

Or sth more sophisticated (see Ben’s thread)
Seems to work fine.

Problem 3 - LV removes elements that shoelace places into the light-DOM

This happens with <sl-breadcrumb>, code:

<sl-breadcrumb>
  <sl-icon name="arrow-right" slot="separator" aria-hidden="true"></sl-icon>
  <sl-breadcrumb-item>
    <sl-icon slot="prefix" name="house"></sl-icon>First
  </sl-breadcrumb-item>
  <sl-breadcrumb-item>Second</sl-breadcrumb-item>
  <sl-breadcrumb-item>Third</sl-breadcrumb-item>
</sl-breadcrumb>

This is how the breadcrumbs look like after first render (correct):
image

Note the arrow-icon that shoelace dynamically put into the separator slot of the item:

after a rerender it looks like:
image

Note the missing arrow-icon:
image

Problem 4 - LV removes generated classes

happens with <button-group>, code:

<sl-button-group label="Alignment">
  <sl-button size="small">Left</sl-button>
  <sl-button size="small">Center</sl-button>
  <sl-button size="small">Right</sl-button>
</sl-button-group>

first render (correct):
image

note the classes:

after rerender:

classes missing:
image

Problem 5 - ARIA

Seems like shoelace puts some important aria attributes which LV removes, didn’t look into that.

Problem 6 - Forms

This does absolutely not work right now, see Ben’s thread.

5 Likes

This is great. Let’s see if we can fix these.

When we were testing it out we found that adding an ID to some of the Shoelace slots helped with patching. It triggers different behavior in morphdom since the id is the nodeKey so rather than patching as children it patches directly.

There’s a more drastic option as well…

// WARNING: Lazy example. There's probably much more to it.
onBeforeElUpdated: (from, to) => {
  // Effectively make sure Shoelace always wins the attr merge by assigning from
  // back over it.
  // 
  // This could be done better with a "Managed Attr" list where instead of this
  // all or nothing approach below one could say: 
  // "Ok, for attr ABC of element XYZ we never patch it."
  // or even handle some specifically...
  // "For class of element XYZ we can merge the classes, but never remove"
  //
  // BUT I expect this would result in unnecessary renders all the time?
  //
  // This is why I think there are ultimately low-level changes involved if we want
  // to support arbitrary WCs
  //
  if (from.tagName.startsWith("SL-")) {
    [...to.attributes, ...from.attributes].forEach((attr) => {
      to.setAttribute(attr.name, attr.value); 
    });
},
2 Likes

@Sebb if you have a repo where you’re testing stuff I’m happy to help out. I’m benkimpel on github.

Wow that’s brutal.
And as far as I understand the problem now, this is the only way (without too much special-case-handling)

I don’t see how LV could ever handle arbitrary WC-libraries that do not follow at least these rules:

  • Do not generate elements in the light-DOM
  • Do not change the value of attributes that are not in a special namespace (and could thus be easily protected from LV)

I made the repo public.

1 Like

Here’s the relevant issue in shoelace’s repo. Unfortunately I don’t think this is something that’s going to get addressed

That’s easily fixed by the onBeforeElUpdated callback which sets the defaults if not set.

I would’ve thought you’d need a phx-update="ignore“ on the shoelace components that use the light dom/change attributes. What’s the outcome when you use it?

Sure I could do that and it would be OK for several components.
But what about those that take children?
This would get messy quickly.

Yeah. That’s kind of what i was trying to get at with my element adapter proposal. With WCs that can do anything there has to be some translation layer between what lv is trying to do and how a WC can perform or ignore that operation and we can’t expect the WC author to do so. It’s most obvious in forms right now due to all of the element hardcoding. Aria, light dom mods, and simpler things could be handled in onBeforeElUpdated. It wouldn’t be pretty but it could work.

And sure, we could ignore updates but then the markup rendered from phoenix and passed into a WC is basically static (from phx side) and we have a new set of problems.

This is a tough one and it might just be that lv will only work with very simple WCs.

(I hadnt tested breadcrumbs, btw. Glad you tried that one.)

For example, this fixes the button group…

// onBeforeElUpdated

// Protect sl- classes
if (from.tagName.startsWith("SL-")) {
  from.classList.forEach((cls) => {
    if (cls.startsWith("sl-")) {
      to.classList.add(cls);
    }
  });
}

But how fragile is all of this? idk

2 Likes

Oh I didn’t even notice that the classes are namespaced. That’s a start.
But true. I think that all of this leads to flaky stuff.
With lots of testing and insight you can get some version of shoelace working.
But they may break everything with the next bugfix-release.

I’ve not used web components yet so I am just curious. You want to do stuff like this?

<sl-something ...>
  <%= @name %>
  <.some_function_component ...>
</sl-something>

Yes thats, why you can’t just update-ignore (everything).

I played with and thought about this a little more.

I think it is not possible to use web-components with LV the way I imagined.

What about this:

  • wrap all components in function components and do not allow setting any custom attributes directly on the WC-elements
  • prevent all changes LV does with a onBeforeElUpdated-callback (see above) - note: can’t just use phx-update=ignore because of nested elements.
  • if you really need to set attributes (like class) move this to sth like phx-class and merge this gracefully in onBeforeElUpdated to the real attribute (but the WC-lib may come with better ways to style them and you could also get away to only style plain child-elements of the WC-elements)
  • still a problem how to handle elements generated in the light-DOM. It seems like at least for shoelace this does not happen too often. Maybe just don’t use those and rebuild as a plain function component. But this could still be a problem if the WC-lib introduces sth like this in a release.

I also just re-read this post Adobe Spectrum 2 web components with LiveView - #36 by TunkShif by @TunkShif which gives a very good overview of all the challenges.

2 Likes

These are pretty much the constraints that we arrived at in our exploration, but we didn’t pursue it further because of missing form support. (See function component example from our other thread for example of wrapping shoelace.) I think if you took the approach you’re describing you could get something decent in place. I think there are edge cases around morphdom patching children so watch out there.

@Sebb I think this probably has the same solution as the reflected attributes getting clobbered, which boils down to: do what Shoelace would do in your wrapping function so there is nothing to patch.

So in this case you’re setting a Shoelace slot on sl-breadcrumb which in turn is setting the slot on each sl-breadcrumb-item. Here’s how i’d solve this one:

# NOT TESTED

##
## Function component Shoelace wrappers
##

# ATTRS SKIPPED
slot :inner_block, required: true
slot :separator

def sl_breadcrumb(assigns) do
  ~H"""
  <sl-breadcrumb ... >
    <%= render_slot(@inner_block, %{separator: sl_breadcrumb_separator(assigns)} %>
  </sl-breadcrumb>
  """
end

defp sl_breadcrumb_separator(%{separator: []} = assigns) do
  ~H"""
  <sl-icon name="arrow-right" slot="separator" aria-hidden="true" library="default"></sl-icon>
  """
end

defp sl_breadcrumb_separator(assigns) do
  ~H"""
  <%= render_slot(@separator) %>
  """
end

# ATTRS SKIPPED
slot :inner_block, required: true
slot :separator

def sl_breadcrumb_item(assigns) do
  ~H"""
  <sl-breadcrumb-item ... >
    <%= render_slot(@inner_block) %>
    <%= render_slot(sl_breadcrumb_separator(assigns)) %>
  </sl-breadcrumb-item>
  """
end

##
## USAGE
##

## Regular usage

<.sl_breadcrumb>
  <.sl_breadcrumb_item>First</.sl_breadcrumb>
  <.sl_breadcrumb_item>Second</.sl_breadcrumb>
  <.sl_breadcrumb_item>Third</.sl_breadcrumb>
</.sl_breadcrumb>

## Usage w/ custom breadcrumb separator and override for last item

<.sl_breadcrumb let={breadcrumb}>
  <:separator>
    <.sl_icon slot="separator"... />
  </:separator>

  <.sl_breadcrumb_item separator={breadcrumb.separator}>First</.sl_breadcrumb_item>
  <.sl_breadcrumb_item separator={breadcrumb.separator}>Second</.sl_breadcrumb_item>
  <.sl_breadcrumb_item>
    Third
    <:separator>
      <span slot="separator">special something</span>
    </:separator>
  </.sl_breadcrumb_item>
</.sl_breadcrumb>

See if that works for you. (I won’t have time to test anything for a while now since I’ve mostly concluded our spike on this.)

Thanks for your input!
I’ll look at that as soon as I can.

Just another thought:
Did you ever consider going around the LV-form stuff altogether by catching formdata (on submit) or change on changes of form elements? (and send it to the LV-server, obviously)

Only briefly because, iirc, there were other challenges around losing focus on inputs and such. So the problems were a bit higher-level… or lower-level. not sure how best to describe that. :smiley:

This is most likely the best solution, but it demands a deep understanding of the WC-lib and lots of attention on each WC-release.

I reread your post. Did you try to patch phoenix_html as a POC? Looking at the code it does not seem to be too hard…? And it should solve the problems with focus and selection.