Keep nested dropdown menus on screen (#7168)

* Fix compact chat submenus

* Apply compact submenu layout globally

* Measure compact submenu overlap

* Measure submenu layout width
This commit is contained in:
Michael Han 2026-07-16 03:13:23 -07:00 committed by GitHub
commit fb7381f5f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 98 additions and 2 deletions

View file

@ -2,10 +2,11 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import type * as React from "react";
import * as React from "react";
import { Tick02Icon } from "@/lib/tick-icon";
import { ChevronRightStandardIcon } from "@/lib/chevron-icons";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { HugeiconsIcon } from "@hugeicons/react";
@ -206,6 +207,14 @@ function DropdownMenuShortcut({
);
}
function assignRef<T>(ref: React.Ref<T> | undefined, value: T | null) {
if (typeof ref === "function") {
ref(value);
} else if (ref) {
ref.current = value;
}
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
@ -242,17 +251,63 @@ function DropdownMenuSubTrigger({
function DropdownMenuSubContent({
className,
sideOffset,
style,
ref,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
const isMobile = useIsMobile();
const [contentWidth, setContentWidth] = React.useState(0);
const resizeObserverRef = React.useRef<ResizeObserver | null>(null);
const composedRef = React.useCallback(
(
element: React.ComponentRef<
typeof DropdownMenuPrimitive.SubContent
> | null,
) => {
resizeObserverRef.current?.disconnect();
resizeObserverRef.current = null;
assignRef(ref, element);
if (!element) return;
const updateContentWidth = () => {
setContentWidth(element.offsetWidth);
};
updateContentWidth();
if (typeof ResizeObserver !== "undefined") {
resizeObserverRef.current = new ResizeObserver(updateContentWidth);
resizeObserverRef.current.observe(element);
}
},
[ref],
);
React.useEffect(
() => () => {
resizeObserverRef.current?.disconnect();
},
[],
);
const compactSideOffset =
isMobile && contentWidth > 0 ? -contentWidth : sideOffset;
return (
// Portaled like DropdownMenuContent: rendered inline, the fixed popper
// wrapper is a descendant of the parent menu's scroll container, so any
// transform there turns on overflow clipping and hides the submenu.
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
ref={composedRef}
data-slot="dropdown-menu-sub-content"
sideOffset={compactSideOffset}
style={{
...style,
visibility:
isMobile && contentWidth === 0 ? "hidden" : style?.visibility,
}}
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 bg-popover text-popover-foreground min-w-36 rounded-lg p-1 duration-100 z-50 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden",
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 bg-popover text-popover-foreground min-w-36 max-w-[calc(100vw-2rem)] rounded-lg p-1 duration-100 z-50 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden",
className,
)}
{...props}

View file

@ -0,0 +1,41 @@
"""Compact viewport contracts for nested dropdown menus."""
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
DROPDOWN_MENU = REPO / "studio/frontend/src/components/ui/dropdown-menu.tsx"
FRONTEND_SRC = REPO / "studio/frontend/src"
def test_shared_submenu_uses_its_layout_width_on_mobile():
source = DROPDOWN_MENU.read_text(encoding = "utf-8")
assert 'import { useIsMobile } from "@/hooks/use-mobile";' in source
assert "element.offsetWidth" in source
assert "element.getBoundingClientRect().width" not in source
assert "new ResizeObserver(updateContentWidth)" in source
assert "isMobile && contentWidth > 0 ? -contentWidth : sideOffset" in source
assert "sideOffset={compactSideOffset}" in source
assert 'isMobile && contentWidth === 0 ? "hidden"' in source
assert "-248" not in source
def test_shared_submenu_never_exceeds_the_compact_viewport():
source = DROPDOWN_MENU.read_text(encoding = "utf-8")
assert "max-w-[calc(100vw-2rem)]" in source
def test_consumers_do_not_duplicate_compact_offset_logic():
for path in FRONTEND_SRC.rglob("*.tsx"):
if path == DROPDOWN_MENU:
continue
source = path.read_text(encoding = "utf-8")
assert "compactSubmenuOffset" not in source, path
def test_all_submenu_consumers_use_the_shared_primitive():
for path in FRONTEND_SRC.rglob("*.tsx"):
if path == DROPDOWN_MENU:
continue
source = path.read_text(encoding = "utf-8")
assert "DropdownMenuPrimitive.SubContent" not in source, path