Skip to content

refactor(core)!: centralize popup positioning - #1904

Merged
mihar-22 merged 2 commits into
mainfrom
refactor/popup-positioning
Aug 3, 2026
Merged

refactor(core)!: centralize popup positioning#1904
mihar-22 merged 2 commits into
mainfrom
refactor/popup-positioning

Conversation

@mihar-22

@mihar-22 mihar-22 commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

Centralize popup positioning in @videojs/core/dom and migrate Tooltip, Popover, and root Menu positioning in HTML and React to thin adapters. This is the first menu-simplification step and deliberately leaves menu navigation, viewport transitions, and skin behavior unchanged.

Changes

  • Add one shared PopupPositioner for CSS anchor setup, boundary geometry, CSS variable output, observers, scroll handling, and cleanup
  • Reduce the HTML PositionController and React usePopupPosition integration to lifecycle adapters
  • Remove duplicated component-level measurement effects and obsolete low-level positioning exports
  • Preserve existing popup offset variables, resolved-side state, and menu view synchronization
Implementation details

The core DOM positioner owns all structural style writes and restores consumer-authored inline styles during cleanup. HTML discovers elements and forwards options; React owns refs and synchronizes the positioner in a layout effect without keeping geometry in React state.

This intentionally narrows the public surface by removing low-level positioning helpers and CSS variable key types from @videojs/core/dom.

Testing

  • pnpm -F @videojs/core test src/dom/ui/popover/tests/popover-positioning.test.ts src/dom/ui/popover/tests/popup-positioner.test.ts — 43 tests
  • pnpm -F @videojs/html test src/ui/menu/tests/menu-element.test.ts src/ui/popover/tests/popover-element.test.ts src/ui/tooltip/tests/tooltip-element.test.ts — 28 tests
  • pnpm -F @videojs/react test src/ui/menu/tests/menu.test.tsx src/ui/popover/tests/popover.test.tsx src/ui/tooltip/tests/tooltip.test.tsx — 37 tests
  • pnpm -F @videojs/core build
  • pnpm -F @videojs/html build
  • pnpm -F @videojs/react build
  • pnpm build:sandbox
  • pnpm typecheck
  • pnpm lint
  • pnpm check:workspace
  • git diff --check

Note

Medium Risk
Cross-cutting UI positioning refactor with a breaking @videojs/core/dom export change; behavior is covered by new/updated tests but regressions in flip/sizing or style restoration are possible.

Overview
Introduces a shared PopupPositioner in @videojs/core/dom that owns anchor setup, boundary measurement, inline style writes, scroll/resize/ResizeObserver tracking, and cleanup (including restoring consumer-authored inline styles).

HTML PositionController and React usePopupPosition are reduced to lifecycle adapters; popover, tooltip, and root menu elements stop duplicating measurement/getAnchorPositionStyle logic and call sync with boundary, container, and optional cssVars/onSideChange instead.

The public DOM surface is narrowed: getAnchorNameStyle, getPopoverCSSVars, and PopoverCSSVarKey / TooltipCSSVarKey are removed; ManualOffsets is renamed to PositioningOffsets. Trigger/popup anchor-name handling moves into the positioner (merging with existing anchor-name values when supported).

Reviewed by Cursor Bugbot for commit 94d5164. Bugbot is set up for automated code reviews on this repo. Configure here.

Move popup geometry, observers, style updates, and cleanup into a shared DOM positioner.

Use thin HTML and React adapters around the shared lifecycle.

BREAKING CHANGE: Low-level positioning helpers and CSS variable key types are no longer exported.
@netlify

netlify Bot commented Jul 31, 2026

Copy link
Copy Markdown

Deploy Preview for vjs10-site ready!

Name Link
🔨 Latest commit 94d5164
🔍 Latest deploy log https://app.netlify.com/projects/vjs10-site/deploys/6a711a89219c7d00081762e1
😎 Deploy Preview https://deploy-preview-1904--vjs10-site.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v10-sandbox Ready Ready Preview Aug 3, 2026 10:48pm

Request Review

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Size Report

🎨 @videojs/html

