Files
supabase/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelIntegrationConnectionForm.tsx
T
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

393 lines
16 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import Link from 'next/link'
import { useState } from 'react'
import { useForm, useWatch } from 'react-hook-form'
import { toast } from 'sonner'
import {
Alert,
AlertDescription,
AlertTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Switch,
} from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import * as z from 'zod'
import { FormActions } from '@/components/ui/Forms/FormActions'
import { InlineLink } from '@/components/ui/InlineLink'
import type {
EnvironmentTargets,
Integration,
IntegrationProjectConnection,
} from '@/data/integrations/integrations.types'
import { useVercelConnectionUpdateMutation } from '@/data/integrations/vercel-connection-update-mutate'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { DOCS_URL } from '@/lib/constants'
const VercelIntegrationConnectionForm = ({
disabled,
connection,
integration,
}: {
disabled?: boolean
connection: IntegrationProjectConnection
integration: Integration
}) => {
// NOTE(kamil): Ignore sync targets for Vercel Marketplace as it's not synchronized using integration,
// but through a separate marketplace mechanism. It's not theoretically necessary, but we might have some stale data.
const { data: org } = useSelectedOrganizationQuery()
const { data: project } = useSelectedProjectQuery()
const isBranchingEnabled = project?.is_branch_enabled === true
const envSyncTargets =
org?.managed_by === 'vercel-marketplace' ? [] : (connection.env_sync_targets ?? [])
const FormSchema = z.object({
environmentVariablesProduction: z.boolean().default(envSyncTargets.includes('production')),
environmentVariablesPreview: z.boolean().default(envSyncTargets.includes('preview')),
environmentVariablesDevelopment: z.boolean().default(envSyncTargets.includes('development')),
publicEnvVarPrefix: z.string().optional(),
})
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
environmentVariablesProduction: envSyncTargets.includes('production'),
environmentVariablesPreview: envSyncTargets.includes('preview'),
environmentVariablesDevelopment: envSyncTargets.includes('development'),
publicEnvVarPrefix: connection.public_env_var_prefix,
},
})
const [showConfirmation, setShowConfirmation] = useState(false)
const [pendingValues, setPendingValues] = useState<z.infer<typeof FormSchema> | null>(null)
const { mutate: updateVercelConnection, isPending } = useVercelConnectionUpdateMutation({
onSuccess: () => {
form.reset(form.getValues())
setShowConfirmation(false)
setPendingValues(null)
toast.success(`Updated Vercel connection`)
},
})
// Syncing the `preview` or `development` target sends this project's production credentials
// (anon and service role keys, database connection strings) to those Vercel environments. Watch
// the toggles so we can warn inline and gate the save behind a confirmation.
const [syncPreview, syncDevelopment] = useWatch({
control: form.control,
name: ['environmentVariablesPreview', 'environmentVariablesDevelopment'],
})
const isSyncingNonProdEnvs = syncPreview || syncDevelopment
const selectedNonProdTargets: string[] = []
if (pendingValues?.environmentVariablesPreview) selectedNonProdTargets.push('Preview')
if (pendingValues?.environmentVariablesDevelopment) selectedNonProdTargets.push('Development')
function performUpdate(data: z.infer<typeof FormSchema>) {
const targets: EnvironmentTargets[] = []
if (data.environmentVariablesProduction) targets.push('production')
if (data.environmentVariablesPreview) targets.push('preview')
if (data.environmentVariablesDevelopment) targets.push('development')
updateVercelConnection({
id: connection.id,
envSyncTargets: targets,
publicEnvVarPrefix: data.publicEnvVarPrefix?.trim(),
organizationIntegrationId: integration.id,
})
}
function onSubmit(data: z.infer<typeof FormSchema>) {
// Always confirm when Preview/Development sync is enabled to discourage leaking production
// credentials into preview deployments and steer users toward Branching instead.
if (data.environmentVariablesPreview || data.environmentVariablesDevelopment) {
setPendingValues(data)
setShowConfirmation(true)
return
}
performUpdate(data)
}
const vercelConnectionFormId = `vercel-connection-form-${connection.id}`
return (
<Form {...form}>
<form
id={vercelConnectionFormId}
onSubmit={form.handleSubmit(onSubmit)}
className={'w-full space-y-6'}
>
<div className="px-6 py-4 flex flex-col gap-y-4">
<div className="flex flex-col gap-4">
{org?.managed_by === 'vercel-marketplace' ? (
<Alert>
<AlertTitle className="text-sm">Vercel Marketplace managed project</AlertTitle>
<AlertDescription className="text-xs">
This project is managed via Vercel Marketplace. Environment variables are
automatically synchronized for your connected Vercel projects. This integration
purpose is synchronizing preview deployments environment variables with our{' '}
<Link
target="_blank"
rel="noreferrer"
href={`${DOCS_URL}/guides/platform/branching`}
className="underline"
>
Branching
</Link>{' '}
feature.
</AlertDescription>
</Alert>
) : (
<div>
<div className="space-y-1 mb-4">
<h5 className="text-foreground">Sync environment variables to Vercel</h5>
<p className="text-sm text-foreground-light">
Choose which Vercel environments receive this project&apos;s{' '}
<span className="text-foreground">production</span> credentials. Most projects
only need <code>production</code>.
</p>
<p className="text-sm text-foreground-light">
With{' '}
<InlineLink href={`/project/${project?.ref}/branches`}>Branching</InlineLink>{' '}
enabled, leave Preview and Development off: Branching gives each preview branch
its own database and syncs those credentials to your Vercel Preview deployments.
</p>
</div>
<div className="space-y-4">
<FormField
control={form.control}
name="environmentVariablesProduction"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Production"
description={
<>
Syncs to the Vercel <code>production</code> env.
</>
}
>
<FormControl>
<Switch
disabled={disabled}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItemLayout>
)}
/>
<FormField
control={form.control}
name="environmentVariablesPreview"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Preview"
description={
<>
Syncs to the Vercel <code>preview</code> env.
</>
}
>
<FormControl>
<Switch
disabled={disabled}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItemLayout>
)}
/>
<FormField
control={form.control}
name="environmentVariablesDevelopment"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Development"
description={
<>
Syncs to the Vercel <code>development</code> env.
</>
}
>
<FormControl>
<Switch
disabled={disabled}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItemLayout>
)}
/>
{isSyncingNonProdEnvs && (
<Admonition
type="warning"
title={
isBranchingEnabled
? 'Not recommended with Branching'
: 'These environments will use production credentials'
}
>
<p>
Preview and Development sync your production credentials, including the
service role key and database password, to those Vercel environments.
</p>
{isBranchingEnabled ? (
<p>
<InlineLink href={`/project/${project?.ref}/branches`}>
Branching
</InlineLink>{' '}
already provisions isolated credentials for each preview branch. With
Preview enabled, deployments use production credentials until a branch
finishes provisioning, which can affect production data. We recommend
leaving Preview and Development off.
</p>
) : (
<p>
To give preview deployments their own isolated credentials, use{' '}
<InlineLink href={`/project/${project?.ref}/branches`}>
Branching
</InlineLink>{' '}
instead.
</p>
)}
</Admonition>
)}
</div>
</div>
)}
</div>
<h5 className="mt-2 text-foreground">Customize public environment variable prefix</h5>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="publicEnvVarPrefix"
render={({ field }) => (
<FormItem className="grid gap-2 md:grid md:grid-cols-12 space-y-0">
<FormLabel className="flex flex-col space-y-2 col-span-4 text-sm justify-center text-foreground-light">
Prefix
</FormLabel>
<FormControl className="col-span-8">
<Input
{...field}
className="w-full"
disabled={disabled}
placeholder="An empty prefix will result in no public env vars"
/>
</FormControl>
<FormDescription className="col-start-5 col-span-8 text-xs">
e.g.{' '}
{(
[
['NEXT_PUBLIC_', 'NEXT_PUBLIC_'],
['VITE_PUBLIC_', 'VITE_PUBLIC_'],
['PUBLIC_', 'PUBLIC_'],
] as const
).map(([label, value], index) => (
<span key={value}>
{index > 0 && ', '}
<button
type="button"
tabIndex={disabled ? -1 : 0}
disabled={disabled}
className="font-mono text-xs rounded-sm bg-surface-200 px-1 py-0.5 hover:bg-surface-300 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => field.onChange(value)}
>
{label}
</button>
</span>
))}
, etc.
</FormDescription>
<FormMessage className="col-start-5 col-span-8" />
</FormItem>
)}
/>
</div>
{form.formState.isDirty ? (
<p className="mt-2 text-sm text-warning">
Note: Changing these settings will <strong>not</strong> trigger a resync of
environment variables.
</p>
) : (
<div className="mt-2 h-5 w-full" />
)}
<FormActions
disabled={disabled}
form={vercelConnectionFormId}
hasChanges={form.formState.isDirty}
isSubmitting={isPending}
handleReset={() => form.reset()}
/>
</div>
</form>
<ConfirmationModal
visible={showConfirmation}
variant="warning"
size="medium"
loading={isPending}
title="Sync production credentials?"
confirmLabel="Sync credentials"
confirmLabelLoading="Saving"
onCancel={() => {
setShowConfirmation(false)
setPendingValues(null)
}}
onConfirm={() => {
if (pendingValues) performUpdate(pendingValues)
}}
alert={{
title: 'These environments will use production credentials',
description:
'Including the project ref, API URL, anon and service role keys, and database connection strings.',
}}
>
<div className="space-y-3 text-sm text-foreground-light">
<p>
This syncs your production credentials to the{' '}
<span className="text-foreground">{selectedNonProdTargets.join(' and ')}</span>{' '}
environment{selectedNonProdTargets.length > 1 ? 's' : ''} of your connected Vercel
project.
</p>
{isBranchingEnabled ? (
<p>
Branching already provisions isolated credentials for each preview branch. With this
enabled, deployments use production credentials until a branch finishes provisioning,
which can affect production data. We recommend leaving it off.
</p>
) : (
<p>
To use isolated credentials for preview deployments instead, enable{' '}
<InlineLink href={`/project/${project?.ref}/branches`}>Branching</InlineLink>.
</p>
)}
</div>
</ConfirmationModal>
</Form>
)
}
export default VercelIntegrationConnectionForm