Skip to content

Bugfix/issue 384 viewport clamping#401

Merged
motirebuma merged 3 commits into
QuoteVote:mainfrom
OtavioXimarelli:bugfix/issue-384-viewport-clamping
Jul 2, 2026
Merged

Bugfix/issue 384 viewport clamping#401
motirebuma merged 3 commits into
QuoteVote:mainfrom
OtavioXimarelli:bugfix/issue-384-viewport-clamping

Conversation

@OtavioXimarelli

Copy link
Copy Markdown
Member

PR — RC1-032: Render highlight popup in portal and clamp within viewport

Branch: bugfix/issue-384-viewport-clampingmain
Closes: #384
Labels: bug rc1 highlight-popup layout frontend


Problem

The highlight popup was rendered as a regular child element inside SelectionPopover's DOM parent. This caused two distinct bugs:

1. Overflow clipping
Any ancestor with overflow: hidden (present in multiple layout containers) clipped the popup at the parent's edge. Selecting text near a container boundary made the popup appear cut off or invisible.

2. No viewport clamping
The old computePopoverBox positioned the popup relative to the [data-selectable] element's bounding box without checking viewport edges. Selecting text near the left or right edge caused the popup to render partially or fully off-screen.

The old positioning used three separate layout branches (desktop / tablet right-side / mobile) with coordinates relative to the parent container, all of which could still overflow the viewport:

// Old: position relative to parent container — could overflow viewport
setPopoverBox({
  top: selectionBox.top - popupH - 8 - targetBox.top,
  left: selectionBox.left - targetBox.left + selectionBox.width / 2 - popupW / 2,
  right: 0,
})

There was also a handleMouseEnter callback (introduced in the same branch) that mutated the live selection range on hover — a regression against the no-regressions acceptance criterion.


Fix

SelectionPopover.tsx — four concrete changes:

1. SSR guard (mounted state)

Added a mounted state flag set in a useEffect. The component returns null on the server to avoid document access during SSR:

const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
if (!mounted) return null

2. Portal rendering

The popup div is now rendered via createPortal(…, document.body), escaping all ancestor overflow constraints. It overlays the full page regardless of the DOM hierarchy:

return createPortal(
  <div id="selectionPopover" ref={popoverRef} style={...}>,
  document.body
)

3. Unified viewport-aware positioning with horizontal clamping and vertical flip

Replaced the three-branch layout with a single algorithm that works at all viewport widths:

// Horizontal: centre over selection, clamp within [10px, viewport - popupWidth - 10px]
const viewportLeft = selectionBox.left + selectionBox.width / 2 - popoverWidth / 2
const clampedViewportLeft = Math.max(
  margin,
  Math.min(window.innerWidth - popoverWidth - margin, viewportLeft)
)
const pageLeft = clampedViewportLeft + window.scrollX

// Vertical: flip below the selection when there is not enough space above
if (selectionBox.top < popoverHeight + topOffset + 10) {
  pageTop = selectionBox.bottom + window.scrollY + topOffset  // below
} else {
  pageTop = selectionBox.top + window.scrollY - popoverHeight - topOffset  // above
}

Coordinates are now in page-absolute space (viewport coord + scrollX/Y) so the absolutely-positioned portal element tracks the selection correctly even on scrolled pages.

4. Resize and scroll recalculation

While the popup is visible, computePopoverBox is re-run via requestAnimationFrame on the next two frames (to allow the portal to paint and report its measured size), and on every resize and passive scroll event:

useEffect(() => {
  if (showPopover) {
    const rafId1 = requestAnimationFrame(computePopoverBox)
    const rafId2 = requestAnimationFrame(() => requestAnimationFrame(computePopoverBox))
    window.addEventListener('resize', computePopoverBox)
    window.addEventListener('scroll', computePopoverBox, { passive: true })
    return () => {
      cancelAnimationFrame(rafId1)
      cancelAnimationFrame(rafId2)
      window.removeEventListener('resize', computePopoverBox)
      window.removeEventListener('scroll', computePopoverBox)
    }
  }
}, [showPopover, computePopoverBox])

5. Removed handleMouseEnter

Removed the handleMouseEnter callback and onMouseEnter prop that were introduced in the first commit on this branch. The callback mutated the active Range on hover, which is the root cause of #382 and a direct regression against this issue's acceptance criterion.

SelectionPopover.test.tsx — two targeted changes:

  • container.querySelector('#selectionPopover')document.querySelector('#selectionPopover') throughout, because the popup now renders into document.body via the portal and is not a child of the React test container.
  • Replaced 'handles mouse enter to expand selection' (which asserted the buggy behavior with toBeDefined()) with 'does not alter selection or call onSelect when mouse enters the popover' asserting expect(onSelect).not.toHaveBeenCalled().

Test results

PASS src/__tests__/components/VotingComponents/SelectionPopover.test.tsx
  SelectionPopover
    ✓ renders children when showPopover is true
    ✓ does not render children when showPopover is false
    ✓ calls onDeselect when selection is cleared
    ✓ applies custom topOffset
    ✓ renders above the post sticky action bar (z-index > 10) by default
    ✓ applies custom styles
    ✓ handles selection change events
    ✓ handles mobile selection events
    ✓ clears selection when showPopover becomes false
    ✓ does not alter selection or call onSelect when mouse enters the popover
    ✓ computes popover position correctly for desktop
    ✓ computes popover position correctly for tablet
    ✓ computes popover position correctly for mobile
    ✓ handles missing selectable element gracefully
    ✓ handles collapsed selection
    ✓ cleans up interval on unmount