Path Base initial PR initial Diff % Lazy
/video/minimal-skin 53.72 kB 54.03 kB +321 B +0.6% 0 B 🔺
/video/minimal-skin.tailwind 54.30 kB 54.67 kB +385 B +0.7% 0 B 🔺
/audio/minimal-skin 42.45 kB 42.83 kB +399 B +0.9% 0 B 🔺
/audio/skin 46.03 kB 46.41 kB +390 B +0.8% 0 B 🔺
/audio/skin.tailwind 46.51 kB 46.82 kB +318 B +0.7% 0 B 🔺
/live-video/minimal-skin.tailwind 50.51 kB 50.89 kB +388 B +0.8% 0 B 🔺
/live-video/skin 52.28 kB 52.66 kB +385 B +0.7% 0 B 🔺
/live-video/skin.tailwind 52.92 kB 53.30 kB +384 B +0.7% 0 B 🔺
/live-audio/minimal-skin 34.65 kB 35.13 kB +491 B +1.4% 0 B 🔺
/live-audio/minimal-skin.tailwind 34.08 kB 34.56 kB +493 B +1.4% 0 B 🔺
/live-audio/skin 38.41 kB 38.82 kB +428 B +1.1% 0 B 🔺
/live-audio/skin.tailwind 37.92 kB 38.37 kB +461 B +1.2% 0 B 🔺
/ui/compounds 34.49 kB 34.83 kB +346 B +1.0% 0 B 🔺
/ui/menu 19.29 kB 19.74 kB +458 B +2.3% 0 B 🔺
/ui/popover 6.87 kB 7.40 kB +549 B +7.8% 🔺
/ui/tooltip 8.06 kB 8.60 kB +549 B +6.7% 0 B 🔺
/video (default) 53.76 kB 54.17 kB +426 B +0.8% 0 B 🔺
/video (default + hls) 189.72 kB 190.15 kB +439 B +0.2% 0 B 🔺
/audio (default) 46.03 kB 46.37 kB +341 B +0.7% 0 B 🔺
/audio (minimal) 42.47 kB 42.78 kB +319 B +0.7% 0 B 🔺
Presets (7)
Entry Initial Lazy
/video (default) 54.17 kB 53.52 kB
/video (default + hls) 190.15 kB 53.52 kB
/video (minimal) 53.98 kB 53.52 kB
/video (minimal + hls) 189.98 kB 53.52 kB
/audio (default) 46.37 kB 53.52 kB
/audio (minimal) 42.78 kB 53.52 kB
/background 5.00 kB 53.52 kB
Media (12)
Entry Initial Lazy
/media/background-video 1.14 kB
/media/container 2.58 kB 53.52 kB
/media/dash-video 211.14 kB
/media/google-cast 6.46 kB
/media/hlsjs-video 137.92 kB
/media/mux-audio 138.88 kB
/media/mux-data 153.96 kB
/media/mux-video 139.06 kB
/media/native-hls-video 5.34 kB
/media/simple-hls-audio-only 19.92 kB
/media/simple-hls-video 24.27 kB
/media/vimeo-video 12.31 kB
Players (5)
Entry Initial Lazy
/video/player 8.98 kB 53.52 kB
/audio/player 6.19 kB 53.52 kB
/background/player 4.72 kB 53.52 kB
/live-video/player 8.39 kB 53.52 kB
/live-audio/player 6.21 kB 53.52 kB
Skins (30)
Entry Type Initial Lazy
/video/minimal-skin.css css 5.92 kB
/video/skin.css css 5.92 kB
/video/minimal-skin js 54.03 kB 53.52 kB
/video/minimal-skin.tailwind js 54.67 kB 53.52 kB
/video/skin js 54.14 kB 53.52 kB
/video/skin.tailwind js 54.75 kB 53.52 kB
/audio/minimal-skin.css css 4.11 kB
/audio/skin.css css 4.05 kB
/audio/minimal-skin js 42.83 kB 53.52 kB
/audio/minimal-skin.tailwind js 43.27 kB 53.52 kB
/audio/skin js 46.41 kB 53.52 kB
/audio/skin.tailwind js 46.82 kB 53.52 kB
/background/skin.css css 133 B
/background/skin js 1.14 kB
/live-video/minimal-skin.css css 5.92 kB
/live-video/skin.css css 5.92 kB
/live-video/minimal-skin js 50.25 kB 53.52 kB
/live-video/minimal-skin.tailwind js 50.89 kB 53.52 kB
/live-video/skin js 52.66 kB 53.52 kB
/live-video/skin.tailwind js 53.30 kB 53.52 kB
/live-audio/minimal-skin.css css 4.11 kB
/live-audio/skin.css css 4.05 kB
/live-audio/minimal-skin js 35.13 kB 53.52 kB
/live-audio/minimal-skin.tailwind js 34.56 kB 53.52 kB
/live-audio/skin js 38.82 kB 53.52 kB
/live-audio/skin.tailwind js 38.37 kB 53.52 kB
/global.css css 183 B
/shared.css css 104 B
/tailwind.css css 161 B
/skin-element js 1.46 kB
UI Components (39)
Entry Initial
/ui/airplay-button 2.53 kB
/ui/alert-dialog 2.90 kB
/ui/alert-dialog-close 2.58 kB
/ui/alert-dialog-description 2.52 kB
/ui/alert-dialog-title 2.47 kB
/ui/audio-track-radio-group 3.02 kB
/ui/buffering-indicator 2.64 kB
/ui/captions-button 2.59 kB
/ui/captions-radio-group 3.08 kB
/ui/cast-button 2.52 kB
/ui/compounds 3.14 kB
/ui/controls 2.92 kB
/ui/error-dialog 2.93 kB
/ui/fullscreen-button 2.52 kB
/ui/hotkey 2.62 kB
/ui/menu 2.96 kB
/ui/mute-button 2.61 kB
/ui/pip-button 2.55 kB
/ui/play-button 2.52 kB
/ui/playback-rate-button 2.62 kB
/ui/playback-rate-radio-group 2.88 kB
/ui/popover 3.12 kB
/ui/poster 2.41 kB
/ui/quality-radio-group 3.00 kB
/ui/seek-button 2.56 kB
/ui/seek-indicator 2.79 kB
/ui/seek-indicator-value 488 B
/ui/slider 2.97 kB
/ui/status-announcer 2.45 kB
/ui/status-indicator 2.61 kB
/ui/status-indicator-value 465 B
/ui/thumbnail 2.62 kB
/ui/time 2.93 kB
/ui/time-slider 2.95 kB
/ui/tooltip 2.84 kB
/ui/volume-indicator 2.56 kB
/ui/volume-indicator-fill 487 B
/ui/volume-indicator-value 499 B
/ui/volume-slider 2.95 kB

