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:
null
.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.DOMContentLoaded
event fires.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):
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.
getElementById
can return null
, see this Q&A.