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:
parent
1b3d728d78
commit
fb7381f5f2
2 changed files with 98 additions and 2 deletions
|
|
@ -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}
|
||||
|
|
|
|||
41
tests/studio/test_compact_dropdown_submenus.py
Normal file
41
tests/studio/test_compact_dropdown_submenus.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue