Implementing custom select element in Phoenix LiveView

Hello all, I made a custom select element here: https://codepen.io/tiotolstoy/pen/wvKKoQG?editors=1010

I am trying to integrate this into my LiveView component and the JavaScript does not work as expected. When I click the dropdown, it doesn’t come down.

When I print some elements, they don’t show up where they should (like they do in the codepen).
For example, if I print const dropdownArrow = document.querySelector(".dropdown__arrow"); using Chrome DevTools, I hover over the print statement and it shows the arrow somewhere different than where it is on the DOM.

The LiveView component is pretty standard itself (basically like any one in documentation / tutorials). I’ve been debugging this for a few days; it seems to me that there’s something about JS and LiveView that I’m not aware of.

How do you start the js found in the codepen? Did you use javascript hooks?

export default {
  init: function () {

    const dropdown = document.getElementsByClassName("c-dropdown")[0];
    if (!dropdown) {
      return
    }

    const SPACEBAR_KEY_CODE = [0, 32];
    const ENTER_KEY_CODE = 13;
    const DOWN_ARROW_KEY_CODE = 40;
    const UP_ARROW_KEY_CODE = 38;
    const ESCAPE_KEY_CODE = 27;

    const list = document.querySelector(".c-dropdown__list");
    const listContainer = document.querySelector(".c-dropdown__list-container");
    const dropdownArrow = document.querySelector(".c-dropdown__arrow");
    console.log(dropdownArrow);
    const listItems = document.querySelectorAll(".c-dropdown__list-item");
    const dropdownSelectedNode = document.querySelector(
      "#c-dropdown__selected"
    );
    console.log(dropdownSelectedNode)
    const listItemIds = [];

    dropdownSelectedNode.addEventListener("click", e =>
      toggleListVisibility(e)
    );

    dropdownSelectedNode.addEventListener("keydown", e =>
      toggleListVisibility(e)
    );

    listItems.forEach(item => listItemIds.push(item.id));

    listItems.forEach(item => {
      item.addEventListener("click", e => {
        setSelectedListItem(e);
        closeList();
      });

      item.addEventListener("keydown", e => {
        switch (e.keyCode) {
          case ENTER_KEY_CODE:
            setSelectedListItem(e);
            closeList();
            return;

          case DOWN_ARROW_KEY_CODE:
            focusNextListItem(DOWN_ARROW_KEY_CODE);
            return;

          case UP_ARROW_KEY_CODE:
            focusNextListItem(UP_ARROW_KEY_CODE);
            return;

          case ESCAPE_KEY_CODE:
            closeList();
            return;

          default:
            return;
        }
      });
    });

    function setSelectedListItem(e) {
      let selectedTextToAppend = document.createTextNode(e.target.innerText);
      dropdownSelectedNode.innerHTML = null;
      dropdownSelectedNode.appendChild(selectedTextToAppend);
    }

    function closeList() {
      list.classList.remove("open");
      dropdownArrow.classList.remove("expanded");
      listContainer.setAttribute("aria-expanded", false);
    }

    function toggleListVisibility(e) {
      let opendropdown =
        SPACEBAR_KEY_CODE.includes(e.keyCode) || e.keyCode === ENTER_KEY_CODE;

      if (e.keyCode === ESCAPE_KEY_CODE) {
        closeList();
      }

      if (e.type === "click" || opendropdown) {
        list.classList.toggle("open");
        dropdownArrow.classList.toggle("expanded");
        listContainer.setAttribute(
          "aria-expanded",
          list.classList.contains("open")
        );
      }

      if (e.keyCode === DOWN_ARROW_KEY_CODE) {
        focusNextListItem(DOWN_ARROW_KEY_CODE);
      }

      if (e.keyCode === UP_ARROW_KEY_CODE) {
        focusNextListItem(UP_ARROW_KEY_CODE);
      }
    }

    function focusNextListItem(direction) {
      const activeElementId = document.activeElement.id;
      if (activeElementId === "c-dropdown__selected") {
        document.querySelector(`#${listItemIds[0]}`).focus();
      } else {
        const currentActiveElementIndex = listItemIds.indexOf(
          activeElementId
        );
        if (direction === DOWN_ARROW_KEY_CODE) {
          const currentActiveElementIsNotLastItem =
            currentActiveElementIndex < listItemIds.length - 1;
          if (currentActiveElementIsNotLastItem) {
            const nextListItemId = listItemIds[currentActiveElementIndex + 1];
            document.querySelector(`#${nextListItemId}`).focus();
          }
        } else if (direction === UP_ARROW_KEY_CODE) {
          const currentActiveElementIsNotFirstItem =
            currentActiveElementIndex > 0;
          if (currentActiveElementIsNotFirstItem) {
            const nextListItemId = listItemIds[currentActiveElementIndex - 1];
            document.querySelector(`#${nextListItemId}`).focus();
          }
        }
      }
    }
  }
}

I guess this is simply executed at start of your normal app.js. It’s not going to work well like that if at all. Liveview is managing the DOM (unless you set it to ignore nodes) of your markup, therefore those dom nodes you’re selecting in your code are not guaranteed to stick around in the face of updates. They might be removed or updated at any time. This means you cannot plainly attach event listeners to nodes or store references to dom nodes in variables. You’ll need to use liveview hooks to attach this stuff to dom nodes and depending on your needs implement update logic/restarts on update – or if no updates are needed consider making liveview ignore those parts of the dom. To summarize: This javascript is not ready to deal with DOM nodes changing beneath it’s functionality.

4 Likes

This is very helpful and gives me good direction. Thanks!

Is this all I would need

  // Used to register listener in liveview
  hook: {
    mounted() {
      registerListener(this.el)
    }
  },
  // Used to register listener when dom is loaded normally
  init: function () {
    getElements().forEach(registerListener)
  }
}

Or something similar?

Depends. Do you plan on changing the options of the select with liveview?

I’m passing in options, correct; And it’s a liveview component