Files
supabase/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.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

175 lines
6.1 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 { IS_PLATFORM, useFlag } from 'common'
import { useParams } from 'common/hooks'
import dayjs from 'dayjs'
import { Check, Copy } from 'lucide-react'
import { useRouter } from 'next/router'
import { useMemo, useState, type MouseEvent } from 'react'
import { cn, copyToClipboard, TableCell, TableRow } from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { formatErrorRate } from './EdgeFunctionsListItem.utils'
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
import { useEdgeFunctionsLastHourStatsQuery } from '@/data/edge-functions/edge-functions-last-hour-stats-query'
import {
useEdgeFunctionsQuery,
type EdgeFunctionsResponse,
} from '@/data/edge-functions/edge-functions-query'
import { normalizeFunctionIds } from '@/data/edge-functions/keys'
import { createNavigationHandler } from '@/lib/navigation'
interface EdgeFunctionsListItemProps {
function: EdgeFunctionsResponse
}
export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemProps) => {
const router = useRouter()
const { ref } = useParams()
const [isCopied, setIsCopied] = useState(false)
const showLastHourStats = useFlag('edgeFunctionsRequestMetrics')
const { data: endpoint } = useProjectApiUrl({ projectRef: ref })
const functionUrl = `${endpoint}/functions/v1/${item.slug}`
const handleNavigation = createNavigationHandler(
`/project/${ref}/functions/${item.slug}${IS_PLATFORM ? '' : `/code`}`,
router
)
const { data: functions } = useEdgeFunctionsQuery({ projectRef: ref })
const functionIds = useMemo(() => {
if (!showLastHourStats || !functions) return []
return normalizeFunctionIds(functions.map((item) => item.id))
}, [functions, showLastHourStats])
// [Joshen] We may be paginating the edge functions query in the future
// So this will eventually need to be a list of visibleFunctionIds instead + debounced
const {
data: lastHourStatsAll,
isPending: isStatsPending,
isError: isStatsError,
} = useEdgeFunctionsLastHourStatsQuery(
{ projectRef: ref, functionIds },
{ enabled: showLastHourStats }
)
const lastHourStats = lastHourStatsAll?.[item.id]
return (
<TableRow
key={item.id}
onClick={handleNavigation}
onAuxClick={handleNavigation}
onKeyDown={handleNavigation}
tabIndex={0}
className="cursor-pointer focus-inset"
>
<TableCell>
<p className="text-sm text-foreground whitespace-nowrap py-2">{item.name}</p>
</TableCell>
<TableCell>
<div className="text-xs text-foreground-light flex gap-2 items-center truncate">
<p title={functionUrl} className="font-mono truncate hidden md:inline max-w-120">
{functionUrl}
</p>
<button
type="button"
tabIndex={0}
className="text-foreground-lighter hover:text-foreground transition"
onClick={(event: MouseEvent<HTMLButtonElement>) => {
function onCopy(value: string) {
setIsCopied(true)
copyToClipboard(value)
setTimeout(() => setIsCopied(false), 3000)
}
event.stopPropagation()
onCopy(functionUrl)
}}
>
{isCopied ? (
<div className="text-brand">
<Check size={14} strokeWidth={3} />
</div>
) : (
<div className="relative">
<div className="block">
<Copy size={14} strokeWidth={1.5} />
</div>
</div>
)}
</button>
</div>
</TableCell>
<TableCell className="hidden 2xl:table-cell whitespace-nowrap">
{item.created_at ? (
<TimestampInfo
className="text-sm text-foreground-light whitespace-nowrap"
utcTimestamp={item.created_at}
label={dayjs(item.created_at).fromNow()}
/>
) : (
<span className="text-sm text-foreground-light"></span>
)}
</TableCell>
<TableCell className="lg:table-cell">
{item.updated_at ? (
<TimestampInfo
className="text-sm text-foreground-light whitespace-nowrap"
utcTimestamp={item.updated_at}
label={dayjs(item.updated_at).fromNow()}
/>
) : (
<span className="text-sm text-foreground-light"></span>
)}
</TableCell>
{showLastHourStats && (
<>
<TableCell className="lg:table-cell whitespace-nowrap">
{isStatsPending ? (
<ShimmeringLoader className="w-12" />
) : isStatsError ? (
<p className="text-foreground-lighter" title="Failed to load stats">
-
</p>
) : (
<p className="text-foreground-light">
{lastHourStats !== undefined ? lastHourStats.requestsCount.toLocaleString() : '-'}
</p>
)}
</TableCell>
<TableCell className="lg:table-cell whitespace-nowrap">
{isStatsPending ? (
<ShimmeringLoader className="w-12" />
) : isStatsError ? (
<p className="text-foreground-lighter" title="Failed to load stats">
-
</p>
) : lastHourStats !== undefined ? (
<span
className={cn(
'text-sm',
lastHourStats.errorRate >= 1
? 'text-destructive'
: lastHourStats.errorRate > 0.1
? 'text-warning'
: 'text-foreground-light'
)}
>
{formatErrorRate(lastHourStats.errorRate)}
</span>
) : (
<p className="text-foreground-lighter">-</p>
)}
</TableCell>
</>
)}
<TableCell className="hidden 2xl:table-cell">
<p className="text-foreground-light">{item.version}</p>
<button tabIndex={-1} className="sr-only">
Go to function details
</button>
</TableCell>
</TableRow>
)
}