improve scroll handling for non-modal DropdownMenu in classification and face selection dialogs

This commit is contained in:
Josh Hawkins 2026-05-12 11:48:35 -05:00
parent 4e90d254ed
commit 077f4a601a
2 changed files with 129 additions and 95 deletions

View File

@ -102,6 +102,19 @@ export default function ClassificationSelectionDialog({
// control // control
const [newClass, setNewClass] = useState(false); const [newClass, setNewClass] = useState(false);
// Non-modal Radix DropdownMenu doesn't propagate wheel events to nested
// scroll containers, so attach a non-passive listener that scrolls manually.
const scrollContainerRef = useCallback((el: HTMLDivElement | null) => {
if (!el || !isDesktop) return;
const handleWheel = (e: WheelEvent) => {
if (el.scrollHeight <= el.clientHeight) return;
e.preventDefault();
el.scrollTop += e.deltaY;
};
el.addEventListener("wheel", handleWheel, { passive: false });
return () => el.removeEventListener("wheel", handleWheel);
}, []);
// components // components
const Selector = isDesktop ? DropdownMenu : Drawer; const Selector = isDesktop ? DropdownMenu : Drawer;
const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger; const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
@ -122,60 +135,62 @@ export default function ClassificationSelectionDialog({
title={t("createCategory.new")} title={t("createCategory.new")}
onSave={(newCat) => onCategorizeImage(newCat)} onSave={(newCat) => onCategorizeImage(newCat)}
/> />
// keep modal false on desktop to prevent dismissable layer pointer events
<Tooltip> // issue with dialog auto-close
<Selector {...(isDesktop ? { modal: false } : {})}> <Selector {...(isDesktop ? { modal: false } : {})}>
<SelectorTrigger asChild> <Tooltip>
<TooltipTrigger asChild={isChildButton}>{children}</TooltipTrigger> <TooltipTrigger asChild={isChildButton}>
</SelectorTrigger> <SelectorTrigger asChild>{children}</SelectorTrigger>
<SelectorContent </TooltipTrigger>
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")} <TooltipContent>
onCloseAutoFocus={(e) => e.preventDefault()} {tooltipLabel ?? t("categorizeImage")}
> </TooltipContent>
{isMobile && ( </Tooltip>
<DrawerHeader className="sr-only"> <SelectorContent
<DrawerTitle>Details</DrawerTitle> ref={scrollContainerRef}
<DrawerDescription>Details</DrawerDescription> className={cn(
</DrawerHeader> isDesktop && "scrollbar-container max-h-[40dvh] overflow-y-auto",
)} isMobile && "mx-1 gap-2 rounded-t-2xl px-4",
<DropdownMenuLabel> )}
{dialogLabel ?? t("categorizeImageAs")} onCloseAutoFocus={(e) => e.preventDefault()}
</DropdownMenuLabel> >
<div {isMobile && (
className={cn( <DrawerHeader className="sr-only">
"flex max-h-[40dvh] flex-col overflow-y-auto", <DrawerTitle>Details</DrawerTitle>
isMobile && "gap-2 pb-4", <DrawerDescription>Details</DrawerDescription>
)} </DrawerHeader>
)}
<DropdownMenuLabel>
{dialogLabel ?? t("categorizeImageAs")}
</DropdownMenuLabel>
<div className={cn("flex flex-col", isMobile && "gap-2 pb-4")}>
{filteredClasses
.sort((a, b) => {
if (a === "none") return 1;
if (b === "none") return -1;
return a.localeCompare(b);
})
.map((category) => (
<SelectorItem
key={category}
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => onCategorizeImage(category)}
>
{category === "none"
? t("details.none")
: category.replaceAll("_", " ")}
</SelectorItem>
))}
<Separator />
<SelectorItem
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => setNewClass(true)}
> >
{filteredClasses {t("createCategory.new")}
.sort((a, b) => { </SelectorItem>
if (a === "none") return 1; </div>
if (b === "none") return -1; </SelectorContent>
return a.localeCompare(b); </Selector>
})
.map((category) => (
<SelectorItem
key={category}
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => onCategorizeImage(category)}
>
{category === "none"
? t("details.none")
: category.replaceAll("_", " ")}
</SelectorItem>
))}
<Separator />
<SelectorItem
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => setNewClass(true)}
>
{t("createCategory.new")}
</SelectorItem>
</div>
</SelectorContent>
</Selector>
<TooltipContent>{tooltipLabel ?? t("categorizeImage")}</TooltipContent>
</Tooltip>
</div> </div>
); );
} }

