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,14 +135,23 @@ 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>
</TooltipTrigger>
<TooltipContent>
{tooltipLabel ?? t("categorizeImage")}
</TooltipContent>
</Tooltip>
<SelectorContent <SelectorContent
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")} ref={scrollContainerRef}
className={cn(
isDesktop && "scrollbar-container max-h-[40dvh] overflow-y-auto",
isMobile && "mx-1 gap-2 rounded-t-2xl px-4",
)}
onCloseAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}
> >
{isMobile && ( {isMobile && (
@ -141,12 +163,7 @@ export default function ClassificationSelectionDialog({
<DropdownMenuLabel> <DropdownMenuLabel>
{dialogLabel ?? t("categorizeImageAs")} {dialogLabel ?? t("categorizeImageAs")}
</DropdownMenuLabel> </DropdownMenuLabel>
<div <div className={cn("flex flex-col", isMobile && "gap-2 pb-4")}>
className={cn(
"flex max-h-[40dvh] flex-col overflow-y-auto",
isMobile && "gap-2 pb-4",
)}
>
{filteredClasses {filteredClasses
.sort((a, b) => { .sort((a, b) => {
if (a === "none") return 1; if (a === "none") return 1;
@ -174,8 +191,6 @@ export default function ClassificationSelectionDialog({
</div> </div>
</SelectorContent> </SelectorContent>
</Selector> </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,14 +96,21 @@ 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>
</TooltipTrigger>
<TooltipContent>{tooltipLabel ?? t("trainFace")}</TooltipContent>
</Tooltip>
<SelectorContent <SelectorContent
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")} ref={scrollContainerRef}
className={cn(
isDesktop && "scrollbar-container max-h-[40dvh] overflow-y-auto",
isMobile && "mx-1 gap-2 rounded-t-2xl px-4",
)}
onCloseAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}
> >
{isMobile && ( {isMobile && (
@ -104,8 +124,9 @@ export default function FaceSelectionDialog({
</DropdownMenuLabel> </DropdownMenuLabel>
<div <div
className={cn( className={cn(
"flex max-h-[40dvh] flex-col overflow-y-auto overflow-x-hidden", "flex flex-col",
isMobile && "gap-2 pb-4", isMobile &&
"max-h-[40dvh] gap-2 overflow-y-auto overflow-x-hidden pb-4",
)} )}
> >
{filteredNames.sort().map((faceName) => ( {filteredNames.sort().map((faceName) => (
@ -127,8 +148,6 @@ export default function FaceSelectionDialog({
</div> </div>
</SelectorContent> </SelectorContent>
</Selector> </Selector>
<TooltipContent>{tooltipLabel ?? t("trainFace")}</TooltipContent>
</Tooltip>
</div> </div>
); );
} }