Skip to content

Fix remaining issues of callout positioning (#12529)#12530

Open
msynk wants to merge 2 commits into
bitfoundation:developfrom
msynk:12529-blazorui-callout-positioning-remaining-issues
Open

Fix remaining issues of callout positioning (#12529)#12530
msynk wants to merge 2 commits into
bitfoundation:developfrom
msynk:12529-blazorui-callout-positioning-remaining-issues

Conversation

@msynk

@msynk msynk commented Jun 24, 2026

Copy link
Copy Markdown
Member

closes #12529

Summary by CodeRabbit

  • New Features

    • Callouts now stay better anchored when the visible viewport changes, such as when the on-screen keyboard appears or during pinch-zoom.
    • Callouts also reposition automatically if their content size changes.
  • Bug Fixes

    • Improved callout placement to reduce drifting, overlap, and misalignment.
    • Scrollable callout content now respects the current visible screen area more accurately.

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 0c47e8b9-8968-4d92-9190-62ee2e8b6adb

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Callouts.ts is updated to fix callout positioning by switching from layout-viewport to visual-viewport coordinates (handling iOS keyboard and pinch-zoom). All positioning branches now anchor via top/left with offsetTop/offsetLeft. A ResizeObserver is added to reposition the callout when its own size changes.

Changes

Callout Positioning Overhaul

Layer / File(s) Summary
Visual viewport coordinate model in position()
src/BlazorUI/Bit.BlazorUI/Scripts/Callouts.ts
Removes layout-viewport variables (layoutHeight, visibleTop/Bottom/Left/Right) and derives visualWidth, visualHeight, offsetTop, offsetLeft from Utils.getViewport(). Rewrites distance-to-edge calculations and all positioning branches to anchor exclusively via top/left with offsetTop/offsetLeft adjustments; scrollContainer.maxHeight now uses visible-viewport distances.
ResizeObserver lifecycle and toggle wiring
src/BlazorUI/Bit.BlazorUI/Scripts/Callouts.ts
Adds _calloutResizeObserver static field. Updates toggle() to capture the position() return value and call observeCalloutResize(callout) before returning. Adds observeCalloutResize() (skips initial callback, triggers reposition() on resize) and unobserveCalloutResize() (disconnects/clears observer, called on reset).

Sequence Diagram(s)

sequenceDiagram
  actor User
  participant toggle as Callouts.toggle()
  participant position as Callouts.position()
  participant Utils as Utils.getViewport()
  participant ResizeObserver
  participant reposition as Callouts.reposition()

  User->>toggle: open callout
  toggle->>position: position(callout, component, ...)
  position->>Utils: getViewport()
  Utils-->>position: visualWidth, visualHeight, offsetTop, offsetLeft
  position-->>toggle: positioning result
  toggle->>ResizeObserver: observeCalloutResize(callout)
  toggle-->>User: return result

  Note over ResizeObserver: callout content changes height
  ResizeObserver->>reposition: reposition()
  reposition->>position: position(callout, component, ...)
  position->>Utils: getViewport()
  Utils-->>position: updated viewport geometry
  position-->>reposition: updated positioning result
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 Hoppity-hop, the callout won't drift,
Visual viewport gives positioning a lift!
No more iOS keyboard mishap,
The ResizeObserver fills the gap.
Top and left, anchored just right—
The bunny says callouts shine bright! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately describes the main change: fixing remaining callout positioning issues.
Linked Issues check ✅ Passed The changes directly address #12529 by refining callout positioning and adding resize-aware repositioning.
Out of Scope Changes check ✅ Passed The only code changes stay focused on callout positioning behavior and resize handling, with no clear unrelated additions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/BlazorUI/Bit.BlazorUI/Scripts/Callouts.ts`:
- Around line 220-228: The side-placement logic in Callouts.ts is overwriting
the previously clamped left position for the callout when neither horizontal
side has enough space. Update the placement branch that sets callout.style.left
so it preserves the clamped value or reapplies clamping after the final left
calculation, using the existing positioning logic around the isRtl and
distanceToLeft/distanceToRight checks. This should prevent negative or
off-viewport left offsets in the layout path that also adjusts
scrollContainer.style.maxHeight and callout.style.top.
- Around line 265-271: Do not suppress the first ResizeObserver notification in
Callouts._calloutResizeObserver, because it can already represent the first
post-open size change; remove the initial-flag gating in the resize callback and
let the observer call Callouts.reposition() on the first notification as well so
the callout updates immediately after opening.
- Around line 255-277: `Callouts.clear()` can leave the active ResizeObserver
attached because it goes through `replaceCurrent()` without disconnecting first.
Update the `replaceCurrent()` path in `Callouts` to call
`unobserveCalloutResize()` before clearing/replacing the current callout, so
`_calloutResizeObserver` is always disconnected when the current callout is
removed. Keep the existing cleanup in `reset()` and reuse the same helper for
consistency.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: cc6c5789-7bf8-4583-beee-124a8692dab0

📥 Commits

Reviewing files that changed from the base of the PR and between 243312d and 828be31.

📒 Files selected for processing (1)
  • src/BlazorUI/Bit.BlazorUI/Scripts/Callouts.ts

Comment on lines +220 to +228
} else if ((isRtl ? distanceToLeft : distanceToRight) >= calloutWidth) {
callout.style.bottom = (layoutHeight - visibleBottom + 2) + 'px';
callout.style.left = (isRtl ? (componentX - calloutWidth - 1) : (componentX + componentWidth + 1)) + 'px';
callout.style.left = ((isRtl ? (componentX - calloutWidth - 1) : (componentX + componentWidth + 1)) + offsetLeft) + 'px';
scrollContainer.style.maxHeight = Math.max(0, visualHeight - scrollOffset - headerHeight - footerHeight - 10) + 'px';
// Beside the component, anchored to the bottom of the visible area.
callout.style.top = (Math.max(0, visualHeight - callout.offsetHeight - 2) + offsetTop) + 'px';
} else {
callout.style.bottom = (layoutHeight - visibleBottom + 2) + 'px';
callout.style.left = (isRtl ? (componentX + componentWidth + 1) : (componentX - calloutWidth - 1)) + 'px';
callout.style.left = ((isRtl ? (componentX + componentWidth + 1) : (componentX - calloutWidth - 1)) + offsetLeft) + 'px';
scrollContainer.style.maxHeight = Math.max(0, visualHeight - scrollOffset - headerHeight - footerHeight - 10) + 'px';
callout.style.top = (Math.max(0, visualHeight - callout.offsetHeight - 2) + offsetTop) + 'px';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Clamp side-placement left offsets after overriding the initial value.

Lines 221 and 226 replace the already-clamped left value. If both horizontal sides are too narrow, this can set a negative/off-viewport left, leaving the callout partly inaccessible.

Proposed fix
+                const clampLeft = (value: number) => Math.max(0, Math.min(value, visualWidth - calloutWidth - 3));
+
                 } else if ((isRtl ? distanceToLeft : distanceToRight) >= calloutWidth) {
-                    callout.style.left = ((isRtl ? (componentX - calloutWidth - 1) : (componentX + componentWidth + 1)) + offsetLeft) + 'px';
+                    callout.style.left = (clampLeft(isRtl ? (componentX - calloutWidth - 1) : (componentX + componentWidth + 1)) + offsetLeft) + 'px';
                     scrollContainer.style.maxHeight = Math.max(0, visualHeight - scrollOffset - headerHeight - footerHeight - 10) + 'px';
                     // Beside the component, anchored to the bottom of the visible area.
                     callout.style.top = (Math.max(0, visualHeight - callout.offsetHeight - 2) + offsetTop) + 'px';
                 } else {
-                    callout.style.left = ((isRtl ? (componentX + componentWidth + 1) : (componentX - calloutWidth - 1)) + offsetLeft) + 'px';
+                    callout.style.left = (clampLeft(isRtl ? (componentX + componentWidth + 1) : (componentX - calloutWidth - 1)) + offsetLeft) + 'px';
                     scrollContainer.style.maxHeight = Math.max(0, visualHeight - scrollOffset - headerHeight - footerHeight - 10) + 'px';
                     callout.style.top = (Math.max(0, visualHeight - callout.offsetHeight - 2) + offsetTop) + 'px';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/BlazorUI/Bit.BlazorUI/Scripts/Callouts.ts` around lines 220 - 228, The
side-placement logic in Callouts.ts is overwriting the previously clamped left
position for the callout when neither horizontal side has enough space. Update
the placement branch that sets callout.style.left so it preserves the clamped
value or reapplies clamping after the final left calculation, using the existing
positioning logic around the isRtl and distanceToLeft/distanceToRight checks.
This should prevent negative or off-viewport left offsets in the layout path
that also adjusts scrollContainer.style.maxHeight and callout.style.top.

Comment on lines +255 to +277
Callouts.unobserveCalloutResize();
}

// Watches the currently open callout for size changes (content updates) and re-runs
// positioning so its anchored edge stays glued to the component. A single observer is
// kept for whichever callout is currently open.
private static observeCalloutResize(callout: HTMLElement) {
Callouts.unobserveCalloutResize();
if (typeof ResizeObserver === 'undefined') return;

// Skip the initial synchronous callback (the callout was just positioned); only react
// to subsequent size changes to avoid an unnecessary reposition right after opening.
let initial = true;
Callouts._calloutResizeObserver = new ResizeObserver(() => {
if (initial) { initial = false; return; }
Callouts.reposition();
});
Callouts._calloutResizeObserver.observe(callout);
}

private static unobserveCalloutResize() {
Callouts._calloutResizeObserver?.disconnect();
Callouts._calloutResizeObserver = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Disconnect the observer when the current callout is cleared through replaceCurrent().

reset() now disconnects the observer, but clear() calls replaceCurrent() directly. That path can leave _calloutResizeObserver holding a detached callout until another reset/open occurs.

Proposed fix
             //close the previous one
             if (callout.calloutId !== current.calloutId) {
+                Callouts.unobserveCalloutResize();
+                Callouts._currentParams = null;
+
                 const previousCallout = document.getElementById(current.calloutId);
                 if (previousCallout) {
                     previousCallout.style.display = 'none';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/BlazorUI/Bit.BlazorUI/Scripts/Callouts.ts` around lines 255 - 277,
`Callouts.clear()` can leave the active ResizeObserver attached because it goes
through `replaceCurrent()` without disconnecting first. Update the
`replaceCurrent()` path in `Callouts` to call `unobserveCalloutResize()` before
clearing/replacing the current callout, so `_calloutResizeObserver` is always
disconnected when the current callout is removed. Keep the existing cleanup in
`reset()` and reuse the same helper for consistency.

Comment on lines +265 to +271
// Skip the initial synchronous callback (the callout was just positioned); only react
// to subsequent size changes to avoid an unnecessary reposition right after opening.
let initial = true;
Callouts._calloutResizeObserver = new ResizeObserver(() => {
if (initial) { initial = false; return; }
Callouts.reposition();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major

🧩 Analysis chain

🌐 Web query:

When is the first ResizeObserver callback delivered after observe(), and can element size changes before that callback be coalesced into the first notification?

💡 Result:

First callback timing after observe: ResizeObserver callbacks are delivered by the “broadcast active resize observations” / “deliver resize loop error notification” steps in the ResizeObserver specification, and the delivery happens before paint (i.e., after layout, before the frame is presented). This means the first notification for an already-rendered, non-zero-size target can be delivered in the same frame as the observe call, but still before paint [1][2][3]. Can size changes before that first callback be coalesced into the first notification? - Yes, in general: the spec’s “broadcast active resize observations” gathers active targets and creates entries from “active observations” just before invoking each observer’s callback; it also loops to “deliver all pending notifications” until no pending notifications remain. If the element’s size changes again before the callback is invoked (while those changes are still pending for that observation delivery cycle), the notification mechanism can include the latest measured sizes in the entries delivered by that first callback [3][1]. - More specifically about coalescing vs deferral: ResizeObserver may deliver multiple iterations within the same overall frame until it can’t deliver more without risking infinite resize loops. During this process, cyclic/recursive notifications are constrained by DOM-depth; notifications that don’t meet the depth condition are deferred to later paint/frames rather than being delivered in the same callback wave [1][2][3]. So “coalescing” can happen within the same delivery loop for eligible targets, but some resize changes may be deferred to a later frame depending on dependency depth (to prevent infinite loops) [1][2].

Citations:


🏁 Script executed:

sed -n '220,310p' src/BlazorUI/Bit.BlazorUI/Scripts/Callouts.ts

Repository: bitfoundation/bitplatform

Length of output: 5224


Don’t skip the first ResizeObserver callback. It’s async, so the first notification can already include the first post-open size change; suppressing it can leave the callout positioned for the old size until another resize occurs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/BlazorUI/Bit.BlazorUI/Scripts/Callouts.ts` around lines 265 - 271, Do not
suppress the first ResizeObserver notification in
Callouts._calloutResizeObserver, because it can already represent the first
post-open size change; remove the initial-flag gating in the resize callback and
let the observer call Callouts.reposition() on the first notification as well so
the callout updates immediately after opening.

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.

The callout positioning still has some issues

1 participant