Skip to content

feat(brain/tooltip): add viewport-aware auto-flip positioning#1316

Open
Musta-Pollo wants to merge 2 commits intospartan-ng:mainfrom
Musta-Pollo:fix/tooltip-viewport-auto-flip
Open

feat(brain/tooltip): add viewport-aware auto-flip positioning#1316
Musta-Pollo wants to merge 2 commits intospartan-ng:mainfrom
Musta-Pollo:fix/tooltip-viewport-auto-flip

Conversation

@Musta-Pollo
Copy link
Copy Markdown

@Musta-Pollo Musta-Pollo commented Apr 13, 2026

Problem

BrnTooltip builds a CDK overlay position strategy with only one position and no fallbacks:

// Current behavior — only the preferred position, no fallbacks
private _buildPositionStrategy() {
    return this._overlayPositionBuilder
        .flexibleConnectedTo(this._elementRef)
        .withPositions([this._getAdjustedPosition()]); // single position
}

When the trigger element is near a viewport edge (e.g. a button in a top app bar with the default position="top"), the tooltip overflows off-screen instead of flipping to the opposite side.

Additionally, even if fallback positions were added, _show() hardcodes the arrow direction from the position input signal rather than the CDK-resolved position — so the arrow would point the wrong way after a flip.

Reproduction

StackBlitz (bug): stackblitz.com/github/Musta-Pollo/spartan-tooltip-viewport-demo

The demo places buttons at all 4 viewport edges with tooltips. Hover them to see the clipping.

Steps:

  1. Open the StackBlitz above
  2. Hover buttons at the top, bottom, left, or right edges
  3. Observe: tooltip renders on the preferred side and clips off-screen

Before (tooltip clips off viewport)

A button at the very top of the screen with position="top" (the default):

         ┌─ tooltip tries to render here (clipped!)
┌────────▼────────────────────┐ viewport edge
│ [Button]                    │
│                             │
└─────────────────────────────┘

After (tooltip auto-flips to bottom)

┌─────────────────────────────┐ viewport edge
│ [Button]                    │
│  ┌──────▼──────┐            │
│  │  Tooltip     │  flipped to bottom
│  └─────────────┘            │  arrow points up (correct)
└─────────────────────────────┘

StackBlitz (fix): stackblitz.com/github/Musta-Pollo/spartan-tooltip-viewport-demo/tree/with-fix — side-by-side before/after comparison


How the fix works

The fix has 4 parts:

Part 1: Fallback position map (brn-tooltip-position.ts)

export const BRN_TOOLTIP_FALLBACK_POSITIONS: Record<
  BrnTooltipPosition,
  BrnTooltipPosition[]
> = {
  top: ["bottom", "right", "left"],
  bottom: ["top", "right", "left"],
  left: ["right", "top", "bottom"],
  right: ["left", "top", "bottom"],
};

Defines the fallback order for each preferred position. The opposite side is always first (most natural flip — tooltip above becomes tooltip below). Perpendicular sides are last resorts.

Part 2: Position strategy with fallbacks (brn-tooltip.ts)

private _buildPositionStrategy() {
    return this._overlayPositionBuilder
        .flexibleConnectedTo(this._elementRef)
        .withPositions(this._getAllPositions())   // 4 positions now (was 1)
        .withViewportMargin(8);                   // 8px breathing room from edges
}

private _getAllPositions(): ConnectedPosition[] {
    const preferred = this.position();
    return [preferred, ...BRN_TOOLTIP_FALLBACK_POSITIONS[preferred]]
        .map((pos) => this._getAdjustedPositionFor(pos));
}

Instead of passing 1 position to CDK, we pass 4. CDK's FlexibleConnectedPositionStrategy tries them in order — the first position that fits in the viewport wins. For position="top", this produces [top, bottom, right, left].

_getAdjustedPositionFor(pos) was renamed from _getAdjustedPosition() — it now accepts a pos parameter so it can generate CDK config for any position (not just the preferred one), while still handling RTL offset flipping.

withViewportMargin(8) tells CDK the tooltip needs at least 8px clearance from the viewport edge to count as "fitting".

