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.