feat(brain/tooltip): add viewport-aware auto-flip positioning#1316
feat(brain/tooltip): add viewport-aware auto-flip positioning#1316Musta-Pollo wants to merge 2 commits intospartan-ng:mainfrom
Conversation
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>
5655e00 to
7439dc6
Compare
Greptile SummaryThis PR adds viewport-aware auto-flip to
Confidence Score: 4/5Safe for the primary use-case (initial flip on show), but The core flip logic (fallback positions array +
Important Files Changed
Sequence DiagramsequenceDiagram
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
Reviews (1): Last reviewed commit: "feat(tooltip): add viewport-aware auto-f..." | Re-trigger Greptile |
| if (resolved !== this.position()) { | ||
| compRef.instance.setProps( | ||
| null, | ||
| resolved, | ||
| this._config.tooltipContentClasses, | ||
| this._config.arrowClasses(resolved), | ||
| this._config.svgClasses, | ||
| ); | ||
| } | ||
| }); | ||
| } | ||
|
|
There was a problem hiding this comment.
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.
| 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'; | ||
| } |
There was a problem hiding this comment.
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>
|
I hope this is well explained and that the examples are also helpfull. |
Problem
BrnTooltipbuilds a CDK overlay position strategy with only one position and no fallbacks: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 thepositioninput 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:
Before (tooltip clips off viewport)
A button at the very top of the screen with
position="top"(the default):After (tooltip auto-flips to bottom)
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)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)Instead of passing 1 position to CDK, we pass 4. CDK's
FlexibleConnectedPositionStrategytries them in order — the first position that fits in the viewport wins. Forposition="top", this produces[top, bottom, right, left]._getAdjustedPositionFor(pos)was renamed from_getAdjustedPosition()— it now accepts aposparameter 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.
How it works:
strategy.positionChangeswith the actualConnectedPositionthat was usedresolveTooltipPosition(pair)reverse-maps that CDK object back to aBrnTooltipPositionstring ('top','bottom', etc.) by comparingoriginX/originY/overlayX/overlayYfields againstBRN_TOOLTIP_POSITIONS_MAPsetProps(null, resolved, ...)updatesdata-sideand arrow CSS classes onBrnTooltipContentto match the actual rendered sideWhy
Subscriptioninstead oftake(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
resolveTooltipPositionreturnsnull: 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())When the tooltip hides, unsubscribe from position changes. Without this: memory leak (subscription lives past detach) and potential ghost updates calling
setPropson a destroyed component. Setting toundefinedensures the next_show()creates a fresh subscription.Data flow summary
position="top"near viewport top[top, bottom, right, left]in ordertopdoesn't fit (8px viewport margin check fails) → CDK picksbottompositionChangesemits with thebottomconnection pairresolveTooltipPositionmaps it back to'bottom'setPropsupdatesdata-side="bottom"and arrow classes → arrow now points up (correct)_hide()unsubscribes, detaches overlayFiles changed
brn-tooltip-position.tsBRN_TOOLTIP_FALLBACK_POSITIONS— fallback order map for all 4 sidesresolveTooltipPosition()— reverse-maps CDKConnectedPosition→BrnTooltipPositionbrn-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 topositionChangesto sync arrow on flip_hide()— unsubscribes from position changes_positionChangeSub— new field storing the per-show subscriptionindex.tsBRN_TOOLTIP_FALLBACK_POSITIONSandresolveTooltipPositionfor consumers who build custom tooltip wrappersBackwards compatibility
position="top"still means "prefer top", but now gracefully falls backFlexibleConnectedPositionStrategybehavior)Test plan
position="top"flips to bottomposition="bottom"flips to topposition="left"flips to rightposition="right"flips to leftdata-sideattribute) matches the actual rendered side after flipslide-in-from-*) match the actual side🤖 Generated with Claude Code