Files
supabase/apps/studio/components/ui/AiAssistantDropdown.tsx
kemal.earth 88e916a4c0 fix(studio): focus state for buttons with dropdown (#49055)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

We've quite a few instances where some buttons have a dropdown
appendage. The focus state for these were broken as well as visually
regarding the separator. This first pass fixes the instances we have in
studio, I've left potentially adding this to our design system fragment
components as another PR.

| Before | After |
|--------|--------|
| <img width="531" height="133" alt="Screenshot 2026-08-13 at 11 29 36"
src="https://github.com/user-attachments/assets/70747fd0-11d4-4670-85fa-d76f3564837b"
/> | <img width="519" height="130" alt="Screenshot 2026-08-13 at 11 40
15"
src="https://github.com/user-attachments/assets/5920bebb-81ce-4962-908b-5f61526ca7ca"
/> |
| <img width="538" height="146" alt="Screenshot 2026-08-13 at 11 29 48"
src="https://github.com/user-attachments/assets/f1c7018f-cd43-47fa-b4c0-045af350f80b"
/> | <img width="515" height="148" alt="Screenshot 2026-08-13 at 11 39
56"
src="https://github.com/user-attachments/assets/fee09113-433a-4b5d-ac68-3ab3f2565d34"
/> |
| <img width="538" height="143" alt="Screenshot 2026-08-13 at 11 29 52"
src="https://github.com/user-attachments/assets/3063d391-99b2-4599-9cdf-edd0b2cdfdf0"
/> | <img width="529" height="159" alt="Screenshot 2026-08-13 at 11 40
05"
src="https://github.com/user-attachments/assets/4bdeb9e0-8324-45df-a211-8c7fd0ee11a6"
/> |




<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved keyboard focus visibility across Studio controls, including
token management, email settings, replication, log drains, query
insights, infrastructure, storage, and assistant actions.
* Focused buttons in adjacent or split-button groups now appear above
neighboring controls, preventing borders and overlays from obscuring the
active selection.
* Preserved existing button behavior, layout, and appearance while
improving focus-state clarity.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-13 12:45:06 +01:00

218 lines
5.7 KiB
TypeScript

import { AiAssistantSource } from 'common/telemetry-constants'
import { Chatgpt, Claude } from 'icons'
import { Check, ChevronDown, Copy } from 'lucide-react'
import Link from 'next/link'
import { ComponentProps, ReactNode, useEffect, useState } from 'react'
import {
AiIconAnimation,
Button,
cn,
copyToClipboard,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { useTrack } from '@/lib/telemetry/track'
type TelemetrySource = AiAssistantSource
const EXTERNAL_AI_TOOLS = [
{
label: 'Ask ChatGPT',
url: 'https://chatgpt.com/',
promptParam: 'q',
icon: Chatgpt,
toolId: 'chatgpt' as const,
},
{
label: 'Ask Claude',
url: 'https://claude.ai/new',
promptParam: 'q',
icon: Claude,
toolId: 'claude' as const,
},
]
export interface AiAssistantDropdownItem {
label: string
icon?: ReactNode
href?: string
onClick?: () => void
}
export interface AiAssistantDropdownProps {
buildPrompt: () => string
label: string
iconOnly?: boolean
onOpenAssistant: () => void
onCopyPrompt?: () => void
telemetrySource?: TelemetrySource
size?: ComponentProps<typeof Button>['size']
variant?: ComponentProps<typeof Button>['variant']
disabled?: boolean
loading?: boolean
className?: string
tooltip?: string
copyLabel?: string
showExternalAI?: boolean
additionalDropdownItems?: AiAssistantDropdownItem[]
}
export function AiAssistantDropdown({
buildPrompt,
label,
iconOnly = false,
onOpenAssistant,
onCopyPrompt,
telemetrySource,
size = 'tiny',
variant = 'default',
disabled = false,
loading = false,
className,
tooltip,
copyLabel = 'Copy prompt',
showExternalAI = false,
additionalDropdownItems,
}: AiAssistantDropdownProps) {
const track = useTrack()
const [showCopied, setShowCopied] = useState(false)
const [isOpen, setIsOpen] = useState(false)
useEffect(() => {
if (!showCopied) return
const timer = setTimeout(() => setShowCopied(false), 2000)
return () => clearTimeout(timer)
}, [showCopied])
const handleCopyPrompt = () => {
const prompt = buildPrompt()
copyToClipboard(prompt)
setShowCopied(true)
setIsOpen(false)
onCopyPrompt?.()
if (telemetrySource) {
track('ai_prompt_copied', { source: telemetrySource })
}
}
const handleOpenExternalAI = (tool: (typeof EXTERNAL_AI_TOOLS)[number]) => {
const prompt = buildPrompt()
window.open(
`${tool.url}?${tool.promptParam}=${encodeURIComponent(prompt)}`,
'_blank',
'noreferrer'
)
if (telemetrySource) {
track('ai_external_tool_clicked', { source: telemetrySource, tool: tool.toolId })
}
}
const handleOpenAssistant = () => {
onOpenAssistant()
if (telemetrySource) {
track('ai_assistant_dropdown_button_clicked', { source: telemetrySource })
}
}
const buttonContent = (
<div className={cn('flex items-center', iconOnly ? 'gap-0' : 'gap-0')}>
{/* Main button */}
<Button
variant={variant}
size={size}
disabled={disabled}
onClick={handleOpenAssistant}
icon={<AiIconAnimation size={iconOnly ? 16 : 14} loading={loading} />}
className={cn(
'rounded-r-none border-r-0 focus-visible:z-10',
iconOnly && 'px-1.5',
className
)}
>
{!iconOnly && label}
</Button>
{/* Dropdown trigger */}
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button
aria-label="More actions"
variant={variant}
size={size}
disabled={disabled}
className={cn('rounded-l-none px-1 focus-visible:z-10', iconOnly && 'px-1')}
icon={<ChevronDown size={12} />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onClick={handleCopyPrompt} className="gap-2">
{showCopied ? <Check size={14} className="text-brand" /> : <Copy size={14} />}
{showCopied ? 'Copied!' : copyLabel}
</DropdownMenuItem>
{showExternalAI && (
<>
<DropdownMenuSeparator />
{EXTERNAL_AI_TOOLS.map((tool) => (
<DropdownMenuItem
key={tool.url}
className="gap-2"
onClick={() => handleOpenExternalAI(tool)}
>
<tool.icon size={14} />
{tool.label}
</DropdownMenuItem>
))}
</>
)}
{additionalDropdownItems && additionalDropdownItems.length > 0 && (
<>
<DropdownMenuSeparator />
{additionalDropdownItems.map((item, i) => (
<DropdownMenuItem key={i} onClick={item.onClick} className="gap-2">
{item.href ? (
<Link href={item.href} target="_blank" rel="noreferrer">
{item.icon}
{item.label}
</Link>
) : (
<>
{item.icon}
{item.label}
</>
)}
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
// Wrap in tooltip for icon-only mode
if (iconOnly && tooltip) {
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-flex">{buttonContent}</div>
</TooltipTrigger>
<TooltipContent side="bottom">{tooltip}</TooltipContent>
</Tooltip>
)
}
return buttonContent
}