Skip to content

Commit e1006de

Browse files
mscolnickmanzt
andauthored
vscode: portals in vscode (#6803)
Intelligently set the portal container and collision boundary when inside vscode --------- Co-authored-by: Trevor Manz <[email protected]>
1 parent 56df456 commit e1006de

File tree

9 files changed

+182
-20
lines changed

9 files changed

+182
-20
lines changed

frontend/src/components/ui/context-menu.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import type { VariantProps } from "class-variance-authority";
1010
import * as React from "react";
1111
import { StyleNamespace } from "@/theme/namespace";
1212
import { cn } from "@/utils/cn";
13-
import { withFullScreenAsRoot } from "./fullscreen";
13+
import {
14+
MAX_HEIGHT_OFFSET,
15+
withFullScreenAsRoot,
16+
withSmartCollisionBoundary,
17+
} from "./fullscreen";
1418
import {
1519
MenuShortcut,
1620
menuContentCommon,
@@ -25,6 +29,12 @@ const ContextMenu = ContextMenuPrimitive.Root;
2529
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
2630
const ContextMenuGroup = ContextMenuPrimitive.Group;
2731
const ContextMenuPortal = withFullScreenAsRoot(ContextMenuPrimitive.Portal);
32+
const InternalContextMenuContent = withSmartCollisionBoundary(
33+
ContextMenuPrimitive.Content,
34+
);
35+
const InternalContextMenuSubContent = withSmartCollisionBoundary(
36+
ContextMenuPrimitive.SubContent,
37+
);
2838
const ContextMenuSub = ContextMenuPrimitive.Sub;
2939
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
3040

@@ -52,7 +62,7 @@ const ContextMenuSubContent = React.forwardRef<
5262
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
5363
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
5464
>(({ className, ...props }, ref) => (
55-
<ContextMenuPrimitive.SubContent
65+
<InternalContextMenuSubContent
5666
ref={ref}
5767
className={cn(
5868
menuContentCommon({ subcontent: true }),
@@ -72,7 +82,7 @@ const ContextMenuContent = React.forwardRef<
7282
>(({ className, scrollable = true, ...props }, ref) => (
7383
<ContextMenuPortal>
7484
<StyleNamespace>
75-
<ContextMenuPrimitive.Content
85+
<InternalContextMenuContent
7686
ref={ref}
7787
className={cn(
7888
menuContentCommon(),
@@ -83,7 +93,7 @@ const ContextMenuContent = React.forwardRef<
8393
style={{
8494
...props.style,
8595
maxHeight: scrollable
86-
? "calc(var(--radix-context-menu-content-available-height) - 30px)"
96+
? `calc(var(--radix-context-menu-content-available-height) - ${MAX_HEIGHT_OFFSET}px)`
8797
: undefined,
8898
}}
8999
{...props}

frontend/src/components/ui/dropdown-menu.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import { Check, ChevronRight, Circle } from "lucide-react";
55
import React from "react";
66
import { StyleNamespace } from "@/theme/namespace";
77
import { cn } from "@/utils/cn";
8-
import { withFullScreenAsRoot } from "./fullscreen";
8+
import {
9+
MAX_HEIGHT_OFFSET,
10+
withFullScreenAsRoot,
11+
withSmartCollisionBoundary,
12+
} from "./fullscreen";
913
import {
1014
MenuShortcut,
1115
menuContentCommon,
@@ -21,6 +25,12 @@ const DropdownMenu = DropdownMenuPrimitive.Root;
2125
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
2226
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
2327
const DropdownMenuPortal = withFullScreenAsRoot(DropdownMenuPrimitive.Portal);
28+
const InternalDropdownMenuContent = withSmartCollisionBoundary(
29+
DropdownMenuPrimitive.Content,
30+
);
31+
const InternalDropdownMenuSubContent = withSmartCollisionBoundary(
32+
DropdownMenuPrimitive.SubContent,
33+
);
2434
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
2535
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
2636

@@ -47,7 +57,7 @@ const DropdownMenuSubContent = React.forwardRef<
4757
React.ComponentRef<typeof DropdownMenuPrimitive.SubContent>,
4858
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
4959
>(({ className, ...props }, ref) => (
50-
<DropdownMenuPrimitive.SubContent
60+
<InternalDropdownMenuSubContent
5161
ref={ref}
5262
className={cn(
5363
menuContentCommon({ subcontent: true }),
@@ -68,7 +78,7 @@ const DropdownMenuContent = React.forwardRef<
6878
>(({ className, scrollable = true, sideOffset = 4, ...props }, ref) => (
6979
<DropdownMenuPortal>
7080
<StyleNamespace>
71-
<DropdownMenuPrimitive.Content
81+
<InternalDropdownMenuContent
7282
ref={ref}
7383
sideOffset={sideOffset}
7484
className={cn(
@@ -80,7 +90,7 @@ const DropdownMenuContent = React.forwardRef<
8090
style={{
8191
...props.style,
8292
maxHeight: scrollable
83-
? "calc(var(--radix-dropdown-menu-content-available-height) - 30px)"
93+
? `calc(var(--radix-dropdown-menu-content-available-height) - ${MAX_HEIGHT_OFFSET}px)`
8494
: undefined,
8595
}}
8696
{...props}

frontend/src/components/ui/fullscreen.tsx

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
/* Copyright 2024 Marimo. All rights reserved. */
22

3-
import { useState } from "react";
3+
import type { PopoverContentProps } from "@radix-ui/react-popover";
4+
import React, { useState } from "react";
5+
import { isInVscodeExtension } from "@/core/vscode/is-in-vscode";
46
import { useEventListener } from "@/hooks/useEventListener";
57

8+
const VSCODE_OUTPUT_CONTAINER_SELECTOR = "[data-vscode-output-container]";
9+
10+
// vscode has smaller viewport so we need all the max-height we can get.
11+
// Otherwise, we give a 30px buffer to the max-height.
12+
export const MAX_HEIGHT_OFFSET = isInVscodeExtension() ? 0 : 30;
13+
614
/**
715
* Get the full screen element if we are in full screen mode
816
*/
@@ -25,14 +33,120 @@ export function withFullScreenAsRoot<
2533
container?: Element | DocumentFragment | null;
2634
},
2735
>(Component: React.ComponentType<T>) {
36+
const FindClosestVscodeOutputContainer = (props: T) => {
37+
const [closest, setClosest] = React.useState<Element | null>(null);
38+
const el = React.useRef<HTMLDivElement>(null);
39+
40+
React.useLayoutEffect(() => {
41+
if (!el.current) {
42+
return;
43+
}
44+
45+
const found = closestThroughShadowDOMs(
46+
el.current,
47+
VSCODE_OUTPUT_CONTAINER_SELECTOR,
48+
);
49+
setClosest(found);
50+
}, []);
51+
52+
return (
53+
<>
54+
<div ref={el} className="contents invisible" />
55+
<Component {...props} container={closest} />
56+
</>
57+
);
58+
};
59+
2860
const Comp = (props: T) => {
2961
const fullScreenElement = useFullScreenElement();
62+
63+
// If we are in the VSCode extension, we use the VSCode output container
64+
const vscodeOutputContainer = isInVscodeExtension();
65+
if (vscodeOutputContainer) {
66+
return <FindClosestVscodeOutputContainer {...props} />;
67+
}
68+
3069
if (!fullScreenElement) {
3170
return <Component {...props} />;
3271
}
72+
3373
return <Component {...props} container={fullScreenElement} />;
3474
};
3575

3676
Comp.displayName = Component.displayName;
3777
return Comp;
3878
}
79+
80+
/**
81+
* HOC wrapping a PortalContent component to set a better collision boundary,
82+
* when inside vscode.
83+
*/
84+
export function withSmartCollisionBoundary<
85+
T extends {
86+
collisionBoundary?: PopoverContentProps["collisionBoundary"];
87+
},
88+
>(Component: React.ComponentType<T>) {
89+
const FindClosestVscodeOutputContainer = (props: T) => {
90+
const [closest, setClosest] = React.useState<Element | null>(null);
91+
const el = React.useRef<HTMLDivElement>(null);
92+
93+
React.useLayoutEffect(() => {
94+
if (!el.current) {
95+
return;
96+
}
97+
98+
const found = closestThroughShadowDOMs(
99+
el.current,
100+
VSCODE_OUTPUT_CONTAINER_SELECTOR,
101+
);
102+
setClosest(found);
103+
}, []);
104+
105+
return (
106+
<>
107+
<div ref={el} className="contents invisible" />
108+
<Component {...props} collisionBoundary={closest} />
109+
</>
110+
);
111+
};
112+
113+
const Comp = (props: T) => {
114+
// If we are in the VSCode extension, we use the VSCode output container
115+
const vscodeOutputContainer = isInVscodeExtension();
116+
if (vscodeOutputContainer) {
117+
return <FindClosestVscodeOutputContainer {...props} />;
118+
}
119+
120+
return <Component {...props} />;
121+
};
122+
123+
Comp.displayName = Component.displayName;
124+
return Comp;
125+
}
126+
127+
/**
128+
* Find the closest element (with .closest), but through shadow DOMs.
129+
*/
130+
function closestThroughShadowDOMs(
131+
element: Element,
132+
selector: string,
133+
): Element | null {
134+
let currentElement: Element | null = element;
135+
136+
while (currentElement) {
137+
const cellElement = currentElement.closest(selector);
138+
if (cellElement) {
139+
return cellElement;
140+
}
141+
142+
const root = currentElement.getRootNode();
143+
currentElement =
144+
root instanceof ShadowRoot ? root.host : currentElement.parentElement;
145+
146+
if (currentElement === root) {
147+
break;
148+
}
149+
}
150+
151+
return null;
152+
}

frontend/src/components/ui/popover.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,22 @@ import * as PopoverPrimitive from "@radix-ui/react-popover";
44
import * as React from "react";
55
import { StyleNamespace } from "@/theme/namespace";
66
import { cn } from "@/utils/cn";
7-
import { withFullScreenAsRoot } from "./fullscreen";
7+
import {
8+
MAX_HEIGHT_OFFSET,
9+
withFullScreenAsRoot,
10+
withSmartCollisionBoundary,
11+
} from "./fullscreen";
812

913
const Popover = PopoverPrimitive.Root;
1014

1115
const PopoverTrigger = PopoverPrimitive.Trigger;
1216
const PopoverPortal = withFullScreenAsRoot(PopoverPrimitive.Portal);
1317
const PopoverClose = PopoverPrimitive.Close;
1418

19+
const InternalPopoverContent = withSmartCollisionBoundary(
20+
PopoverPrimitive.Content,
21+
);
22+
1523
const PopoverContent = React.forwardRef<
1624
React.ElementRef<typeof PopoverPrimitive.Content>,
1725
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
@@ -32,7 +40,7 @@ const PopoverContent = React.forwardRef<
3240
) => {
3341
const content = (
3442
<StyleNamespace>
35-
<PopoverPrimitive.Content
43+
<InternalPopoverContent
3644
ref={ref}
3745
align={align}
3846
sideOffset={sideOffset}
@@ -44,7 +52,7 @@ const PopoverContent = React.forwardRef<
4452
style={{
4553
...props.style,
4654
maxHeight: scrollable
47-
? "calc(var(--radix-popover-content-available-height) - 30px)"
55+
? `calc(var(--radix-popover-content-available-height) - ${MAX_HEIGHT_OFFSET}px)`
4856
: undefined,
4957
}}
5058
{...props}

frontend/src/components/ui/range-slider.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
/* Copyright 2024 Marimo. All rights reserved. */
22

33
import * as SliderPrimitive from "@radix-ui/react-slider";
4-
import { TooltipPortal } from "@radix-ui/react-tooltip";
54
import * as React from "react";
65
import { useLocale } from "react-aria";
76
import { cn } from "@/utils/cn";
87
import { prettyScientificNumber } from "@/utils/numbers";
98
import { useBoolean } from "../../hooks/useBoolean";
109
import {
1110
TooltipContent,
11+
TooltipPortal,
1212
TooltipProvider,
1313
TooltipRoot,
1414
TooltipTrigger,

frontend/src/components/ui/select.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
import * as React from "react";
1313
import { StyleNamespace } from "@/theme/namespace";
1414
import { cn } from "@/utils/cn";
15-
import { withFullScreenAsRoot } from "./fullscreen";
15+
import { withFullScreenAsRoot, withSmartCollisionBoundary } from "./fullscreen";
1616
import { MENU_ITEM_DISABLED } from "./menu-items";
1717
import { selectStyles } from "./native-select";
1818

@@ -60,13 +60,17 @@ const SelectTrigger = React.forwardRef<
6060
);
6161
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
6262

63+
const InternalSelectContent = withSmartCollisionBoundary(
64+
SelectPrimitive.Content,
65+
);
66+
6367
const SelectContent = React.forwardRef<
6468
React.ElementRef<typeof SelectPrimitive.Content>,
6569
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
6670
>(({ className, children, position = "popper", ...props }, ref) => (
6771
<SelectPortal>
6872
<StyleNamespace>
69-
<SelectPrimitive.Content
73+
<InternalSelectContent
7074
ref={ref}
7175
className={cn(
7276
"max-h-[300px] relative z-50 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
@@ -94,7 +98,7 @@ const SelectContent = React.forwardRef<
9498
<SelectPrimitive.ScrollDownButton className="flex items-center justify-center h-[20px] bg-background text-muted-foreground cursor-default">
9599
<ChevronDownIcon className="h-4 w-4 opacity-50" />
96100
</SelectPrimitive.ScrollDownButton>
97-
</SelectPrimitive.Content>
101+
</InternalSelectContent>
98102
</StyleNamespace>
99103
</SelectPortal>
100104
));

frontend/src/components/ui/slider.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
/* Copyright 2024 Marimo. All rights reserved. */
22

33
import * as SliderPrimitive from "@radix-ui/react-slider";
4-
import { TooltipPortal } from "@radix-ui/react-tooltip";
54
import * as React from "react";
65
import { useLocale } from "react-aria";
76
import { cn } from "@/utils/cn";
87
import { prettyScientificNumber } from "@/utils/numbers";
98
import { useBoolean } from "../../hooks/useBoolean";
109
import {
1110
TooltipContent,
11+
TooltipPortal,
1212
TooltipProvider,
1313
TooltipRoot,
1414
TooltipTrigger,

0 commit comments

Comments
 (0)