diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js index 06747e03..63c64568 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js @@ -1,5 +1,6 @@ import PropTypes from "prop-types"; import { CapacityBadges } from "@/shared/components"; +import ActionMenu from "@/shared/components/ActionMenu"; export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onRemove, onTest, isTesting, onDisable, caps, thinkingSuffix }) { const displayModel = thinkingSuffix ? `${fullModel}(${thinkingSuffix})` : fullModel; @@ -16,8 +17,30 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test : undefined; const deleteModel = onRemove || (isCustom ? onDeleteAlias : onDisable); const deletesPermanently = !!deleteModel; - - const actionButtonClass = "inline-flex size-7 items-center justify-center rounded-md text-text-muted transition-[background-color,color,transform,box-shadow] duration-150 hover:bg-background hover:text-text-main focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"; + const actionItems = [ + ...(onTest ? [{ + id: "test", + icon: isTesting ? "progress_activity" : "science", + label: isTesting ? "Testing model…" : "Test model", + onSelect: onTest, + disabled: isTesting, + spinning: isTesting, + }] : []), + { + id: "copy", + icon: copied === `model-${model.id}` ? "check" : "content_copy", + label: copied === `model-${model.id}` ? "Model ID copied" : "Copy model ID", + onSelect: () => onCopy(displayModel, `model-${model.id}`), + }, + ...(deleteModel ? [{ + id: "delete", + icon: "delete", + label: deletesPermanently ? "Permanently delete model" : "Hide model", + onSelect: deleteModel, + danger: true, + dividerBefore: true, + }] : []), + ]; return (
@@ -35,44 +58,8 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test
-
- {onTest && ( - - )} - - {deleteModel &&
diff --git a/src/shared/components/ActionMenu.js b/src/shared/components/ActionMenu.js new file mode 100644 index 00000000..156387aa --- /dev/null +++ b/src/shared/components/ActionMenu.js @@ -0,0 +1,128 @@ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; +import PropTypes from "prop-types"; + +const MENU_WIDTH = 192; +const VIEWPORT_GUTTER = 12; + +/** + * Compact, reusable overflow menu for contextual row actions. + */ +export default function ActionMenu({ ariaLabel, items, title = "More options" }) { + const [isOpen, setIsOpen] = useState(false); + const [opensRight, setOpensRight] = useState(true); + const menuRef = useRef(null); + const triggerRef = useRef(null); + const menuId = useId(); + + const updateHorizontalPlacement = () => { + const triggerRect = triggerRef.current?.getBoundingClientRect(); + if (!triggerRect) return; + + setOpensRight(triggerRect.left + MENU_WIDTH <= window.innerWidth - VIEWPORT_GUTTER); + }; + + useEffect(() => { + if (!isOpen) return undefined; + + const handlePointerDown = (event) => { + if (!menuRef.current?.contains(event.target)) setIsOpen(false); + }; + + const handleKeyDown = (event) => { + if (event.key === "Escape") { + setIsOpen(false); + menuRef.current?.querySelector("button")?.focus(); + } + }; + + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + window.addEventListener("resize", updateHorizontalPlacement); + updateHorizontalPlacement(); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("resize", updateHorizontalPlacement); + }; + }, [isOpen]); + + const handleSelect = (item) => { + if (item.disabled) return; + setIsOpen(false); + item.onSelect(); + }; + + return ( +
+ + + {isOpen && ( + + )} +
+ ); +} + +ActionMenu.propTypes = { + ariaLabel: PropTypes.string.isRequired, + items: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string.isRequired, + icon: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + onSelect: PropTypes.func.isRequired, + disabled: PropTypes.bool, + dividerBefore: PropTypes.bool, + danger: PropTypes.bool, + spinning: PropTypes.bool, + })).isRequired, + title: PropTypes.string, +}; \ No newline at end of file diff --git a/src/shared/components/index.js b/src/shared/components/index.js index 0941377e..101a121a 100644 --- a/src/shared/components/index.js +++ b/src/shared/components/index.js @@ -21,6 +21,7 @@ export { default as McpMarketplaceModal } from "./McpMarketplaceModal"; export { default as UsageStats } from "./UsageStats"; export { default as LanguageSwitcher } from "./LanguageSwitcher"; export { default as HeaderMenu } from "./HeaderMenu"; +export { default as ActionMenu } from "./ActionMenu"; export { default as ChangelogModal } from "./ChangelogModal"; export { default as RequestLogger } from "./RequestLogger"; export { default as KiroAuthModal } from "./KiroAuthModal";