diff --git a/frontend/libs/components/model-selector/src/lib/classy-model-selector.tsx b/frontend/libs/components/model-selector/src/lib/classy-model-selector.tsx index 434aedb881..462b621de1 100644 --- a/frontend/libs/components/model-selector/src/lib/classy-model-selector.tsx +++ b/frontend/libs/components/model-selector/src/lib/classy-model-selector.tsx @@ -23,6 +23,7 @@ interface ClassyModelSelectorProps { triggerLabel?: string; providersByModel?: Partial>; providerTooltipDelayMs?: number; + maxListHeight?: number | string; } const DEFAULT_PROVIDER_OPTIONS: Provider[] = [Provider.ArtCraft]; @@ -100,6 +101,7 @@ export function ClassyModelSelector({ page, providersByModel, providerTooltipDelayMs = 300, + maxListHeight = "60vh", ...popoverProps }: ClassyModelSelectorProps) { const { selectedModels, setSelectedModel, setSelectedProvider } = @@ -107,7 +109,7 @@ export function ClassyModelSelector({ const selectedModel = selectedModels[page] || items[0]?.model; const selectedProvider = useSelectedProviderForModel(page, selectedModel?.id); const selectedProvidersByModel = useClassyModelSelectorStore( - (s) => s.selectedProviders[page] ?? {} + (s) => s.selectedProviders[page] ?? {}, ); // For the first mount, make sure the selected model is set for other components to listen @@ -145,9 +147,9 @@ export function ClassyModelSelector({ items.map((item) => { const modelId = item.model?.id; const allowedProviders: Provider[] = modelId - ? providersByModel?.[modelId] ?? + ? (providersByModel?.[modelId] ?? PROVIDER_LOOKUP_BY_PAGE[page]?.[modelId] ?? - DEFAULT_PROVIDER_OPTIONS + DEFAULT_PROVIDER_OPTIONS) : DEFAULT_PROVIDER_OPTIONS; return { @@ -198,7 +200,7 @@ export function ClassyModelSelector({ page, providersByModel, providerTooltipDelayMs, - ] + ], ); return ( @@ -208,6 +210,7 @@ export function ClassyModelSelector({ items={modelList} onSelect={handleModelSelect} mode="hoverSelect" + maxListHeight={maxListHeight} {...popoverProps} buttonClassName="rounded-xl bg-ui-controls/90 hover:bg-ui-controls text-left shadow-sm px-3 py-1 gap-3 border border-ui-controls-border" renderTrigger={(selectedItem) => { diff --git a/frontend/libs/components/popover/src/lib/popover.tsx b/frontend/libs/components/popover/src/lib/popover.tsx index 5ff0872129..7d151dab89 100644 --- a/frontend/libs/components/popover/src/lib/popover.tsx +++ b/frontend/libs/components/popover/src/lib/popover.tsx @@ -1,4 +1,5 @@ -import { ReactNode, useRef, useEffect, useState } from "react"; +import { ReactNode, useRef, useEffect, useState, useCallback } from "react"; +import { createPortal } from "react-dom"; import { Popover, Transition, @@ -11,11 +12,127 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faCheck, faChevronUp, + faChevronDown, faCircleCheck, } from "@fortawesome/pro-solid-svg-icons"; import { Model, ModelInfo } from "@storyteller/model-list"; import { Tooltip } from "@storyteller/ui-tooltip"; +interface PortalTooltipProps { + children: React.ReactElement; + content: ReactNode; + delay?: number; + className?: string; + onOpenChange?: (open: boolean) => void; +} + +function PortalTooltip({ + children, + content, + delay = 300, + className, + onOpenChange, +}: PortalTooltipProps) { + const [isShowing, setIsShowing] = useState(false); + const [isVisible, setIsVisible] = useState(true); + const [position, setPosition] = useState({ top: 0, left: 0 }); + const triggerRef = useRef(null); + const tooltipRef = useRef(null); + const [isHoveringTrigger, setIsHoveringTrigger] = useState(false); + const [isHoveringTooltip, setIsHoveringTooltip] = useState(false); + const showTimeoutRef = useRef(null); + const closeTimeoutRef = useRef(null); + + const checkVisibilityAndUpdatePosition = useCallback(() => { + if (!triggerRef.current) return; + const rect = triggerRef.current.getBoundingClientRect(); + + const scrollParent = triggerRef.current.closest("[data-scroll-container]"); + if (scrollParent) { + const parentRect = scrollParent.getBoundingClientRect(); + const isInView = + rect.top >= parentRect.top - 10 && + rect.bottom <= parentRect.bottom + 10; + setIsVisible(isInView); + if (!isInView) return; + } + + setPosition({ + top: rect.top + rect.height / 2, + left: rect.right + 10, + }); + }, []); + + useEffect(() => { + const shouldShow = isHoveringTrigger || isHoveringTooltip; + if (shouldShow) { + if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); + checkVisibilityAndUpdatePosition(); + if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current); + showTimeoutRef.current = setTimeout(() => setIsShowing(true), delay); + } else { + if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current); + if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = setTimeout(() => setIsShowing(false), 150); + } + return () => { + if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current); + if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); + }; + }, [ + isHoveringTrigger, + isHoveringTooltip, + delay, + checkVisibilityAndUpdatePosition, + ]); + + useEffect(() => { + onOpenChange?.(isShowing && isVisible); + }, [isShowing, isVisible, onOpenChange]); + + useEffect(() => { + if (!isShowing) return; + const handleScroll = () => checkVisibilityAndUpdatePosition(); + window.addEventListener("scroll", handleScroll, true); + return () => window.removeEventListener("scroll", handleScroll, true); + }, [isShowing, checkVisibilityAndUpdatePosition]); + + return ( + <> +
setIsHoveringTrigger(true)} + onMouseLeave={() => setIsHoveringTrigger(false)} + > + {children} +
+ {isShowing && + isVisible && + createPortal( +
setIsHoveringTooltip(true)} + onMouseLeave={() => setIsHoveringTooltip(false)} + style={{ + position: "fixed", + top: position.top, + left: position.left, + transform: "translateY(-50%)", + zIndex: 9999, + }} + className={twMerge( + "pointer-events-auto rounded-lg bg-ui-panel p-3 shadow-xl border border-ui-panel-border text-base-fg", + className, + )} + > + {content} +
, + document.body, + )} + + ); +} + // Global hover manager to debounce close across adjacent hover popovers let globalCloseTimer: NodeJS.Timeout | null = null; const cancelGlobalClose = () => { @@ -83,6 +200,7 @@ interface PopoverMenuProps { onOpenChange?: (open: boolean) => void; closeOnUnhover?: boolean; renderTrigger?: (selected?: PopoverItem, open?: boolean) => ReactNode; + maxListHeight?: number | string; } export const PopoverMenu = ({ @@ -106,10 +224,41 @@ export const PopoverMenu = ({ onOpenChange, closeOnUnhover = false, renderTrigger, + maxListHeight, }: PopoverMenuProps) => { const selectedItem = items.find((item) => item.selected); const [openTooltipIdx, setOpenTooltipIdx] = useState(null); + const scrollContainerRef = useRef(null); + const [canScrollUp, setCanScrollUp] = useState(false); + const [canScrollDown, setCanScrollDown] = useState(false); + const [scrollReady, setScrollReady] = useState(false); + + const updateScrollIndicators = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return; + const { scrollTop, scrollHeight, clientHeight } = container; + setCanScrollUp(scrollTop > 1); + setCanScrollDown(scrollTop + clientHeight < scrollHeight - 1); + }, []); + + const scrollToSelectedItem = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return; + const selectedElement = container.querySelector("[data-selected='true']"); + if (selectedElement) { + const containerRect = container.getBoundingClientRect(); + const elementRect = selectedElement.getBoundingClientRect(); + const relativeTop = + elementRect.top - containerRect.top + container.scrollTop; + const scrollTo = + relativeTop - containerRect.height / 2 + elementRect.height / 2; + container.scrollTop = Math.max(0, scrollTo); + } + updateScrollIndicators(); + setScrollReady(true); + }, [updateScrollIndicators]); + const handleItemClick = (item: PopoverItem, close: () => void) => { if (mode === "button" && item.action && onPanelAction) { onPanelAction(item.action); @@ -126,7 +275,7 @@ export const PopoverMenu = ({ "transition-all duration-150", "bg-ui-controls px-3 text-base-fg hover:bg-ui-controls/80 border border-ui-controls-border", "active:scale-95 transform", - buttonClassName + buttonClassName, ); const positionClasses = { @@ -192,7 +341,7 @@ export const PopoverMenu = ({ () => { closeFn(); }, - mode === "hoverSelect" ? 200 : 120 + mode === "hoverSelect" ? 200 : 120, ); }; @@ -205,14 +354,35 @@ export const PopoverMenu = ({ const thisId = ++popoverIdCounter; useEffect(() => { onOpenChange?.(open); + let scrollTimeoutId: NodeJS.Timeout | undefined; if (open && (mode === "hoverSelect" || closeOnUnhover)) { // Broadcast that this popover opened; others should close window.dispatchEvent( new CustomEvent(ST_POPOVER_OPEN_EVENT, { detail: { id: thisId }, - }) + }), ); } + let resizeObserver: ResizeObserver | null = null; + if (open && maxListHeight) { + setScrollReady(false); + scrollTimeoutId = setTimeout(() => { + scrollToSelectedItem(); + const container = scrollContainerRef.current; + if (container) { + container.addEventListener( + "scroll", + updateScrollIndicators, + ); + resizeObserver = new ResizeObserver( + updateScrollIndicators, + ); + resizeObserver.observe(container); + } + }, 20); + } else if (!open) { + setScrollReady(false); + } const handler = (e: Event) => { const detail = (e as CustomEvent).detail as { id: number }; if (detail?.id !== thisId && open) { @@ -222,13 +392,25 @@ export const PopoverMenu = ({ }; window.addEventListener( ST_POPOVER_OPEN_EVENT, - handler as EventListener + handler as EventListener, ); - return () => + return () => { + if (scrollTimeoutId) clearTimeout(scrollTimeoutId); + const container = scrollContainerRef.current; + if (container) { + container.removeEventListener( + "scroll", + updateScrollIndicators, + ); + } + if (resizeObserver) { + resizeObserver.disconnect(); + } window.removeEventListener( ST_POPOVER_OPEN_EVENT, - handler as EventListener + handler as EventListener, ); + }; }, [open]); return null; })()} @@ -252,7 +434,7 @@ export const PopoverMenu = ({ // Use global close so moving to another hover popover cancels this scheduleGlobalClose( () => close(), - mode === "hoverSelect" ? 200 : 120 + mode === "hoverSelect" ? 200 : 120, ); }} > @@ -324,14 +506,14 @@ export const PopoverMenu = ({ "absolute transform-gpu z-50", positionClasses[position], alignClasses[align], - position === "bottom" ? "origin-top" : "origin-bottom" + position === "bottom" ? "origin-top" : "origin-bottom", )} >
handlePanelMouseLeave(close)} @@ -359,110 +541,44 @@ export const PopoverMenu = ({ : children}
) : mode === "hoverSelect" ? ( -
- {items.map((item, index) => ( -
- {item.hoverTooltip ? ( - void - ) => ReactNode - )(close) - : item.hoverTooltip +
+ {maxListHeight && canScrollUp && ( +
+ +
+ )} +
- setOpenTooltipIdx((prev) => - open ? index : prev === index ? null : prev - ) - } - > -
{ - if (!item.disabled) { - handleItemClick(item, close); - } - }} - className={twMerge( - "group flex cursor-pointer items-start gap-2 rounded-lg px-2 py-2 transition-all", - item.selected - ? "bg-ui-controls/70 border-l-4 border-primary" - : "hover:bg-ui-controls/50", - !item.selected && openTooltipIdx === index - ? "bg-ui-controls/50" - : "", - item.disabled - ? "!cursor-not-allowed opacity-50" - : "" - )} - style={{ minHeight: 48 }} - > -
-
- {showIconsInList && ( - - {item.icon} - - )} -
-
- - {item.label} - -
+ : undefined + } + > + {items.map((item, index) => { + const tooltipContent = + typeof item.hoverTooltip === "function" + ? ( + item.hoverTooltip as ( + close: () => void, + ) => ReactNode + )(close) + : item.hoverTooltip; - {item.description && ( -
- {item.description} -
- )} - -
- {item.badges && - Array.isArray(item.badges) && - item.badges.map((badge, i) => ( -
- - {badge?.icon && ( - {badge.icon} - )} - {badge?.label || ""} - -
- ))} -
-
-
- - {/* Optional trailing content (e.g., tooltip trigger) */} - {item.trailing && ( -
- {item.trailing} -
- )} - - {item.selected && - (item.selectedRight ?? ( - - - - ))} -
-
- - ) : ( + const itemRow = (
{ if (!item.disabled) { handleItemClick(item, close); @@ -473,9 +589,12 @@ export const PopoverMenu = ({ item.selected ? "bg-ui-controls/70 border-l-4 border-primary" : "hover:bg-ui-controls/50", + !item.selected && openTooltipIdx === index + ? "bg-ui-controls/50" + : "", item.disabled ? "!cursor-not-allowed opacity-50" - : "" + : "", )} style={{ minHeight: 48 }} > @@ -519,26 +638,80 @@ export const PopoverMenu = ({
- {/* Optional trailing content (e.g., tooltip trigger) */} {item.trailing && (
{item.trailing}
)} - {item.selected && ( - - - - )} + {item.selected && + (item.selectedRight ?? ( + + + + ))}
- )} - {item.divider && ( -
- )} + ); + + return ( +
+ {item.hoverTooltip ? ( + maxListHeight ? ( + + setOpenTooltipIdx((prev) => + open + ? index + : prev === index + ? null + : prev, + ) + } + > + {itemRow} + + ) : ( + + setOpenTooltipIdx((prev) => + open + ? index + : prev === index + ? null + : prev, + ) + } + > + {itemRow} + + ) + ) : ( + itemRow + )} + {item.divider && ( +
+ )} +
+ ); + })} +
+ {maxListHeight && canScrollDown && ( +
+
- ))} + )} {showAddButton && onAdd && (