authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-10-11 14:27:41-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 18:27:41-03:00
logcafe620e01106262b8876f1bb927189337523b75
treed99203843fe97f6f70cafa1f0023d16b3a44ae7c
parent81dbc870dad7b64d9920c7526862f52993266c93
signature Signed by PGP key B5690EEEBB952194

fix(desktop): tooltips no longer open under the Windows and Linux window controls (#17672)


2 files changed, 47 insertions(+), 9 deletions(-)

apps/web/src/components/ui/tooltip.tsx+28-9
...@@ -2,6 +2,10 @@ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";...@@ -2,6 +2,10 @@ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
2import { createContext, use, useEffect, useRef, type ComponentProps, type RefObject } from "react";2import { createContext, use, useEffect, useRef, type ComponentProps, type RefObject } from "react";
33
4import { cn } from "~/lib/utils";4import { cn } from "~/lib/utils";
5import { useNativeTitlebarHeight } from "~/lib/windowControlsOverlay";
6
7// Base UI's default, restated because a padding object zeroes the sides it omits.
8const COLLISION_PADDING = 5;
59
6const TooltipProvider = TooltipPrimitive.Provider;10const TooltipProvider = TooltipPrimitive.Provider;
711
...@@ -80,6 +84,28 @@ function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) {...@@ -80,6 +84,28 @@ function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) {
80 );84 );
81}85}
8286
87/**
88 * Treats the native titlebar strip as off-screen, so a tooltip that would open
89 * under the window controls flips to its other side. Rendered inside the
90 * portal so only open tooltips track the titlebar.
91 */
92function TooltipPositioner(props: TooltipPrimitive.Positioner.Props) {
93 const titlebarHeight = useNativeTitlebarHeight();
94 return (
95 <TooltipPrimitive.Positioner
96 className="pointer-events-none z-[140] h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom,transform] data-instant:transition-none"
97 collisionPadding={{
98 top: COLLISION_PADDING + titlebarHeight,
99 right: COLLISION_PADDING,
100 bottom: COLLISION_PADDING,
101 left: COLLISION_PADDING,
102 }}
103 data-slot="tooltip-positioner"
104 {...props}
105 />
106 );
107}
108
83function TooltipPopup({109function TooltipPopup({
84 className,110 className,
85 align = "center",111 align = "center",
...@@ -99,14 +125,7 @@ function TooltipPopup({...@@ -99,14 +125,7 @@ function TooltipPopup({
99}) {125}) {
100 return (126 return (
101 <TooltipPrimitive.Portal>127 <TooltipPrimitive.Portal>
102 <TooltipPrimitive.Positioner128 <TooltipPositioner align={align} anchor={anchor} side={side} sideOffset={sideOffset}>
103 align={align}
104 anchor={anchor}
105 className="pointer-events-none z-[140] h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom,transform] data-instant:transition-none"
106 data-slot="tooltip-positioner"
107 side={side}
108 sideOffset={sideOffset}
109 >
110 <TooltipPrimitive.Popup129 <TooltipPrimitive.Popup
111 className={cn(130 className={cn(
112 "relative flex h-(--popup-height,auto) w-(--popup-width,auto) origin-(--transform-origin) text-balance rounded-md text-popover-foreground text-xs transition-[width,height,scale,opacity] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-md)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:duration-0 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",131 "relative flex h-(--popup-height,auto) w-(--popup-width,auto) origin-(--transform-origin) text-balance rounded-md text-popover-foreground text-xs transition-[width,height,scale,opacity] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-md)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:duration-0 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
...@@ -129,7 +148,7 @@ function TooltipPopup({...@@ -129,7 +148,7 @@ function TooltipPopup({
129 {children}148 {children}
130 </TooltipPrimitive.Viewport>149 </TooltipPrimitive.Viewport>
131 </TooltipPrimitive.Popup>150 </TooltipPrimitive.Popup>
132 </TooltipPrimitive.Positioner>151 </TooltipPositioner>
133 </TooltipPrimitive.Portal>152 </TooltipPrimitive.Portal>
134 );153 );
135}154}
apps/web/src/lib/windowControlsOverlay.ts+19
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { useSyncExternalStore } from "react";
2
1import { isWindowsPlatform } from "./utils";3import { isWindowsPlatform } from "./utils";
24
3const WCO_CLASS_NAME = "wco";5const WCO_CLASS_NAME = "wco";
...@@ -6,6 +8,7 @@ const ELECTRON_WINDOWS_CLASS_NAME = "electron-windows";...@@ -6,6 +8,7 @@ const ELECTRON_WINDOWS_CLASS_NAME = "electron-windows";
68
7interface WindowControlsOverlayLike {9interface WindowControlsOverlayLike {
8 readonly visible: boolean;10 readonly visible: boolean;
11 getTitlebarAreaRect(): DOMRect;
9 addEventListener(type: "geometrychange", listener: EventListener): void;12 addEventListener(type: "geometrychange", listener: EventListener): void;
10 removeEventListener(type: "geometrychange", listener: EventListener): void;13 removeEventListener(type: "geometrychange", listener: EventListener): void;
11}14}
...@@ -43,6 +46,22 @@ export function syncDocumentWindowControlsOverlayClass(): () => void {...@@ -43,6 +46,22 @@ export function syncDocumentWindowControlsOverlayClass(): () => void {
43 };46 };
44}47}
4548
49function subscribeTitlebarArea(listener: () => void): () => void {
50 const overlay = getWindowControlsOverlay();
51 overlay?.addEventListener("geometrychange", listener);
52 return () => overlay?.removeEventListener("geometrychange", listener);
53}
54
55function getNativeTitlebarHeight(): number {
56 const overlay = getWindowControlsOverlay();
57 return overlay?.visible ? overlay.getTitlebarAreaRect().bottom : 0;
58}
59
60/** Height of the strip at the top of the window where native window controls paint over the page. */
61export function useNativeTitlebarHeight(): number {
62 return useSyncExternalStore(subscribeTitlebarArea, getNativeTitlebarHeight, () => 0);
63}
64
46function getElectronPlatformClassNames(65function getElectronPlatformClassNames(
47 platform: string,66 platform: string,
48):67):