mirror of
https://github.com/supabase/supabase.git
synced 2026-09-07 02:20:52 +08: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 -->
377 lines
13 KiB
TypeScript
377 lines
13 KiB
TypeScript
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { useParams } from 'common'
|
|
import {
|
|
ArrowLeft,
|
|
Check,
|
|
ChevronRight,
|
|
Columns,
|
|
List,
|
|
RefreshCw,
|
|
Search,
|
|
Upload,
|
|
X,
|
|
} from 'lucide-react'
|
|
import { useRef, useState, type ChangeEvent } from 'react'
|
|
import {
|
|
Button,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuSub,
|
|
DropdownMenuSubContent,
|
|
DropdownMenuSubTrigger,
|
|
DropdownMenuTrigger,
|
|
} from 'ui'
|
|
import { Input } from 'ui-patterns/DataInputs/Input'
|
|
|
|
import { STORAGE_SORT_BY, STORAGE_SORT_BY_ORDER, STORAGE_VIEWS } from '../Storage.constants'
|
|
import { useStoragePreference } from '../StorageExplorer/useStoragePreference'
|
|
import { uploadFilesToBucket } from './BucketFilePickerDialog.utils'
|
|
import { useBucketFilePickerStateSnapshot } from './BucketFilePickerState'
|
|
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
|
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
|
|
import { storageKeys } from '@/data/storage/keys'
|
|
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
|
|
|
const VIEW_OPTIONS = [
|
|
{ key: STORAGE_VIEWS.COLUMNS, name: 'As columns' },
|
|
{ key: STORAGE_VIEWS.LIST, name: 'As list' },
|
|
]
|
|
|
|
const SORT_BY_OPTIONS = [
|
|
{ key: STORAGE_SORT_BY.NAME, name: 'Name' },
|
|
{ key: STORAGE_SORT_BY.CREATED_AT, name: 'Time created' },
|
|
{ key: STORAGE_SORT_BY.UPDATED_AT, name: 'Time modified' },
|
|
{ key: STORAGE_SORT_BY.LAST_ACCESSED_AT, name: 'Time last accessed' },
|
|
]
|
|
|
|
const SORT_ORDER_OPTIONS = [
|
|
{ key: STORAGE_SORT_BY_ORDER.ASC, name: 'Ascending' },
|
|
{ key: STORAGE_SORT_BY_ORDER.DESC, name: 'Descending' },
|
|
]
|
|
|
|
const HeaderBreadcrumbs = ({
|
|
breadcrumbs,
|
|
selectBreadcrumb,
|
|
}: {
|
|
breadcrumbs: string[]
|
|
selectBreadcrumb: (i: number) => void
|
|
}) => {
|
|
// Max 5 crumbs, otherwise replace middle segment with ellipsis and only
|
|
// have the first 2 and last 2 crumbs visible
|
|
const ellipsis = '...'
|
|
const breadcrumbsWithIndexes = breadcrumbs.map((name: string, index: number) => {
|
|
return { name, index }
|
|
})
|
|
|
|
const formattedBreadcrumbs =
|
|
breadcrumbsWithIndexes.length <= 5
|
|
? breadcrumbsWithIndexes
|
|
: breadcrumbsWithIndexes
|
|
.slice(0, 2)
|
|
.concat([{ name: ellipsis, index: -1 }])
|
|
.concat(
|
|
breadcrumbsWithIndexes.slice(
|
|
breadcrumbsWithIndexes.length - 2,
|
|
breadcrumbsWithIndexes.length
|
|
)
|
|
)
|
|
|
|
return (
|
|
<div className="ml-3 flex min-w-0 flex-1 items-center overflow-hidden">
|
|
{formattedBreadcrumbs.map((crumb, idx: number) => {
|
|
const isEllipsis = crumb.name === ellipsis
|
|
const isActive = crumb.index === breadcrumbs.length - 1
|
|
|
|
return (
|
|
<div className="flex shrink-0 items-center" key={`${crumb.index}-${crumb.name}`}>
|
|
{idx !== 0 && (
|
|
<ChevronRight size={14} strokeWidth={2} className="text-foreground-muted mx-1" />
|
|
)}
|
|
{isEllipsis ? (
|
|
<span className="max-w-24 truncate text-sm text-foreground-light">{crumb.name}</span>
|
|
) : isActive ? (
|
|
<span className="max-w-24 truncate text-sm text-foreground">{crumb.name}</span>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
tabIndex={0}
|
|
className="max-w-24 truncate border-0 bg-transparent p-0 text-left text-sm text-foreground-lighter transition-colors hover:text-foreground focus-visible:text-foreground"
|
|
onClick={() => selectBreadcrumb(crumb.index)}
|
|
>
|
|
{crumb.name}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const BucketFilePickerHeader = () => {
|
|
const { ref: projectRef } = useParams()
|
|
const queryClient = useQueryClient()
|
|
|
|
const [isSearching, setIsSearching] = useState(false)
|
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
|
const [isUploading, setIsUploading] = useState(false)
|
|
|
|
const uploadButtonRef = useRef<HTMLInputElement | null>(null)
|
|
|
|
const { hostEndpoint } = useProjectApiUrl({ projectRef: projectRef! })
|
|
|
|
const { view, sortBy, sortByOrder, setSortBy, setSortByOrder, setView } = useStoragePreference(
|
|
projectRef!
|
|
)
|
|
|
|
const {
|
|
bucket,
|
|
columns,
|
|
itemSearchString,
|
|
setItemSearchString,
|
|
popColumn,
|
|
popColumnAtIndex,
|
|
setSelectedFilePreview,
|
|
} = useBucketFilePickerStateSnapshot()
|
|
|
|
const { can: canUpdateStorage } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
|
|
|
|
const breadcrumbs = columns
|
|
const backDisabled = columns.length < 1
|
|
|
|
const onSelectBack = () => {
|
|
popColumn()
|
|
setSelectedFilePreview(undefined)
|
|
}
|
|
|
|
const onSelectUpload = () => {
|
|
if (uploadButtonRef.current) {
|
|
uploadButtonRef.current.click()
|
|
}
|
|
}
|
|
|
|
const handleFilesUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
|
if (!hostEndpoint) {
|
|
console.error('Host endpoint not available')
|
|
return
|
|
}
|
|
const files = Array.from(event.target.files || [])
|
|
try {
|
|
setIsUploading(true)
|
|
await uploadFilesToBucket({
|
|
files,
|
|
projectRef: projectRef!,
|
|
hostEndpoint,
|
|
bucketName: bucket.name,
|
|
bucketId: bucket.id,
|
|
currentPath: columns.join('/'),
|
|
queryClient,
|
|
})
|
|
queryClient.invalidateQueries({
|
|
queryKey: storageKeys.objects(projectRef!, bucket.id, columns.join('/')),
|
|
})
|
|
} catch (error) {
|
|
console.error('Failed to upload files:', error)
|
|
// Consider showing a toast notification to the user
|
|
} finally {
|
|
event.target.value = ''
|
|
setIsUploading(false)
|
|
}
|
|
}
|
|
|
|
/** Methods for searching */
|
|
const toggleSearch = () => {
|
|
setIsSearching(true)
|
|
}
|
|
|
|
const onCancelSearch = () => {
|
|
setIsSearching(false)
|
|
setItemSearchString('')
|
|
}
|
|
|
|
/** Methods for breadcrumbs */
|
|
|
|
const selectBreadcrumb = (columnIndex: number) => {
|
|
popColumnAtIndex(columnIndex)
|
|
}
|
|
|
|
const refreshData = async () => {
|
|
setIsRefreshing(true)
|
|
const queryKey = storageKeys.objects(projectRef!, bucket.id, '').filter(Boolean)
|
|
try {
|
|
await queryClient.refetchQueries({ queryKey: queryKey, type: 'active' })
|
|
} finally {
|
|
setIsRefreshing(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-t-md border-b border-overlay bg-surface-100">
|
|
<div className="overflow-x-auto overflow-y-hidden">
|
|
<div className="flex min-h-[40px] w-max min-w-full items-center justify-between">
|
|
{/* Navigation */}
|
|
<div className="flex min-w-0 flex-1 items-center overflow-hidden pl-2 py-[7px]">
|
|
{breadcrumbs.length > 0 && (
|
|
<>
|
|
<Button
|
|
icon={<ArrowLeft size={16} strokeWidth={2} />}
|
|
size="tiny"
|
|
variant="text"
|
|
className="shrink-0 px-1"
|
|
disabled={backDisabled}
|
|
onClick={() => {
|
|
onSelectBack()
|
|
}}
|
|
/>
|
|
<div className="mx-1 h-5 shrink-0 border-r border-strong" />
|
|
</>
|
|
)}
|
|
{breadcrumbs.length > 0 ? (
|
|
<HeaderBreadcrumbs breadcrumbs={breadcrumbs} selectBreadcrumb={selectBreadcrumb} />
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex shrink-0 items-center whitespace-nowrap py-[7px]">
|
|
<div className="flex shrink-0 items-center space-x-1 px-2">
|
|
<Button
|
|
size="tiny"
|
|
icon={<RefreshCw />}
|
|
variant="text"
|
|
loading={isRefreshing}
|
|
onClick={refreshData}
|
|
>
|
|
Reload
|
|
</Button>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="text"
|
|
icon={
|
|
view === 'LIST' ? (
|
|
<List size={16} strokeWidth={2} />
|
|
) : (
|
|
<Columns size={16} strokeWidth={2} />
|
|
)
|
|
}
|
|
>
|
|
View
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-40 min-w-0">
|
|
{VIEW_OPTIONS.map((option) => (
|
|
<DropdownMenuItem key={option.key} onClick={() => setView(option.key)}>
|
|
<div className="flex items-center justify-between w-full">
|
|
<p>{option.name}</p>
|
|
{view === option.key && (
|
|
<Check size={16} className="text-brand" strokeWidth={2} />
|
|
)}
|
|
</div>
|
|
</DropdownMenuItem>
|
|
))}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>Sort by</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent className="w-44">
|
|
{SORT_BY_OPTIONS.map((option) => (
|
|
<DropdownMenuItem key={option.key} onClick={() => setSortBy(option.key)}>
|
|
<div className="flex items-center justify-between w-full">
|
|
<p>{option.name}</p>
|
|
{sortBy === option.key && (
|
|
<Check size={16} className="text-brand" strokeWidth={2} />
|
|
)}
|
|
</div>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>Sort order</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent>
|
|
{SORT_ORDER_OPTIONS.map((option) => (
|
|
<DropdownMenuItem
|
|
key={option.key}
|
|
onClick={() => setSortByOrder(option.key)}
|
|
>
|
|
<div className="flex items-center justify-between w-full">
|
|
<p>{option.name}</p>
|
|
{sortByOrder === option.key && (
|
|
<Check size={16} className="text-brand" strokeWidth={2} />
|
|
)}
|
|
</div>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
|
|
<div className="h-6 shrink-0 border-r border-control" />
|
|
<div className="flex shrink-0 items-center space-x-1 px-2">
|
|
<div className="hidden">
|
|
<input ref={uploadButtonRef} type="file" multiple onChange={handleFilesUpload} />
|
|
</div>
|
|
<ButtonTooltip
|
|
icon={<Upload size={16} strokeWidth={2} />}
|
|
variant="text"
|
|
disabled={!canUpdateStorage}
|
|
loading={isUploading}
|
|
onClick={onSelectUpload}
|
|
tooltip={{
|
|
content: {
|
|
side: 'bottom',
|
|
text: !canUpdateStorage
|
|
? 'You need additional permissions to upload files'
|
|
: undefined,
|
|
},
|
|
}}
|
|
>
|
|
Upload files
|
|
</ButtonTooltip>
|
|
</div>
|
|
|
|
<div className="h-6 shrink-0 border-r border-control" />
|
|
<div className="flex shrink-0 items-center px-2">
|
|
{isSearching ? (
|
|
<Input
|
|
size="tiny"
|
|
autoFocus
|
|
className="w-52"
|
|
icon={<Search />}
|
|
actions={[
|
|
<Button
|
|
key="cancel"
|
|
size="tiny"
|
|
variant="text"
|
|
icon={<X />}
|
|
onClick={onCancelSearch}
|
|
className="p-0 h-5 w-5"
|
|
/>,
|
|
]}
|
|
placeholder="Search for a file or folder"
|
|
type="text"
|
|
value={itemSearchString}
|
|
onChange={(event) => setItemSearchString(event.target.value)}
|
|
/>
|
|
) : (
|
|
<Button
|
|
icon={<Search />}
|
|
size="tiny"
|
|
variant="text"
|
|
className="px-1"
|
|
onClick={toggleSearch}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|