mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
## 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 -->
273 lines
8.8 KiB
TypeScript
273 lines
8.8 KiB
TypeScript
// End of third-party imports
|
|
|
|
import { compact } from 'lodash'
|
|
import { FileCode, Plus, X } from 'lucide-react'
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ChangeEvent,
|
|
type RefObject,
|
|
} from 'react'
|
|
import { toast } from 'sonner'
|
|
import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
|
|
|
import { createSupportStorageClient } from './support-storage-client'
|
|
import { InlineLink } from '@/components/ui/InlineLink'
|
|
import { useGenerateAttachmentURLsMutation } from '@/data/support/generate-attachment-urls-mutation'
|
|
import { uuidv4 } from '@/lib/helpers'
|
|
import { useProfile } from '@/lib/profile'
|
|
|
|
const MAX_ATTACHMENTS = 5
|
|
|
|
const removeAttachmentButtonClassName = cn(
|
|
'absolute -top-1 -right-1 size-4 shrink-0 rounded-full bg-red-900 p-0 cursor-pointer',
|
|
'outline-hidden focus-visible:ring-2 focus-visible:ring-foreground-muted focus-visible:ring-offset-1 focus-visible:ring-offset-background'
|
|
)
|
|
|
|
const RemoveAttachmentIcon = () => (
|
|
<X
|
|
aria-hidden="true"
|
|
strokeWidth={3}
|
|
className="pointer-events-none absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 text-contrast"
|
|
/>
|
|
)
|
|
|
|
const uploadAttachments = async ({ userId, files }: { userId: string; files: File[] }) => {
|
|
const supportSupabaseClient = createSupportStorageClient()
|
|
|
|
const filesToUpload = Array.from(files)
|
|
const uploadedFiles = await Promise.all(
|
|
filesToUpload.map(async (file) => {
|
|
const suffix = file.name.endsWith('.har') ? 'har' : file.type.split('/')[1]
|
|
const prefix = `${userId}/${uuidv4()}.${suffix}`
|
|
const options = { cacheControl: '3600' }
|
|
|
|
const { data, error } = await supportSupabaseClient.storage
|
|
.from('support-attachments')
|
|
.upload(prefix, file, options)
|
|
|
|
if (error) console.error('Failed to upload:', file.name, error)
|
|
return data
|
|
})
|
|
)
|
|
const keys = compact(uploadedFiles).map((file) => file.path)
|
|
return keys
|
|
}
|
|
|
|
export function useAttachmentUpload() {
|
|
const { profile } = useProfile()
|
|
const uploadButtonRef = useRef<HTMLInputElement>(null)
|
|
const [uploadedFiles, setUploadedFiles] = useState<File[]>([])
|
|
const [uploadedDataUrls, setUploadedDataUrls] = useState<string[]>([])
|
|
|
|
const { mutateAsync: generateAttachmentURLs } = useGenerateAttachmentURLsMutation()
|
|
|
|
const isFull = uploadedFiles.length >= MAX_ATTACHMENTS
|
|
|
|
const addFile = useCallback(() => {
|
|
uploadButtonRef.current?.click()
|
|
}, [])
|
|
|
|
const handleFileUpload = useCallback(
|
|
async (event: ChangeEvent<HTMLInputElement>) => {
|
|
event.persist()
|
|
const items = event.target.files || (event as any).dataTransfer.items
|
|
const itemsCopied = Array.prototype.map.call(items, (item: any) => item) as File[]
|
|
const itemsToBeUploaded = itemsCopied.slice(0, MAX_ATTACHMENTS - uploadedFiles.length)
|
|
|
|
setUploadedFiles(uploadedFiles.concat(itemsToBeUploaded))
|
|
if (items.length + uploadedFiles.length > MAX_ATTACHMENTS) {
|
|
toast(`Only up to ${MAX_ATTACHMENTS} attachments are allowed`)
|
|
}
|
|
event.target.value = ''
|
|
},
|
|
[uploadedFiles]
|
|
)
|
|
|
|
const removeFileUpload = useCallback(
|
|
(idx: number) => {
|
|
const updatedFiles = uploadedFiles.slice()
|
|
updatedFiles.splice(idx, 1)
|
|
setUploadedFiles(updatedFiles)
|
|
|
|
const updatedDataUrls = uploadedDataUrls.slice()
|
|
uploadedDataUrls.splice(idx, 1)
|
|
setUploadedDataUrls(updatedDataUrls)
|
|
},
|
|
[uploadedFiles, uploadedDataUrls]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!uploadedFiles) return
|
|
const objectUrls = uploadedFiles.map((file) => {
|
|
if (file.name.endsWith('.har')) {
|
|
return file.name
|
|
} else {
|
|
return URL.createObjectURL(file)
|
|
}
|
|
})
|
|
setUploadedDataUrls(objectUrls)
|
|
|
|
return () => {
|
|
objectUrls.forEach((url: any) => void URL.revokeObjectURL(url))
|
|
}
|
|
}, [uploadedFiles])
|
|
|
|
const createAttachments = useCallback(async () => {
|
|
if (!profile?.id) {
|
|
console.error('[Support Form > uploadAttachments] Unable to upload files, missing user ID')
|
|
toast.error('Unable to upload attachments')
|
|
return []
|
|
}
|
|
|
|
if (uploadedFiles.length === 0) return
|
|
|
|
try {
|
|
const filenames = await uploadAttachments({ userId: profile.gotrue_id, files: uploadedFiles })
|
|
const urls = await generateAttachmentURLs({ bucket: 'support-attachments', filenames })
|
|
return urls
|
|
} catch {
|
|
// Ignore attachments upload errors, images are additional context and support can ask for more if needed
|
|
return
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [profile, uploadedFiles])
|
|
|
|
return useMemo(
|
|
() => ({
|
|
uploadButtonRef,
|
|
isFull,
|
|
addFile,
|
|
handleFileUpload,
|
|
removeFileUpload,
|
|
createAttachments,
|
|
uploadedDataUrls,
|
|
}),
|
|
[isFull, addFile, handleFileUpload, removeFileUpload, createAttachments, uploadedDataUrls]
|
|
)
|
|
}
|
|
|
|
interface AttachmentUploadDisplayProps {
|
|
uploadButtonRef: RefObject<HTMLInputElement | null>
|
|
isFull: boolean
|
|
uploadedDataUrls: string[]
|
|
addFile: () => void
|
|
handleFileUpload: (event: ChangeEvent<HTMLInputElement>) => Promise<void>
|
|
removeFileUpload: (idx: number) => void
|
|
}
|
|
|
|
export function AttachmentUploadDisplay({
|
|
uploadButtonRef,
|
|
isFull,
|
|
uploadedDataUrls,
|
|
addFile,
|
|
handleFileUpload,
|
|
removeFileUpload,
|
|
}: AttachmentUploadDisplayProps) {
|
|
const { profile } = useProfile()
|
|
|
|
if (!profile) {
|
|
return (
|
|
<div>
|
|
<h3 className="text-sm text-foreground">Attachments</h3>
|
|
<p className="text-sm text-foreground-lighter mt-2">
|
|
Uploads are only supported when logged in. Please reply to the acknowledgement email you
|
|
will receive with any screenshots you'd like to upload.
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-y-4">
|
|
<div className="flex flex-col gap-y-1">
|
|
<p className="text-sm text-foreground">Attachments</p>
|
|
<p className="text-sm text-foreground-lighter">
|
|
Optionally upload up to {MAX_ATTACHMENTS} relevant images or{' '}
|
|
<InlineLink href="https://github.com/orgs/supabase/discussions/36540">
|
|
HAR files
|
|
</InlineLink>
|
|
</p>
|
|
</div>
|
|
<input
|
|
multiple
|
|
type="file"
|
|
ref={uploadButtonRef}
|
|
className="hidden"
|
|
accept="image/png, image/jpeg, .har"
|
|
onChange={handleFileUpload}
|
|
/>
|
|
<div className="flex items-center gap-x-2">
|
|
{uploadedDataUrls.map((url, idx) => {
|
|
if (url.endsWith('.har')) {
|
|
return (
|
|
<div
|
|
key={url}
|
|
className="border relative h-14 w-14 rounded-sm flex items-center justify-center"
|
|
>
|
|
<Tooltip>
|
|
<TooltipTrigger className="cursor-default" onClick={(e) => e.preventDefault()}>
|
|
<div className="flex flex-col items-center justify-center gap-y-1">
|
|
<FileCode className="text-foreground-light" size={16} />
|
|
<p className="text-[10px] font-mono text-foreground-light tracking-wide leading-none">
|
|
HAR
|
|
</p>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">{url}</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<button
|
|
type="button"
|
|
tabIndex={0}
|
|
aria-label="Remove attachment"
|
|
className={removeAttachmentButtonClassName}
|
|
onClick={() => removeFileUpload(idx)}
|
|
>
|
|
<RemoveAttachmentIcon />
|
|
</button>
|
|
</div>
|
|
)
|
|
} else {
|
|
return (
|
|
<div
|
|
key={url}
|
|
style={{ backgroundImage: `url("${url}")` }}
|
|
className="relative h-14 w-14 rounded-sm bg-cover bg-center bg-no-repeat"
|
|
>
|
|
<button
|
|
type="button"
|
|
tabIndex={0}
|
|
aria-label="Remove attachment"
|
|
className={removeAttachmentButtonClassName}
|
|
onClick={() => removeFileUpload(idx)}
|
|
>
|
|
<RemoveAttachmentIcon />
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
})}
|
|
{!isFull && (
|
|
<button
|
|
type="button"
|
|
tabIndex={0}
|
|
aria-label="Add attachment"
|
|
className={cn(
|
|
'border border-stronger opacity-50 transition hover:opacity-100',
|
|
'group flex h-14 w-14 cursor-pointer items-center justify-center rounded-sm',
|
|
'outline-hidden focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-foreground-muted focus-visible:ring-offset-1 focus-visible:ring-offset-background'
|
|
)}
|
|
onClick={addFile}
|
|
>
|
|
<Plus strokeWidth={2} size={20} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|