View File

@ -23,7 +23,7 @@ import {
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import React, { ReactNode, useMemo, useState } from "react"; import React, { ReactNode, useCallback, useMemo, useState } from "react";
import TextEntryDialog from "./dialog/TextEntryDialog"; import TextEntryDialog from "./dialog/TextEntryDialog";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
@ -61,6 +61,19 @@ export default function FaceSelectionDialog({
// control // control
const [newFace, setNewFace] = useState(false); const [newFace, setNewFace] = useState(false);
// Non-modal Radix DropdownMenu doesn't propagate wheel events to nested
// scroll containers, so attach a non-passive listener that scrolls manually.
const scrollContainerRef = useCallback((el: HTMLDivElement | null) => {
if (!el || !isDesktop) return;
const handleWheel = (e: WheelEvent) => {
if (el.scrollHeight <= el.clientHeight) return;
e.preventDefault();
el.scrollTop += e.deltaY;
};
el.addEventListener("wheel", handleWheel, { passive: false });
return () => el.removeEventListener("wheel", handleWheel);
}, []);
// components // components
const Selector = isDesktop ? DropdownMenu : Drawer; const Selector = isDesktop ? DropdownMenu : Drawer;
const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger; const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
@ -83,52 +96,58 @@ export default function FaceSelectionDialog({
onSave={(newName) => onTrainAttempt(newName)} onSave={(newName) => onTrainAttempt(newName)}
/> />
)} )}
// keep modal false on desktop to prevent dismissable layer pointer events
<Tooltip> // issue with dialog auto-close
<Selector {...(isDesktop ? { modal: false } : {})}> <Selector {...(isDesktop ? { modal: false } : {})}>
<SelectorTrigger asChild> <Tooltip>
<TooltipTrigger asChild={isChildButton}>{children}</TooltipTrigger> <TooltipTrigger asChild={isChildButton}>
</SelectorTrigger> <SelectorTrigger asChild>{children}</SelectorTrigger>
<SelectorContent </TooltipTrigger>
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")} <TooltipContent>{tooltipLabel ?? t("trainFace")}</TooltipContent>
onCloseAutoFocus={(e) => e.preventDefault()} </Tooltip>
> <SelectorContent
{isMobile && ( ref={scrollContainerRef}
<DrawerHeader className="sr-only"> className={cn(
<DrawerTitle>Details</DrawerTitle> isDesktop && "scrollbar-container max-h-[40dvh] overflow-y-auto",
<DrawerDescription>Details</DrawerDescription> isMobile && "mx-1 gap-2 rounded-t-2xl px-4",
</DrawerHeader> )}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{isMobile && (
<DrawerHeader className="sr-only">
<DrawerTitle>Details</DrawerTitle>
<DrawerDescription>Details</DrawerDescription>
</DrawerHeader>
)}
<DropdownMenuLabel>
{dialogLabel ?? t("trainFaceAs")}
</DropdownMenuLabel>
<div
className={cn(
"flex flex-col",
isMobile &&
"max-h-[40dvh] gap-2 overflow-y-auto overflow-x-hidden pb-4",
)} )}
<DropdownMenuLabel> >
{dialogLabel ?? t("trainFaceAs")} {filteredNames.sort().map((faceName) => (
</DropdownMenuLabel>
<div
className={cn(
"flex max-h-[40dvh] flex-col overflow-y-auto overflow-x-hidden",
isMobile && "gap-2 pb-4",
)}
>
{filteredNames.sort().map((faceName) => (
<SelectorItem
key={faceName}
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => onTrainAttempt(faceName)}
>
{faceName}
</SelectorItem>
))}
<DropdownMenuSeparator />
<SelectorItem <SelectorItem
key={faceName}
className="flex cursor-pointer gap-2 smart-capitalize" className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => setNewFace(true)} onClick={() => onTrainAttempt(faceName)}
> >
{t("createFaceLibrary.new")} {faceName}
</SelectorItem> </SelectorItem>
</div> ))}
</SelectorContent> <DropdownMenuSeparator />
</Selector> <SelectorItem
<TooltipContent>{tooltipLabel ?? t("trainFace")}</TooltipContent> className="flex cursor-pointer gap-2 smart-capitalize"
</Tooltip> onClick={() => setNewFace(true)}
>
{t("createFaceLibrary.new")}
</SelectorItem>
</div>
</SelectorContent>
</Selector>
</div> </div>
); );
} }