Files
supabase/apps/studio/components/interfaces/ConnectSheet/ConnectConfigSection.tsx
Danny White e3d7267845 fix(studio): chip away explicit-tabindex ratchet debt (#48040)
## What kind of change does this PR introduce?

A11y cleanup follow-up to #47984 /
[DEPR-626](https://linear.app/supabase/issue/DEPR-626).

## What is the current behavior?

Studio had 82 ratcheted `supabase/require-explicit-tabindex` violations
(raw `<button>` / `role="button"` without explicit `tabIndex`).

## What is the new behavior?

- Explicit `tabIndex={0}` (or disabled → `-1`) on those Studio call
sites across nav, `components/ui`, Database, Storage, and the remainder
- Ratchet baseline cleared (**82 → 0**) and the rule **removed from the
Studio ratchet** (debt is gone; ratchet is temporary)
- Rule remains a shared **`warn`** for now — promoting to `error` (and
sweeping www/docs/design-system) is a follow-up
- Also fixed the learn/ui-library call sites that surfaced while
experimenting with error promotion
- Small follow-ups where making controls focusable exposed gaps:
accessible names, disabled/focus consistency, focus-ring polish on
To-test surfaces, home section `KeyboardSensor`, and an E2E locator
tightened after `aria-label="Remove column"`

Prefer migrating to `Button` from `ui` in future touch-ups; this PR
takes the minimal path so Studio debt can stay at zero.

## Additional context

Batches landed together so baseline conflicts stayed simple while
chipping away:

- Hotspots / nav (FirstLevelNav, Marketplace, AttachmentUpload, Column,
Tabs, …)
- `components/ui` shared
- Database + Storage
- Remainder

**Out of scope / intentional deferrals**

- Promoting `supabase/require-explicit-tabindex` to a lint **error**
(follow-up after www/docs/design-system sweeps)
- Tabs/Radio roving, tooltips, context menus, in-menu items
- Full keyboard-accessible tab-close UX (close stays hover +
`tabIndex={-1}`; context menu still closes tabs)
- Data API docs links (`/project/<ref>/api` redirect)

**Reviewer notes**

- Rule only flags raw `<button>` / `role="button"` without a `tabIndex`
prop. `Button` from `ui` already bakes this in
- `tabIndex={-1}` is intentional for disabled controls, in-menu /
roving-focus children, and hover-only tab close
- For dnd-kit grips, put `tabIndex` **after** `{...attributes}` so it
isn’t overwritten (TS2783)

### To test

Use **Safari** with macOS Keyboard navigation **off** (System Settings →
Keyboard). Chrome once for a sanity pass. For each surface below: Tab
until the control is focused, then activate with Enter/Space where
relevant.

1. **API Docs side panel** (Table Editor → open a table → **API docs**)
- Floating API Docs panel — **not** `/project/<ref>/api` (that redirects
to Data API docs; language ToggleGroup uses arrow keys; links are out of
scope)
- Left nav buttons — Tab through several and activate one; active
highlight / navigation still works

2. **Integrations → Marketplace**
- Enable **Integrations layout** feature preview first (avatar menu →
Feature previews)
   - `/org/<slug>/integrations` or project integrations marketplace
   - “Clear all”, grid/list toggles — Tab + activate

3. **Table Editor → create a table → Columns**
- Drag handles only appear while **creating** (not when editing an
existing table)
   - Tab to grip / remove (X) / sensitive-data eye if shown

4. **Project Home** — section drag handles
   - Tab to a grip (visible focus ring)
- Optional: Space to pick up, arrows to move, Space/Esc to drop
(KeyboardSensor added)
   - Mouse dnd still works

5. **Storage → Policies** — expand/collapse bucket list chevron
(design-system focus ring, no stuck grey open bg)

6. **Support form** (Help → Support) — attachment remove (×) and
add-attachment control when visible

Disabled controls should be **skipped** by Tab.

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

* **Accessibility Improvements**
* Improved keyboard navigation throughout Studio by explicitly managing
focus (`tabIndex`) across many interactive controls (menus, tabs,
tables, charts, dialogs, navigation, and form actions).
* Disabled or non-interactive controls are now removed from the tab
order (or made unfocusable), while available actions remain reachable.
* Ensured `type="button"` on relevant controls to prevent unintended
submissions, and refined keyboard focus behavior for various toggles and
copy/remove actions.
* **Chores**
* Updated the ESLint rule baseline configuration to match the new focus
behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-21 08:22:43 +10:00

275 lines
9.6 KiB
TypeScript

import { Box, Cable, Database, Server, Sparkles } from 'lucide-react'
import {
cn,
RadioGroupStacked,
RadioGroupStackedItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
import type { ConnectMode, FieldOption, ResolvedField } from './Connect.types'
import { ConnectionIcon } from './ConnectionIcon'
const MODE_ICONS: Record<string, React.ReactNode> = {
framework: <Box size={16} strokeWidth={1.5} />,
direct: <Database size={16} strokeWidth={1.5} />,
orm: <Cable size={16} strokeWidth={1.5} />,
mcp: <Sparkles size={16} strokeWidth={1.5} />,
server: <Server size={16} strokeWidth={1.5} />,
}
interface ConnectConfigSectionProps {
activeFields: ResolvedField[]
state: Record<string, string | boolean | string[]>
onFieldChange: (fieldId: string, value: string | boolean | string[]) => void
getFieldOptions: (fieldId: string) => FieldOption[]
}
export function ConnectConfigSection({
activeFields,
state,
onFieldChange,
getFieldOptions,
}: ConnectConfigSectionProps) {
if (activeFields.length === 0) return null
return (
<div className="flex flex-col gap-y-4">
{activeFields.map((field) => {
const options = getFieldOptions(field.id)
const value = state[field.id]
// Skip fields with no options (or single option that's auto-selected)
// Exception: switch and multi-select fields don't require options
if (field.type !== 'switch' && field.type !== 'multi-select') {
if (options.length === 0) return null
if (options.length === 1) return null
}
switch (field.type) {
case 'radio-grid':
return (
<FormItemLayout
key={field.id}
isReactForm={false}
layout="horizontal"
label={field.label}
>
<RadioGroupStacked
value={String(value ?? '')}
onValueChange={(v) => onFieldChange(field.id, v)}
className="flex-row gap-3 space-y-0"
>
{options.map((option) => (
<RadioGroupStackedItem
key={option.value}
id={`connect-${field.id}-${option.value}`}
value={option.value}
label=""
className="flex-1 rounded-lg text-left"
>
<div className="flex items-center gap-2">
{option.icon && <ConnectionIcon supportsDarkMode icon={option.icon} />}
<span className="text-sm">{option.label}</span>
</div>
</RadioGroupStackedItem>
))}
</RadioGroupStacked>
</FormItemLayout>
)
case 'radio-list':
return (
<FormItemLayout
key={field.id}
isReactForm={false}
layout="horizontal"
label={field.label}
>
<RadioGroupStacked
value={String(value ?? '')}
onValueChange={(v) => onFieldChange(field.id, v)}
>
{options.map((option) => (
<RadioGroupStackedItem
key={option.value}
id={`connect-${field.id}-${option.value}`}
value={option.value}
label=""
className="w-full text-left"
>
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
{option.icon && <ConnectionIcon icon={option.icon} />}
<span className="text-sm">{option.label}</span>
</div>
{option.description && (
<span className="text-sm text-foreground-lighter">
{option.description}
</span>
)}
</div>
</RadioGroupStackedItem>
))}
</RadioGroupStacked>
</FormItemLayout>
)
case 'select':
return (
<FormItemLayout
key={field.id}
isReactForm={false}
layout="horizontal"
label={field.label}
description={field.description}
>
<Select
value={String(value ?? '')}
onValueChange={(v) => onFieldChange(field.id, v)}
>
<SelectTrigger
size="small"
className="[&>span:first-child]:flex [&>span:first-child]:items-center [&>span:first-child]:gap-x-2"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className="[&>span:last-child]:flex [&>span:last-child]:items-center [&>span:last-child]:gap-x-2"
>
{/*
[Joshen] Omitting MCP icons for now as the images are not optimized (large)
and is causing noticeably latency issues on the browser (even with the existing Connect UI)
*/}
{field.id === 'framework' && option.icon && (
<ConnectionIcon icon={option.icon} />
)}
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItemLayout>
)
case 'switch':
return (
<FormItemLayout
key={field.id}
isReactForm={false}
layout="horizontal"
label={field.label}
description={field.description}
className="[&>div>label>span]:break-keep! [&>div>label>span]:text-balance"
>
<Switch
id={field.id}
checked={Boolean(value)}
onCheckedChange={(v) => onFieldChange(field.id, v)}
/>
</FormItemLayout>
)
case 'multi-select':
return (
<FormItemLayout
key={field.id}
isReactForm={false}
layout="horizontal"
label={field.label}
description={field.description}
>
<MultiSelector
values={Array.isArray(value) ? value : []}
onValuesChange={(v) => onFieldChange(field.id, v)}
>
<MultiSelectorTrigger
className="w-full"
label="All features except Storage enabled by default"
badgeLimit="wrap"
showIcon={true}
/>
<MultiSelectorContent>
<MultiSelectorList>
{options.map((option) => (
<MultiSelectorItem
key={option.value}
value={option.value}
className="items-start"
>
<div className="flex flex-col ml-2 gap-y-0.5">
<span className="font-medium">{option.label}</span>
{option.description && (
<span className="text-xs text-foreground-light">
{option.description}
</span>
)}
</div>
</MultiSelectorItem>
))}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
</FormItemLayout>
)
default:
return null
}
})}
</div>
)
}
interface ModeSelectorProps {
modes: Array<{ id: ConnectMode; label: string; description: string }>
selected: ConnectMode
onChange: (mode: ConnectMode) => void
}
export function ModeSelector({ modes, selected, onChange }: ModeSelectorProps) {
return (
<div
className="grid rounded-lg border overflow-hidden"
style={{ gridTemplateColumns: `repeat(${modes.length}, minmax(0, 1fr))` }}
>
{modes.map((mode) => (
<button
key={mode.id}
type="button"
tabIndex={0}
onClick={() => onChange(mode.id)}
className={cn(
'flex flex-col items-center gap-2 p-4 transition-colors border-r last:border-r-0',
selected === mode.id
? 'bg-surface-200'
: 'border-default hover:border-strong hover:bg-surface-100 '
)}
>
<span className="text-foreground-light">{MODE_ICONS[mode.id]}</span>
<div>
<p className="heading-default text-center">{mode.label}</p>
<p className="text-sm text-foreground-lighter text-center">{mode.description}</p>
</div>
</button>
))}
</div>
)
}