ui model selector update with provider selection (#890)

sub menu inside model selector

<img width="646" height="705" alt="image"
src="https://github.com/user-attachments/assets/f946c8ea-d48d-4d0f-8dd6-95772b0161c1"
/>
This commit is contained in:
BFlat authored and GitHub committed 2025-10-29 15:33:47 +07:00
1 parent dc0e59b8ce
commit a7826f8be3
11 files changed
+595 -180

No files matched your search

@@ -448,20 +448,11 @@ const PageDraw = () => {
items={CANVAS_2D_PAGE_MODEL_LIST}
page={PAGE_ID}
panelTitle="Select Model"
panelClassName="min-w-[280px]"
panelClassName="min-w-[300px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
showIconsInList
triggerLabel="Model"
/>
{/*<ProviderSelector
page={PAGE_ID}
model={selectedImageModel}
providersByModel={PROVIDER_LOOKUP_BY_PAGE[PAGE_ID]}
panelTitle="Select Provider"
panelClassName="min-w-[220px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
triggerLabel="Provider"
/>*/}
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
@@ -470,20 +470,11 @@ const PageEdit = () => {
items={IMAGE_EDITOR_PAGE_MODEL_LIST}
page={PAGE_ID}
panelTitle="Select Model"
panelClassName="min-w-[280px]"
panelClassName="min-w-[300px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-base-fg opacity-80 hover:opacity-100"
showIconsInList
triggerLabel="Model"
/>
{/*<ProviderSelector
page={PAGE_ID}
model={selectedImageModel}
providersByModel={PROVIDER_LOOKUP_BY_PAGE[PAGE_ID]}
panelTitle="Select Provider"
panelClassName="min-w-[220px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-base-fg opacity-80 hover:opacity-100"
triggerLabel="Provider"
/>*/}
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
@@ -548,20 +548,11 @@ export const PageEditor = () => {
items={STAGE_3D_PAGE_MODEL_LIST}
page={PAGE_ID}
panelTitle="Select Model"
panelClassName="min-w-[280px]"
panelClassName="min-w-[300px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
showIconsInList
triggerLabel="Model"
/>
{/*<ProviderSelector
page={PAGE_ID}
model={selectedImageModel}
providersByModel={PROVIDER_LOOKUP_BY_PAGE[PAGE_ID]}
panelTitle="Select Provider"
panelClassName="min-w-[220px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
triggerLabel="Provider"
/>*/}
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
@@ -233,20 +233,11 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
page={PAGE_ID}
mode="hoverSelect"
panelTitle="Select Model"
panelClassName="min-w-[280px]"
panelClassName="min-w-[300px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
showIconsInList
triggerLabel="Model"
/>
{/*<ProviderSelector
page={PAGE_ID}
model={selectedImageModel}
providersByModel={PROVIDER_LOOKUP_BY_PAGE[PAGE_ID]}
panelTitle="Select Provider"
panelClassName="min-w-[220px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
triggerLabel="Provider"
/>*/}
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
@@ -208,20 +208,11 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
items={IMAGE_TO_VIDEO_PAGE_MODEL_LIST}
page={PAGE_ID}
panelTitle="Select Model"
panelClassName="min-w-[280px]"
panelClassName="min-w-[300px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
showIconsInList
triggerLabel="Model"
/>
{/*<ProviderSelector
page={PAGE_ID}
model={selectedVideoModel}
providersByModel={PROVIDER_LOOKUP_BY_PAGE[PAGE_ID]}
panelTitle="Select Provider"
panelClassName="min-w-[220px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
triggerLabel="Provider"
/>*/}
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
+18 -1
View File
@@ -57,12 +57,15 @@ body {
--st-controls-rgb: 62 62 65;
--st-controls-border: #3f3f3f;
--st-fg: #ffffff;
--st-fg-rgb: 255 255 255;
--st-divider: #515168;
--st-gallery-vignette-alpha: 0.6;
/* Photo tint (default neutral) */
--st-photo-tint-rgb: 0 0 0;
--st-photo-tint-alpha: 0;
--st-photo-grayscale: 1;
/* Theme-aware badge alpha (default dark) */
--st-badge-alpha: 0.4;
}
/* Light theme */
@@ -75,11 +78,14 @@ body {
--st-controls-rgb: 238 238 239;
--st-controls-border: #dcdcdf; /* requested light button border */
--st-fg: #111827;
--st-fg-rgb: 17 24 39;
--st-divider: #d1d5db;
--st-color-scheme: light;
--st-photo-tint-rgb: 0 0 0;
--st-photo-tint-alpha: 0;
--st-photo-grayscale: 1;
/* lighter badge background for light themes */
--st-badge-alpha: 0.1;
}
/* Gray (current) */
@@ -92,12 +98,14 @@ body {
--st-controls-rgb: 62 62 65;
--st-controls-border: #464646;
--st-fg: #ffffff;
--st-fg-rgb: 255 255 255;
--st-divider: #424242;
--st-color-scheme: dark;
--st-gallery-vignette-alpha: 0.3;
--st-photo-tint-rgb: 0 0 0;
--st-photo-tint-alpha: 0;
--st-photo-grayscale: 1;
--st-badge-alpha: 0.4;
}
/* Full black */
@@ -110,11 +118,13 @@ body {
--st-controls-rgb: 17 17 17;
--st-controls-border: #242424;
--st-fg: #ffffff;
--st-fg-rgb: 255 255 255;
--st-divider: #303030;
--st-color-scheme: dark;
--st-photo-tint-rgb: 0 0 0;
--st-photo-tint-alpha: 0;
--st-photo-grayscale: 1;
--st-badge-alpha: 0.4;
}
/* Aurora gradient theme */
@@ -132,11 +142,13 @@ body {
--st-controls-rgb: 26 31 53;
--st-controls-border: #1e2237;
--st-fg: #f8fafc;
--st-fg-rgb: 248 250 252;
--st-divider: #2a3152;
--st-color-scheme: dark;
--st-photo-tint-rgb: 22 32 56; /* slightly darker bluish tint */
--st-photo-tint-alpha: 0.34;
--st-photo-grayscale: 0;
--st-badge-alpha: 0.4;
}
/* Sunset gradient theme: purple to orange */
@@ -153,11 +165,13 @@ body {
--st-controls-rgb: 39 25 39;
--st-controls-border: #3a253a;
--st-fg: #f8fafc;
--st-fg-rgb: 248 250 252;
--st-divider: #4a2e3a;
--st-color-scheme: dark;
--st-photo-tint-rgb: 96 32 48; /* warmer tint for images */
--st-photo-tint-alpha: 0.3;
--st-photo-grayscale: 0;
--st-badge-alpha: 0.4;
}
/* Fallback classes hooking common UI areas to variables without refactors */
@@ -179,8 +193,11 @@ body {
.bg-ui-controls {
background: var(--st-controls);
}
.bg-ui-badge {
background-color: rgba(0, 0, 0, var(--st-badge-alpha));
}
.text-base-fg {
color: var(--st-fg);
color: rgb(var(--st-fg-rgb) / 1);
}
.border-ui-divider {
border-color: var(--st-divider);
@@ -212,6 +212,7 @@ export default {
"controls-button": "rgb(var(--st-controls-rgb) / <alpha-value>)",
divider: "var(--st-divider)",
},
"base-fg": "rgb(var(--st-fg-rgb) / <alpha-value>)",
media: {
"audio-tts": "#6AAA88",
"audio-v2v": "#6691B9",
@@ -1,7 +1,16 @@
import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
import { useClassyModelSelectorStore } from "./classy-model-selector-store";
import {
useClassyModelSelectorStore,
useSelectedProviderForModel,
} from "./classy-model-selector-store";
import { useEffect, useMemo } from "react";
import { ModelPage } from "./model-pages";
import { Provider } from "@storyteller/tauri-api";
import { getProviderDisplayName, getProviderIcon } from "./provider-icons";
import { Model } from "@storyteller/model-list";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCircleCheck, faChevronUp } from "@fortawesome/pro-solid-svg-icons";
import { PROVIDER_LOOKUP_BY_PAGE } from "./provider-lookup";
interface ClassyModelSelectorProps {
items: Omit<PopoverItem, "selected">[];
@@ -12,15 +21,98 @@ interface ClassyModelSelectorProps {
panelClassName?: string;
showIconsInList?: boolean;
triggerLabel?: string;
providersByModel?: Partial<Record<string, Provider[]>>;
providerTooltipDelayMs?: number;
}
const DEFAULT_PROVIDER_OPTIONS: Provider[] = [
Provider.ArtCraft,
Provider.Fal,
Provider.Sora,
];
function ProviderTooltipContent({
page,
modelId,
model,
modelLabel,
allowedProviders,
onFinished,
}: {
page: ModelPage;
modelId?: string;
model?: Model;
modelLabel?: string;
allowedProviders: Provider[];
onFinished?: () => void;
}) {
const { setSelectedModel, setSelectedProvider } =
useClassyModelSelectorStore();
const selectedProvider = useSelectedProviderForModel(page, modelId);
// Initialize provider for this model if missing
useEffect(() => {
if (!modelId) return;
if (!selectedProvider && allowedProviders.length > 0) {
setSelectedProvider(page, modelId, allowedProviders[0]);
}
}, [page, modelId, selectedProvider, allowedProviders, setSelectedProvider]);
if (!modelId) return null as any;
return (
<div className="flex flex-col gap-1">
<div className="mb-1 mt-0.5 px-1.5 text-sm font-normal text-base-fg opacity-70">
Select Provider
</div>
<div className="flex flex-col gap-0">
{allowedProviders.map((p) => (
<button
key={p}
onClick={() => {
if (model) {
setSelectedModel(page, model);
}
setSelectedProvider(page, modelId, p);
onFinished?.();
}}
type="button"
className={`group flex cursor-pointer items-center justify-between rounded-lg px-2 py-2 transition-all ${
selectedProvider === p
? "bg-ui-controls/70 border-l-4 border-primary"
: "hover:bg-ui-controls/50"
}`}
>
<span className="flex items-center gap-2 text-sm text-base-fg">
<span className="text-lg">{getProviderIcon(p)}</span>
{getProviderDisplayName(p)}
</span>
{selectedProvider === p && (
<span className="text-primary text-xl font-bold bg-white rounded-full p-0 h-4 w-4 flex items-center justify-center">
<FontAwesomeIcon icon={faCircleCheck} />
</span>
)}
</button>
))}
</div>
</div>
);
}
export function ClassyModelSelector({
items,
page,
providersByModel,
providerTooltipDelayMs = 300,
...popoverProps
}: ClassyModelSelectorProps) {
const { selectedModels, setSelectedModel } = useClassyModelSelectorStore();
const { selectedModels, setSelectedModel, setSelectedProvider } =
useClassyModelSelectorStore();
const selectedModel = selectedModels[page] || items[0]?.model;
const selectedProvider = useSelectedProviderForModel(page, selectedModel?.id);
const selectedProvidersByModel = useClassyModelSelectorStore(
(s) => s.selectedProviders[page] ?? {}
);
// For the first mount, make sure the selected model is set for other components to listen
useEffect(() => {
@@ -30,6 +122,23 @@ export function ClassyModelSelector({
}
}, []);
// Initialize a default provider for each model so we can render icons even when not selected
useEffect(() => {
for (const item of items) {
const modelId = item.model?.id;
if (!modelId) continue;
if (selectedProvidersByModel[modelId]) continue;
const allowed =
providersByModel?.[modelId] ??
PROVIDER_LOOKUP_BY_PAGE[page]?.[modelId] ??
DEFAULT_PROVIDER_OPTIONS;
if (allowed.length > 0) {
setSelectedProvider(page, modelId, allowed[0]);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items, providersByModel, page, selectedProvidersByModel]);
const handleModelSelect = (item: PopoverItem) => {
console.log(`Model selector changed on page "${page}": `, item.model);
setSelectedModel(page, item.model!);
@@ -37,21 +146,108 @@ export function ClassyModelSelector({
const modelList = useMemo(
() =>
items.map((item) => ({
...item,
selected: item.model === selectedModel,
})),
[items, selectedModel]
items.map((item) => {
const modelId = item.model?.id;
const allowedProviders: Provider[] = modelId
? providersByModel?.[modelId] ??
PROVIDER_LOOKUP_BY_PAGE[page]?.[modelId] ??
DEFAULT_PROVIDER_OPTIONS
: DEFAULT_PROVIDER_OPTIONS;
return {
...item,
selected: item.model === selectedModel,
hoverTooltip: (close: () => void) => (
<ProviderTooltipContent
page={page}
modelId={modelId}
model={item.model as Model | undefined}
modelLabel={item.label}
allowedProviders={allowedProviders}
onFinished={close}
/>
),
tooltipDelayMs: providerTooltipDelayMs,
trailing:
item.model !== selectedModel
? (() => {
const prov = modelId
? selectedProvidersByModel[modelId]
: undefined;
const iconProvider = prov ?? allowedProviders[0];
return iconProvider ? (
<div className="mr-1 rounded-md p-1.5 bg-ui-controls/60 group-hover:bg-ui-controls/80 transition-colors">
<span className="text-base-fg/70 group-hover:text-base-fg/90 text-lg">
{getProviderIcon(iconProvider)}
</span>
</div>
) : undefined;
})()
: undefined,
selectedRight:
item.model === selectedModel && selectedProvider ? (
<div className="mr-1 rounded-md p-1.5 bg-primary/60 group-hover:bg-primary/80 transition-colors">
<span className="text-base-fg/70 group-hover:text-base-fg/90 text-lg">
{getProviderIcon(selectedProvider)}
</span>
</div>
) : undefined,
} as PopoverItem;
}),
[
items,
selectedModel,
selectedProvider,
selectedProvidersByModel,
page,
providersByModel,
providerTooltipDelayMs,
]
);
return (
<PopoverMenu
items={modelList}
onSelect={handleModelSelect}
mode="hoverSelect"
{...popoverProps}
buttonClassName="border-0 bg-transparent p-0 hover:bg-transparent text-lg hover:opacity-80 shadow-none"
/>
<div className="flex items-center gap-3">
<span className="text-base-fg/90 text-base font-semibold">Model</span>
<PopoverMenu
items={modelList}
onSelect={handleModelSelect}
mode="hoverSelect"
{...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) => {
const modelTitle = selectedItem?.label ?? "";
const providerIcon = selectedProvider
? getProviderIcon(selectedProvider)
: null;
return (
<div className="flex items-center justify-between w-full gap-3">
<div className="flex min-w-0 flex-col">
<div className="flex items-center gap-2 min-w-0">
<span className="truncate text-base font-semibold text-base-fg">
{modelTitle}
</span>
</div>
<div className="flex items-center gap-1.5 text-base-fg/60 text-[13px] -mt-[1px]">
<span>via</span>
{providerIcon && (
<span className="opacity-70">{providerIcon}</span>
)}
<span className="truncate">
{selectedProvider
? getProviderDisplayName(selectedProvider)
: ""}
</span>
</div>
</div>
<FontAwesomeIcon
icon={faChevronUp}
className="text-base text-base-fg/70 self-center"
/>
</div>
);
}}
/>
</div>
);
}
@@ -1,3 +1,7 @@
///////////////////////////////////////////
// DEPRECATED: Use ClassyModelSelector only
///////////////////////////////////////////
import { useEffect, useMemo } from "react";
import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
import { ModelPage } from "./model-pages";
@@ -1,4 +1,4 @@
import { ReactNode, useRef, useEffect } from "react";
import { ReactNode, useRef, useEffect, useState } from "react";
import {
Popover,
Transition,
@@ -14,6 +14,7 @@ import {
faCircleCheck,
} from "@fortawesome/pro-solid-svg-icons";
import { Model, ModelInfo } from "@storyteller/model-list";
import { Tooltip } from "@storyteller/ui-tooltip";
// Global hover manager to debounce close across adjacent hover popovers
let globalCloseTimer: NodeJS.Timeout | null = null;
@@ -35,6 +36,8 @@ const scheduleGlobalClose = (fn: () => void, delayMs: number) => {
const ST_POPOVER_OPEN_EVENT = "st-popover-open";
let popoverIdCounter = 0;
type HoverTooltipContent = ReactNode | ((close: () => void) => ReactNode);
export interface PopoverItem {
label: string;
selected: boolean;
@@ -49,6 +52,14 @@ export interface PopoverItem {
}>;
modelInfo?: ModelInfo;
model?: Model; // NB: Let's migrate to using this.
// Optional trailing content rendered on the far right of each list row
// (e.g., a settings button or tooltip trigger)
trailing?: ReactNode;
// Optional tooltip content to show when hovering the entire row
hoverTooltip?: HoverTooltipContent;
tooltipDelayMs?: number;
// Optional custom right-side node shown when item is selected
selectedRight?: ReactNode;
}
interface PopoverMenuProps {
@@ -71,6 +82,7 @@ interface PopoverMenuProps {
panelActionLabel?: string;
onOpenChange?: (open: boolean) => void;
closeOnUnhover?: boolean;
renderTrigger?: (selected?: PopoverItem, open?: boolean) => ReactNode;
}
export const PopoverMenu = ({
@@ -93,8 +105,10 @@ export const PopoverMenu = ({
panelActionLabel,
onOpenChange,
closeOnUnhover = false,
renderTrigger,
}: PopoverMenuProps) => {
const selectedItem = items.find((item) => item.selected);
const [openTooltipIdx, setOpenTooltipIdx] = useState<number | null>(null);
const handleItemClick = (item: PopoverItem, close: () => void) => {
if (mode === "button" && item.action && onPanelAction) {
@@ -260,22 +274,31 @@ export const PopoverMenu = ({
}}
ref={popoverButtonRef}
>
{triggerIcon}
{mode === "toggle" && selectedItem ? (
<span className="truncate">{selectedItem.label}</span>
) : null}
{mode === "default" && triggerLabel ? (
<span className="truncate">{triggerLabel}</span>
) : null}
{mode === "hoverSelect" && selectedItem ? (
<div className="flex items-center gap-1.5">
<span className="opacity-70">{triggerLabel}</span>
<div className="flex items-center gap-2">
{renderTrigger ? (
renderTrigger(selectedItem, open)
) : (
<>
{triggerIcon}
{mode === "toggle" && selectedItem ? (
<span className="truncate">{selectedItem.label}</span>
<FontAwesomeIcon icon={faChevronUp} className="text-sm" />
</div>
</div>
) : null}
) : null}
{mode === "default" && triggerLabel ? (
<span className="truncate">{triggerLabel}</span>
) : null}
{mode === "hoverSelect" && selectedItem ? (
<div className="flex items-center gap-1.5">
<span className="opacity-70">{triggerLabel}</span>
<div className="flex items-center gap-2">
<span className="truncate">{selectedItem.label}</span>
<FontAwesomeIcon
icon={faChevronUp}
className="text-sm"
/>
</div>
</div>
) : null}
</>
)}
</PopoverButton>
<Transition
@@ -339,70 +362,178 @@ export const PopoverMenu = ({
<div className="flex flex-col gap-0 text-sm text-base-fg">
{items.map((item, index) => (
<div key={index}>
<div
onClick={() => {
if (!item.disabled) {
handleItemClick(item, close);
{item.hoverTooltip ? (
<Tooltip
content={
typeof item.hoverTooltip === "function"
? (
item.hoverTooltip as (
close: () => void
) => ReactNode
)(close)
: item.hoverTooltip
}
}}
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.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>
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"
: ""
)}
<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>
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>
{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>
{item.description && (
<div className="truncate text-xs text-base-fg/60 mt-0.5">
{item.description}
{/* Optional trailing content (e.g., tooltip trigger) */}
{item.trailing && (
<div className="ml-2 mr-1 flex items-center">
{item.trailing}
</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-black/40 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>
{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.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>
</Tooltip>
) : (
<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.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>
{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 && (
<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>
</div>
)}
{item.divider && (
<div className="my-1 border-t border-white/10" />
)}
@@ -428,59 +559,164 @@ export const PopoverMenu = ({
<div className="flex flex-col gap-0 text-sm text-base-fg">
{items.map((item, index) => (
<div key={index}>
<Button
className={twMerge(
"flex w-full items-center shadow-none justify-between px-1.5",
"bg-transparent hover:bg-ui-controls/60",
mode === "toggle" && item.selected
? "hover:bg-ui-controls/80"
: "",
item.disabled
? "!cursor-not-allowed opacity-50"
: "",
"border-0"
)}
onClick={() =>
!item.disabled && handleItemClick(item, close)
}
variant="secondary"
disabled={item.disabled}
>
<div className="flex items-center gap-2 truncate">
{showIconsInList && item.icon}
{mode === "toggle" ? (
{item.hoverTooltip ? (
<Tooltip
content={
typeof item.hoverTooltip === "function"
? (
item.hoverTooltip as (
close: () => void
) => ReactNode
)(close)
: item.hoverTooltip
}
position="right"
delay={item.tooltipDelayMs ?? 1000}
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
)
}
>
<Button
className={twMerge(
"flex w-full items-center shadow-none justify-between px-1.5",
"bg-transparent hover:bg-ui-controls/60",
openTooltipIdx === index
? "bg-ui-controls/60"
: "",
mode === "toggle" && item.selected
? "hover:bg-ui-controls/80"
: "",
item.disabled
? "!cursor-not-allowed opacity-50"
: "",
"border-0"
)}
onClick={() =>
!item.disabled &&
handleItemClick(item, close)
}
variant="secondary"
disabled={item.disabled}
>
<div className="flex items-center gap-2 truncate">
{showIconsInList && item.icon}
{mode === "toggle" ? (
<span
className={twMerge(
"truncate",
item.selected
? "text-base-fg"
: "text-base-fg/70"
)}
>
{item.label}
</span>
) : (
<span className="truncate">
{item.label}
</span>
)}
</div>
{/* Optional trailing content on the right */}
{item.trailing && (
<div className="ml-2 flex items-center">
{item.trailing}
</div>
)}
{/* Optional trailing content on the right */}
{item.trailing && (
<div className="ml-2 flex items-center">
{item.trailing}
</div>
)}
{mode === "toggle" && (
<span
className={twMerge(
"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"
)}
>
{item.selected && (
<FontAwesomeIcon
icon={faCheck}
className="text-base-fg text-xs font-bold"
/>
)}
</span>
)}
</Button>
</Tooltip>
) : (
<Button
className={twMerge(
"flex w-full items-center shadow-none justify-between px-1.5",
"bg-transparent hover:bg-ui-controls/60",
mode === "toggle" && item.selected
? "hover:bg-ui-controls/80"
: "",
item.disabled
? "!cursor-not-allowed opacity-50"
: "",
"border-0"
)}
onClick={() =>
!item.disabled && handleItemClick(item, close)
}
variant="secondary"
disabled={item.disabled}
>
<div className="flex items-center gap-2 truncate">
{showIconsInList && item.icon}
{mode === "toggle" ? (
<span
className={twMerge(
"truncate",
item.selected
? "text-base-fg"
: "text-base-fg/70"
)}
>
{item.label}
</span>
) : (
<span className="truncate">
{item.label}
</span>
)}
</div>
{/* Optional trailing content on the right */}
{item.trailing && (
<div className="ml-2 flex items-center">
{item.trailing}
</div>
)}
{mode === "toggle" && (
<span
className={twMerge(
"truncate",
"ml-2 h-5 w-5 rounded-full border flex items-center justify-center transition-colors",
item.selected
? "text-base-fg"
: "text-base-fg/70"
? "border-primary bg-primary"
: "border-transparent bg-transparent"
)}
>
{item.label}
{item.selected && (
<FontAwesomeIcon
icon={faCheck}
className="text-base-fg text-xs font-bold"
/>
)}
</span>
) : (
<span className="truncate">{item.label}</span>
)}
</div>
{mode === "toggle" && (
<span
className={twMerge(
"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"
)}
>
{item.selected && (
<FontAwesomeIcon
icon={faCheck}
className="text-base-fg text-xs font-bold"
/>
)}
</span>
)}
</Button>
</Button>
)}
{item.divider && (
<div className="my-1 border-t border-white/10" />
)}
@@ -16,6 +16,7 @@ interface TooltipProps {
* immediately when the cursor leaves the trigger. Useful for menus.
*/
interactive?: boolean;
onOpenChange?: (open: boolean) => void;
}
export const Tooltip = ({
@@ -28,6 +29,7 @@ export const Tooltip = ({
imageSrc,
description,
interactive = false,
onOpenChange,
}: TooltipProps) => {
const [isShowing, setIsShowing] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
@@ -122,6 +124,10 @@ export const Tooltip = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isHoveringTrigger, isHoveringTooltip, interactive]);
useEffect(() => {
onOpenChange?.(isShowing);
}, [isShowing, onOpenChange]);
return (
<div
ref={triggerRef}