Files
supabase/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuringView.tsx
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

240 lines
8.3 KiB
TypeScript

import { ArrowRight, Check, ExternalLink, Lightbulb, X } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useMemo } from 'react'
import {
Button,
Button_Shadcn_,
Card,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageHeader,
PageHeaderAside,
PageHeaderDescription,
PageHeaderIcon,
PageHeaderMeta,
PageHeaderSummary,
PageHeaderTitle,
} from 'ui-patterns/PageHeader'
import {
PageSection,
PageSectionAside,
PageSectionContent,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import type {
ProjectSecurityActionDetails,
ProjectSecurityActionType,
ProjectSecurityTable,
} from './ProjectNeedsSecuring.types'
import {
buildSecurityPromptMarkdown,
formatRlsDescription,
getTableKey,
getTablePoliciesHref,
} from './ProjectNeedsSecuring.utils'
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown'
import { AlertError } from '@/components/ui/AlertError'
import { createNavigationHandler } from '@/lib/navigation'
import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
const StatusCell = ({ enabled, label }: { enabled: boolean; label: string }) => (
<div className="flex items-center gap-2 text-sm">
{enabled ? (
<Check size={14} className="text-brand" aria-hidden="true" />
) : (
<X size={14} className="text-destructive" aria-hidden="true" />
)}
<span>{label}</span>
</div>
)
export const ProjectNeedsSecuringView = ({
projectRef,
issueCount,
tables,
isLoading,
error,
onDismiss,
onTrackAction,
}: {
projectRef: string
issueCount: number
tables: ProjectSecurityTable[]
isLoading: boolean
error?: { message: string } | null
onDismiss: () => void
onTrackAction: (type: ProjectSecurityActionType, details?: ProjectSecurityActionDetails) => void
}) => {
const router = useRouter()
const aiSnap = useAiAssistantStateSnapshot()
const { openSidebar } = useSidebarManagerSnapshot()
const promptMarkdown = useMemo(
() => buildSecurityPromptMarkdown(issueCount, tables),
[issueCount, tables]
)
const handleOpenAssistant = () => {
onTrackAction('ask_assistant')
openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
aiSnap.newChat({
name: 'Review project security',
initialInput: promptMarkdown,
})
}
return (
<div className="flex flex-1 flex-col overflow-y-auto">
<PageHeader size="default">
<PageHeaderMeta>
<PageHeaderIcon>
<div className="shrink-0 w-14 h-14 relative bg-destructive-200 border border-destructive-400 rounded-md flex items-center justify-center">
<Lightbulb size={20} strokeWidth={1.5} className="text-destructive" />
</div>
</PageHeaderIcon>
<PageHeaderSummary>
<PageHeaderTitle>Your project needs securing</PageHeaderTitle>
<PageHeaderDescription>{formatRlsDescription(issueCount)}</PageHeaderDescription>
</PageHeaderSummary>
<PageHeaderAside>
<Button asChild variant="text" iconRight={<ArrowRight />}>
<Link
href={`/project/${projectRef}`}
onClick={() => {
onTrackAction('skip_to_home')
onDismiss()
}}
>
Skip to home
</Link>
</Button>
</PageHeaderAside>
</PageHeaderMeta>
</PageHeader>
<PageContainer size="default" className="pb-12">
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Review and fix</PageSectionTitle>
</PageSectionSummary>
<PageSectionAside>
<AiAssistantDropdown
label="Ask Assistant"
size="tiny"
buildPrompt={() => promptMarkdown}
onOpenAssistant={handleOpenAssistant}
onCopyPrompt={() => onTrackAction('copy_prompt')}
copyLabel="Copy Markdown"
disabled={isLoading}
/>
</PageSectionAside>
</PageSectionMeta>
<PageSectionContent>
{isLoading ? (
<GenericSkeletonLoader />
) : error ? (
<AlertError
projectRef={projectRef}
error={error}
subject="Failed to retrieve project tables"
/>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Schema</TableHead>
<TableHead>
<div className="flex items-center gap-1.5">
<span>Accessible via Data API</span>
<Button_Shadcn_ asChild variant="ghost" size="icon" className="h-6 w-6">
<Link
href={`/project/${projectRef}/integrations/data_api/settings`}
target="_blank"
rel="noreferrer"
aria-label="Open Data API settings"
>
<ExternalLink size={14} aria-hidden="true" />
</Link>
</Button_Shadcn_>
</div>
</TableHead>
<TableHead>RLS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tables.map((table) => {
const policiesHref = getTablePoliciesHref(
projectRef,
table.schema,
table.name
)
const handleNavigation = createNavigationHandler(policiesHref, router)
const trackViewPolicies = () =>
onTrackAction('view_policies', {
schema: table.schema,
tableName: table.name,
})
return (
<TableRow
key={getTableKey(table)}
className="relative cursor-pointer focus-inset"
onClick={(event) => {
trackViewPolicies()
handleNavigation(event)
}}
onAuxClick={(event) => {
if (event.button === 1) trackViewPolicies()
handleNavigation(event)
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') trackViewPolicies()
handleNavigation(event)
}}
tabIndex={0}
>
<TableCell className="font-medium">{table.name}</TableCell>
<TableCell>{table.schema}</TableCell>
<TableCell>
<StatusCell
enabled={table.dataApiAccessible}
label={table.dataApiAccessible ? 'Accessible' : 'Not accessible'}
/>
</TableCell>
<TableCell>
<StatusCell
enabled={table.rlsEnabled}
label={table.rlsEnabled ? 'Enabled' : 'Disabled'}
/>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Card>
)}
</PageSectionContent>
</PageSection>
</PageContainer>
</div>
)
}