Skip to content

Tooltip

@flyze/lib-core-angular 1.0.0-alpha.34· latest

A hover tooltip that can also be opened from code, with no @angular/material/tooltip dependency. The panel is a CDK ComponentPortal in an overlay; @angular/cdk is the only thing it needs.

There are three ways in, and which one you want depends on what you are holding:

  • FyzTooltip — a standalone directive, [fyzTooltip], for anything with a template.
  • FyzTooltipService — for a consumer that holds an HTMLElement and nothing else. attach() is the method to reach for.
  • exportAs: 'fyzTooltip' — the directive instance in the template, for opening a tooltip from an event of your own without injecting anything.

The service owns every overlay, keyed by host element, and the directive is sugar over it. That is the load-bearing decision and it is not the shape MatTooltip uses — ADR 0007 says why, and why making it look like MatTooltip breaks the case this exists for.

For task-oriented steps, see guides/using-the-tooltip.

Requires the CDK overlay stylesheet. The panel is positioned by @angular/cdk/overlay, and without those structural rules .cdk-overlay-container is not position: fixed and the tooltip lands in the document flow rather than next to its anchor. An app with a Material theme already has them: mat.core() includes @include cdk.overlay() (node_modules/@angular/material/core/_core.scss:12). An app without one must add @import '@angular/cdk/overlay-prebuilt.css'; itself. Nothing in this library ships them.

InputTypeDefaultEffect
fyzTooltipstringthe text; an empty string suppresses the tooltip entirely
fyzTooltipClassstring | string[][]class(es) on the panel — resolved globally, see below
fyzTooltipShowDelaynumber0ms the pointer must rest on the host before it shows
fyzTooltipHideDelaynumber0ms the tooltip stays up after the pointer leaves
fyzTooltipDisabledbooleanfalsesuppresses the tooltip, and closes one already up

fyzTooltipShowDelay / fyzTooltipHideDelay coerce through numberAttribute and fyzTooltipDisabled through booleanAttribute, so the static attribute forms work.

Plus public show() and hide() on the instance, reachable via exportAs.

MethodMeaning
attach(host, options | factory)wire hover, get back an idempotent teardown — the one to reach for
show(host, options)show now, no hover involved
hide(host, hideDelay?)run the exit animation; no-op when nothing is open
hideImmediately(host)remove at once, no animation and no delay — for teardown paths
hideAll()close every open panel, ignoring each one’s configured hideDelay
update(host, partial)change a panel that is already up; no-op when nothing is open
isOpen(host)whether a panel exists for this host

host is HTMLElement | ElementRef<HTMLElement> everywhere, normalised by one private helper (fyz-tooltip.service.ts:340) that matches FyzMenuTrigger’s anchor setter, so the two directives accept the same thing.

export interface FyzTooltipOptions {
text: string;
fyzTooltipClass?: string | string[];
showDelay?: number;
hideDelay?: number;
}

hideDelay lives in the options rather than only on hide() because the hide is usually triggered from somewhere with no access to them — a mouseleave listener, a teardown. It is stored on the entry and reused; hide() still takes an explicit delay to override it for one call.

FYZ_TOOLTIP_PANE_CLASS ('fyz-tooltip-pane') is put on the overlay pane, as a stable hook for code that needs to find the pane rather than the panel inside it.

attach() registers mouseenter / mouseleave and returns an idempotent stop function that removes both listeners and disposes a panel that may still be on screen. That last part is the whole point: forgetting it is the regression in commit 0dd3d62e, “tooltip was stuck if the button is destroyed”. The directive’s ngOnDestroy is one call to it.

Pass a factory rather than a fixed object whenever the text or the delays depend on state that changes after the call. It is re-read on every hover, so there is nothing to keep in sync and no listener to re-register; returning nullish, or options with empty text, suppresses that hover.

The teardown uses hideImmediately(), not hide(): a teardown cannot wait out a hideDelay, and there is no exit animation worth running for an anchor that is already gone.

Delays, and the re-entrancy guard that makes hideDelay safe

Section titled “Delays, and the re-entrancy guard that makes hideDelay safe”

A hideDelay exists so a pointer that slips off an edge and comes straight back keeps the tooltip it already earned. That only works because show() checks for a panel that is already visible and refreshes it in place (fyz-tooltip.service.ts:92) instead of building a second overlay.

Without that guard a hideDelay is actively harmful. isVisible only flips when the hide timer fires, so on re-entry the old panel is still visible: creating a second overlay puts two panels on the same spot for the length of the delay and overwrites the map entry, leaving the first unreachable by hide(), hideAll() or any teardown. Angular Material has the same guard at the top of its own show().

