Files
supabase/apps/studio/components/ui/AIAssistantPanel/SupportAssistantSuccessCardContent.tsx
Danny White 3bca21b3f8 chore(a11y): convert leftover focus recipes to focus-ring (#48219)
## What kind of change does this PR introduce?

Accessibility cleanup (DEPR-628).

## What is the current behavior?

Leftover call sites still use ad-hoc focus recipes
(`ring-foreground-muted`, `outline-brand`, Dialog/Sheet `focus:` rings,
etc.) instead of the shared utilities from #41575.

## What is the new behavior?

Converts those leftovers across `packages/ui`, Studio, www, docs, and
design-system to `focus-ring`, preferring `focus-visible`. Keeps
documented exceptions (`group-focus-visible`, InputGroup `:has()`).

## To test

Tab through controls (keyboard only). Expect a consistent offset ring on
`:focus-visible`, not a green/brand/custom stack, and no ring animation.

### www (marketing)

Preview:
https://zone-www-dot-com-git-danny-depr-628-focus-ring-fbccf9-supabase.vercel.app

- Global nav on `/`: Product, Developers, Solutions dropdowns; logo;
hamburger + mobile menu
- `/features`: view toggles and feature cards
- `/company`: card links
- `/changelog`: timeline / entry links
- `/partners/catalog`: grid/list toggle and partner cards
- `/pricing`: compute section expand control
- Product / Modules / Solutions sticky navs on product pages (e.g.
`/database`, `/storage`)
- `/state-of-startups`: TwoOptionToggle if present

### docs

Preview:
https://docs-git-danny-depr-628-focus-ring-long-tail-supabase.vercel.app

- Any guide page: top nav dropdowns and items
- Narrow viewport: hamburger, then mobile menu links + close
- Guide with PromptPanel / tabs: tab to prompt actions and tab list

### studio (dashboard)

Preview:
https://studio-staging-git-danny-depr-628-focus-ring-long-tail-supabase.vercel.app

- Project home: Connect section tiles; drag-handle focus on sortable
sections
- Integrations marketplace (`/project/<ref>/integrations`): featured
cards, list/grid toggle, list rows
- Auth (`/project/<ref>/auth/oauth-apps`,
`/project/<ref>/auth/providers`): open create/edit sheet, tab to close
(X)
- Database policies (`/project/<ref>/database/policies`): open policy
editor sheet, tab to close
- Storage policies (`/project/<ref>/storage/files/policies`): bucket
section links; policy modal close
- Query performance (`/project/<ref>/observability/query-performance`):
info icon buttons on metrics
- Replication pipeline detail (if available): slot lag / status info
icons
- Support (`/support/new`): attachment add/remove controls
- Table editor: spreadsheet import preview checkboxes; row text/JSON
editor TwoOptionToggle
- Any Dialog/Sheet/toast close (X): ring on keyboard focus only, not
mouse click

### design-system

Preview:
https://design-system-git-danny-depr-628-focus-ring-long-tail-supabase.vercel.app

- Colour palette swatches (keyboard focus)
- Form patterns sidepanel example: avatar / focusable control in the
example

## Additional context

- Linear: [DEPR-628](https://linear.app/supabase/issue/DEPR-628)
- Follow-ups: form-group CSS (DEPR-629), Storage columns selection
(DEPR-630), ESLint rule (DEPR-632)

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

* **Accessibility & Usability**
* Standardized keyboard focus indicators across navigation, dialogs,
forms, buttons, toggles, links, and tooltips using a consolidated focus
style.
* Improved toggle controls to use proper button semantics (instead of
clickable text), including `aria-pressed`/disabled handling and better
keyboard navigation.

* **Visual Updates**
* Harmonized hover/focus ring visuals across the design system, Studio,
documentation, and marketing pages while preserving existing layout and
interaction behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-23 08:52:22 +10:00

244 lines
7.9 KiB
TypeScript

import type { UIMessage as MessageType } from '@ai-sdk/react'
import { ArrowUpRight } from 'lucide-react'
import dynamic from 'next/dynamic'
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import type { JSX } from 'react'
import type { StreamdownProps } from 'streamdown'
import {
AiIconAnimation,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
cn,
Skeleton,
} from 'ui'
import { buildSupportAssistantPrompt } from '@/components/interfaces/Support/SupportAssistant.utils'
import type { SubmittedSupportRequest } from '@/components/interfaces/Support/SupportForm.state'
import { NO_PROJECT_MARKER } from '@/components/interfaces/Support/SupportForm.utils'
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { useTrack } from '@/lib/telemetry/track'
import {
useAiAssistantState,
useAiAssistantStateSnapshot,
type AiAssistantState,
} from '@/state/ai-assistant-state'
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
type SupportAssistantPreviewChat = AiAssistantState['chatInstances'][string]
const EMPTY_MESSAGES: MessageType[] = []
const Streamdown = dynamic<StreamdownProps>(
() => import('streamdown').then((mod) => mod.Streamdown),
{ ssr: false }
)
interface SupportAssistantSuccessCardContentProps {
request: SubmittedSupportRequest
className?: string
}
function hasProjectScopedAssistantContext(projectRef: string | undefined) {
return projectRef !== undefined && projectRef !== NO_PROJECT_MARKER
}
export function SupportAssistantSuccessCardContent({
request,
className,
}: SupportAssistantSuccessCardContentProps) {
const hasAssistantContext = hasProjectScopedAssistantContext(request.projectRef)
const aiAssistant = useAiAssistantStateSnapshot()
const aiAssistantState = useAiAssistantState()
const { openSidebar } = useSidebarManagerSnapshot()
const track = useTrack()
const createdChatIdRef = useRef<string | null>(null)
const [chatId, setChatId] = useState<string>()
const chat = chatId ? aiAssistant.chatInstances[chatId] : undefined
const assistantPrompt = useMemo(() => buildSupportAssistantPrompt(request), [request])
useEffect(() => {
if (!hasAssistantContext) return
if (createdChatIdRef.current) return
const newChatId = aiAssistant.newChat({
name: 'Support request',
initialMessage: assistantPrompt,
})
createdChatIdRef.current = newChatId
setChatId(newChatId)
}, [aiAssistant, assistantPrompt, hasAssistantContext])
const handleOpenAssistant = () => {
track(
'support_assistant_follow_up_card_clicked',
{ ticketCategory: request.category },
{
project: request.projectRef,
organization: request.organizationSlug,
}
)
if (chatId) {
// Tag the chat as a support chat on first engagement so its messages and
// lifecycle sync to Front. Gated on the click (rather than on chat creation)
// so chats the user never opens don't create Front conversations.
const chat = aiAssistantState.chats[chatId]
if (chat && !chat.supportMetadata) {
chat.supportMetadata = {
subject: request.subject,
category: request.category,
severity: request.severity,
organizationSlug: request.organizationSlug,
projectRef: request.projectRef,
library: request.library,
affectedServices: request.affectedServices,
allowSupportAccess: request.allowSupportAccess,
// Reuse the Front conversation created at submit so AI messages thread into it.
frontConversationId: request.frontConversationId,
threadRef: request.threadRef,
isSupportChat: true,
lifecycleStatus: 'bot_active',
lastSyncedMessageCount: 0,
isSyncing: false,
isLifecycleSyncing: false,
}
// Flush any messages produced before the user engaged (the initial prompt
// and any assistant reply). Subsequent turns sync via the onFinish hook.
void import('@/state/ai-chat-front-sync')
.then(({ syncSupportChatToFront }) => syncSupportChatToFront(chatId, aiAssistantState))
.catch(() => {})
}
aiAssistantState.selectChat(chatId)
}
openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
}
if (!hasAssistantContext) return null
return (
<Card
role="button"
tabIndex={0}
aria-label="Open assistant response"
onClick={handleOpenAssistant}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleOpenAssistant()
}
}}
className={cn(
'group cursor-pointer bg-muted/50 transition-colors hover:bg-muted/50 focus-ring',
className
)}
>
<CardHeader className="flex-row items-center justify-between gap-4 space-y-0">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border bg-background">
<AiIconAnimation size={14} />
</div>
<div className="min-w-0 space-y-1">
<CardTitle>While you wait</CardTitle>
<CardDescription>Assistant may be able to help</CardDescription>
</div>
</div>
<ArrowUpRight
size={14}
strokeWidth={1.5}
className="shrink-0 text-foreground-lighter transition-colors group-hover:text-foreground"
aria-hidden
/>
</CardHeader>
{chat ? (
<SupportAssistantResponsePreview chat={chat as SupportAssistantPreviewChat} />
) : (
<CardContent>
<SupportAssistantResponseLoadingSkeleton />
</CardContent>
)}
</Card>
)
}
function useChatMessages(chat: SupportAssistantPreviewChat | undefined) {
const subscribe = useCallback(
(onStoreChange: () => void) => {
return chat?.['~registerMessagesCallback']?.(onStoreChange) ?? (() => {})
},
[chat]
)
const getSnapshot = useCallback(() => chat?.messages ?? EMPTY_MESSAGES, [chat])
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}
function getAssistantMessageText(message: MessageType) {
return (
message.parts
?.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('') ?? ''
)
}
function SupportAssistantResponsePreview({ chat }: { chat: SupportAssistantPreviewChat }) {
const messages = useChatMessages(chat)
const latestAssistantMessage = [...messages]
.reverse()
.find((message) => message.role === 'assistant')
if (!latestAssistantMessage) {
return (
<CardContent>
<SupportAssistantResponseLoadingSkeleton />
</CardContent>
)
}
const previewText = getAssistantMessageText(latestAssistantMessage)
return (
<CardContent className="relative max-h-48 overflow-hidden">
<SupportAssistantPreviewMarkdown>{previewText}</SupportAssistantPreviewMarkdown>
</CardContent>
)
}
function SupportAssistantPreviewMarkdown({ children }: { children: string }) {
return (
<Streamdown
className="prose prose-sm dark:prose-dark max-w-none space-y-3 text-sm text-foreground-light prose-p:my-0 prose-strong:font-medium prose-strong:text-foreground prose-code:text-xs prose-li:my-0 prose-ul:my-0 prose-ol:my-0"
components={supportAssistantPreviewMarkdownComponents}
>
{children}
</Streamdown>
)
}
function SupportAssistantPreviewImage({ src }: JSX.IntrinsicElements['img']) {
return <span className="font-mono text-foreground-lighter">[Image: {src?.toString()}]</span>
}
const supportAssistantPreviewMarkdownComponents: StreamdownProps['components'] = {
img: SupportAssistantPreviewImage,
}
function SupportAssistantResponseLoadingSkeleton() {
return (
<div className="space-y-2">
<Skeleton className="h-4 w-[82%]" />
<Skeleton className="h-4 w-[92%]" />
<Skeleton className="h-4 w-[68%]" />
</div>
)
}