angulartypescriptangular-material

Dynamically add matTooltip in text for specific keywords


I am working in a personal Angular project (using Angular Material) and I can't find a solution for this problem.

For example, I have a long text like this :

When you take the Dodge action, you focus entirely on avoiding attacks. Until the start of your next turn, any Attack roll made against you has disadvantage if you can see the attacker, and you make Dexterity Saving Throws with advantage. You lose this benefit if you are Incapacitated (as explained in Conditions ) or if your speed drops to 0.

I have some keywords, here disadvantage, advantage incapacitated. I would like to make the font bold and add a tooltip to add a small explanation for each keyword.

I have no problem making the font bold with a pipe, but I can't find a solution for adding a tooltip for the keywords. Using innerHTML does not work, here is my current solution :

<div innerHTML="description | transformKeyWord"></div>

And my pipe :

transform(value: string, ...args: unknown[]): unknown {
    let result = value
      result = result.replace('advantage', '<span matTooltip="Advantage description">advantage</span>')
      result = result.replace('incapacitated', '<b [matTooltip]="Incapacitated description">incapacitated</b>')
    return result;
  }

I don't think this is the good solution using a pipe with innerHTML, that is why I am asking for help.


Solution

  • TLDR: https://stackblitz.com/edit/angular-ivy-86uxvs?file=src/app/app.component.html

    The way I see it, each keyword is an object with a word and a tooltip. So I'll define the schema for that.

    export type keyword = {
      word: string;
      tooltip: string;
    };
    

    I'll define the keywords in an object for easy lookup:

      keywords: { [key: string]: keyword } = {
        disadvantage: {
          word: 'disadvantage',
          tooltip: 'advantage description',
        },
        incapacitated: {
          word: 'incapacitated',
          tooltip: 'incapacitated description',
        },
      };
    

    I think the best way is to split the string into an array of strings and keywords, so we can print them to the html in order. Here's a function that does that - I put all this in a service because it seems fitting.

      toDynamicText(text: string): (string | keyword)[] {
        const res: (string | keyword)[] = [];
        const tokens = text.split(' ');
        let i = 0;
        for (const token of tokens) {
          let keyword = this.keywords[token.toLowerCase()]; //undefined if word is not a keyword
          if (keyword) {
            i = res.push(keyword);
          } else {
            if (!res[i]) res[i] = token;
            else res[i] += ' ' + token;
          }
        }
        return res;
      }
    

    output array looks like this:

    [
      "When you take the Dodge action, you focus entirely on avoiding attacks. Until the start of your next turn, any Attack roll made against you has",
      {
        "word": "disadvantage",
        "tooltip": "advantage description"
      },
      "if you can see the attacker, and you make Dexterity Saving Throws with advantage. You lose this benefit if you are",
      {
        "word": "incapacitated",
        "tooltip": "incapacitated description"
      },
      "(as explained in Conditions ) or if your speed drops to 0. "
    ]
    

    and here's an example of how to use it:

      text =
        'When you take the Dodge action, you focus entirely on avoiding attacks. ' +
        'Until the start of your next turn, any Attack roll made against you has disadvantage if you can see the attacker, ' +
        'and you make Dexterity Saving Throws with advantage. ' +
        'You lose this benefit if you are Incapacitated (as explained in Conditions ) or if your speed drops to 0. ';
    
      dynamicText: (string | keyword)[] = [];
    
      constructor(private dynamicTextService: DynamicTextService) {}
      ngOnInit(): void {
        this.dynamicText = this.dynamicTextService.toDynamicText(this.text);
      }
    
      isString(token: any) {
        return typeof token === 'string';
      }
      
      //Typescript compiler wants a typecast, so we have two helper functions
      getTooltip(token: string | keyword) {
        return (token as keyword).tooltip;
      }
    
      getWord(token: string | keyword) {
        return (token as keyword).word;
      }
    
    <p>
      <ng-container *ngFor="let token of dynamicText">
        <ng-container *ngIf="isString(token)">{{ token }}</ng-container>
        <ng-container *ngIf="!isString(token)"
          ><b [matTooltip]="getTooltip(token)" style="cursor: pointer">
            {{ getWord(token) }}
          </b></ng-container
        >
      </ng-container>
    </p>
    

    You could make it more robust by ensuring that the tooltip and word properties exist before trying to return them.

    I would make this a component with text as an input variable.