Sizes are marginal over the root entry point.

⚛️ @videojs/react

Path Base initial PR initial Diff % Lazy
/live-audio/minimal-skin 25.04 kB 25.45 kB +415 B +1.6% 0 B 🔺
/live-audio/minimal-skin.tailwind 28.33 kB 28.69 kB +372 B +1.3% 0 B 🔺
/live-audio/skin 25.09 kB 25.50 kB +417 B +1.6% 0 B 🔺
/live-audio/skin.tailwind 28.52 kB 28.85 kB +333 B +1.1% 0 B 🔺
/ui/menu 19.28 kB 19.86 kB +594 B +3.0% 0 B 🔺
/ui/popover 6.68 kB 7.29 kB +624 B +9.1% 🔺
/ui/tooltip 7.29 kB 7.91 kB +634 B +8.5% 🔺
Presets (7)
Entry Initial Lazy
/video (default) 42.48 kB 53.52 kB
/video (default + hls) 177.07 kB 53.52 kB
/video (minimal) 42.88 kB 53.52 kB
/video (minimal + hls) 177.63 kB 53.52 kB
/audio (default) 34.74 kB 53.52 kB
/audio (minimal) 34.78 kB 53.52 kB
/background 581 B
Media (11)
Entry Initial
/media/background-video 394 B
/media/dash-video 209.54 kB
/media/google-cast 5.35 kB
/media/hlsjs-video 136.40 kB
/media/mux-audio 137.23 kB
/media/mux-data 152.93 kB
/media/mux-video 137.38 kB
/media/native-hls-video 3.54 kB
/media/simple-hls-audio-only 18.18 kB
/media/simple-hls-video 22.64 kB
/media/vimeo-video 10.46 kB
Skins (27)
Entry Type Initial Lazy
/tailwind.css css 161 B
/video/minimal-skin.css css 5.81 kB
/video/skin.css css 5.81 kB
/video/minimal-skin js 42.72 kB 53.52 kB
/video/minimal-skin.tailwind js 48.96 kB 53.52 kB
/video/skin js 42.45 kB 53.52 kB
/video/skin.tailwind js 48.64 kB 53.52 kB
/audio/minimal-skin.css css 3.97 kB
/audio/skin.css css 3.90 kB
/audio/minimal-skin js 34.70 kB 53.52 kB
/audio/minimal-skin.tailwind js 37.19 kB 53.52 kB
/audio/skin js 34.65 kB 53.52 kB
/audio/skin.tailwind js 39.03 kB 53.52 kB
/background/skin.css css 90 B
/background/skin js 272 B
/live-video/minimal-skin.css css 5.81 kB
/live-video/skin.css css 5.81 kB
/live-video/minimal-skin js 37.69 kB 53.52 kB
/live-video/minimal-skin.tailwind js 43.82 kB 53.52 kB
/live-video/skin js 37.68 kB 53.52 kB
/live-video/skin.tailwind js 43.89 kB 53.52 kB
/live-audio/minimal-skin.css css 3.97 kB
/live-audio/skin.css css 3.90 kB
/live-audio/minimal-skin js 25.45 kB 53.52 kB
/live-audio/minimal-skin.tailwind js 28.69 kB 53.52 kB
/live-audio/skin js 25.50 kB 53.52 kB
/live-audio/skin.tailwind js 28.85 kB 53.52 kB
UI Components (33)
Entry Initial
/ui/airplay-button 2.74 kB
/ui/alert-dialog 2.83 kB
/ui/audio-track 2.56 kB
/ui/buffering-indicator 2.77 kB
/ui/captions-button 2.72 kB
/ui/captions-radio-group 2.56 kB
/ui/cast-button 2.71 kB
/ui/controls 2.71 kB
/ui/error-dialog 2.76 kB
/ui/fullscreen-button 2.78 kB
/ui/gesture 2.62 kB
/ui/hotkey 2.64 kB
/ui/live-button 2.64 kB
/ui/menu 2.74 kB
/ui/mute-button 2.69 kB
/ui/pip-button 2.71 kB
/ui/play-button 2.72 kB
/ui/playback-rate 2.71 kB
/ui/playback-rate-button 2.71 kB
/ui/popover 3.17 kB
/ui/poster 2.74 kB
/ui/quality 2.58 kB
/ui/seek-button 2.74 kB
/ui/seek-indicator 2.71 kB
/ui/slider 2.71 kB
/ui/status-announcer 2.54 kB
/ui/status-indicator 2.66 kB
/ui/thumbnail 2.73 kB
/ui/time 2.47 kB
/ui/time-slider 2.81 kB
/ui/tooltip 3.11 kB
/ui/volume-indicator 2.62 kB
/ui/volume-slider 2.83 kB