A show that is still pending is a different case — it is disposed rather than refreshed, because a pending panel is not visible and there is nothing to keep.

Both timers are cleared in both show() and hide(), not just the opposite one. A second hide() before the first fired would otherwise overwrite #hideTimeoutId and orphan the earlier timer, which cancelPendingAnimations() then has no handle on. Angular Material carries the same orphan.

Disposal is driven by animationend, so an animation that never runs would mean an overlay that is never disposed — a panel on screen forever. A consumer class with animation: none, or a zero duration, does exactly that.

The panel asks the browser whether the class it just applied actually animates (#isAnimationSuppressed, fyz-tooltip-panel.component.ts:190) and finalises on the spot when it does not, marking the panel with the diagnostic class fyz-tooltip-no-animation — which has no rule anywhere and exists only to be asserted on.

Not a fallback timer, which is the obvious alternative and is worse: a timer fires on a schedule of its own, so a consumer that deliberately slowed the exit animation would have the panel yanked mid-transition. Probing computed style asks the only question that matters, at the only moment it can be answered. Angular Material probes on show only; this probes both toggles, covering a consumer that suppresses just one of the two.

A missing getComputedStyle counts as suppressed: there is no way to tell, and treating it as animated is the failure mode that leaks an overlay.

One position: centred below the anchor, offsetY: 8. No flip — a tooltip near the bottom edge is pushed back into view by FlexibleConnectedPositionStrategy, not moved above the anchor.

show() calls updatePosition() explicitly (fyz-tooltip.service.ts:161) rather than leaving it to the CDK. OverlayRef.attach() does not apply its position strategy synchronously — it defers to NgZone.onStable (node_modules/@angular/cdk/fesm2022/overlay.mjs:794), so the panel is measured with its content in it. That only ever fires for a caller inside the Angular zone, and this service deliberately runs its listeners outside it, so an imperative show() would leave the pane at the top left of the viewport. The options are applied and rendered before the call, so the panel measures correctly.

update() repositions too, because the text may have changed width and the panel is centred on its host. That is not optional: with overlayX: 'center' the flexible strategy re-centres via flexbox for free, but the pushed path bakes a pixel left from the width measured at apply(), and nothing in overlay.mjs observes the panel for resizes. Repositioning has to be an explicit push.

pointer-events: none is set inline on the overlay pane (fyz-tooltip.service.ts:142), after attach() — the CDK resets the property itself while attaching.

It cannot go in the panel’s stylesheet. The element that eats the click is .cdk-overlay-pane, which the CDK gives pointer-events: auto, and the pane is the panel component’s parent — no rule under emulated encapsulation can ever reach it. Two other routes were rejected: ViewEncapsulation.None on the panel would unscope every rule it has and make consumer overrides depend on stylesheet order, and a global stylesheet shipped by the library would add a second “remember to import our CSS” requirement on top of the CDK overlay one. An inline style needs neither and cannot lose a specificity contest.

Three declarations that only work together (components/fyz-tooltip-panel/fyz-tooltip-panel.component.scss:25-34):

  • max-width — without it the panel grows sideways until the pane’s own max-width: 100% stops it at the window edge, so long text arrives as a full-width band nobody tracks back across.
  • overflow-wrap: anywhere — the other half. A max-width alone trades a too-wide tooltip for a clipped one the first time a single unbreakable token turns up.
  • box-sizing: border-boxmax-width applies to the content box, so without it the padding is added on top and --fyz-tooltip-max-width: 240px yields a 248px panel. Overriding --fyz-tooltip-padding would silently widen every tooltip past the max.

Three steps per property: the --fyz-tooltip-* override knob, then the theme variable FlyzeThemingService writes to :root (ADR 0004), then a hard-coded last resort so the panel still renders in an app that uses neither.

color: var(--fyz-tooltip-text-color, var(--system-system-background, #fff));
background-color: var(--fyz-tooltip-background-color, var(--system-label, #616161));

The panel is a reversed surface, so it takes the theme’s two inverse tokens and swaps them: --system-label is the primary text colour and becomes the background, --system-system-background is the page colour and becomes the text. #404040 on #ffffff in the light theme, #ffffff on #000000 in the dark one. It stays legible because those two tokens are defined as each other’s opposite — not because two unrelated tokens happen to flip together, which is the trap here. A spec asserts both exist in both variations and that they differ, because a theme key is only ever added or renamed, never removed with a compiler error to show for it.

--system-label rather than --system-label-default: the two hold identical values in both schemes, but guides/theming-an-app documents --system-label, and -default is the odd suffix out among the -secondary / -tertiary / -quaternary variants.

Override knobs: --fyz-tooltip-text-color, --fyz-tooltip-background-color, --fyz-tooltip-padding, --fyz-tooltip-border-radius, --fyz-tooltip-font-size, --fyz-tooltip-max-width.

A panel that is already open follows its inputs

Section titled “A panel that is already open follows its inputs”

The factory passed to attach() only runs on the next mouseenter, so a panel that is already up would otherwise keep the values it opened with. The directive’s ngOnChanges pushes changes through update(), and one guard covers both reasons there may be nothing to show — disabled set, or the text emptied.

ngOnChanges rather than signals, deliberately. Its cost is one record bag and one SimpleChange per pass in which an input actually changed, with the body skipped otherwise: nanoseconds, and not per change-detection cycle. Signal inputs are the better shape, but only in their finished form — input() removes the accessor boilerplate entirely, whereas the intermediate setter-over-signal shape available on Angular 17.3 is twice the code for identical behaviour, on @developerPreview APIs, and defers the update by a microtask because effects flush from their own queueMicrotask. It moves to input() + effect() in one pass with the Angular 18/19 upgrade.

The listeners are registered inside NgZone.runOutsideAngular (fyz-tooltip.service.ts:293): a pointer crossing a toolbar would otherwise run a full change-detection pass per button, and nothing here needs one. Visibility is toggled by writing classes straight onto the element, so it works even on a view detached from the CD tree, and the panel’s inputs are pushed with ComponentRef.setInput() followed by an explicit detectChanges(). The panel is OnPush, so without that explicit check nothing would render.

True from the moment show() attaches an overlay, so it covers a panel still inside its showDelay and not on screen yet. That is deliberate: a caller asking is usually deciding whether to open or close, and a pending panel must count as open or it attaches a second one over the first. Distinct from the panel’s own isVisible, which only flips when the entry animation starts.

It exists because the alternative is a consumer keeping a parallel boolean in sync with this service by hand — and getting it wrong the first time a panel closes by a route it did not initiate.

  • No accessibility. No aria-describedby, no role="tooltip", no focus or blur trigger, no Escape. The panel is aria-hidden="true", because with no aria-describedby wiring it is purely visual and sits at the end of <body> — leaving it exposed only surfaces a context-free string in browse mode. MatTooltip marks its visual panel the same way and exposes the text through a separate visually-hidden element; that second half is deferred. Keyboard-only and screen-reader users get nothing from this version.
  • One position. No flip; near a viewport edge the panel is pushed rather than moved.
  • A consumer that calls show() and never tears down leaks a panel whose anchor is gone. attach() makes the correct path the short one, but show() is still reachable. The flagged section of the /fyz-tooltip playground demonstrates this on purpose.
  • fyzTooltipClass resolves globally. The panel renders into .cdk-overlay-container at the end of <body>, outside the consumer’s view scope, so emulated encapsulation will not match a component-scoped rule.
  • A consumer rule needs !important to beat the panel’s own declarations — the panel’s rules carry its _ngcontent attribute (specificity 0,2,0) and a bare consumer class is 0,1,0.
  • No touch or long-press gestures, no RTL handling, no SSR guards. This library touches document and window directly throughout.
  • No rich content — text only, no TemplateRef or component.

Four things this rests on, each of which would break quietly:

  • ComponentRef.setInput() semantics for the panel’s inputs.
  • OverlayRef.dispose() remaining safe to call after detach().
  • OverlayRef.attach() still not positioning synchronously — if it started to, the explicit updatePosition() becomes redundant rather than wrong.
  • FlexibleConnectedPositionStrategy keeping _canPush = true as its default (node_modules/@angular/cdk/fesm2022/overlay.mjs:1162). The single position relies on push to stay on screen; if that flipped, a tooltip near an edge would be clipped rather than nudged.

The spec file next to the source covers all of this in a real browser. Run it before trusting an upgrade, and open /fyz-tooltip for the parts a spec cannot assert: click-through and wrapping. Theming is now covered automatically too, by e2e/specs/theming.spec.ts.

Viewport-edge push is unverified. It is asserted by neither the spec nor the playground: that page has no edge-positioned anchor, so no panel on it can reach a viewport edge. Covering it needs an edge-anchored row added to the playground first — the menu-trigger playground’s .edge-field is the pattern to copy.