There are some things that a web developer knows they shouldn't attempt. Making clever use of contenteditable. Building custom form controls. Making complicated custom elements without a framework. But do we really know what we think we know? Why not try to do all three, just for fun? Could it really be that bad?
Narrator: it would indeed be that bad.
This article is building on the previous one on proper attribute/property relations in custom elements.
Read that first if you haven't yet. In this piece we're taking it a step further to build a custom element that handles input.
The mission is simple: implement a basic version of <input type="text" />
but with display: inline
layout.
A simple element
Let's start by just throwing something against the wall and playing around with it.
And here's how we use it:
This is simple, clean, and horribly broken. For one, the form cannot see these controls at all and submits the empty object.
Form-associated elements
To fix that, we have to make a form-associated custom element.
This is done through the magic of the formAssociated
property and ElementInternals.
ElementInternals
offers a control surface for setting the behavior of our custom element as part of a form.
The this.#internals.role = 'textbox'
assignment sets a default role that can be overridden by the element's user through the role
attribute or property, just like for built-in form controls.
By calling this.#internals.setFormValue
every time the control's value changes the form will know what value to submit.
But ... while the form does submit the values for our controls now, it does not see the changes we make. That's because we aren't responding to input yet.
Looking for input
Ostensibly responding to input is just adding a few event listeners in connectedCallback
and removing them again in disconnectedCallback
.
But doing it that way quickly gets verbose. An easy alternative is to instead rely on some of the built-in event logic magic,
namely that events bubble and that objects can be listeners too.
I prefer this pattern because it simplifies the code a lot compared to having separate handler functions. Attaching event listeners in the constructor instead of attaching and detaching them in the lifecycle callbacks is another simplification. It may seem like blasphemy to never clean up the event listeners, but DOM event listeners are weakly bound and garbage collection of the element can still occur with them attached. So this is fine.
In the event handler logic there's some verbosity to deal with the fallout of working with contenteditable. As this code is not the focus of this article, I won't dally on it except to remark that contenteditable is still just as annoying as you thought it was.
With these changes our element will now also emit input
and change
events just like a built-in HTML form control.
But, you may have noticed another issue has cropped up. The standard form reset button does not actually reset the form.
Read the instructions
You see, when we said static formAssociated = true
we entered into a contract to faithfully implement the expected behavior of a form control.
That means we have a bunch of extra work to do.
There's a LOT going on there. It's too much to explain, so let me sum up.
- The
value
attribute now corresponds to adefaultValue
property, which is the value shown until changed and also the value that the form will reset the field to. - The
value
property contains only the modified value and does not correspond to an attribute. - The control can be marked disabled or read-only through attribute or property.
- The form callbacks are implemented, so the control can be reset to its default value, will restore its last value after back-navigation, and will disable itself when it is in a disabled fieldset.
With some style
Up to this point we've been using some stand-in styling. However, it would be nice to have some default styling that can be bundled with our custom form control. Something like this:
The styles are isolated by scoping them to the name of our custom element, and the use of @layer puts them at the lowest priority, so that any user style will override the default style, just like for the built-in form controls. The use of variables offers an additional way to quickly restyle the control.
In the styling we also see the importance of properly thinking out disabled and focused state behavior. The upside and downside of building a custom form control is that we get to implement all the behavior that's normally built-in to the browser.
We're now past the 150 lines mark, just to get to the low bar of implementing the browser's mandatory form control behavior. So, are we done? Well, not quite. There's still one thing that form controls do, and although it's optional it's also kind of required.
Validation
Built-in form controls come with a validity API. To get an idea of what it means to implement it
in a custom form control, let's add one validation attribute: required
.
It doesn't seem like it should take a lot of work, right?
The code for the example is exactly like it would be for built-in controls:
The ElementInternals
interface is doing a lot of the work here, but we still have to proxy its methods and properties.
You can tell however that by this point we're deep in the weeds of custom elements, because of the rough edges.
- The example is using the
input-inline:invalid
style instead of:user-invalid
because :user-invalid is not supported on custom elements yet. - An ugly hack is needed to get the properly localized message for a required field that matches that of built-in controls.
- Safari flat-out won't show validation messages on non-shadowed form-associated custom elements if we don't give it an anchor to set them to, requiring another ugly hack.
In conclusion
We've established by now that it is indeed feasible to build a custom form control and have it take part in regular HTML forms, but also that it is a path surrounded by peril as well as laborious to travel. Whether it is worth doing is in the eye of the beholder.
Along that path we also learned some lessons on how to handle input in custom elements, and have proven yet again that contenteditable
,
while less painful than it once was, is an attribute that can only be used in anger.
Regardless, the full source code of the input-inline
form control is on GitHub.