Skip to main content

Exercise 3: Custom Directives

Objective

Practice creating custom attribute and structural directives to extend HTML functionality.

Prerequisites

  • Understanding of Angular directives
  • Basic knowledge of DOM manipulation
  • Familiarity with HostListener and HostBinding

Setup

ng new directive-playground --standalone --routing=false --style=css
cd directive-playground
ng serve

Part 1: Attribute Directives

Task 1.1: Tooltip Directive

Create a custom tooltip directive that shows a tooltip on hover.

ng generate directive directives/tooltip --standalone

Requirements:

  • Show tooltip on mouse enter
  • Hide tooltip on mouse leave
  • Accept custom tooltip text via input
  • Position tooltip above the element
  • Style the tooltip with CSS

Implementation:

// src/app/directives/tooltip.directive.ts
import {
Directive,
Input,
ElementRef,
HostListener,
Renderer2,
OnDestroy,
} from '@angular/core';

@Directive({
selector: '[appTooltip]',
standalone: true,
})
export class TooltipDirective implements OnDestroy {
@Input() appTooltip = '';
@Input() tooltipPosition: 'top' | 'bottom' | 'left' | 'right' = 'top';

private tooltipElement: HTMLElement | null = null;

constructor(private el: ElementRef, private renderer: Renderer2) {}

@HostListener('mouseenter') onMouseEnter() {
if (!this.tooltipElement) {
this.show();
}
}

@HostListener('mouseleave') onMouseLeave() {
if (this.tooltipElement) {
this.hide();
}
}

private show() {
// Create tooltip element
this.tooltipElement = this.renderer.createElement('span');
this.renderer.appendChild(
this.tooltipElement,
this.renderer.createText(this.appTooltip)
);

// Add CSS classes
this.renderer.addClass(this.tooltipElement, 'custom-tooltip');
this.renderer.addClass(
this.tooltipElement,
`tooltip-${this.tooltipPosition}`
);

// Append to body
this.renderer.appendChild(document.body, this.tooltipElement);

// Position the tooltip
this.setPosition();
}

private hide() {
if (this.tooltipElement) {
this.renderer.removeChild(document.body, this.tooltipElement);
this.tooltipElement = null;
}
}

private setPosition() {
if (!this.tooltipElement) return;

const hostPos = this.el.nativeElement.getBoundingClientRect();
const tooltipPos = this.tooltipElement.getBoundingClientRect();

let top = 0;
let left = 0;

switch (this.tooltipPosition) {
case 'top':
top = hostPos.top - tooltipPos.height - 10;
left = hostPos.left + (hostPos.width - tooltipPos.width) / 2;
break;
case 'bottom':
top = hostPos.bottom + 10;
left = hostPos.left + (hostPos.width - tooltipPos.width) / 2;
break;
case 'left':
top = hostPos.top + (hostPos.height - tooltipPos.height) / 2;
left = hostPos.left - tooltipPos.width - 10;
break;
case 'right':
top = hostPos.top + (hostPos.height - tooltipPos.height) / 2;
left = hostPos.right + 10;
break;
}

this.renderer.setStyle(this.tooltipElement, 'top', `${top}px`);
this.renderer.setStyle(this.tooltipElement, 'left', `${left}px`);
}

ngOnDestroy() {
this.hide();
}
}

Styles:

/* src/styles.css */
.custom-tooltip {
position: absolute;
background-color: #333;
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 14px;
z-index: 1000;
white-space: nowrap;
pointer-events: none;
}

.custom-tooltip::after {
content: '';
position: absolute;
border: 5px solid transparent;
}

.tooltip-top::after {
top: 100%;
left: 50%;
transform: translateX(-50%);
border-top-color: #333;
}

.tooltip-bottom::after {
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-bottom-color: #333;
}

.tooltip-left::after {
left: 100%;
top: 50%;
transform: translateY(-50%);
border-left-color: #333;
}

.tooltip-right::after {
right: 100%;
top: 50%;
transform: translateY(-50%);
border-right-color: #333;
}

Usage:

@Component({
selector: 'app-root',
standalone: true,
imports: [TooltipDirective],
template: `
<button appTooltip="Click me to submit" tooltipPosition="top">
Submit
</button>

<button appTooltip="Delete this item" tooltipPosition="right">
Delete
</button>
`,
})
export class AppComponent {}

Task 1.2: Copy to Clipboard Directive

Create a directive that copies text to clipboard on click.

// src/app/directives/copy-clipboard.directive.ts
import { Directive, Input, HostListener, inject } from '@angular/core';

@Directive({
selector: '[appCopyClipboard]',
standalone: true,
})
export class CopyClipboardDirective {
@Input() appCopyClipboard = '';
@Input() successMessage = 'Copied!';

@HostListener('click', ['$event']) onClick(event: Event) {
event.preventDefault();
this.copyToClipboard();
}

private async copyToClipboard() {
try {
await navigator.clipboard.writeText(this.appCopyClipboard);
this.showFeedback();
} catch (err) {
console.error('Failed to copy:', err);
}
}

private showFeedback() {
// Show success message (you can enhance this with a toast service)
alert(this.successMessage);
}
}