Part 3: Arrow direction sync on flip (brn-tooltip.ts_show())

This is the most important part. Without it, the tooltip flips correctly but the arrow points the wrong way.

const strategy = this._overlayRef?.getConfig().positionStrategy as
  | FlexibleConnectedPositionStrategy
  | undefined;
if (strategy && this._componentRef) {
  const compRef = this._componentRef;
  this._positionChangeSub = strategy.positionChanges
    .pipe(takeUntilDestroyed(this._destroyRef))
    .subscribe((change) => {
      const resolved = resolveTooltipPosition(change.connectionPair);
      if (resolved) {
        compRef.instance.setProps(
          null, // don't change the text
          resolved, // new position (e.g. 'bottom')
          this._config.tooltipContentClasses,
          this._config.arrowClasses(resolved), // arrow classes for new side
          this._config.svgClasses,
        );
      }
    });
}

How it works:

  1. When CDK picks a fallback position, it emits on strategy.positionChanges with the actual ConnectedPosition that was used
  2. resolveTooltipPosition(pair) reverse-maps that CDK object back to a BrnTooltipPosition string ('top', 'bottom', etc.) by comparing originX/originY/overlayX/overlayY fields against BRN_TOOLTIP_POSITIONS_MAP
  3. setProps(null, resolved, ...) updates data-side and arrow CSS classes on BrnTooltipContent to match the actual rendered side

Why Subscription instead of take(1): The subscription stays alive for the entire time the tooltip is visible. This handles not just the initial flip, but also browser window resize and scroll while the tooltip is open. If the viewport changes while a tooltip is shown, CDK may re-evaluate and pick a different position — the arrow stays in sync.

Why resolveTooltipPosition returns null: If the CDK somehow produces a position that doesn't match any known configuration, we skip the update rather than applying a wrong default. This is a safety guard — in practice it shouldn't happen.

Part 4: Cleanup on hide (brn-tooltip.ts_hide())

private _hide(): void {
    if (!this._componentRef || this._tooltipHovered) return;
    this._clearAriaDescribedBy();
    this._positionChangeSub?.unsubscribe();   // NEW
    this._positionChangeSub = undefined;       // NEW
    // ... rest unchanged
}

When the tooltip hides, unsubscribe from position changes. Without this: memory leak (subscription lives past detach) and potential ghost updates calling setProps on a destroyed component. Setting to undefined ensures the next _show() creates a fresh subscription.


Data flow summary

  1. User hovers button with position="top" near viewport top
  2. CDK overlay opens, tries positions [top, bottom, right, left] in order
  3. top doesn't fit (8px viewport margin check fails) → CDK picks bottom
  4. positionChanges emits with the bottom connection pair
  5. resolveTooltipPosition maps it back to 'bottom'
  6. setProps updates data-side="bottom" and arrow classes → arrow now points up (correct)
  7. User moves mouse away → _hide() unsubscribes, detaches overlay

Files changed

brn-tooltip-position.ts

  • Added BRN_TOOLTIP_FALLBACK_POSITIONS — fallback order map for all 4 sides
  • Added resolveTooltipPosition() — reverse-maps CDK ConnectedPositionBrnTooltipPosition

brn-tooltip.ts

  • _buildPositionStrategy() — provides all 4 positions + withViewportMargin(8)
  • _getAllPositions() — new helper building [preferred, ...fallbacks] array
  • _getAdjustedPositionFor(pos) — renamed, now parameterized for any position
  • _updatePosition() — uses fallback positions (not just preferred) on direction change
  • _show() — subscribes to positionChanges to sync arrow on flip
  • _hide() — unsubscribes from position changes
  • _positionChangeSub — new field storing the per-show subscription

index.ts

  • Exports BRN_TOOLTIP_FALLBACK_POSITIONS and resolveTooltipPosition for consumers who build custom tooltip wrappers

