Some elements are easier to standardize than others. Take buttons for instance. Since a button is a standalone element, it’s easy to style it with css.
In order to give inputs full functionality, they need extras like a label, an enclosing div to style the perimeter, the input field itself and an accepted place to display error messages for validation. Let’s simplify the layout to something like this for the purpose of this discussion:
<div>
<label for="first_name">First Name</label>
<input id="first_name" name="first_name"/>
</div>
Of course, we could set a class for the external div and repeat these 3 elements for every input our site needs. We could also make a component called <my-input></input>
and include this in our template.
Lately, I’ve been trying to think natively about elements that I add to the page. If a native element can do the job, then let it drive the process. If there’s a shortcoming, use angular to pick up the slack. So, let’s add a native input element with formControl to the page and see where that gets us.
<input id="first_name" [formControl]="first_name" name="first_name" placeholder="John Smith"/>
This is a very practical implementation, but it’s missing a wrapping div and label. Labels are used to support aria and the wrapping div provides a scaffolding to support a flex-col.
As it so happens, a directive is perfect for this job. A directive can be assigned to this input and can then act on it structurally. Assuming we call the directive appInput
we can settle on this as our base input field. Let’s also add a label so we can grab it with our directive:
<input appInput id="first_name" [formControl]="first_name" name="first_name" label="First Name" placeholder="John Smith"/>
Now we can write a directive that will listen for appInput on an element and do some work when needed. Let’s look at some code and I’ll break it down.
First, let’s import Directive, ElementRef, OnInit and Renderer2.
Next, let’s define a standalone directive and define the selector as appInput.
Let’s take advantage of ngOninit by implementing OnInit.
We need to define elementRef and renderer in the constructor.
Now we can get to work:
We can assign local variable, el, to our input field tagged with appInput.
Let’s also grab a reference to the nextSIbling(el). This is just a reference to the next element on the DOM. We are trying to determine our place in the DOM tree so we can use it later.
Let’s store the label on the input as const labelText
Let’s grab the class off the input field so we can use that to apply to the surrounding div.
Let’s grab a reference to the parentNode. We will use this later to re-insert the three elements.
Let’s create our label element and assign it to const label.
Let’s include a for element on the label and assign it to the id of out input element. The for element logically binds the label to the input.
Let’s add an arbitrary class I’ll name
input
for our css to target.Let’s populate the label with the inputLabel reference we grabbed earlier.
Now let’s make out enclosing div.
Now we can add the class that we grabbed from the input element to the div. This is especially helpful for any classes that may affect the width of the input field, but honestly, I prefer to set all input fields to full width by default and let the enclosing structure dictate the input field widths. It’s just one less thing to think about during form creation. If you’ve dealt with material, you feel my pain here.
We can conditionally decide to not include the label if there is no label defined on the input element. This syntax says, “insert a label as a child to the div we just created if the label is present on the input field”.
Now we can put the original input field into the div as well.
Last, we need to take our fully formed div and place it back on the dom just before the sibling that we took note of at the beginning. The parenNode in this case is most likely the enclosing formControl, but it could be any parent enclosing the orginal input.
import { Directive, ElementRef, OnInit, Renderer2 } from '@angular/core';
@Directive({
standalone: true,
selector: '[appInput]'
})
export class InputDirective implements OnInit {
constructor(
private elementRef: ElementRef,
private renderer: Renderer2
) { }
ngOnInit() {
const el = this.elementRef.nativeElement;
// We need to know what the next element is so we can insert the new div before it at the end.
const nextEl = this.renderer.nextSibling(el);
const labelText = this.elementRef.nativeElement.getAttribute('label');
const inputClass = this.elementRef.nativeElement.getAttribute('class');
const parentNode = this.renderer.parentNode(this.elementRef.nativeElement);
const label: HTMLLabelElement = document.createElement('label');
label.htmlFor = this.elementRef.nativeElement.id;
label.innerHTML = labelText;
const div: HTMLDivElement = document.createElement('div');
div.classList.add('input');
// So we can control the width of the div based on the input
div.classList.add(inputClass);
// if labelText has a value, add the label to the div
labelText ? div.appendChild(label) : null;
// Add the cloned input to the div
div.appendChild(el);
// Add the div to the parent node before nextEl
parentNode.insertBefore(div, nextEl);
}
}
And that’s it. You’ve just created a directive that converts this:
<input appInput id="first_name" [formControl]="first_name" name="first_name" label="First Name" placeholder="John Smith"/>
to this:
<div class="input">
<label for="first_name">First Name</label>
<input id="first_name" [formControl]="first_name" name="first_name" label="First Name" placeholder="John Smith"/>
</div>
With some highly targeted scss (and tailwind), we can make sure this div with a class of input is styled the way we like:
div.input {
@apply flex flex-col gap-0.5 bg-white w-full rounded-sm border border-gray-divider py-2 px-2 focus-within:border-gray-content focus-within:ring-1 focus-within:ring-gray-content font-brand hover:border-gray-content;
// Targets the label if it's directly below the div with input class.
> label {
@apply text-3xs p-0 px-1 text-gray-content-label truncate;
}
// Targets the input if it's directly below the div with input class.
> input {
@apply w-full text-[13px] font-normal leading-5 border-0 p-0 px-1 text-gray-content placeholder-gray-content-label focus:ring-0 rounded-sm;
}
}
Now we have a reusable directive to restyle a standard input and all we have to do is add appInput
to it! With styling, here is the base input:
And here is the input after the directive and css:
I haven’t seen much written on this topic. There’s plenty related to adding and removing elements, but not much related to enclosing an existing element. Input fields have a lot of native properties that can be broken by going the component route. This is a pattern that I expect we will use a lot because it allows us to use native html elements with very small modifications (directive call) to add any styles (including extra elements).