scrolling model list update (#942)

This commit is contained in:
BFlat authored and GitHub committed 2025-12-22 16:59:53 +07:00
1 parent 19258c257c
commit 68ff2b484b
3 files changed
+316 -140

No files matched your search

@@ -23,6 +23,7 @@ interface ClassyModelSelectorProps {
triggerLabel?: string;
providersByModel?: Partial<Record<string, Provider[]>>;
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) => {
@@ -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<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const [isHoveringTrigger, setIsHoveringTrigger] = useState(false);
const [isHoveringTooltip, setIsHoveringTooltip] = useState(false);
const showTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(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 (
<>
<div
ref={triggerRef}
onMouseEnter={() => setIsHoveringTrigger(true)}
onMouseLeave={() => setIsHoveringTrigger(false)}
>
{children}
</div>
{isShowing &&
isVisible &&
createPortal(
<div
ref={tooltipRef}
onMouseEnter={() => 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}
</div>,
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<number | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(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",
)}
>
<div
className={twMerge(
"z-10 min-w-48 mt-2 rounded-lg bg-ui-panel p-1.5 shadow-lg border border-ui-panel-border",
"z-10 min-w-48 mt-2 rounded-lg bg-ui-panel p-1.5 shadow-lg border border-ui-panel-border overflow-visible",
position === "top" ? "mb-2" : "mt-2",
panelClassName
panelClassName,
)}
onMouseEnter={handlePanelMouseEnter}
onMouseLeave={() => handlePanelMouseLeave(close)}
@@ -359,110 +541,44 @@ export const PopoverMenu = ({
: children}
</div>
) : mode === "hoverSelect" ? (
<div className="flex flex-col gap-0 text-sm text-base-fg">
{items.map((item, index) => (
<div key={index}>
{item.hoverTooltip ? (
<Tooltip
content={
typeof item.hoverTooltip === "function"
? (
item.hoverTooltip as (
close: () => void
) => ReactNode
)(close)
: item.hoverTooltip
<div className="relative flex flex-col text-sm text-base-fg overflow-visible">
{maxListHeight && canScrollUp && (
<div className="absolute top-0 left-0 right-0 z-20 flex justify-center bg-gradient-to-b from-ui-panel via-ui-panel/80 to-transparent py-1.5 pointer-events-none">
<FontAwesomeIcon
icon={faChevronUp}
className="text-base-fg/60 text-xs animate-bounce"
/>
</div>
)}
<div
ref={scrollContainerRef}
data-scroll-container
className="flex flex-col gap-0 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none] transition-opacity duration-75"
style={
maxListHeight
? {
maxHeight: maxListHeight,
overflowY: "auto",
opacity: scrollReady ? 1 : 0,
}
position="right"
delay={item.tooltipDelayMs ?? 300}
interactive
className="!pointer-events-auto z-50 min-w-48 rounded-lg bg-ui-panel p-1.5 shadow-lg border border-ui-panel-border"
onOpenChange={(open) =>
setOpenTooltipIdx((prev) =>
open ? index : prev === index ? null : prev
)
}
>
<div
onClick={() => {
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 }}
>
<div className="flex items-center gap-2 w-full">
<div className="flex items-start gap-2 grow">
{showIconsInList && (
<span className="mt-1 flex h-5 w-5 items-center justify-center text-lg text-base-fg/80">
{item.icon}
</span>
)}
<div className="flex flex-1 flex-col min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="truncate font-semibold text-base-fg text-base">
{item.label}
</span>
</div>
: undefined
}
>
{items.map((item, index) => {
const tooltipContent =
typeof item.hoverTooltip === "function"
? (
item.hoverTooltip as (
close: () => void,
) => ReactNode
)(close)
: item.hoverTooltip;
{item.description && (
<div className="truncate text-xs text-base-fg/60 mt-0.5">
{item.description}
</div>
)}
<div className="flex flex-row gap-1 flex-wrap mt-1.5">
{item.badges &&
Array.isArray(item.badges) &&
item.badges.map((badge, i) => (
<div
key={i}
className="flex items-center gap-1 min-w-0"
>
<span className="inline-flex items-center rounded bg-ui-badge px-1.5 py-0.5 text-xs font-medium text-base-fg gap-1">
{badge?.icon && (
<span>{badge.icon}</span>
)}
{badge?.label || ""}
</span>
</div>
))}
</div>
</div>
</div>
{/* Optional trailing content (e.g., tooltip trigger) */}
{item.trailing && (
<div className="ml-2 mr-1 flex items-center">
{item.trailing}
</div>
)}
{item.selected &&
(item.selectedRight ?? (
<span className="text-primary text-xl font-bold bg-white rounded-full p-0 h-4 w-4 flex items-center justify-center mr-1">
<FontAwesomeIcon
icon={faCircleCheck}
/>
</span>
))}
</div>
</div>
</Tooltip>
) : (
const itemRow = (
<div
data-selected={
item.selected ? "true" : undefined
}
onClick={() => {
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 = ({
</div>
</div>
{/* Optional trailing content (e.g., tooltip trigger) */}
{item.trailing && (
<div className="ml-2 mr-1 flex items-center">
{item.trailing}
</div>
)}
{item.selected && (
<span className="text-primary text-xl font-bold bg-white rounded-full p-0 h-4 w-4 flex items-center justify-center mr-1">
<FontAwesomeIcon icon={faCircleCheck} />
</span>
)}
{item.selected &&
(item.selectedRight ?? (
<span className="text-primary text-xl font-bold bg-white rounded-full p-0 h-4 w-4 flex items-center justify-center mr-1">
<FontAwesomeIcon icon={faCircleCheck} />
</span>
))}
</div>
</div>
)}
{item.divider && (
<div className="my-1 border-t border-white/10" />
)}
);
return (
<div key={index}>
{item.hoverTooltip ? (
maxListHeight ? (
<PortalTooltip
content={tooltipContent}
delay={item.tooltipDelayMs ?? 300}
className="min-w-48"
onOpenChange={(open) =>
setOpenTooltipIdx((prev) =>
open
? index
: prev === index
? null
: prev,
)
}
>
{itemRow}
</PortalTooltip>
) : (
<Tooltip
content={tooltipContent}
position="right"
delay={item.tooltipDelayMs ?? 300}
interactive
className="!pointer-events-auto z-50 min-w-48 rounded-lg bg-ui-panel p-1.5 shadow-lg border border-ui-panel-border"
onOpenChange={(open) =>
setOpenTooltipIdx((prev) =>
open
? index
: prev === index
? null
: prev,
)
}
>
{itemRow}
</Tooltip>
)
) : (
itemRow
)}
{item.divider && (
<div className="my-1 border-t border-white/10" />
)}
</div>
);
})}
</div>
{maxListHeight && canScrollDown && (
<div className="absolute bottom-0 left-0 right-0 z-20 flex justify-center bg-gradient-to-t from-ui-panel via-ui-panel/80 to-transparent py-1.5 pointer-events-none">
<FontAwesomeIcon
icon={faChevronDown}
className="text-base-fg/60 text-xs animate-bounce"
/>
</div>
))}
)}
{showAddButton && onAdd && (
<Button
variant="secondary"
@@ -546,7 +719,7 @@ export const PopoverMenu = ({
"w-full mb-0.5 mt-2 border-none py-1",
disableAddButton
? "cursor-not-allowed bg-[#7B7B84]/50 opacity-50"
: "bg-[#7B7B84] hover:bg-[#8c8c96]"
: "bg-[#7B7B84] hover:bg-[#8c8c96]",
)}
onClick={onAdd}
disabled={disableAddButton}
@@ -565,7 +738,7 @@ export const PopoverMenu = ({
typeof item.hoverTooltip === "function"
? (
item.hoverTooltip as (
close: () => void
close: () => void,
) => ReactNode
)(close)
: item.hoverTooltip
@@ -576,7 +749,7 @@ export const PopoverMenu = ({
className="!pointer-events-auto z-50 min-w-48 rounded-lg bg-ui-panel p-1.5 shadow-lg border border-ui-panel-border"
onOpenChange={(open) =>
setOpenTooltipIdx((prev) =>
open ? index : prev === index ? null : prev
open ? index : prev === index ? null : prev,
)
}
>
@@ -593,7 +766,7 @@ export const PopoverMenu = ({
item.disabled
? "!cursor-not-allowed opacity-50"
: "",
"border-0"
"border-0",
)}
onClick={() =>
!item.disabled &&
@@ -610,7 +783,7 @@ export const PopoverMenu = ({
"truncate",
item.selected
? "text-base-fg"
: "text-base-fg/70"
: "text-base-fg/70",
)}
>
{item.label}
@@ -640,7 +813,7 @@ export const PopoverMenu = ({
"ml-2 h-5 w-5 rounded-full border flex items-center justify-center transition-colors",
item.selected
? "border-primary bg-primary"
: "border-transparent bg-transparent"
: "border-transparent bg-transparent",
)}
>
{item.selected && (
@@ -664,7 +837,7 @@ export const PopoverMenu = ({
item.disabled
? "!cursor-not-allowed opacity-50"
: "",
"border-0"
"border-0",
)}
onClick={() =>
!item.disabled && handleItemClick(item, close)
@@ -680,7 +853,7 @@ export const PopoverMenu = ({
"truncate",
item.selected
? "text-base-fg"
: "text-base-fg/70"
: "text-base-fg/70",
)}
>
{item.label}
@@ -704,7 +877,7 @@ export const PopoverMenu = ({
"ml-2 h-5 w-5 rounded-full border flex items-center justify-center transition-colors",
item.selected
? "border-primary bg-primary"
: "border-transparent bg-transparent"
: "border-transparent bg-transparent",
)}
>
{item.selected && (
@@ -729,7 +902,7 @@ export const PopoverMenu = ({
"w-full mb-0.5 mt-2 py-1 border-0",
disableAddButton
? "cursor-not-allowed opacity-50"
: ""
: "",
)}
onClick={onAdd}
disabled={disableAddButton}
@@ -153,7 +153,7 @@ export const Tooltip = ({
show={isShowing}
enter={twMerge(
"transition ease-out duration-200",
delay ? `delay-[${delay}ms]` : "delay-[300ms]"
delay ? `delay-[${delay}ms]` : "delay-[300ms]",
)}
enterFrom="opacity-0"
enterTo="opacity-100"
@@ -184,7 +184,7 @@ export const Tooltip = ({
? "pointer-events-auto p-3"
: "px-2.5 py-1.5 text-[13px] font-medium pointer-events-none",
"text-base-fg",
className ? className : ""
className ? className : "",
)}
>
{interactive ? (