Tooltip
@flyze/lib-core-angular 1.0.0-alpha.34· latestA 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 anHTMLElementand 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-containeris notposition: fixedand 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.
Public surface
Section titled “Public surface”Directive inputs
Section titled “Directive inputs”| Input | Type | Default | Effect |
|---|---|---|---|
fyzTooltip | string | — | the text; an empty string suppresses the tooltip entirely |
fyzTooltipClass | string | string[] | [] | class(es) on the panel — resolved globally, see below |
fyzTooltipShowDelay | number | 0 | ms the pointer must rest on the host before it shows |
fyzTooltipHideDelay | number | 0 | ms the tooltip stays up after the pointer leaves |
fyzTooltipDisabled | boolean | false | suppresses 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.
Service
Section titled “Service”| Method | Meaning |
|---|---|
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.
How it works
Section titled “How it works”attach() and teardown
Section titled “attach() and teardown”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 when the animation never runs
Section titled “Disposal when the animation never runs”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.
Positioning
Section titled “Positioning”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.
Clicks pass through the panel
Section titled “Clicks pass through the panel”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.
Wrapping
Section titled “Wrapping”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 ownmax-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. Amax-widthalone trades a too-wide tooltip for a clipped one the first time a single unbreakable token turns up.box-sizing: border-box—max-widthapplies to the content box, so without it the padding is added on top and--fyz-tooltip-max-width: 240pxyields a 248px panel. Overriding--fyz-tooltip-paddingwould silently widen every tooltip past the max.
Theming
Section titled “Theming”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.
Change detection and the zone
Section titled “Change detection and the zone”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.
isOpen()
Section titled “isOpen()”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.
Known limitations
Section titled “Known limitations”- No accessibility. No
aria-describedby, norole="tooltip", no focus or blur trigger, no Escape. The panel isaria-hidden="true", because with noaria-describedbywiring it is purely visual and sits at the end of<body>— leaving it exposed only surfaces a context-free string in browse mode.MatTooltipmarks 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, butshow()is still reachable. The flagged section of the/fyz-tooltipplayground demonstrates this on purpose. fyzTooltipClassresolves globally. The panel renders into.cdk-overlay-containerat the end of<body>, outside the consumer’s view scope, so emulated encapsulation will not match a component-scoped rule.- A consumer rule needs
!importantto beat the panel’s own declarations — the panel’s rules carry its_ngcontentattribute (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
documentandwindowdirectly throughout. - No rich content — text only, no
TemplateRefor component.
On a CDK or Angular upgrade
Section titled “On a CDK or Angular upgrade”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 afterdetach().OverlayRef.attach()still not positioning synchronously — if it started to, the explicitupdatePosition()becomes redundant rather than wrong.FlexibleConnectedPositionStrategykeeping_canPush = trueas 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.
Related
Section titled “Related”- guides/using-the-tooltip — how to use it
- ADR 0007 — why the service owns the overlay
- ADR 0004 — the
--system-*variables - features/mat-menu-trigger — the other CDK-overlay directive here, and the
source of the
anchorconvention