Files
supabase/apps/studio/components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerHeader.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

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>
)
}