Task 1.3: Auto-resize Textarea Directive

Create a directive that auto-resizes textarea based on content.

// src/app/directives/auto-resize.directive.ts
import { Directive, ElementRef, HostListener, OnInit } from '@angular/core';

@Directive({
selector: 'textarea[appAutoResize]',
standalone: true,
})
export class AutoResizeDirective implements OnInit {
constructor(private el: ElementRef) {}

ngOnInit() {
this.resize();
}

@HostListener('input') onInput() {
this.resize();
}

private resize() {
const textarea = this.el.nativeElement;
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
}
}

// Usage
<textarea appAutoResize placeholder='Type something...'></textarea>;

Part 2: Structural Directives

Task 2.1: Repeat Directive

Create a structural directive that repeats an element N times.

// src/app/directives/repeat.directive.ts
import { Directive, Input, TemplateRef, ViewContainerRef, OnChanges } from '@angular/core';

@Directive({
selector: '[appRepeat]',
standalone: true
})
export class RepeatDirective implements OnChanges {
@Input() appRepeat: number = 0;

constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef
) {}

ngOnChanges() {
this.viewContainer.clear();

for (let i = 0; i < this.appRepeat; i++) {
this.viewContainer.createEmbeddedView(this.templateRef, {
$implicit: i,
index: i,
first: i === 0,
last: i === this.appRepeat - 1,
even: i % 2 === 0,
odd: i % 2 !== 0
});
}
}
}

// Usage
@Component({
template: `
<div *appRepeat="5; let i; let isFirst = first">
Item {{ i }} {{ isFirst ? '(First)' : '' }}
</div>
`
})

Task 2.2: Delayed Render Directive

Create a directive that delays rendering of content.

// src/app/directives/delayed-render.directive.ts
import { Directive, Input, TemplateRef, ViewContainerRef, OnInit } from '@angular/core';

@Directive({
selector: '[appDelayedRender]',
standalone: true
})
export class DelayedRenderDirective implements OnInit {
@Input() appDelayedRender: number = 0; // delay in milliseconds

constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef
) {}

ngOnInit() {
setTimeout(() => {
this.viewContainer.createEmbeddedView(this.templateRef);
}, this.appDelayedRender);
}
}

// Usage
<div *appDelayedRender="2000">
This content appears after 2 seconds
</div>

Task 2.3: Var Directive (Template Variable)

Create a directive that allows declaring variables in templates.

// src/app/directives/var.directive.ts
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';

@Directive({
selector: '[appVar]',
standalone: true
})
export class VarDirective {
@Input() set appVar(context: any) {
this.viewContainer.clear();
this.viewContainer.createEmbeddedView(this.templateRef, { appVar: context });
}

constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef
) {}
}

// Usage
<div *appVar="computeExpensiveValue() as result">
{{ result.name }}
{{ result.value }}
</div>

Part 3: Advanced Directives

Task 3.1: Permission Directive

Create a directive that shows/hides elements based on user permissions.

// src/app/directives/has-permission.directive.ts
import { Directive, Input, TemplateRef, ViewContainerRef, OnInit, inject } from '@angular/core';

interface User {
permissions: string[];
}

// Mock auth service
class AuthService {
getCurrentUser(): User {
return {
permissions: ['read', 'write', 'admin']
};
}
}

@Directive({
selector: '[appHasPermission]',
standalone: true,
providers: [AuthService]
})
export class HasPermissionDirective implements OnInit {
@Input() appHasPermission: string[] = [];
@Input() appHasPermissionStrategy: 'any' | 'all' = 'any';

private authService = inject(AuthService);

constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef
) {}

ngOnInit() {
const user = this.authService.getCurrentUser();
const hasPermission = this.checkPermissions(user.permissions);

if (hasPermission) {
this.viewContainer.createEmbeddedView(this.templateRef);
} else {
this.viewContainer.clear();
}
}

private checkPermissions(userPermissions: string[]): boolean {
if (this.appHasPermissionStrategy === 'all') {
return this.appHasPermission.every(p => userPermissions.includes(p));
}
return this.appHasPermission.some(p => userPermissions.includes(p));
}
}

// Usage
<button *appHasPermission="['admin']">
Admin Only
</button>

<div *appHasPermission="['read', 'write']; strategy: 'all'">
Requires both read and write permissions
</div>

Task 3.2: Infinite Scroll Directive

Create a directive that detects when user scrolls to bottom.

// src/app/directives/infinite-scroll.directive.ts
import {
Directive,
ElementRef,
EventEmitter,
Output,
OnInit,
OnDestroy,
} from '@angular/core';

@Directive({
selector: '[appInfiniteScroll]',
standalone: true,
})
export class InfiniteScrollDirective implements OnInit, OnDestroy {
@Output() scrolled = new EventEmitter<void>();

private observer!: IntersectionObserver;

constructor(private el: ElementRef) {}

ngOnInit() {
const options = {
root: null,
rootMargin: '100px',
threshold: 0.1,
};

this.observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
this.scrolled.emit();
}
}, options);

