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";
22import { createContext, use, useEffect, useRef, type ComponentProps, type RefObject } from "react";
33
44import { 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
610const TooltipProvider = TooltipPrimitive.Provider;
711
......@@ -80,6 +84,28 @@ function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) {
8084 );
8185}
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
83109function TooltipPopup({
84110 className,
85111 align = "center",
......@@ -99,14 +125,7 @@ function TooltipPopup({
99125}) {
100126 return (
101127 <TooltipPrimitive.Portal>
102 <TooltipPrimitive.Positioner
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 >
128 <TooltipPositioner align={align} anchor={anchor} side={side} sideOffset={sideOffset}>
110129 <TooltipPrimitive.Popup
111130 className={cn(
112131 "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({
129148 {children}
130149 </TooltipPrimitive.Viewport>
131150 </TooltipPrimitive.Popup>
132 </TooltipPrimitive.Positioner>
151 </TooltipPositioner>
133152 </TooltipPrimitive.Portal>
134153 );
135154}
apps/web/src/lib/windowControlsOverlay.ts+19
......@@ -1,3 +1,5 @@
1import { useSyncExternalStore } from "react";
2
13import { isWindowsPlatform } from "./utils";
24
35const WCO_CLASS_NAME = "wco";
......@@ -6,6 +8,7 @@ const ELECTRON_WINDOWS_CLASS_NAME = "electron-windows";
68
79interface WindowControlsOverlayLike {
810 readonly visible: boolean;
11 getTitlebarAreaRect(): DOMRect;
912 addEventListener(type: "geometrychange", listener: EventListener): void;
1013 removeEventListener(type: "geometrychange", listener: EventListener): void;
1114}
......@@ -43,6 +46,22 @@ export function syncDocumentWindowControlsOverlayClass(): () => void {
4346 };
4447}
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
4665function getElectronPlatformClassNames(
4766 platform: string,
4867):