I have a tooltip element floating above the cursor. The size of the tooltip is obviously dependent on its content. I also need the tooltip to be completely visible inside the viewport, thus I set the tooltip's position in relation to its size and the cursor's location.
Here's the template:
@if (cursorLocation()) {
<div #tooltipContainer
[style.top]="'max(' + (tooltipContainer.offsetHeight + 40) + 'px,' + cursorLocation()!.top + 'px)'"
[style.left]="'min(100vw - ' + (10 + tooltipContainer.offsetWidth / 2) + 'px, max(' + (10 + tooltipContainer.offsetWidth / 2) + 'px,' + cursorLocation()!.left + 'px))'">
<div class="mat-mdc-dialog-content">
@if (dataHandler.selectedWord()) {
{{dataHandler.selectedWord()?.translation}}
} @else {
<wb-loading-screen></wb-loading-screen>
}
</div>
</div>
}
The whole thing is slightly more complex but the important information is the #tooltipContainer and @if content section.
Here's the important bits of the components:
export class BaseTooltipComponent {
public readonly cursorLocation: ModelSignal<{ top: number, left: number } | undefined> = model.required();
}
export class WordTooltipComponent extends BaseTooltipComponent {
public readonly dataHandler: WordHandler = inject(WordHandler);
public readonly ids: InputSignal<{ languageId: string,
wordId: string } | undefined> = input.required();
constructor() {
super();
effect(this._initWords.bind(this), { allowSignalWrites: true });
}
private _initWords(): void {
if (!this.ids()) { return; }
this.dataHandler.read(this.ids()!.wordId, this.ids()!.languageId);
}
}
If the tooltip is to be shown, it receives the cursor's current location. Then, in case of the specific tooltip for a word, it also receives two IDs, one for the language and one for the word, then asks a data handler to read this information, which it either takes from its internal cache or requests from the database. Once complete, the dataHandler updates its signal property selectedWord. This then calls to update the content within the tooltip, i.e. what's inside of the @if. Once it does, the following error is thrown:
ERROR RuntimeError: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value for 'top': 'max(166px, 442px)'. Current value: 'max(154px, 442px)'
The way I understand this is that only the content of the tooltip gets re-rendered when the selectedWord is updated. But, the changing content changes the #tooltipContainer's size and this then updates the [style.top] and [style.left] values outside of the corresponding angular lifecycle step as the container itself isn't being re-rendered, which obviously complains (though the tooltip still renders at the correct position).
What I do not understand is why this only happens for offsetHeight. If I remove that from the [style.top] binding despite offsetWidth still being present and updated, no exception is thrown. I've also tried various combinations of the top and left argument order, tried to switch the bindings for top and left, so left would contain the offsetHeight. For whatever reason, it's only an issue for offsetHeight.
Thus I'm actually not sure if my assessment of this issue is correct, otherwise it should also happen for the offsetWidth, shouldn't it? But, in case it is, is there a way to tell angular to re-render the parent container component when selectedWord is being set and the container's content changes without having to wrap the whole container in the @if-statement?