this.observer.observe(this.el.nativeElement);
}

ngOnDestroy() {
this.observer.disconnect();
}
}

// Usage
@Component({
template: `
<div class="item-list">
@for (item of items; track item.id) {
<div class="item">{{ item.name }}</div>
}
<div appInfiniteScroll (scrolled)="loadMore()"></div>
</div>
`,
})
export class ListComponent {
items: any[] = [];

loadMore() {
// Load more items
console.log('Loading more items...');
}
}

Complete Example Application

// src/app/app.component.ts
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TooltipDirective } from './directives/tooltip.directive';
import { CopyClipboardDirective } from './directives/copy-clipboard.directive';
import { AutoResizeDirective } from './directives/auto-resize.directive';
import { RepeatDirective } from './directives/repeat.directive';
import { DelayedRenderDirective } from './directives/delayed-render.directive';
import { HasPermissionDirective } from './directives/has-permission.directive';

@Component({
selector: 'app-root',
standalone: true,
imports: [
CommonModule,
FormsModule,
TooltipDirective,
CopyClipboardDirective,
AutoResizeDirective,
RepeatDirective,
DelayedRenderDirective,
HasPermissionDirective,
],
template: `
<div class="container">
<h1>Custom Directives Demo</h1>

<!-- Tooltip Examples -->
<section>
<h2>Tooltip Directive</h2>
<button appTooltip="Save your changes" tooltipPosition="top">
Save
</button>
<button appTooltip="Cancel operation" tooltipPosition="right">
Cancel
</button>
</section>

<!-- Copy to Clipboard -->
<section>
<h2>Copy to Clipboard</h2>
<div class="code-block">
<code>{{ codeSnippet }}</code>
<button
[appCopyClipboard]="codeSnippet"
successMessage="Code copied!"
>
Copy
</button>
</div>
</section>

<!-- Auto-resize Textarea -->
<section>
<h2>Auto-resize Textarea</h2>
<textarea
appAutoResize
placeholder="Type multiple lines..."
[(ngModel)]="textContent"
></textarea>
</section>

<!-- Repeat Directive -->
<section>
<h2>Repeat Directive</h2>
<div *appRepeat="5; let i; let isLast = last" class="repeat-item">
Item {{ i + 1 }} {{ isLast ? '(Last)' : '' }}
</div>
</section>

<!-- Delayed Render -->
<section>
<h2>Delayed Render</h2>
<div *appDelayedRender="1000" class="delayed-content">
This appeared after 1 second!
</div>
</section>

<!-- Permission Directive -->
<section>
<h2>Permission-based Content</h2>
<button *appHasPermission="['admin']">Admin Action</button>
<button *appHasPermission="['read']">Read Action</button>
<button *appHasPermission="['super-admin']">
Super Admin (Hidden)
</button>
</section>
</div>
`,
styles: [
`
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}

section {
margin: 2rem 0;
padding: 1rem;
border: 1px solid #ddd;
border-radius: 8px;
}

h2 {
margin-top: 0;
color: #333;
}

button {
margin: 0.5rem;
padding: 0.5rem 1rem;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}

button:hover {
background: #0056b3;
}

.code-block {
background: #f5f5f5;
padding: 1rem;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}

textarea {
width: 100%;
min-height: 60px;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-family: monospace;
resize: none;
overflow: hidden;
}

.repeat-item {
padding: 0.5rem;
margin: 0.25rem 0;
background: #e9ecef;
border-radius: 4px;
}

.delayed-content {
padding: 1rem;
background: #d4edda;
border-radius: 4px;
animation: fadeIn 0.5s;
}

@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`,
],
})
export class AppComponent {
codeSnippet = 'npm install @angular/core';
textContent = '';
}

Testing Your Directives

  1. Run the application: ng serve
  2. Test each directive:
    • Hover over buttons to see tooltips
    • Click copy button to copy code
    • Type in textarea to see auto-resize
    • Check repeated elements
    • Watch delayed content appear
    • Verify permission-based visibility

Bonus Challenges

Challenge 1: Loading State Directive

Create a directive that shows a loading spinner on an element.

Challenge 2: Drag and Drop Directive

Create directives for drag and drop functionality.

Challenge 3: Debounce Click Directive

Create a directive that debounces button clicks.

Challenge 4: Form Validation Directive

Create a custom validation directive for forms.

Challenge 5: Sticky Directive

Create a directive that makes elements sticky on scroll.

Key Concepts Practiced

✅ Custom attribute directives ✅ Custom structural directives ✅ HostListener and HostBinding ✅ ElementRef and Renderer2 ✅ TemplateRef and ViewContainerRef ✅ DOM manipulation ✅ Event handling ✅ Lifecycle hooks in directives

Time Estimate

  • Basic directives: 2-3 hours
  • Advanced directives: 3-4 hours
  • Complete with styling: 4-6 hours
  • With bonus challenges: 6-8 hours

Next Steps

  • Create more complex directives
  • Add unit tests for directives
  • Combine multiple directives
  • Explore DOM manipulation techniques