Search code examples
javascripthtmldommaterialize

Why does a Materialize Select component fail to initialize on dynamic creation of elements?


When a Materialize Select component is initialized with a "static" element (i.e. included in an HTML markup or previously inserted into DOM), everything works as expected:

document.addEventListener('DOMContentLoaded', function() {
  var elems = document.querySelectorAll('select');
  var instances = M.FormSelect.init(elems);
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css" rel="stylesheet" />
<div class="input-field col s12">
  <select>
    <option value="" disabled selected>Choose your option</option>
    <option value="1">Option 1</option>
    <option value="2">Option 2</option>
    <option value="3">Option 3</option>
  </select>
  <label>Materialize Select</label>
</div>

However, when creating the component dynamically, the initialization fails:

const makeOption = (maybeOption) => {

  const {
    text,
    value,
    selected
  } = typeof maybeOption === "string" ? {
      text: maybeOption,
      value: maybeOption
    } :
    maybeOption;

  const opt = document.createElement("option");
  opt.innerText = text;
  opt.value = value;
  opt.selected = selected;

  return opt;
};

const makeSelectInput = ({
  id,
  name,
  empty = true,
  label,
  options = [],
  parent,
  placeholder,
  value,
  classes = []
}) => {

  const wrap = document.createElement("div");
  wrap.classList.add("input-field", ...classes);

  const sel = document.createElement("select");
  sel.id = id || name;
  sel.name = name;

  const lbl = document.createElement("label");
  lbl.htmlFor = id || name;
  lbl.innerText = label;

  if (empty) {
    sel.options.add(makeOption({
      text: placeholder || "",
      value: ""
    }));
  }

  options.map(makeOption).forEach((o) => sel.options.add(o));

  wrap.append(sel, lbl);

  parent.append(wrap);

  const elems = wrap.querySelectorAll(`#${id || name}`);

  M.FormSelect.init(elems);

  return sel;
};

window.addEventListener("DOMContentLoaded", () => {

  const parent = document.createElement("div");
  
  try {
    makeSelectInput({ 
     parent,
     options: ["Apples", "Oranges"], 
     name: "fruits",
     label: "Fruits"
   });
 }
 catch({ message }) {
  console.log(message);
 }

});
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css" rel="stylesheet" />

The error message thrown is as follows:

"TypeError: Cannot set property 'tabIndex' of null"

I understand that the root cause lies in a failure to select (or create) a supplementary element during the initialization of the component, but what exactly causes the error to occur in the second instance described above, but not in the first?


There are similar Q&As dealing with the error when using VueJS, React, or Angular (also this) frameworks, but none are concerned with DOM manipulation with vanilla JavaScript. That said, to summarize, there is a couple of possible solutions to the issue at hand:

  1. A mysterious one involving switching from a class selector to an id selector - unfortunately, I fail to grasp what would change unless the class selector was malformed and resulted in the root element being passed in as null.
  2. Initializing in the componentDidMount lifecycle hook - also explaining the "how", but not the "why" part of the issue. But usage of the hook at the very least highlights that the issue has something to do with insertion into DOM.
  3. Making sure the DOM finished loading, but as you can see from the second code snippet, the component is initialized after the DOMContentLoaded event fires.

Solution

  • TL;DR

    All root elements must be in the DOM by the time you initialize the Materialize FormSelect component because Materialize uses getElementById method that returns null on non-appended elements.

    Explanation

    If you inspect the error stack trace points to, you will find out that the error is thrown during a call to _makeDropdownFocusable method of the component. This is how the method source currently looks like:

    _makeDropdownFocusable() {
      // Needed for arrow key navigation
      this.dropdownEl.tabIndex = 0;
    
      // Only set tabindex if it hasn't been set by user
      $(this.dropdownEl)
        .children()
        .each(function(el) {
          if (!el.getAttribute('tabindex')) {
            el.setAttribute('tabindex', 0);
          }
        });
    }
    

    Note the dropdownEl property of this - obviously, as the error states, it is null, therefore trying to set a property on null results in an error. Now we need to understand how the dropdownEl is first set. It is first set in the class constructor:

    this.dropdownEl = document.getElementById(this.id);
    

    What does this tell us? That the only way for the dropdownEl to be set to null is to fail the getElementById query which does return null on no match, meaning something is wrong with this.id, which is set via:

    this.id = M.getIdFromTrigger(el);
    

    Where the el is the input element created under the hood and assigned to this.input:

    this.dropdown = M.Dropdown.init(this.input, dropdownOptions);
    

    Now, let us see how the getIdFromTrigger is defined:

    M.getIdFromTrigger = function(trigger) {
      let id = trigger.getAttribute('data-target');
      if (!id) {
        id = trigger.getAttribute('href');
        if (id) {
          id = id.slice(1);
        } else {
          id = '';
        }
      }
      return id;
    };
    

    Pay attention to the getAttribute method - it tells us to check if the generated input has a data-target attribute. The attribute value should be of the following form (where {{}} denotes a variable part):

    select-options-{{uuid here}}
    

    So far, so good. During runtime, if you check the debugger, this step correctly returns an input Id, so you might wonder why on the next line getElementById returns null. Take a look at the screenshot below if you do not believe me (Chrome DevTools + minified Materialize):

    chrome devtools screenshot

    At first, it seems odd but turns out the real culprit is easy to identify: the HTML elements forming the skeleton of the select element are not in the document. Why this matter? Because getElementById fails on non-document elements, to quote MDN:

    Elements not in the document are not searched by getElementById(). When creating an element and assigning it an ID, you have to insert the element into the document tree with Node.insertBefore() or a similar method before you can access it with getElementById()

    That is all there is - Materialize assumes your elements are already appended to the document and has no guards against it.


    1. For a general reference on when getElementById can return null, see this Q&A.