DEV Community

Eduard Krivanek
Eduard Krivanek

Posted on • Updated on

Angular Clickable Component

Working on one of my side projects I had an interesting problem. I wanted to implement accessibility 👀 . I know, not the topic we usually talk about, but dark days sometimes come and we have to brace ourselves.

The Task

Here is the problem. I had some components where I wanted to implement the following additions:

  • have an clickable signal input where I can set value from the parent whether the component is clickable
  • if clickable is set to true, add some css class to the component by ngClass and also tabIndex to be accessible by the keyboard
  • if clickable is set, add an (click) and (keydown.enter) method on the component where both of them will notify the parent component that the child was clicked

The Naive Solution

With all this in mind, the implementation to the components went something like this

@Component({
  selector: 'app-test',
  standalone: true,
  imports: [/* imports */],
  template: `
    <div
      (keydown.enter)="onClick()"
      (click)="onClick()"
      [ngClass]="{
        'g-clickable': clickable()
      }"
      [tabIndex]="clickable() ? 0 : -1"
    >
        <!-- whetever body -->
    </div>
  `,
  styles: ``,
})
export class TestComponent {
  itemClicked = output<void>();

  clickable = input(false);


  onClick(): void {
    if (this.clickable()) {
      this.itemClicked.emit();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The g-clickable contains some global css class to make the component clickable. All in all it was decent , I would approve the PR, however, here is where the problem starts.

What if I have to implement clickable behaviour on multiple components?

Well I may create a wrapper component a wrap each component, which should be clickable by this one. It would work. Is there anything else ? Welcome to an not-so-spoken Angular v15 feature - composition API (yes I liked my article, give it a like).

The Proper Solution

With composition API what we can do is create a ClickableDirective and then use hostDirectives in components to export then itemClicked and clickable to parent components.

import {
  Directive,
  ElementRef,
  InputSignal,
  OnDestroy,
  OutputEmitterRef,
  Renderer2,
  effect,
  inject,
  input,
  output,
} from '@angular/core';

interface Clickable {
  itemClicked: OutputEmitterRef<void>;
  clickable: InputSignal<boolean | undefined> | InputSignal<boolean>;
}

@Directive({
  selector: '[appClickable]',
  standalone: true,
})
export class ClickableDirective implements OnDestroy, Clickable {
  itemClicked = output<void>();
  clickable = input(false);

  private elementRef = inject(ElementRef);
  private renderer = inject(Renderer2);

  /**
   * references for event listeners to destroy them when directive is destroyed
   */
  private clickMouseRef: (() => void) | null = null;
  private clickKeyboardRef: (() => void) | null = null;

  clickableChangeEffect = effect(() => {
    const isClickable = this.clickable();
    if (isClickable) {
      this.addClickableEffect();
    } else {
      this.removeClickableEffect();
    }
  });

  ngOnDestroy() {
    this.removeClickableEffect();
  }

  private addClickableEffect() {
    // add clickable class
    this.renderer.addClass(this.elementRef.nativeElement, 'g-clickable');

    // add tab index
    this.renderer.setAttribute(this.elementRef.nativeElement, 'tabIndex', '0');

    // on click by mouse dispatch event
    this.clickMouseRef = this.renderer.listen(
        this.elementRef.nativeElement, 'click', () => {
          this.itemClicked.emit();
        }
      );

    // on click by keyboard dispatch event
    this.clickKeyboardRef = this.renderer.listen(
        this.elementRef.nativeElement, 'keydown.enter', () => {
          this.itemClicked.emit();
        }
      );
  }

  private removeClickableEffect() {
    // remove clickable class
    this.renderer.removeClass(this.elementRef.nativeElement, 'g-clickable');
    // remove tab index
    this.renderer.removeAttribute(this.elementRef.nativeElement, 'tabIndex');
    // remove click event listener
    if (this.clickMouseRef) {
      this.clickMouseRef();
    }
    // remove keyboard event listener
    if (this.clickKeyboardRef) {
      this.clickKeyboardRef();
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

then going back to your component which you want to make clickable you can do the following

@Component({
  selector: 'app-test',
  standalone: true,
  imports: [ClickableDirective, /* imports */],
  hostDirectives: [
    {
      directive: ClickableDirective, // <-- magic
      inputs: ['clickable'], 
      outputs: ['itemClicked'],
    },
  ],
  template: `
    <div>
    <!-- whetever body -->
    </div>
  `,
  styles: ``,
})
export class TestComponent {

}
Enter fullscreen mode Exit fullscreen mode

For all this examples I am attaching a stackblitz link: https://stackblitz.com/edit/stackblitz-starters-z8a4ca

Summary

Composition API in Angular v15 is not something you see being used very frequently. I myself struggle finding use-cases for it (or maybe it is just lack of my knowledge), nonetheless, if you know about any use-case, feel free to share in the comments or connect with me on: dev.to | LinkedIn| Personal Website | Github.

Top comments (0)