Backwards compatibility

  • No breaking changesposition="top" still means "prefer top", but now gracefully falls back
  • No new inputs — auto-flip is always enabled (matching CDK's intended FlexibleConnectedPositionStrategy behavior)
  • The fallback order and viewport margin match common tooltip libraries (Material, Radix, etc.)

Test plan

  • Tooltip at top of viewport with position="top" flips to bottom
  • Tooltip at bottom of viewport with position="bottom" flips to top
  • Tooltip at left edge with position="left" flips to right
  • Tooltip at right edge with position="right" flips to left
  • Arrow direction (data-side attribute) matches the actual rendered side after flip
  • CSS animations (slide-in-from-*) match the actual side
  • RTL layout: offsets are correctly mirrored
  • No behavior change when there's enough space (tooltip stays on preferred side)
  • Resize browser while tooltip is open — arrow stays in sync if position changes

🤖 Generated with Claude Code

BrnTooltip currently builds a CDK overlay position strategy with only a
single position and no fallbacks. When the trigger element is near a
viewport edge (e.g. a button in a top app bar with position="top"),
the tooltip overflows off-screen instead of flipping to the opposite
side.

This commit:
- Adds fallback positions so the CDK FlexibleConnectedPositionStrategy
  tries the opposite side (and then the remaining two sides) when the
  preferred position doesn't fit
- Adds withViewportMargin(8) to prevent tooltips from touching the
  viewport edge
- Subscribes to positionChanges to update BrnTooltipContent's position
  signal and arrow classes when the CDK resolves to a fallback position
- Exports BRN_TOOLTIP_FALLBACK_POSITIONS and resolveTooltipPosition
  for consumers who need custom position logic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Musta-Pollo Musta-Pollo force-pushed the fix/tooltip-viewport-auto-flip branch from 5655e00 to 7439dc6 Compare April 13, 2026 12:48
@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Apr 13, 2026

Greptile Summary

This PR adds viewport-aware auto-flip to BrnTooltip by supplying all four CDK ConnectedPosition entries (preferred + three fallbacks) via withPositions(), adding an 8 px viewport margin, and subscribing to positionChanges to sync the data-side attribute and arrow CSS classes when CDK selects a fallback position.

  • The positionChanges.pipe(take(1)) subscription is disposed after the very first position event. CDK continues to recalculate positions on viewport resize; those subsequent changes are silently ignored, leaving data-side and the arrow pointing in the wrong direction after a resize-triggered flip.

Confidence Score: 4/5

Safe for the primary use-case (initial flip on show), but take(1) leaves data-side stale after viewport resize while the tooltip is visible.

The core flip logic (fallback positions array + withViewportMargin) is correct. The one P2 finding — take(1) dropping subsequent position events — is a real functional gap: arrow direction and data-side can desync from the actual rendered side after a resize. While it won't break the tooltip text itself, it's a visual correctness regression that's easy to hit in responsive layouts, so it warrants a 4 rather than 5.

brn-tooltip.ts — the positionChanges subscription lifecycle (lines 238–249) needs attention before merge.

Important Files Changed

Filename Overview
libs/brain/tooltip/src/lib/brn-tooltip.ts Core directive: adds _getAllPositions() with fallbacks and withViewportMargin(8), and subscribes to positionChanges to update arrow direction — but take(1) means only the first position event is handled, so viewport-resize re-positions won't update data-side or arrow classes.
libs/brain/tooltip/src/lib/brn-tooltip-position.ts Adds BRN_TOOLTIP_FALLBACK_POSITIONS map and resolveTooltipPosition() helper; logic is correct for the four standard positions, though the silent 'top' default on unmatched positions is a latent concern.
libs/brain/tooltip/src/index.ts Exports the two new public symbols from brn-tooltip-position.ts; change is correct and intentional.

Sequence Diagram

sequenceDiagram
    participant User
    participant BrnTooltip
    participant CDK as FlexibleConnectedPositionStrategy
    participant BrnTooltipContent

    User->>BrnTooltip: mouseenter
    BrnTooltip->>CDK: overlayRef.attach(tooltipPortal)
    BrnTooltip->>BrnTooltipContent: setProps(text, preferredPos, ...)
    BrnTooltip->>CDK: strategy.positionChanges.pipe(take(1)).subscribe()
    CDK-->>BrnTooltip: positionChanges (1st: preferred OR fallback)
    alt CDK chose fallback
        BrnTooltip->>BrnTooltipContent: setProps(null, resolvedPos, ...)
    end
    Note over BrnTooltip,CDK: take(1) — subscription disposed here

    User->>User: resize browser window
    CDK-->>CDK: recalculates position (flips)
    Note over BrnTooltip,BrnTooltipContent: No subscription active → data-side / arrow NOT updated
Loading

Reviews (1): Last reviewed commit: "feat(tooltip): add viewport-aware auto-f..." | Re-trigger Greptile

Comment on lines +238 to +249
if (resolved !== this.position()) {
compRef.instance.setProps(
null,
resolved,
this._config.tooltipContentClasses,
this._config.arrowClasses(resolved),
this._config.svgClasses,
);
}
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 take(1) leaves data-side stale after a re-position

take(1) consumes the very first positionChanges emission and then disposes the subscription. CDK's FlexibleConnectedPositionStrategy recalculates positions on every viewport resize; after the first emission, those recalculations are silently ignored. Concretely: if the preferred side fits on initial show (no flip), take(1) fires with the preferred position and no setProps call is made — then a viewport resize flips the tooltip visually, but data-side and the arrow classes remain stale.

A clean fix is to store a per-show Subscription and explicitly unsubscribe it when the tooltip is hidden, dropping take(1) so all subsequent re-positions are also reflected:

// In _show():
this._positionChangeSub = strategy.positionChanges
  .pipe(takeUntilDestroyed(this._destroyRef))
  .subscribe((change) => {
    const resolved = resolveTooltipPosition(change.connectionPair);
    compRef.instance.setProps(null, resolved, ...);
  });

// In _hide(), before detach():
this._positionChangeSub?.unsubscribe();
this._positionChangeSub = undefined;

Without take(1) the comparison if (resolved !== this.position()) should also be removed so that returning to the preferred side after a flip correctly resets the arrow back.

Comment on lines +45 to +57
export function resolveTooltipPosition(pair: ConnectedPosition): BrnTooltipPosition {
for (const [pos, config] of Object.entries(BRN_TOOLTIP_POSITIONS_MAP)) {
if (
pair.originX === config.originX &&
pair.originY === config.originY &&
pair.overlayX === config.overlayX &&
pair.overlayY === config.overlayY
) {
return pos as BrnTooltipPosition;
}
}
return 'top';
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Silent 'top' fallback can produce a wrong arrow direction

When no match is found, resolveTooltipPosition silently returns 'top'. Because BRN_TOOLTIP_POSITIONS_MAP only checks originX/Y and overlayX/Y (not offsetX/Y), this path is unreachable with the current four standard positions. However, the silent default means that if any consumer passes a custom position through withPositions() in the future, or if CDK ever normalises 'start'/'end' to 'left'/'right' in connectionPair, the mismatch would be swallowed rather than surfaced, potentially rendering the arrow in the wrong direction with no warning.

Consider returning null so the caller can decide:

export function resolveTooltipPosition(pair: ConnectedPosition): BrnTooltipPosition | null {
    for (const [pos, config] of Object.entries(BRN_TOOLTIP_POSITIONS_MAP)) {
        if (
            pair.originX === config.originX &&
            pair.originY === config.originY &&
            pair.overlayX === config.overlayX &&
            pair.overlayY === config.overlayY
        ) {
            return pos as BrnTooltipPosition;
        }
    }
    return null; // no match — let the caller skip the update
}

- Replace `take(1)` with a full subscription stored in `_positionChangeSub`
  so viewport resizes while the tooltip is open correctly update arrow
  direction. The subscription is cleaned up in `_hide()`.
- Remove the `resolved !== this.position()` guard so that returning to
  the preferred side after a flip correctly resets the arrow.
- Change `resolveTooltipPosition` to return `null` instead of silently
  defaulting to `'top'`, so the caller skips the update on unknown
  positions rather than rendering a potentially wrong arrow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Musta-Pollo
Copy link
Copy Markdown
Author

I hope this is well explained and that the examples are also helpfull.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant