Autofocus on input field not always set?

Setup

LiveViewA << LiveComponentA << FormA << multiple input fields, one with autofocus

LiveViewB — push_redirect() —> LiveViewA

Expected behaviour

On loading LiveViewA, the autofocus should be set on a specific text field.

Actual behaviour

When coming from LiveViewB, the autofocus does not work.
When reloading LiveViewA in the browser, the autofocus works correctly.

Versions

{:phoenix, "~> 1.5.9"},
{:phoenix_live_view, "~> 0.15.0"},

Question

Can anyone reproduce this behaviour?
Maybe I am missing something?

Looking at the phoenix_live_view.js source, this function

 restoreFocus(focused, selectionStart, selectionEnd){
    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)
    }
  }

receives its focused argument from this other function

getActiveElement(){
    if(document.activeElement === document.body){
      return this.activeElement || document.activeElement
    } else {
      // document.activeElement can be null in Internet Explorer 11
      return document.activeElement || document.body;
    }
  }

which to me means that the browser first has to autofocus the element to assign the document.activeElement attribute before LiveView takes over.

So it works on the fresh load because the browser renders the page in the disconnected state and autofocuses for you, but when moving between views the diffing algorithm won’t programmatically autofocus the element with the autofocus attribute.

Perhaps set an AutoFocus hook on the element, where you call this.el.focus() in the mounted() callback.

A hook is exactly what I use:

let hook_focus_by_id = {
  mounted() {
    this.handleEvent("focus_by_id", (params) => {
      let el = this.el.querySelector("#" + params.id)
      if (el) {
        el.focus()
      }
    })
  }
}
socket = push_event(socket, "focus_by_id", %{id: "my_input"})

My root layout has a phx-hook="focus_by_id" on it, so I can query my whole view but you could attach with a thinner domspace if you wanted.

It’s quite useful. Lets me focus on “page load” obviously, but also direct the focus depending on server states (validation, showing a popup, “focus next step”, whatever).

I did have a more direct “focus_this_el” hook, but I found I needed the more general DOM query hook too so I just replaced it with that. I do include a push_event in the live view mount to drive initial load state.

2 Likes

That’s a nice strategy for directing the focus around, as long as one remembers to push the event while changing the state. It would prevent race conditions when updating a view with multiple inputs each trying to run the hook individually. Might confuse the user!

You could even generalise the feature to query by any valid selector instead of adding the “#” in the callback.

It’s the way that Phoenix.HTML seems to be going, selecting by element name instead of id. So you could send a selector string like form#myform input[name="model[name]"] to select the form field if it didn’t have an id, or send the #id complete with # part, or focus any other element, like navbar > button.className:last-child

Don’t ask me how I know this, but auto focusing an element on load may impact form value restoration (say, when reconnecting a disconnected LV).

Wrapping the call to focus in a small timeout (~5-10ms) is enough to fix that. There may be a specific event you could hook into (phx-liveview-did-paint or something, I haven’t looked yet, or you could attach your hook to updated() maybe, depends on your implementation and where you are applying the hook).

I think this is LV’s JS intentionally not clobbering a field you might have edited (I know it wants to treat the form as a source of truth) or maybe it’s just a browser thing.