Sizes are marginal over the root entry point.

🧩 @videojs/core

Path Base initial PR initial Diff % Lazy
/dom 18.32 kB 18.98 kB +670 B +3.6% 🔺
Entries (75)
Entry Initial Lazy
. 11.46 kB
/dom 18.98 kB
/i18n 3.07 kB 53.52 kB
/i18n/locales/all 32.44 kB
/i18n/locales/ar 1.14 kB
/i18n/locales/az 1.04 kB
/i18n/locales/bg 1.21 kB
/i18n/locales/bn 1.23 kB
/i18n/locales/bs 951 B
/i18n/locales/ca 1.00 kB
/i18n/locales/cs 1022 B
/i18n/locales/cy 977 B
/i18n/locales/da 943 B
/i18n/locales/de 1.02 kB
/i18n/locales/el 1.40 kB
/i18n/locales/en 732 B
/i18n/locales/es 959 B
/i18n/locales/et 1019 B
/i18n/locales/eu 983 B
/i18n/locales/fa 1.14 kB
/i18n/locales/fi 997 B
/i18n/locales/fr 1.01 kB
/i18n/locales/gd 1.03 kB
/i18n/locales/gl 960 B
/i18n/locales/he 1.06 kB
/i18n/locales/hi 1.24 kB
/i18n/locales/hr 986 B
/i18n/locales/hu 1.02 kB
/i18n/locales/id 868 B
/i18n/locales/it 978 B
/i18n/locales/ja 1.12 kB
/i18n/locales/ko 1.08 kB
/i18n/locales/lt 986 B
/i18n/locales/lv 1.02 kB
/i18n/locales/mr 1.24 kB
/i18n/locales/nb 928 B
/i18n/locales/ne 1.23 kB
/i18n/locales/nl 967 B
/i18n/locales/nn 931 B
/i18n/locales/oc 1.01 kB
/i18n/locales/pl 1.06 kB
/i18n/locales/pt 988 B
/i18n/locales/pt-BR 988 B
/i18n/locales/pt-PT 953 B
/i18n/locales/ro 1.01 kB
/i18n/locales/ru 1.29 kB
/i18n/locales/sk 1.03 kB
/i18n/locales/sl 987 B
/i18n/locales/sr 1017 B
/i18n/locales/sv 962 B
/i18n/locales/te 1.26 kB
/i18n/locales/th 1.22 kB
/i18n/locales/tr 1.01 kB
/i18n/locales/uk 1.32 kB
/i18n/locales/vi 1.03 kB
/i18n/locales/zh 977 B
/i18n/locales/zh-CN 977 B
/i18n/locales/zh-TW 972 B
/i18n/text/airplay 101 B
/i18n/text/buttons 137 B
/i18n/text/captions 93 B
/i18n/text/cast 114 B
/i18n/text/common 90 B
/i18n/text/container 75 B
/i18n/text/errors 277 B
/i18n/text/fullscreen 98 B
/i18n/text/live 126 B
/i18n/text/menu 249 B
/i18n/text/pip 101 B
/i18n/text/playback 80 B
/i18n/text/seek 105 B
/i18n/text/slider 65 B
/i18n/text/status 222 B
/i18n/text/time 214 B
/i18n/text/volume 133 B
🏷️ @videojs/element — no changes
Entries (2)
Entry Initial
. 996 B
/context 943 B
📦 @videojs/store — no changes
Entries (3)
Entry Initial
. 1.39 kB
/html 696 B
/react 361 B
🔧 @videojs/utils — no changes
Entries (13)
Entry Initial
/array 104 B
/dom 3.16 kB
/events 319 B
/function 327 B
/i18n 44 B
/jwt 176 B
/object 505 B
/predicate 265 B
/percent 281 B
/string 239 B
/style 190 B
/time 900 B
/number 158 B
📦 @videojs/media — no changes
Entries (14)
Entry Initial
. 959 B
/dom/audio-host 1.11 kB
/dom/custom-media-element 2.09 kB
/dom/dash 208.99 kB
/dom/google-cast 4.06 kB
/dom/hls-js 136.00 kB
/dom/media-host 1.21 kB
/dom/media-played-ranges 576 B
/dom/mux 159.38 kB
/dom/native-hls 3.02 kB
/dom/simple-hls 22.09 kB
/dom/simple-hls-audio-only 17.73 kB
/dom/video-host 1.40 kB
/dom/vimeo 9.88 kB
📦 @videojs/spf — no changes
Entries (5)
Entry Initial
. 4.46 kB
/dom 6.50 kB
/hls 18.74 kB
/media-tracks 504 B
/background-video 13.42 kB

