Files
supabase/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx
T
Danny White c8aca8d3a0 chore(design-system): standardise keyboard focus rings (#41575)
## 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?

UI / design-system consistency (accessibility).

## What is the current behavior?

Keyboard focus rings are inconsistent across Studio and `packages/ui`:

- Custom Button uses thick `outline` with per-variant colours (brand /
grey / destructive / warning)
- Form controls use muted grey rings (`ring-background-control`)
- Tabs / NavMenu / Radio use soft brand `ring-ring`
- Studio `.inset-focus` uses dark green `outline-brand-600`

Related: [DEPR-354](https://linear.app/supabase/issue/DEPR-354).

## What is the new behavior?

One shared focus recipe, exposed as Tailwind `@utility` classes in
`packages/config/css/utilities.css`:

| Utility | Use when |
| --- | --- |
| `focus-ring` | Buttons, inputs, most controls (offset ring) |
| `focus-inset` | Dense/flush surfaces such as interactive table rows
(renamed from `inset-focus`) |

```txt
# focus-ring
outline-hidden
focus-visible:ring-2
focus-visible:ring-ring
focus-visible:ring-offset-2
focus-visible:ring-offset-background
```

Applied on Button, shadcn form controls, Menu/NavMenu, Command palette
trigger, Studio table rows, and related call sites. Documented in the
design-system accessibility docs. Variants do not change focus ring
colour.

When the ring must appear on a different element than the focused one
(e.g. Menu + ProductMenu `Link` via `group-focus-visible`, or InputGroup
via `:has()`), keep an explicit ring stack. The utilities bake in
`:focus-visible` on the same element.

## Additional context

**Out of scope**

- Full `packages/ui` / Studio / www sweep
- Legacy Studio form-group green box-shadow cleanup
- ESLint rule for bare `outline-none`

## Test plan

Prefer Safari (“hard mode” for `tabIndex`). Expect one soft brand ring
everywhere: not grey, not solid green outline.

### Design system

- [ ]
[Accessibility](https://design-system-git-dnywh-choreimprove-tab-focus-styles-supabase.vercel.app/design-system/docs/accessibility):
recipe docs match what you see
- [ ]
[Button](https://design-system-git-dnywh-choreimprove-tab-focus-styles-supabase.vercel.app/design-system/docs/components/button):
Tab primary / default / danger; same ring colour
- [ ] [Table → Row-level
navigation](https://design-system-git-dnywh-choreimprove-tab-focus-styles-supabase.vercel.app/design-system/docs/components/table#row-level-navigation):
Tab an interactive row; inset outline (`focus-inset`) sits inside the
row

### Studio

- [ ] **Org home → table view** (`/organizations/_` or org projects):
switch to the table layout, Tab onto a project row; inset outline sits
inside the row (list/card view uses CardButton, not `focus-inset`)
- [ ] **Project sidebar** (Database, Auth, Storage, …): Tab the main
product nav links; ring follows the focused item (not the nested section
menus like Tables / Roles)
- [ ] **Storage → Files**: Tab a bucket row; same inset outline as org
table rows
- [ ] **Project Settings → General** (or Compute and Disk): Tab through
inputs, checkboxes, switches, selects; same offset ring, no ring on
mouse click
- [ ] **Header ⌘K** (desktop width): Tab to the search control after
Feedback; same soft brand `focus-ring` (was a thicker
`ring-border-strong` before)
- [ ] **Table Editor or SQL Editor tabs**: focus a tab, Tab to × if
active; close shows a ring
- [ ] **Light + dark**: ring stays visible against both backgrounds
2026-07-22 12:10:07 -04:00

210 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Check, Copy, Github, MoreVertical, Settings } from 'lucide-react'
import { useRouter } from 'next/router'
import { useState } from 'react'
import InlineSVG from 'react-inlinesvg'
import { toast } from 'sonner'
import {
Button,
copyToClipboard,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
TableCell,
TableRow,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { inferProjectStatus } from './ProjectCard.utils'
import { ProjectCardStatus } from './ProjectCardStatus'
import { ComputeBadgeWrapper } from '@/components/ui/ComputeBadgeWrapper'
import PartnerIcon from '@/components/ui/PartnerIcon'
import type { IntegrationProjectConnection } from '@/data/integrations/integrations.types'
import { getManagedByFromOrganizationPartner } from '@/data/organizations/managed-by-utils'
import { getComputeSize, OrgProject } from '@/data/projects/org-projects-infinite-query'
import type { ResourceWarning } from '@/data/usage/resource-warnings-query'
import { BASE_PATH } from '@/lib/constants'
import { MANAGED_BY } from '@/lib/constants/infrastructure'
import { createNavigationHandler } from '@/lib/navigation'
import type { Organization } from '@/types'
export interface ProjectTableRowProps {
project: OrgProject
organization?: Organization
rewriteHref?: string
githubIntegration?: IntegrationProjectConnection
vercelIntegration?: IntegrationProjectConnection
resourceWarnings?: ResourceWarning
}
export const ProjectTableRow = ({
project,
organization,
rewriteHref,
githubIntegration,
vercelIntegration,
resourceWarnings,
}: ProjectTableRowProps) => {
const router = useRouter()
const { name, ref: projectRef } = project
const projectStatus = inferProjectStatus(project.status)
const [isCopied, setIsCopied] = useState(false)
const url = rewriteHref ?? `/project/${project.ref}`
const isGithubIntegrated = githubIntegration !== undefined
const isVercelIntegrated = vercelIntegration !== undefined
const githubRepository = githubIntegration?.metadata.name ?? undefined
const projectManagedBy = getManagedByFromOrganizationPartner(
undefined,
project.integration_source
)
const hasPartnerIcon = projectManagedBy !== MANAGED_BY.SUPABASE
const handleNavigation = createNavigationHandler(url, router)
const handleCopyProjectRef = (e: React.SyntheticEvent) => {
e.stopPropagation()
copyToClipboard(projectRef)
setIsCopied(true)
toast.success('Copied project ID to clipboard')
setTimeout(() => setIsCopied(false), 2000)
}
return (
<>
<TableRow
className="cursor-pointer hover:bg-surface-200 focus-inset"
onClick={handleNavigation}
onAuxClick={handleNavigation}
onKeyDown={handleNavigation}
tabIndex={0}
>
<TableCell>
<div className="flex flex-col gap-y-2">
<div>
<h2 className="text-sm">{name}</h2>
<Tooltip>
<TooltipTrigger asChild>
<button
tabIndex={0}
onClick={handleCopyProjectRef}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleCopyProjectRef(e)
}
}}
className="inline-flex items-center gap-x-1 cursor-pointer border border-transparent border-dashed rounded-sm transition-colors hover:bg-surface-100 hover:border hover:border-strong group font-mono text-xs text-foreground-lighter hover:text-foreground-light px-1 -ml-1"
>
{projectRef}
{isCopied ? (
<Check size={12} strokeWidth={1.25} className="text-brand" />
) : (
<Copy
size={12}
strokeWidth={1.25}
className="opacity-0 group-hover:opacity-100 transition-opacity"
/>
)}
</button>
</TooltipTrigger>
<TooltipContent>Copy project reference</TooltipContent>
</Tooltip>
</div>
{(isGithubIntegrated || isVercelIntegrated || hasPartnerIcon) && (
<div className="flex items-center gap-x-1.5">
{isVercelIntegrated && (
<div className="bg-surface-100 w-5 h-5 p-1 border border-strong rounded-md flex items-center text-black dark:text-white">
<InlineSVG
src={`${BASE_PATH}/img/icons/vercel-icon.svg`}
title="Vercel Icon"
className="w-3"
/>
</div>
)}
<PartnerIcon organization={{ managed_by: projectManagedBy }} />
{isGithubIntegrated && (
<div className="bg-surface-100 flex items-center gap-x-0.5 h-5 pr-1 border border-strong rounded-md">
<div className="w-5 h-5 p-1 flex items-center">
<Github size={12} strokeWidth={1.5} />
</div>
{githubRepository && (
<p className="text-xs text-foreground-light truncate">{githubRepository}</p>
)}
</div>
)}
</div>
)}
</div>
</TableCell>
<TableCell>
<ProjectCardStatus
projectStatus={projectStatus}
resourceWarnings={resourceWarnings}
renderMode="badge"
/>
</TableCell>
<TableCell>
<div className="w-fit">
{project.status !== 'INACTIVE' ? (
<ComputeBadgeWrapper
slug={organization?.slug}
projectRef={project.ref}
cloudProvider={project.cloud_provider}
computeSize={getComputeSize(project)}
resourceWarnings={resourceWarnings}
/>
) : (
<span className="text-xs text-foreground-muted">–</span>
)}
</div>
</TableCell>
<TableCell>
<span className="lowercase text-sm text-foreground-light">
{project.cloud_provider} | {project.region || 'N/A'}
</span>
</TableCell>
<TableCell>
{project.inserted_at ? (
<TimestampInfo
className="text-sm text-foreground-light"
utcTimestamp={project.inserted_at}
/>
) : (
<span className="text-sm text-foreground-light">N/A</span>
)}
</TableCell>
<TableCell className="text-right">
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="default"
icon={<MoreVertical />}
aria-label={`Project ${name} actions`}
className="w-7"
onClick={(e) => e.stopPropagation()}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
className="gap-x-2"
onClick={(e) => {
e.stopPropagation()
router.push(`/project/${projectRef}/settings/general`)
}}
>
<Settings size={14} />
<span>Settings</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
</>
)
}