diff --git a/src/components/skills/view/ProviderSkills.tsx b/src/components/skills/view/ProviderSkills.tsx index 33c42e98..e3a11216 100644 --- a/src/components/skills/view/ProviderSkills.tsx +++ b/src/components/skills/view/ProviderSkills.tsx @@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next'; import { cn } from '../../../lib/utils'; import { + ActionMenu, Badge, Button, Card, @@ -529,7 +530,7 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr const hermesHubPanel = selectedProvider === 'hermes' ? (
-
+
: } Search + ({ + key: action.action, + label: action.label, + description: action.description, + icon: action.icon, + loading: registryBusyKey === action.action, + disabled: Boolean(registryBusyKey && registryBusyKey !== action.action), + onSelect: () => void runRegistryMaintenance(action.action), + }))} + />
{registryResults.length > 0 && ( @@ -610,34 +626,6 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
)} -
-
- - Hub Maintenance -
-
- {HERMES_SKILL_ACTIONS.map((action) => { - const Icon = action.icon; - return ( - - ); - })} -
-
) : null; diff --git a/src/shared/view/ui/ActionMenu.tsx b/src/shared/view/ui/ActionMenu.tsx new file mode 100644 index 00000000..bf83690d --- /dev/null +++ b/src/shared/view/ui/ActionMenu.tsx @@ -0,0 +1,189 @@ +import * as React from 'react'; +import { ChevronDown, Loader2, type LucideIcon } from 'lucide-react'; + +import { cn } from '../../../lib/utils'; + +import { Button } from './Button'; + +type ButtonVariant = 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; +type ButtonSize = 'default' | 'sm' | 'lg' | 'icon'; + +export type ActionMenuItem = { + key: string; + label: string; + description?: string; + icon?: LucideIcon; + onSelect: () => void; + disabled?: boolean; + loading?: boolean; + isDanger?: boolean; + showDividerBefore?: boolean; +}; + +type ActionMenuProps = { + label: string; + items: ActionMenuItem[]; + icon?: LucideIcon; + ariaLabel?: string; + align?: 'left' | 'right'; + variant?: ButtonVariant; + size?: ButtonSize; + className?: string; + triggerClassName?: string; + disabled?: boolean; +}; + +export default function ActionMenu({ + label, + items, + icon: TriggerIcon, + ariaLabel, + align = 'right', + variant = 'outline', + size = 'sm', + className, + triggerClassName, + disabled, +}: ActionMenuProps) { + const [isOpen, setIsOpen] = React.useState(false); + const rootRef = React.useRef(null); + const triggerRef = React.useRef(null); + const menuRef = React.useRef(null); + // Whether closing should move focus back to the trigger. Set for keyboard + // (Escape) and item selection, but left false for outside pointer clicks so + // focus is not stolen from wherever the user clicked. + const restoreFocusRef = React.useRef(false); + const wasOpenRef = React.useRef(false); + const menuId = React.useId(); + + React.useEffect(() => { + if (!isOpen) { + return; + } + + const closeOnOutsideClick = (event: MouseEvent) => { + const target = event.target as Node; + if (rootRef.current && !rootRef.current.contains(target)) { + setIsOpen(false); + } + }; + + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + restoreFocusRef.current = true; + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', closeOnOutsideClick); + document.addEventListener('keydown', closeOnEscape); + return () => { + document.removeEventListener('mousedown', closeOnOutsideClick); + document.removeEventListener('keydown', closeOnEscape); + }; + }, [isOpen]); + + // Move focus into the menu on open and back to the trigger on a keyboard or + // selection close, so keyboard and screen-reader navigation match the menu role. + React.useEffect(() => { + if (isOpen) { + wasOpenRef.current = true; + const menu = menuRef.current; + const firstItem = menu?.querySelector('[role="menuitem"]:not([disabled])'); + (firstItem ?? menu)?.focus(); + return; + } + + if (wasOpenRef.current) { + wasOpenRef.current = false; + if (restoreFocusRef.current) { + triggerRef.current?.focus(); + } + restoreFocusRef.current = false; + } + }, [isOpen]); + + const runItem = (item: ActionMenuItem) => { + if (item.disabled || item.loading) { + return; + } + + restoreFocusRef.current = true; + setIsOpen(false); + item.onSelect(); + }; + + return ( +
+ + + {isOpen && ( + + ); +} diff --git a/src/shared/view/ui/index.ts b/src/shared/view/ui/index.ts index c75e952f..2665fcd7 100644 --- a/src/shared/view/ui/index.ts +++ b/src/shared/view/ui/index.ts @@ -1,4 +1,6 @@ export { Alert, AlertTitle, AlertDescription, alertVariants } from './Alert'; +export { default as ActionMenu } from './ActionMenu'; +export type { ActionMenuItem } from './ActionMenu'; export { Badge, badgeVariants } from './Badge'; export { Button, buttonVariants } from './Button'; export { Confirmation, ConfirmationTitle, ConfirmationRequest, ConfirmationAccepted, ConfirmationRejected, ConfirmationActions, ConfirmationAction } from './Confirmation';