0

I'm re-writing my app to use TailwindCSS, which is driving me to make small components to avoid having to put the same list of CSS classes on every button or link.

That has had me run into this which I am trying to reason out before I just go shoving setters or OnChanges logic into a component whose inputs are static and will not change as I do not currently understand why it's rendering with defaults instead of the input values that are hardcoded into it's definition.

I want to keep these Tailwind components as light as possible so I would like to avoid adding logic when it isn't needed. As such, I'd like an explanation for this:

Component:

import {Component, Input} from '@angular/core';
import {NgClass} from '@angular/common';
import {RouterLink} from '@angular/router';

@Component({
  selector: 'tw-link',
  templateUrl: 'tw-link.component.html',
  styleUrls: ['tw-link.component.scss'],
  standalone: true,
  imports: [
    NgClass,
    RouterLink
  ]
})
export class TwLinkComponent {
  @Input({ required: true }) public linkName: string;
  @Input({ required: true }) public linkHref: string;
  @Input() public linkType: string;
  @Input() public linkExternal: boolean = false;
  @Input() public linkStyle: string = "default";
  @Input() public linkDisabled: boolean = false;
  @Input() public buttonOutline: boolean = false;
}

<a
  [routerLink]="linkExternal ? null : linkHref"
  [attr.href]="linkExternal ? linkHref : null"
  [attr.type]="linkType ? linkType : null"
  [ngClass]="{
    'tw-link-base': true,
    'tw-link-default': linkStyle === 'default',
    'tw-link-primary': linkStyle === 'primary',
    'tw-link-danger': linkStyle === 'danger',
    'tw-link-warning': linkStyle === 'warning',
    'tw-link-info': linkStyle === 'info',
  }"
>
  {{ linkName }}
</a>

Parent component instantiation of child with static input values:

Working component:
<tw-link linkName="About" linkHref="about"></tw-link> 

Not working component:
<tw-link linkName="Chris Routh" linkHref="https://routh.io" [linkExternal]="true"></tw-link>

The first call with an internal link renders perfectly with all the expected Input values being parsed and rendered correctly.

The second call with an external link is where this falls down. The [routerLink]="linkExternal ? null : linkHref" check works as expected, and routerLink is not added to the anchor tag, however the [attr.href]="linkExternal ? linkHref : null" seems to only look at the default value, and never renders the href attribute.

Why does the second use of the component not work? I clearly have a hole in my understanding of Input() and the lifecycle because I would only expect to need a setter if these input values were not static.

FWIW I also have a very similar button component that I'll add here as an example that also works as expected and renders with the static inputs every time:

import {Component, Input} from '@angular/core';
import {NgClass} from '@angular/common';

@Component({
  selector: 'tw-button',
  templateUrl: 'tw-button.component.html',
  styleUrls: ['tw-button.component.scss'],
  standalone: true,
  imports: [
    NgClass
  ]
})
export class TwButtonComponent {
  @Input({ required: true }) public buttonName: string;
  @Input() public buttonType: string = "button";
  @Input() public buttonShape: string = "square";
  @Input() public buttonStyle: string = "default";
  @Input() public buttonOutline: boolean = false;
  @Input() public buttonDisabled: boolean = false;
}
<button
  type="{{ buttonType }}"
  [disabled]="buttonDisabled"
  [class.tw-button-disabled]="buttonDisabled"
  [ngClass]="{
    'tw-button-base': true,
    'tw-button-default' : buttonStyle === 'default',
    'tw-button-primary' : buttonStyle === 'primary',
    'tw-button-danger' : buttonStyle === 'danger',
    'tw-button-warning' : buttonStyle === 'warning',
    'tw-button-info' : buttonStyle === 'info',
    'tw-button-outline' : buttonOutline,
    'tw-button-square': buttonShape === 'square',
    'tw-button-circle': buttonShape === 'circle',
    }"
>
  {{ buttonName }}
</button>
``
Routhinator
  • 3,559
  • 4
  • 24
  • 35
  • it could be because of sanitization. you can read more here bypassSecurityTrustUrl – SO is full of Monkeys Aug 24 '23 at 02:13
  • Hmm, familiar with the sanitation bypass, didn't consider it in this context. Will try a setter just on that prop that handles the bypass and see if that does it. – Routhinator Aug 24 '23 at 04:19
  • Actually that would be a poor test. I'll change the input for that to plain text and also try a few more attributes and see if it really is exclusive to the href, which would be a dead ringer for the sanitizer. – Routhinator Aug 24 '23 at 04:21

1 Answers1

1

The [routerLink] directive is applied to the anchor element regardless of the value passed in. Since null is passed in as the value, the directive sets the null value on the href attribute, clearing the href you've explicitly set.

Directives cannot be applied conditionally, however one workaround exists by splitting your component into two templates and switch between them using the ngTemplateOutlet directive. (Based on https://stackoverflow.com/a/36754176/19748698)

Example:

<ng-container [ngTemplateOutlet]="linkExternal ? externalTemplate : internalTemplate">
  <ng-template #internalTemplate>
    <a [routerLink]="linkHref [attr.type]="linkType ? linkType : null" [ngClass]="{
      'tw-link-base': true,
      'tw-link-default': linkStyle === 'default',
      'tw-link-primary': linkStyle === 'primary',
      'tw-link-danger': linkStyle === 'danger',
      'tw-link-warning': linkStyle === 'warning',
      'tw-link-info': linkStyle === 'info',
    }">{{ linkName }}
    </a>
  </ng-template>
  <ng-template #externalTemplate>
    <a [attr.href]="linkHref" [attr.type]="linkType ? linkType : null" [ngClass]="{
    'tw-link-base': true,
    'tw-link-default': linkStyle === 'default',
    'tw-link-primary': linkStyle === 'primary',
    'tw-link-danger': linkStyle === 'danger',
    'tw-link-warning': linkStyle === 'warning',
    'tw-link-info': linkStyle === 'info',
  }">
      {{ linkName }}
    </a>
  </ng-template>
</ng-container>

The Tailwind classes could be put in a seperate variable to avoid code duplication.

Lennie
  • 81
  • 2
  • Ouch this is kinda gross. I tracked the why back to the github issue, but still ugly to have to duplicate code to make a properly reusable component. But I guess this is the answer as I do not see another way. – Routhinator Aug 24 '23 at 20:45
  • One followup - I added a var/function to hold the ngClass conditions and pointed to that to reduce duplication, and found out that this seems to only use defaults and would trigger a need for a setter or something, not really sure if this is the same as the routerLink problem, but having the ngClass conditions directly in the template (unfortunately duplicated) does not have this issue and works as expected. – Routhinator Aug 24 '23 at 21:14