Tests: 16 passed, 16 total

Acceptance criteria

  • Popup is positioned and clamped within viewport bounds at all screen widths
  • Popup never clips against a parent container — rendered via document.body portal
  • Popup flips below the selection when space above is insufficient
  • Popup coordinates stay correct on scrolled pages (scrollX / scrollY offsets applied)
  • Position recalculates on resize and scroll while visible
  • SSR-safe — returns null until mounted on the client
  • Hovering the popup does not alter the active selection (no regression from RC1-030 Prevent highlighted text from expanding on popup hover #382)
  • No regressions in adjacent workflows

…opover hover

The handleMouseEnter callback was mutating the active text selection range
whenever the user moved their cursor over the popup. This caused two bugs:
- Issue QuoteVote#382: The highlighted text grew/jumped unexpectedly on hover.
- Violated the acceptance criteria for QuoteVote#384 which requires no regressions.

Removes the handler entirely so hovering the popover is a no-op with respect
to the active selection. Updates the regression test to assert that onSelect
is NOT called on mouseenter.

All other positioning/clamping/portal logic for RC1-032 is retained.
@vercel

vercel Bot commented Jun 25, 2026

Copy link
Copy Markdown

@OtavioXimarelli is attempting to deploy a commit to the Louis Girifalco's projects Team on Vercel.

A member of the Team first needs to authorize it.

@motirebuma motirebuma self-requested a review June 25, 2026 22:53

@motirebuma motirebuma left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

PR #401 Review — Viewport Clamping for Highlight Popup

Nice fix! The root cause was clear and the solution is clean. Here are a few quick thoughts:

The good stuff

  • Portal via createPortal is exactly the right move — escapes all those pesky overflow: hidden ancestors.
  • The new single-algorithm positioning is so much cleaner than the old 3-branch desktop/tablet/mobile split.
  • Removing handleMouseEnter was the right call — mutating a live Range on hover was always going to cause trouble.
  • SSR guard with mounted state is necessary and correct for document access.
  • Tests updated correctly — document.querySelector instead of container.querySelector makes sense for portals.

Small things to consider

  • The double requestAnimationFrame trick works, but a quick comment explaining why two frames would help the next person reading this.
  • The eslint-disable comment on setMounted inside useEffect looks unnecessary — setState in effects is totally normal.

Before merging

  • ⚠️ This PR has a merge conflict and needs a rebase onto main before it can land.

Verdict: ✅ Approve (after rebase) — solid work, good test coverage, no regressions.

Copilot AI review requested due to automatic review settings July 2, 2026 21:57
@motirebuma

Copy link
Copy Markdown
Collaborator

@OtavioXimarelli It was a simple merge conflict, and I resolved it for you. This one is ready to merge.

Thank you!.

@OtavioXimarelli @flyblackbox

@motirebuma motirebuma merged commit 7fca478 into QuoteVote:main Jul 2, 2026
2 of 3 checks passed

Copilot AI 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.

Pull request overview

Moves the text-selection highlight popover to render in a document.body portal and updates its positioning logic so it clamps horizontally within the viewport and flips vertically when there isn’t enough space above the selection, addressing overflow clipping and off-screen rendering.

Changes:

  • Render SelectionPopover via createPortal(..., document.body) with page-absolute positioning (scrollX/scrollY) and viewport clamping.
  • Add client-mount guarding plus scroll/resize-driven repositioning while the popover is visible.
  • Update unit tests to query the portal-rendered popover from document rather than the RTL container.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
quotevote-frontend/src/components/VotingComponents/SelectionPopover.tsx Portals the popover to document.body and replaces legacy positioning branches with viewport-aware clamping + vertical flip, including scroll/resize recomputation.
quotevote-frontend/src/tests/components/VotingComponents/SelectionPopover.test.tsx Adjusts tests to locate the popover in document.body after portal rendering.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +143 to +155
// Run on next frames to handle potential layout adjustments and avoid cascading renders lint rule
const rafId1 = requestAnimationFrame(computePopoverBox)
const rafId2 = requestAnimationFrame(() => requestAnimationFrame(computePopoverBox))

window.addEventListener('resize', computePopoverBox)
window.addEventListener('scroll', computePopoverBox, { passive: true })

return () => {
cancelAnimationFrame(rafId1)
cancelAnimationFrame(rafId2)
window.removeEventListener('resize', computePopoverBox)
window.removeEventListener('scroll', computePopoverBox)
}
Comment on lines 91 to 97
it('applies custom topOffset', () => {
const { container } = render(
render(
<SelectionPopover {...defaultProps} showPopover={true} topOffset={50} />,
)
const popover = container.querySelector('#selectionPopover')
const popover = document.querySelector('#selectionPopover')
expect(popover).toBeInTheDocument()
})
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.

3 participants