Files
supabase/apps/design-system/registry/default/example/form-patterns-sidepanel.tsx
Danny White 3bca21b3f8 chore(a11y): convert leftover focus recipes to focus-ring (#48219)
## What kind of change does this PR introduce?

Accessibility cleanup (DEPR-628).

## What is the current behavior?

Leftover call sites still use ad-hoc focus recipes
(`ring-foreground-muted`, `outline-brand`, Dialog/Sheet `focus:` rings,
etc.) instead of the shared utilities from #41575.

## What is the new behavior?

Converts those leftovers across `packages/ui`, Studio, www, docs, and
design-system to `focus-ring`, preferring `focus-visible`. Keeps
documented exceptions (`group-focus-visible`, InputGroup `:has()`).

## To test

Tab through controls (keyboard only). Expect a consistent offset ring on
`:focus-visible`, not a green/brand/custom stack, and no ring animation.

### www (marketing)

Preview:
https://zone-www-dot-com-git-danny-depr-628-focus-ring-fbccf9-supabase.vercel.app

- Global nav on `/`: Product, Developers, Solutions dropdowns; logo;
hamburger + mobile menu
- `/features`: view toggles and feature cards
- `/company`: card links
- `/changelog`: timeline / entry links
- `/partners/catalog`: grid/list toggle and partner cards
- `/pricing`: compute section expand control
- Product / Modules / Solutions sticky navs on product pages (e.g.
`/database`, `/storage`)
- `/state-of-startups`: TwoOptionToggle if present

### docs

Preview:
https://docs-git-danny-depr-628-focus-ring-long-tail-supabase.vercel.app

- Any guide page: top nav dropdowns and items
- Narrow viewport: hamburger, then mobile menu links + close
- Guide with PromptPanel / tabs: tab to prompt actions and tab list

### studio (dashboard)

Preview:
https://studio-staging-git-danny-depr-628-focus-ring-long-tail-supabase.vercel.app

- Project home: Connect section tiles; drag-handle focus on sortable
sections
- Integrations marketplace (`/project/<ref>/integrations`): featured
cards, list/grid toggle, list rows
- Auth (`/project/<ref>/auth/oauth-apps`,
`/project/<ref>/auth/providers`): open create/edit sheet, tab to close
(X)
- Database policies (`/project/<ref>/database/policies`): open policy
editor sheet, tab to close
- Storage policies (`/project/<ref>/storage/files/policies`): bucket
section links; policy modal close
- Query performance (`/project/<ref>/observability/query-performance`):
info icon buttons on metrics
- Replication pipeline detail (if available): slot lag / status info
icons
- Support (`/support/new`): attachment add/remove controls
- Table editor: spreadsheet import preview checkboxes; row text/JSON
editor TwoOptionToggle
- Any Dialog/Sheet/toast close (X): ring on keyboard focus only, not
mouse click

### design-system

Preview:
https://design-system-git-danny-depr-628-focus-ring-long-tail-supabase.vercel.app

- Colour palette swatches (keyboard focus)
- Form patterns sidepanel example: avatar / focusable control in the
example

## Additional context

- Linear: [DEPR-628](https://linear.app/supabase/issue/DEPR-628)
- Follow-ups: form-group CSS (DEPR-629), Storage columns selection
(DEPR-630), ESLint rule (DEPR-632)

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

* **Accessibility & Usability**
* Standardized keyboard focus indicators across navigation, dialogs,
forms, buttons, toggles, links, and tooltips using a consolidated focus
style.
* Improved toggle controls to use proper button semantics (instead of
clickable text), including `aria-pressed`/disabled handling and better
keyboard navigation.

* **Visual Updates**
* Harmonized hover/focus ring visuals across the design system, Studio,
documentation, and marketing pages while preserving existing layout and
interaction behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-23 08:52:22 +10:00

806 lines
31 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { format } from 'date-fns'
import { ExternalLink, Trash, Upload } from 'lucide-react'
import { useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import {
Button,
Calendar,
Checkbox,
Form,
FormControl,
FormField,
FormInputGroupInput,
Input,
InputGroup,
InputGroupAddon,
InputGroupText,
RadioGroupStacked,
RadioGroupStackedItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
Switch,
Textarea,
} from 'ui'
import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input'
import {
DatePicker,
DatePickerButton,
DatePickerContent,
DatePickerTrigger,
} from 'ui-patterns/DatePicker'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { KeyValueFieldArray } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray'
import { getKeyValueFieldArrayValidationIssues } from 'ui-patterns/form/KeyValueFieldArray/validation'
import { SingleValueFieldArray } from 'ui-patterns/form/SingleValueFieldArray/SingleValueFieldArray'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
import * as z from 'zod'
const formSchema = z
.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional(),
maxConnections: z
.union([
z.literal(''),
z.coerce
.number()
.gte(1000, 'Max connections should be at least 1000')
.lte(10000, 'Max connections should not exceed 10000'),
])
.refine((value) => value !== '', 'Max connections is required'),
enableFeature: z.boolean(),
enableRls: z.boolean(),
enableNotifications: z.boolean(),
enableAnalytics: z.boolean(),
region: z.string().min(1, 'Region is required'),
schemas: z.array(z.string()).min(1, 'At least one schema is required'),
queueType: z.enum(['basic', 'partitioned']),
expiryDate: z.date(),
password: z.string().min(8, 'Password must be at least 8 characters'),
duration: z
.union([
z.literal(''),
z.coerce
.number()
.gte(1000, 'Duration should be at least 5ms')
.lte(10000, 'Duration should not exceed 30ms'),
])
.refine((value) => value !== '', 'Duration is required'),
redirectUris: z.array(z.object({ value: z.string().url('Must be a valid URL') })),
httpHeaders: z.array(z.object({ key: z.string().trim(), value: z.string().trim() })),
apiKey: z.string().optional(),
})
.superRefine((data, ctx) => {
getKeyValueFieldArrayValidationIssues({
rows: data.httpHeaders,
keyFieldName: 'key',
valueFieldName: 'value',
keyRequiredMessage: 'Header name is required',
valueRequiredMessage: 'Header value is required',
}).forEach((issue) => {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: issue.message,
path: ['httpHeaders', ...issue.path],
})
})
})
const fakeApiKey = 'sk_live_51H3x4mpl3_4nd_53cur3_k3y_1234567890'
export default function FormPatternsSidePanel() {
const [open, setOpen] = useState(false)
const uploadButtonRef = useRef<HTMLInputElement>(null)
const fileUploadRef = useRef<HTMLInputElement>(null)
const [logoFile, setLogoFile] = useState<File>()
const [logoUrl, setLogoUrl] = useState<string>()
const [uploadedFiles, setUploadedFiles] = useState<File[]>([])
const [isDragging, setIsDragging] = useState(false)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
description: '',
maxConnections: 10,
enableFeature: false,
enableRls: true,
enableNotifications: false,
enableAnalytics: true,
region: '',
schemas: ['public'],
queueType: 'basic',
expiryDate: undefined,
password: '',
duration: 10,
redirectUris: [{ value: '' }],
httpHeaders: [{ key: '', value: '' }],
apiKey: fakeApiKey,
},
})
function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values)
setOpen(false)
}
const formId = 'sidepanel-form'
return (
<>
<Button variant="primary" onClick={() => setOpen(true)}>
Open form panel
</Button>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent size="lg" className="flex flex-col gap-0">
<SheetHeader>
<SheetTitle>Create Configuration</SheetTitle>
</SheetHeader>
<Form {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(onSubmit)}
className="overflow-auto grow px-0"
>
{/* Text Input */}
<SheetSection>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Text Input"
description="Single-line text entry for short values"
>
<FormControl className="col-span-6">
<Input {...field} placeholder="Enter text" />
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
{/* Password Input */}
<SheetSection>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Password Input"
description="Masked input for secure text entry"
>
<FormControl className="col-span-6">
<Input {...field} type="password" placeholder="Enter password" />
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
{/* Copyable Input */}
<SheetSection>
<FormField
control={form.control}
name="apiKey"
render={() => (
<FormItemLayout
layout="horizontal"
label="Copyable Input"
description="Read-only input with copy-to-clipboard functionality"
>
<FormControl className="col-span-6">
<PasswordInput
copy
readOnly
className="input-mono"
value={form.getValues('apiKey') || ''}
onChange={() => {}}
onCopy={() => console.log('Copied to clipboard')}
/>
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
{/* Number Input */}
<SheetSection>
<FormField
control={form.control}
name="maxConnections"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Number Input"
description="Numeric input with min/max validation"
>
<FormControl className="col-span-6">
<Input {...field} type="number" min={1} max={1000} />
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
{/* Input with Units */}
<SheetSection>
<FormField
control={form.control}
name="duration"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Input with Units"
description="Input with additional unit label"
>
<FormControl className="col-span-6">
<InputGroup>
<FormInputGroupInput {...field} type="number" min={5} max={30} />
<InputGroupAddon align="inline-end">
<InputGroupText>ms</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
{/* Textarea */}
<SheetSection>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Textarea"
description="Multi-line text input for longer content"
>
<FormControl className="col-span-6">
<Textarea
{...field}
rows={3}
placeholder="Enter multi-line text"
className="resize-none"
/>
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
{/* Icon Upload */}
<SheetSection>
<FormField
control={form.control}
name="description"
render={() => (
<FormItemLayout
layout="horizontal"
label="Icon upload"
description="For icons, avatars, or small images with preview"
>
<FormControl className="col-span-6">
<div className="flex gap-4 items-center">
<button
tabIndex={0}
type="button"
onClick={() => uploadButtonRef.current?.click()}
className="flex items-center justify-center h-10 w-10 shrink-0 text-foreground-lighter hover:text-foreground-light overflow-hidden rounded-full bg-cover border hover:border-strong focus-ring"
style={{
backgroundImage: logoUrl ? `url("${logoUrl}")` : 'none',
}}
>
{!logoUrl && <Upload size={14} />}
</button>
<div className="flex gap-2 items-center">
<Button
type="button"
variant="outline"
size="tiny"
icon={<Upload size={14} />}
onClick={() => uploadButtonRef.current?.click()}
>
Upload
</Button>
{logoUrl && (
<Button
variant="default"
size="tiny"
icon={<Trash size={12} />}
onClick={() => {
setLogoFile(undefined)
setLogoUrl(undefined)
}}
/>
)}
</div>
<input
type="file"
ref={uploadButtonRef}
className="hidden"
accept="image/png, image/jpeg"
onChange={(e) => {
const files = e.target.files
if (files && files.length > 0) {
const file = files[0]
setLogoFile(file)
setLogoUrl(URL.createObjectURL(file))
e.target.value = ''
}
}}
/>
</div>
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
{/* File Upload */}
<SheetSection>
<FormField
control={form.control}
name="description"
render={() => (
<FormItemLayout
layout="horizontal"
label="File Upload"
description="Drag-and-drop or select files for upload"
>
<FormControl className="col-span-6">
<div
className={`border-2 rounded-lg p-6 text-center bg-muted transition-colors duration-300 ${
isDragging
? 'border-strong border-dashed bg-muted'
: 'border-border border-dashed'
}`}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => {
e.preventDefault()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files)
setUploadedFiles((prev) => [...prev, ...files])
}}
>
<input
type="file"
ref={fileUploadRef}
className="hidden"
multiple
onChange={(e) => {
const files = e.target.files
if (files) {
setUploadedFiles((prev) => [...prev, ...Array.from(files)])
}
e.target.value = ''
}}
/>
<div className="flex flex-col items-center gap-y-2">
<Upload size={20} className="text-foreground-lighter" />
<p className="text-sm text-foreground-light">
{uploadedFiles.length > 0
? `${uploadedFiles.length} file${uploadedFiles.length > 1 ? 's' : ''} selected`
: 'Upload files'}
</p>
<p className="text-xs text-foreground-lighter">
Drag and drop or{' '}
<button
tabIndex={0}
type="button"
onClick={() => fileUploadRef.current?.click()}
className="underline cursor-pointer hover:text-foreground-light"
>
select files
</button>{' '}
to upload
</p>
{uploadedFiles.length > 0 && (
<div className="mt-4 w-full space-y-2">
{uploadedFiles.map((file, idx) => (
<div
key={`${file.name}-${idx}`}
className="flex items-center justify-between gap-2 p-2 bg rounded-sm border"
>
<span className="text-sm text-foreground-light truncate flex-1">
{file.name}
</span>
<Button
variant="default"
size="tiny"
icon={<Trash size={12} />}
onClick={() => {
setUploadedFiles((prev) => prev.filter((_, i) => i !== idx))
}}
/>
</div>
))}
</div>
)}
</div>
</div>
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
{/* Switch */}
<SheetSection>
<FormField
control={form.control}
name="enableFeature"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Switch"
description="Toggle for boolean on/off states"
>
<FormControl className="col-span-6">
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
{/* Checkbox */}
<SheetSection>
<FormItemLayout
layout="horizontal"
label="Checkbox"
description="Boolean values or multiple selections"
>
<div className="col-span-6 w-full flex flex-col gap-4">
<FormField
control={form.control}
name="enableRls"
render={({ field }) => (
<div className="flex items-center w-full justify-start space-x-2">
<FormControl>
<Checkbox
id="enable-rls"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<label
htmlFor="enable-rls"
className="text-sm text-foreground-light leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Enable Row Level Security
</label>
</div>
)}
/>
<FormField
control={form.control}
name="enableNotifications"
render={({ field }) => (
<div className="flex items-center w-full justify-start space-x-2">
<FormControl>
<Checkbox
id="enable-notifications"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<label
htmlFor="enable-notifications"
className="text-sm text-foreground-light leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Enable email notifications
</label>
</div>
)}
/>
<FormField
control={form.control}
name="enableAnalytics"
render={({ field }) => (
<div className="flex items-center w-full justify-start space-x-2">
<FormControl>
<Checkbox
id="enable-analytics"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<label
htmlFor="enable-analytics"
className="text-sm text-foreground-light leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Enable analytics tracking
</label>
</div>
)}
/>
</div>
</FormItemLayout>
</SheetSection>
<Separator className="w-full" />
{/* Select */}
<SheetSection>
<FormField
control={form.control}
name="region"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Select (Dropdown)"
description="Single selection from a list of options"
>
<FormControl className="col-span-6">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
<SelectItem value="us-east-1">US East (N. Virginia)</SelectItem>
<SelectItem value="us-west-2">US West (Oregon)</SelectItem>
<SelectItem value="eu-west-1">EU West (Ireland)</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
{/* Multi-Select */}
<SheetSection>
<FormField
control={form.control}
name="schemas"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Multi-Select"
description="Multiple selection from a list"
>
<div className="col-span-6">
<MultiSelector
onValuesChange={field.onChange}
values={field.value}
size="small"
className="w-full"
>
<MultiSelectorTrigger
mode="inline-combobox"
label="Select options..."
badgeLimit="wrap"
showIcon={false}
deletableBadge
className="w-full"
/>
<MultiSelectorContent>
<MultiSelectorList>
<MultiSelectorItem value="public">public</MultiSelectorItem>
<MultiSelectorItem value="auth">auth</MultiSelectorItem>
<MultiSelectorItem value="storage">storage</MultiSelectorItem>
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
</div>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
{/* Radio Group */}
<SheetSection>
<FormField
control={form.control}
name="queueType"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Radio Group"
description="Single selection from multiple options"
>
<div className="col-span-6">
<RadioGroupStacked value={field.value} onValueChange={field.onChange}>
<RadioGroupStackedItem
value="basic"
label="Option 1"
description="First option description"
/>
<RadioGroupStackedItem
value="partitioned"
label="Option 2"
description="Second option description"
/>
</RadioGroupStacked>
</div>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
{/* Date Picker */}
<SheetSection>
<FormField
control={form.control}
name="expiryDate"
render={({ field, fieldState }) => (
<FormItemLayout
layout="horizontal"
label="Date Picker"
description="Date selection with calendar popover"
>
<FormControl className="col-span-6">
<DatePicker>
<DatePickerTrigger asChild>
<DatePickerButton block isInvalid={fieldState.invalid}>
{field.value ? format(field.value, 'PPP') : 'Pick a date'}
</DatePickerButton>
</DatePickerTrigger>
<DatePickerContent>
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
initialFocus
/>
</DatePickerContent>
</DatePicker>
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
{/* Field Array */}
<SheetSection>
<FormField
control={form.control}
name="redirectUris"
render={() => (
<FormItemLayout
layout="horizontal"
label="Field Array"
description="Dynamic list for adding/removing items"
>
<div className="col-span-6">
<SingleValueFieldArray
control={form.control}
name="redirectUris"
valueFieldName="value"
createEmptyRow={() => ({ value: '' })}
placeholder="https://example.com/callback"
addLabel="Add redirect URI"
removeLabel="Remove redirect URI"
/>
</div>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
{/* Key/Value Field Array */}
<SheetSection>
<FormField
control={form.control}
name="httpHeaders"
render={() => (
<FormItemLayout
layout="horizontal"
label="Key/Value Field Array"
description="Repeated text pairs for headers, parameters, and config entries"
>
<div className="col-span-6">
<KeyValueFieldArray
control={form.control}
name="httpHeaders"
keyFieldName="key"
valueFieldName="value"
createEmptyRow={() => ({ key: '', value: '' })}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
addLabel="Add header"
removeLabel="Remove header"
/>
</div>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
{/* Action Field */}
<SheetSection>
<FormItemLayout
layout="horizontal"
label="Action Field"
description="Button or link for navigation or performable actions"
>
<div className="col-span-6 flex gap-2 items-center">
<Button
variant="default"
icon={<ExternalLink size={14} />}
onClick={() => console.log('Action performed')}
>
View documentation
</Button>
<Button variant="default" onClick={() => console.log('Reset action')}>
Reset API key
</Button>
</div>
</FormItemLayout>
</SheetSection>
</form>
</Form>
<SheetFooter>
<Button
variant="default"
onClick={() => {
form.reset()
setOpen(false)
}}
>
Cancel
</Button>
<Button variant="primary" form={formId} type="submit">
Create
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</>
)
}