‹ Back to blog

Creating an input mask with Lit

I needed to create an input mask for a Lit project I am working on. After a little research, i choose IMask library. In this article, I’ll show you how to create an input mask in a Lit template using a directive.

I could create a custom element to wrap the input element and handle the mask logic, but using a directive is a more elegant and flexible solution.

First, naive version

import { noChange } from "lit";
import { Directive, directive } from "lit/directive.js";
import IMask from "imask";
/**
* @typedef { import('lit').ElementPart } ElementPart
**/
export class InputMaskDirective extends Directive {
/**
* @param {ElementPart} part
* @param {[string]} [mask] directive arguments
* @return {*}
*/
update(part, [mask]) {
const inputEl = part.element.matches("input")
? part.element
: part.element.querySelector("input");
if (!inputEl) {
console.warn("InputMaskDirective: input element not found");
return noChange;
}
IMask(inputEl, {
mask,
});
return noChange;
}
}
export const inputMask = directive(InputMaskDirective);

It’s a simple directive that receives a mask string as an argument and applies it to the input element using the IMask library. The class overrides update method so it can access the part element, checks if element or any of its children is an input and apply the mask to it. Since it does not render anything, it returns noChange.

It should be used as a element part:

import { html } from "lit";
import { inputMask } from "./input-mask.js";
const template = html`<input type="text" ${inputMask("00/00/0000")} /> `;

So far, so good. But there is a problem with this implementation. One of my requirements is to be able to be define the mask in a parent of the input element. Something like the below example:

const template = html`
<div ${inputMask("00/00/0000")}>
<input type="text" />
</div>
`;

Still, the directive will work as expected. But the actual usage is not exactly like that. I have an input function that renders the input element:

import { html } from "lit";
import { inputMask } from "./input-mask.js";
function input(attr, title) {
return html`<label>${title}</label> <input name=${attr} type="text" />`;
}
const template = html`
<div ${inputMask("00/00/0000")}>${input("name", "Name")}</div>
`;

This time, the directive will not work as expected. The problem is that at the time update is called, the child input element is not yet rendered. So, querySelector("input") will return null. Check this Lit Playground to see the problem in action.

Second, improved version

Here is an improved version that uses a MutationObserver to wait for a input element to be added to the DOM:

export class InputMaskDirective extends Directive {
/**
* @param {ElementPart} part
* @param {[string]} [mask] directive arguments
* @return {*}
*/
update(part, [mask]) {
const inputEl = part.element.matches("input")
? part.element
: part.element.querySelector("input");
if (inputEl) {
IMask(inputEl, {
mask,
});
} else {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1 && node.matches("input")) {
IMask(node, {
mask,
});
observer.disconnect();
}
});
});
});
observer.observe(part.element, { childList: true, subtree: true });
}
return noChange;
}
}

This works as expected in all cases.

Conclusion

This article shows how to create an input mask in a Lit template using a directive. It highlights a difference in the timing of the children rendering when using nested templates and how to handle it using a MutationObserver.

The same technique can be used to create other directives that need to access child elements.