Unlocking Directive Selectors: Beyond Custom Attributes
It has been quite some time since the last installment in this series, which explored the more advanced capabilities of Angular directives and their interplay with dependency injection. My focus had shifted to completing a book, and with its draft now finished and available in Early Access, it feels like the right moment to return and give this series a proper conclusion. While today's discussion won't center heavily on dependency injection, we'll take a deep dive into the fascinating world of directive selectors and uncover how they can be harnessed to build remarkably powerful directives.
Leveraging Standard HTML Attributes
Rather than always inventing brand-new custom attributes for our directives, we can often reuse existing, valid HTML attributes as selectors. This approach is particularly handy when we want to augment the behavior of an existing HTML element without introducing a novel attribute name.
Consider a common scenario where we use id attributes to mark elements for E2E testing purposes. However, different testing frameworks might rely on alternative attributes such as data-test-id or data-qa-id. Instead of duplicating the same value across multiple attributes by hand, we can craft a directive that automatically mirrors the id value onto these test-specific attributes:
@Directive({
selector: '[id]',
standalone: true,
})
export class TestingIdDirective {
private readonly elRef = inject<ElementRef<HTMLElement>>(ElementRef);
get id() {
return this.elRef.nativeElement.id;
}
@HostBinding('attr.data-qa-id') get dataQaId() {
return this.id;
}
@HostBinding('attr.data-test-id') get dataTestId() {
return this.id;
}
}
In this implementation, we simply target any element bearing an id attribute and utilize a HostBinding to propagate its value onto data-qa-id and data-test-id. The beauty here is that the directive works universally across any element type—as long as it has an id, it qualifies. You can experiment with this example here.
Granted, this approach is quite broad. Let's narrow our focus to a more specific use case.
Attribute Selectors with Specific Values
Sometimes, we need to target elements not just by the presence of an attribute, but by its specific value. Suppose we have numerous number inputs throughout our application, but we want to enforce that only positive values are permitted. When a negative number is entered, the input should be highlighted with a red outline. We already have a CSS class called .invalid that applies this styling, so our task is to create a directive that targets number inputs, examines their values, and toggles the .invalid class accordingly:
@Directive({
selector: 'input[type="number"]',
standalone: true,
})
export class PositiveNumberDirective {
private readonly elRef = inject<ElementRef<HTMLInputElement>>(ElementRef);
@HostBinding('class.invalid') value = false;
@HostListener('input')
onInput() {
const value = this.elRef.nativeElement.value ?? 0;
this.value = (+value) < 0;
}
}
Again, minimal effort is required from us—simply import the directive into the component, and it automatically attaches to all number inputs, applying the .invalid class when negative values are detected through the HostBinding. A live demonstration is available here.
Let's now explore a selector type that often flies under the radar.
Class Selectors: An Overlooked Feature
It might come as a surprise, but directives can also attach themselves to elements based on their CSS classes! Imagine we want to extend the behavior of all elements that have the .invalid class—for instance, adding a tooltip to them through a separate, pre-existing directive. We can accomplish this elegantly by combining a class selector with the power of Host Directives:
@Directive({
selector: '.invalid',
standalone: true,
hostDirectives: [TooltipDirective],
})
export class InvalidTooltipDirective implements AfterViewInit {
private readonly tooltipRef = inject(TooltipDirective);
ngAfterViewInit() {
this.tooltipRef.title = 'The value is invalid';
}
}
This approach almost delivers what we want, yet it harbors a significant drawback that we'll address shortly after examining the remaining selector types. For now, let's venture into the realm of more sophisticated and versatile selectors.
Navigating Complex Selectors
Up to this point, we've focused on selectors that pinpoint specific elements. While useful, there are situations where we need to target multiple element types or conversely, exclude certain elements from a broad selector. Let's explore these possibilities.
Broadening the Scope with Combined Selectors
There are two primary strategies to expand a directive's reach: making the selector more specific or employing multiple selectors simultaneously. We've already touched upon the first approach with input[type="number"] in our PositiveNumberDirective from earlier. Let's now delve into the second approach—using multiple selectors.
Picture a social media platform where users have an online status determined by various factors; one key indicator is whether they're actively typing. There are multiple input surfaces like input elements and textarea fields. We aim to create a directive that listens to typing events and dispatches an HTTP request to signify the user is online. (We'll skip optimizations like throttling or deduplication for brevity, as they're beyond this article's scope.) Here's how we might construct such a directive:
@Directive({
selector: 'input, textarea',
standalone: true,
})
export class OnlineStatusDirective {
private readonly userService = inject(UserService);
@HostListener('input')
onInput() {
this.userService.setOnlineStatus(true);
}
}
As with our previous directives, simply importing it into the desired component enables it to function seamlessly across multiple element types. You can test this here. Now, let's shift our attention to the final selector technique—one that refines specificity rather than expanding it.
Negating Selectors
There are times when a selector matches exactly the elements we want, but one particular element slips through that we'd rather exclude. Take the PositiveNumberDirective from earlier in this article: it was designed to catch every number input, but imagine there's a single input where we explicitly want to permit negative values.
A common reflex is to add an @Input() flag that controls whether the directive activates:
@Directive({
selector: 'input[type="number"]',
standalone: true,
})
export class PositiveNumberDirective {
@Input() enabled = true;
private readonly elRef = inject(ElementRef);
@HostBinding('class.invalid') get value() {
const value = this.elRef.nativeElement.value ?? 0;
return this.enabled && (+value) < 0;
}
}
That approach functions, but it carries three drawbacks:
- You must set the boolean every time you want the directive to not run — not a huge burden, but still extra friction.
- The
enabledcheck may need to be repeated throughout the directive, especially if it has multiple@HostListeners or@HostBindings, which hurts readability. - The directive is still instantiated by Angular, wasting a bit of processing power on something that won't be used.
Let's look at a cleaner solution: selector negation, often referred to as the :not() selector. Here's how it works:
@Directive({
selector: 'input[type="number"]:not([allowNegative])',
standalone: true,
})
export class PositiveNumberDirective {
private readonly elRef = inject(ElementRef);
@HostBinding('class.invalid') get value() {
const value = this.elRef.nativeElement.value ?? 0;
return (+value) < 0;
}
}
Notice that the directive's internal logic hasn't changed at all — we only modified the selector to exclude any element carrying the allowNegative attribute. From now on, we mark an input with that attribute whenever negative numbers should be permitted, and the directive will simply skip it.
<input type="number" allowNegative />
The :not() specifier can accept any valid directive selector type. A working example of this can be tried out here.
Now that we've explored the various selector tricks, it's worth pausing to consider a few common traps that developers run into when working with directives.
Common Pitfalls
Given the CSS-like flexibility of directive selectors, it's easy to assume they behave exactly like CSS selectors. That assumption would be incorrect. Let's examine three scenarios where the comparison falls apart.
No Parent-Child or Sibling Targeting
It would be convenient to target an element only when it's nested inside a specific parent. But that's simply not possible. You might attempt something like:
@Directive({
selector: 'div > button',
standalone: true,
})
export class ParentChildDirective {
@HostListener('click')
onClick() {
console.log('clicked')
}
}
With a template like this:
<div>
<button>Directive is applied</button>
</div>
<button>Directive should not be applied</button>
Clicking the first button would correctly log "clicked". However, you'd get the same log from the second button, even though it sits outside any div. The reason: Angular takes the final element in the chain of that confusing selector and applies the directive to it. In short, complex relationships — parent, child, sibling, ancestor, descendant — are unsupported in directive selectors. Keep in mind that Angular directive selectors are not true CSS selectors.
No Dynamic Application of Directives
Directives aren't dynamically attached or detached when an element starts matching a selector at runtime. This becomes most evident with class-based selectors. When we created the InvalidTooltipDirective, we pointed out a fundamental flaw: it seems reasonable to expect that adding an invalid class to an element will trigger the directive. But that's false — Angular resolves directives at compile-time. An element needs to have the invalid class from the start; if it gains the class later, the directive won't kick in. You can see this behavior in action here.
Structural Directives Are Attribute-Only
Structural directives rely on a neat bit of abstraction: the asterisk prefix is a shorthand that wraps the element in an <ng-template>. For instance, writing <div * class="some-class">Text</div> is transformed into <ng-template [class]="some-class"><div>Text</div></ng-template>. This is the reason structural directives are restricted to attributes — they're applied to the <ng-template> wrapper, not to the inner element. So you can't, for example, attach a structural directive to that inner div, because it's not the element that carries the asterisk. This limitation might feel constraining, but in practice it's rarely an issue since such logic is seldom needed.
Wrapping Up
This series has been a pleasure to write, and I trust it has been useful to you as well. Angular directives pack a serious punch and often fly under the radar. I'm confident that incorporating them more often — as I've grown to do — can substantially simplify your codebase, particularly the template layer.
A Quick Note
As I mentioned at the start, I've been working on a book. Titled "Modern Angular," it's a deep dive into the impressive features introduced in recent releases (v14–v17), such as standalone components, improved inputs, signals (naturally!), enhanced RxJS integration, SSR, and plenty more. If that sounds interesting, you can check it out here. The manuscript is complete, just undergoing final polish, and is currently in Early Access with the first five chapters published and others on the way. To stay in the loop about new chapters or special offers, follow me on Twitter or LinkedIn.