ℹ️ How to interpret

JS sizes are initial static graph totals (minified + brotli). Lazy dynamic chunks are shown separately when present.

Icon Meaning
No change
🔺 Increased ≤ 10%
🔴 Increased > 10%
🔽 Decreased
🆕 New (no baseline)

Run pnpm size locally to check current initial sizes.

@mihar-22
mihar-22 marked this pull request as ready for review August 3, 2026 18:43
Comment thread packages/html/src/ui/menu/menu-element.ts
@mihar-22
mihar-22 requested a review from sampotts August 3, 2026 19:42

@sampotts sampotts 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.

Nice cleanup! Cheers

@mihar-22
mihar-22 merged commit d27eda2 into main Aug 3, 2026
27 checks passed
@mihar-22
mihar-22 deleted the refactor/popup-positioning branch August 3, 2026 22:55
@luwes luwes mentioned this pull request Aug 3, 2026

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 94d5164. Configure here.

});

return () => positioner.cleanup();
}, [open, anchorName, position, trigger, popupRef, boundary, container, cssVars, onSideChange, positioner]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale trigger blocks initial positioning

High Severity

usePopupPosition passes the render-time trigger into positioner.sync, but on defaultOpen that value is still null because trigger refs have not run yet. The old layout effect read triggerElement after refs attached. Sync bails out, so the first paint(s) skip anchor setup and measurement until a later transition re-render happens to recover.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 94d5164. Configure here.

this.#capturePopupStyles(options.popup, options.cssVars ?? PopoverCSSVars);
applyStyles(options.popup, style);
options.onSideChange?.(side);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Menu skips sync remeasure pass

Medium Severity

PopupPositioner.#position applies geometry once, then onSideChange runs syncMenuViewRoot, which can change the menu box via --media-menu-width. The old menu path remeasured and reapplied after that sync for the JS fallback. Alignment for center/end can stay wrong until a later ResizeObserver tick, and stays wrong if ResizeObserver is missing.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 94d5164. Configure here.

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.

2 participants