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 -->
This commit is contained in:
Danny White authored and GitHub committed 2026-07-21 08:22:43 +10:00
1 parent 37296128e0
commit e3d7267845
68 files changed
+227 -159

No files matched your search

@@ -93,6 +93,8 @@ const NavigationItem: React.FC<NavigationItemProps> = ({
{hasChildren ? (
<>
<button
type="button"
tabIndex={0}
onClick={handleButtonClick}
className={cn('w-full flex items-center justify-between gap-2 zans', itemClasses)}
>
+2 -69
View File
@@ -19,8 +19,7 @@
"jsx-a11y/tabindex-no-positive": 0,
"jsx-a11y/anchor-is-valid": 7,
"jsx-a11y/heading-has-content": 2,
"jsx-a11y/no-distracting-elements": 0,
"supabase/require-explicit-tabindex": 82
"jsx-a11y/no-distracting-elements": 0
},
"ruleFiles": {
"react-hooks/exhaustive-deps": {
@@ -1060,72 +1059,6 @@
"jsx-a11y/heading-has-content": {
"components/layouts/Scaffold.tsx": 2
},
"jsx-a11y/no-distracting-elements": {},
"supabase/require-explicit-tabindex": {
"components/grid/components/grid/ColumnHeader.tsx": 1,
"components/grid/components/header/sort/SortRow.tsx": 1,
"components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx": 1,
"components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx": 1,
"components/interfaces/BranchManagement/WorkflowLogs.tsx": 1,
"components/interfaces/Connect/OrganizationSelector.tsx": 1,
"components/interfaces/ConnectSheet/ConnectConfigSection.tsx": 1,
"components/interfaces/ConnectSheet/ConnectionParameters.tsx": 1,
"components/interfaces/Database/EnumeratedTypes/EnumeratedTypeValueRow.tsx": 1,
"components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx": 1,
"components/interfaces/Database/Privileges/PrivilegesTable.tsx": 1,
"components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx": 1,
"components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaPricingDialog.tsx": 1,
"components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotLagMetrics.tsx": 1,
"components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotStatus.tsx": 1,
"components/interfaces/Database/Replication/ReplicationPipelineStatus/TableReplicationRow.tsx": 1,
"components/interfaces/Database/Roles/RoleRow.tsx": 1,
"components/interfaces/Database/Roles/RolesList.tsx": 2,
"components/interfaces/Database/Triggers/TriggerSheet.tsx": 1,
"components/interfaces/ExplainVisualizer/ExplainVisualizer.NodeRow.tsx": 1,
"components/interfaces/Functions/CommandRender.tsx": 1,
"components/interfaces/Functions/EdgeFunctionsListItem.tsx": 1,
"components/interfaces/Integrations/Integration/MarkdownContent.tsx": 1,
"components/interfaces/Integrations/Marketplace/MarketplaceFilterBar.tsx": 3,
"components/interfaces/LogDrains/LogDrainsCard.tsx": 1,
"components/interfaces/Observability/ServiceHealthTable.tsx": 1,
"components/interfaces/Organization/OrganizationCard.tsx": 1,
"components/interfaces/Organization/PrivateApps/Apps/ViewAppSheet/ViewAppSheetInfo.tsx": 1,
"components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx": 6,
"components/interfaces/ProjectAPIDocs/SecondLevelNav.Layout.tsx": 1,
"components/interfaces/ProjectHome/ConnectSection.tsx": 1,
"components/interfaces/ProjectHome/SortableSection.tsx": 1,
"components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.tsx": 2,
"components/interfaces/QueryPerformance/QueryPerformanceMetrics.tsx": 2,
"components/interfaces/Settings/Integrations/VercelIntegration/VercelIntegrationConnectionForm.tsx": 3,
"components/interfaces/Storage/BucketFilePickerDialog/BucketFilePickerHeader.tsx": 1,
"components/interfaces/Storage/StorageExplorer/FileExplorerRowEditing.tsx": 1,
"components/interfaces/Storage/StorageExplorer/MoveItemsModal.tsx": 1,
"components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketsSection.tsx": 1,
"components/interfaces/Storage/StoragePolicies/StoragePolicyEditorModalTitle.tsx": 1,
"components/interfaces/Storage/StorageSettings/StorageSettings.ValidateSizeLimit.tsx": 1,
"components/interfaces/Support/AttachmentUpload.tsx": 3,
"components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImportPreview.tsx": 1,
"components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/Column.tsx": 3,
"components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx": 1,
"components/layouts/Navigation/LayoutHeader/FeedbackDropdown/FeedbackWidget.tsx": 1,
"components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuItem.tsx": 1,
"components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/TopLevelRouteItem.tsx": 1,
"components/layouts/TableEditorLayout/EntityListItem.tsx": 1,
"components/layouts/Tabs/CollapseButton.tsx": 1,
"components/layouts/Tabs/SortableTab.tsx": 1,
"components/layouts/Tabs/Tabs.tsx": 1,
"components/ui/AIAssistantPanel/ModelSelector.tsx": 1,
"components/ui/Charts/ChartHighlightActions.tsx": 1,
"components/ui/Charts/ComposedChart.utils.tsx": 1,
"components/ui/ComputeBadgeWrapper.tsx": 1,
"components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx": 2,
"components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxAsync.tsx": 1,
"components/ui/DevToolbar/ProjectStatusTab.tsx": 1,
"components/ui/DevToolbar/ResourceWarningsTab.tsx": 2,
"components/ui/FeaturePreviewBadge.tsx": 1,
"components/ui/FilterPopover.tsx": 1,
"components/ui/PasswordStrengthBar.tsx": 1,
"components/ui/SingleStat.tsx": 1
}
"jsx-a11y/no-distracting-elements": {}
}
}
@@ -85,6 +85,7 @@ export function ColumnHeader<R>({
<TooltipTrigger asChild>
<button
className="flex items-center"
tabIndex={0}
onClick={() => openSheet(column.name as string)}
>
<span className="sr-only">View {column.name} index suggestion</span>
@@ -40,6 +40,7 @@ const SortRow = ({ index, columnName, sort, onDelete, onToggle }: SortRowProps)
{...listeners}
className="opacity-50 hover:opacity-100 transition cursor-grab text-foreground"
type="button"
tabIndex={0}
>
<GripVertical size={16} strokeWidth={1.5} />
</button>
@@ -251,6 +251,7 @@ const FeaturePreviewItem = ({
return (
<button
type="button"
tabIndex={0}
key={feature.key}
onClick={() => selectFeaturePreview(feature.key)}
className={cn(
@@ -128,6 +128,7 @@ const FunctionDiff = ({
<li key={fileInfo.key} className="flex">
<button
type="button"
tabIndex={0}
onClick={() => setActiveFileKey(fileInfo.key)}
className={cn(
'flex-1 text-left text-xs px-4 py-2 flex items-center gap-2',
@@ -115,6 +115,7 @@ export const WorkflowLogs = ({ branch }: WorkflowLogsProps) => {
<li key={workflowRun.id} className="flex justify-between px-4 py-3 gap-2">
<button
type="button"
tabIndex={workflowRun.id === projectRef ? -1 : 0}
disabled={workflowRun.id === projectRef}
onClick={() => setSelectedWorkflowRun(workflowRun)}
className="flex items-center gap-2 w-full justify-between"
@@ -193,6 +193,7 @@ const ConnectOrganizationButton = ({
}) => (
<button
type="button"
tabIndex={disabled ? -1 : 0}
disabled={disabled}
onClick={onClick}
aria-pressed={selected}
@@ -253,6 +253,7 @@ export function ModeSelector({ modes, selected, onChange }: ModeSelectorProps) {
<button
key={mode.id}
type="button"
tabIndex={0}
onClick={() => onChange(mode.id)}
className={cn(
'flex flex-col items-center gap-2 p-4 transition-colors border-r last:border-r-0',
@@ -23,6 +23,9 @@ export const ConnectionParameters = ({ parameters, onCopy }: ConnectionParameter
<span className="text-foreground-lighter">{param.key}:</span>
<span className="ml-1 text-foreground">{param.value}</span>
<button
type="button"
tabIndex={0}
aria-label={`Copy ${param.key}`}
onClick={() => {
copyToClipboard(param.value, () => {
setCopiedMap((prev) => ({ ...prev, [param.key]: true }))
@@ -34,7 +37,7 @@ export const ConnectionParameters = ({ parameters, onCopy }: ConnectionParameter
}}
className={cn(
'text-foreground-lighter',
'ml-2 opacity-0 group-hover/param:opacity-100',
'ml-2 opacity-0 group-hover/param:opacity-100 focus-visible:opacity-100',
'hover:text-foreground rounded-xs p-1',
copiedMap[param.key] && 'opacity-100',
'transition-all'
@@ -52,6 +52,7 @@ const EnumeratedTypeValueRow = <TFieldValues extends FieldValues>({
isDisabled ? 'text-foreground-lighter cursor-default!' : 'text-foreground'
}`}
type="button"
tabIndex={isDisabled ? -1 : 0}
disabled={isDisabled}
>
<GripVertical size={16} strokeWidth={1.5} />
@@ -105,6 +105,7 @@ export const PolicyEditorPanelHeader = ({
<Tooltip>
<TooltipTrigger asChild>
<button
tabIndex={0}
aria-expanded={showTools}
aria-controls="ai-chat-assistant"
className={cn(
@@ -73,6 +73,7 @@ const PrivilegesTable = ({
<Table.tr key={column.column_id}>
<Table.td>
<button
tabIndex={0}
onClick={() => handleClickColumnName(column.column_id)}
className="block w-full truncate text-left text-foreground"
>
@@ -76,6 +76,7 @@ const DuckLakeModeSelector = ({
<button
key={option.value}
type="button"
tabIndex={0}
role="radio"
aria-checked={selected}
onClick={() => onChange(option.value)}
@@ -37,6 +37,7 @@ export const ReadReplicaPricingDialog = () => {
<DialogTrigger asChild>
<button
type="button"
tabIndex={0}
className={cn(InlineLinkClassName, 'cursor-pointer text-foreground-light')}
>
Learn more
@@ -133,6 +133,7 @@ export const SlotLagMetricsList = ({
<TooltipTrigger asChild>
<button
type="button"
tabIndex={0}
aria-label={`What is ${field.label}`}
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-surface-200 text-foreground-lighter transition-colors hover:bg-surface-300 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-foreground-lighter"
>
@@ -64,6 +64,7 @@ export const SlotStatusLegend = () => {
<PopoverTrigger asChild>
<button
type="button"
tabIndex={0}
aria-label="What do the slot statuses mean?"
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-surface-200 text-foreground-lighter transition-colors hover:bg-surface-300 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-foreground-lighter"
>
@@ -81,7 +81,11 @@ export const TableReplicationRow = ({
<div className="text-sm text-foreground">
{statusConfig.description}{' '}
{isErrorState && 'reason' in table.state && (
<button className={InlineLinkClassName} onClick={() => onSelectShowError()}>
<button
tabIndex={0}
className={InlineLinkClassName}
onClick={() => onSelectShowError()}
>
View error.
</button>
)}
@@ -117,6 +117,7 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps
<button
id={`collapsible-trigger-${role.id}`}
type="button"
tabIndex={0}
className="group flex w-full items-center justify-between rounded-sm py-3 px-card text-foreground"
onClick={(event) => {
event.preventDefault()
@@ -149,6 +149,7 @@ export const RolesList = () => {
/>
<div className="flex items-center border border-strong rounded-full w-min h-[26px]">
<button
tabIndex={0}
className={cn(
'text-xs w-[80px] h-full text-center rounded-l-full flex items-center justify-center transition',
filterType === 'all'
@@ -161,6 +162,7 @@ export const RolesList = () => {
</button>
<div className="h-full w-px border-r border-strong"></div>
<button
tabIndex={0}
className={cn(
'text-xs w-[80px] h-full text-center rounded-r-full flex items-center justify-center transition',
filterType === 'active'
@@ -420,6 +420,7 @@ export const TriggerSheet = ({
{function_name.length === 0 ? (
<button
type="button"
tabIndex={0}
className={cn(
'relative w-full rounded-sm border border-default',
'bg-surface-200 px-5 py-1 shadow-xs transition-all',
@@ -49,6 +49,7 @@ export function ExplainNodeRow({ node, depth, maxDuration }: ExplainNodeRowProps
{/* Expand/collapse button */}
<button
type="button"
tabIndex={canExpand ? 0 : -1}
onClick={() => canExpand && setIsExpanded(!isExpanded)}
disabled={!canExpand}
className={cn(
@@ -31,6 +31,7 @@ const Command = ({ item }: any) => {
<span>{item.jsx ? item.jsx() : null} </span>
<button
type="button"
tabIndex={0}
className="text-foreground-lighter hover:text-foreground"
onClick={() => {
function onCopy(value: any) {
@@ -74,6 +74,7 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP
</p>
<button
type="button"
tabIndex={0}
className="text-foreground-lighter hover:text-foreground transition"
onClick={(event: MouseEvent<HTMLButtonElement>) => {
function onCopy(value: string) {
@@ -58,6 +58,8 @@ export const MarkdownContent = ({
{supportExpanding && (
<div className={cn('bottom-0', !isExpanded ? 'absolute' : 'relative mt-3')}>
<button
type="button"
tabIndex={0}
className="text-foreground-light hover:text-foreground underline text-sm"
onClick={() => setIsExpanded(!isExpanded)}
>
@@ -201,18 +201,21 @@ export const MarketplaceFilterBar = ({
</DropdownMenu>
{showClear && (
<button
<Button
type="button"
variant="text"
size="tiny"
onClick={onClearFilters}
className="px-1 text-xs text-foreground-light underline-offset-2 hover:underline"
className="text-foreground-light"
>
Clear all
</button>
</Button>
)}
<div className="ml-auto flex rounded-md border">
<button
type="button"
tabIndex={0}
aria-label="Grid view"
onClick={() => onViewModeChange('grid')}
className={cn(
@@ -227,6 +230,7 @@ export const MarketplaceFilterBar = ({
</button>
<button
type="button"
tabIndex={0}
aria-label="List view"
onClick={() => onViewModeChange('list')}
className={cn(
@@ -16,7 +16,7 @@ export const LogDrainsCard = ({
onClick,
}: LogDrainsCardProps) => {
return (
<button className="w-full h-full text-left" onClick={onClick}>
<button type="button" tabIndex={0} className="w-full h-full text-left" onClick={onClick}>
<Card className="p-6 cursor-pointer hover:bg-surface-200 hover:border-strong transition-colors h-48">
<div className="flex flex-col gap-5">
<div className="flex items-start justify-between w-full">
@@ -127,6 +127,7 @@ const ServiceCell = ({
<TooltipTrigger asChild>
<button
type="button"
tabIndex={0}
className="relative z-10 text-foreground-lighter hover:text-foreground-light transition-colors shrink-0"
aria-label={`About ${service.name}`}
>
@@ -118,6 +118,7 @@ export const CreateOrganizationCard = ({
return (
<button
type="button"
tabIndex={disabled ? -1 : 0}
disabled={disabled}
onClick={onClick}
className={cn(
@@ -37,6 +37,8 @@ function CopyableId({ id, label }: { id: string; label: string }) {
return (
<button
type="button"
tabIndex={0}
onClick={handleCopy}
className="inline-flex items-center gap-x-1 cursor-pointer border border-transparent border-dashed rounded-sm transition-colors hover:bg-surface-100 hover:border hover:border-strong group font-mono text-xs text-foreground-light px-1 -ml-1"
>
@@ -86,6 +86,7 @@ export const FirstLevelNav = (): ReactNode => {
return (
<Fragment key={item.key}>
<button
tabIndex={0}
aria-current={isActive ? 'page' : undefined}
className={cn(
'w-full px-3 py-2 rounded-md',
@@ -168,6 +169,7 @@ const Subsections = ({ category }: SubsectionsProps): ReactNode => {
{snippets.map((snippet) => (
<button
key={snippet.key}
tabIndex={0}
className={MENU_BUTTON_CLASSES}
onClick={() => {
navigateToSection(snippet.key)
@@ -197,6 +199,7 @@ const TablesSubsections = (): ReactNode => {
{tables.map((table) => (
<button
key={table.name}
tabIndex={0}
className={MENU_BUTTON_CLASSES}
onClick={() => snap.setActiveDocsSection([API_DOCS_CATEGORIES.ENTITIES, table.name])}
>
@@ -226,6 +229,7 @@ const DbFunctionsSubsections = (): ReactNode => {
{functions.map((fn) => (
<button
key={fn.name}
tabIndex={0}
className={MENU_BUTTON_CLASSES}
onClick={() =>
snap.setActiveDocsSection([API_DOCS_CATEGORIES.STORED_PROCEDURES, fn.name])
@@ -250,6 +254,7 @@ const BucketButton = ({ item: bucket, style }: RowComponentBaseProps<Bucket>) =>
return (
<button
key={bucket.name}
tabIndex={0}
className={cn(MENU_BUTTON_CLASSES, 'py-1')}
style={style}
onClick={() => snap.setActiveDocsSection([API_DOCS_CATEGORIES.STORAGE, bucket.name])}
@@ -301,6 +306,7 @@ const EdgeFunctionsSubsections = (): ReactNode => {
{(edgeFunctions ?? []).map((fn) => (
<button
key={fn.name}
tabIndex={0}
className={MENU_BUTTON_CLASSES}
onClick={() => snap.setActiveDocsSection([API_DOCS_CATEGORIES.EDGE_FUNCTIONS, fn.name])}
>
@@ -153,6 +153,7 @@ const MenuItems = ({ category, menuItemFilter }: MenuItemsProps) => {
{menuItems.map((item) => (
<button
key={item.key}
tabIndex={0}
className="w-full text-left text-sm text-foreground-light px-4 hover:text-foreground"
onClick={() => navigateToSection(item.key)}
>
@@ -77,6 +77,15 @@ export const ConnectSection = () => {
<button
key={action.id}
type="button"
tabIndex={
(action.requiresActiveProject ?? true)
? !isActiveHealthy
? -1
: 0
: !selectedProject?.ref
? -1
: 0
}
disabled={
(action.requiresActiveProject ?? true) ? !isActiveHealthy : !selectedProject?.ref
}
@@ -1,5 +1,17 @@
import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import {
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { useFlag, useParams } from 'common'
import dayjs from 'dayjs'
import { useEffect, useRef } from 'react'
@@ -42,7 +54,10 @@ export const ProjectHome = () => {
const UsageSection = showHomepageUsageDeltas ? ProjectUsageSectionDeltas : ProjectUsageSection
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
@@ -23,10 +23,16 @@ export const SortableSection = ({ id, children }: SortableSectionProps) => {
return (
<div ref={setNodeRef} style={style} className="relative will-change-transform">
<button
type="button"
aria-label="Drag to reorder section"
className="absolute -left-6 top-2 text-foreground-muted hover:text-foreground focus:outline-hidden cursor-grab active:cursor-grabbing"
className={cn(
'absolute -left-6 top-2 text-foreground-muted hover:text-foreground cursor-grab active:cursor-grabbing',
'rounded-sm outline-hidden',
'focus-visible:outline-solid focus-visible:outline-4 focus-visible:outline-offset-1 focus-visible:outline-border-strong'
)}
{...attributes}
{...listeners}
tabIndex={0}
>
<GripVertical size={14} />
</button>
@@ -103,6 +103,7 @@ export const QueryInsightsChart = ({
<button
key={item.dataKey}
type="button"
tabIndex={0}
onClick={() => toggleSeries(item.dataKey)}
className={cn(
'flex items-center gap-1.5 text-[11px] transition-colors cursor-pointer',
@@ -124,6 +125,7 @@ export const QueryInsightsChart = ({
{hasSelection && (
<button
type="button"
tabIndex={0}
onClick={() => toggleSeries(selDataKey)}
className={cn(
'flex items-center gap-1.5 text-[11px] transition-colors cursor-pointer',
@@ -69,6 +69,7 @@ export const QueryPerformanceMetrics = () => {
<TooltipTrigger asChild>
<button
type="button"
tabIndex={0}
aria-label="How are slow queries calculated?"
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-surface-200 text-foreground-lighter transition-colors hover:bg-surface-300 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-foreground-lighter"
onClick={(e) => {
@@ -89,6 +90,7 @@ export const QueryPerformanceMetrics = () => {
<TooltipTrigger asChild>
<button
type="button"
tabIndex={0}
aria-label={`What is ${card.title}?`}
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-surface-200 text-foreground-lighter transition-colors hover:bg-surface-300 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-foreground-lighter"
onClick={(e) => {
@@ -295,35 +295,26 @@ const VercelIntegrationConnectionForm = ({
</FormControl>
<FormDescription className="col-start-5 col-span-8 text-xs">
e.g.{' '}
<code
className="cursor-pointer"
role="button"
onClick={() => {
field.onChange('NEXT_PUBLIC_')
}}
>
NEXT_PUBLIC_
</code>
,{' '}
<code
className="cursor-pointer"
role="button"
onClick={() => {
field.onChange('VITE_PUBLIC_')
}}
>
VITE_PUBLIC_
</code>
,{' '}
<code
className="cursor-pointer"
role="button"
onClick={() => {
field.onChange('PUBLIC_')
}}
>
PUBLIC_
</code>
{(
[
['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>
@@ -97,6 +97,7 @@ const HeaderBreadcrumbs = ({
) : (
<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)}
>
@@ -115,6 +115,7 @@ export const FileExplorerRowEditing = ({
onBlur={(event) => onSaveItemName(itemName.trim() || item.name, event)}
/>
<button
tabIndex={-1}
className="hidden"
type="submit"
onClick={(event) => onSaveItemName(itemName.trim() || item.name, event)}
@@ -85,7 +85,7 @@ export const MoveItemsModal = ({
/>
</FormItemLayout>
<button className="hidden" type="submit" onClick={onConfirmMove} />
<button tabIndex={-1} className="hidden" type="submit" onClick={onConfirmMove} />
</form>
</DialogSection>
<DialogFooter>
@@ -68,18 +68,14 @@ export const BucketsPolicies = ({
Write policies for each bucket to control access to the bucket and its contents
</PageSectionDescription>
</PageSectionSummary>
<CollapsibleTrigger asChild>
<button>
<span className="sr-only">Toggle bucket list</span>
<ChevronUp
size={14}
className={cn(
!expanded && 'rotate-180',
'transition',
'text-foreground-light hover:text-foreground'
)}
/>
</button>
<CollapsibleTrigger
aria-label="Toggle bucket list"
className={cn(
'rounded-md p-1 text-foreground-light hover:text-foreground',
'outline-hidden focus-visible:ring-2 focus-visible:ring-foreground-muted focus-visible:ring-offset-1 focus-visible:ring-offset-background'
)}
>
<ChevronUp size={14} className={cn(!expanded && 'rotate-180', 'transition')} />
</CollapsibleTrigger>
</PageSectionMeta>
<CollapsibleContent>
@@ -29,6 +29,7 @@ export const StoragePolicyEditorModalTitle = ({
<div>
<div className="flex items-center space-x-3">
<button
tabIndex={0}
onClick={onSelectBackFromTemplates}
className={cn(
'cursor-pointer rounded-xs opacity-20 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-foreground-muted',
@@ -123,6 +123,7 @@ export const ValidateSizeLimit = ({
<TooltipTrigger asChild>
<button
type="button"
tabIndex={isValidating ? -1 : 0}
className={cn(
'mt-2 ml-auto text-foreground-lighter',
'w-fit! text-xs',
@@ -22,6 +22,19 @@ 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()
@@ -209,14 +222,12 @@ export function AttachmentUploadDisplay({
<button
type="button"
tabIndex={0}
aria-label="Remove attachment"
className={cn(
'flex h-4 w-4 items-center justify-center rounded-full bg-red-900',
'absolute -top-1 -right-1 cursor-pointer'
)}
className={removeAttachmentButtonClassName}
onClick={() => removeFileUpload(idx)}
>
<X aria-hidden="true" size={10} strokeWidth={3} className="text-contrast" />
<RemoveAttachmentIcon />
</button>
</div>
)
@@ -229,14 +240,12 @@ export function AttachmentUploadDisplay({
>
<button
type="button"
tabIndex={0}
aria-label="Remove attachment"
className={cn(
'flex h-4 w-4 items-center justify-center rounded-full bg-red-900',
'absolute -top-1 -right-1 cursor-pointer'
)}
className={removeAttachmentButtonClassName}
onClick={() => removeFileUpload(idx)}
>
<X aria-hidden="true" size={10} strokeWidth={3} className="text-contrast" />
<RemoveAttachmentIcon />
</button>
</div>
)
@@ -245,9 +254,12 @@ export function AttachmentUploadDisplay({
{!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'
'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}
>
@@ -178,6 +178,7 @@ export const SpreadsheetImportPreview = ({
{errorData !== undefined ? (
<button
type="button"
tabIndex={0}
className="flex items-center space-x-2 cursor-pointer focus:outline-hidden focus-visible:ring-2 focus-visible:ring-primary"
onClick={() => onSelectExpandError(key)}
aria-expanded={isExpanded}
@@ -126,6 +126,7 @@ export const Column = ({
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
tabIndex={0}
className="opacity-50 hover:opacity-100 disabled:hover:opacity-50 transition cursor-grab text-foreground"
type="button"
>
@@ -163,6 +164,7 @@ export const Column = ({
<Tooltip>
<TooltipTrigger asChild>
<button
tabIndex={0}
onClick={() => {
const SENSITIVE_DATA_MARKER = '[SENSITIVE]'
@@ -448,7 +450,13 @@ export const Column = ({
</div>
{!hasImportContent && (
<div className="flex w-[5%] justify-end">
<button className="cursor-pointer" onClick={() => onRemoveColumn()}>
<button
type="button"
tabIndex={0}
aria-label="Remove column"
className="cursor-pointer"
onClick={() => onRemoveColumn()}
>
<X size={16} strokeWidth={1} />
</button>
</div>
@@ -313,7 +313,7 @@ const EdgeFunctionDetailsLayout = ({
onOpenChange={setIsTimestampHoverCardOpen}
>
<HoverCardTrigger asChild>
<button type="button" className="flex items-center gap-2 group">
<button type="button" tabIndex={0} className="flex items-center gap-2 group">
<Clock size={16} strokeWidth={1.5} className="text-foreground-lighter" />
<span className="transition text-foreground-light group-hover:text-foreground underline decoration-dotted decoration-foreground-muted underline-offset-4">
{updatedRelative ?? 'Deploy status unavailable'}
@@ -252,6 +252,9 @@ export const FeedbackWidget = ({ onClose, onSwitchToIssueOptions }: FeedbackWidg
className="cursor-pointer rounded-sm h-[26px] w-[26px] border border-control relative bg-cover bg-center bg-no-repeat"
>
<button
type="button"
tabIndex={0}
aria-label="Remove screenshot"
className={[
'cursor-pointer rounded-full bg-red-900 h-3 w-3',
'flex items-center justify-center absolute -top-1 -right-1',
@@ -39,6 +39,7 @@ export function OrgMenuItem({
{onSubmenuClick ? (
<button
type="button"
tabIndex={disabled ? -1 : 0}
data-active={isActive}
onClick={() => onSubmenuClick?.(item)}
disabled={disabled}
@@ -46,6 +47,10 @@ export function OrgMenuItem({
>
{content}
</button>
) : disabled ? (
<span aria-disabled="true" data-active={isActive} className={menuButtonClass}>
{content}
</span>
) : (
<Link
href={item.href}
@@ -40,6 +40,7 @@ export function TopLevelRouteItem({
{hasItems ? (
<button
type="button"
tabIndex={route.disabled ? -1 : 0}
data-active={isActive}
onClick={() => onTopLevelClick(route)}
disabled={route.disabled}
@@ -48,14 +49,20 @@ export function TopLevelRouteItem({
{content}
</button>
) : route.link ? (
<Link
href={route.link}
onClick={onCloseSheet}
data-active={isActive}
className={menuButtonClass}
>
{content}
</Link>
route.disabled ? (
<span aria-disabled="true" data-active={isActive} className={menuButtonClass}>
{content}
</span>
) : (
<Link
href={route.link}
onClick={onCloseSheet}
data-active={isActive}
className={menuButtonClass}
>
{content}
</Link>
)
) : (
<span data-active={false} className={cn(menuButtonClass, 'cursor-default')}>
{content}
@@ -184,6 +184,7 @@ export const EntityListItem = ({
id={String(entity.id)}
href={buildTableEditorUrl({ projectRef, tableId: entity.id, schema: entity.schema })}
role="button"
tabIndex={0}
aria-label={`View ${entity.name}`}
className={cn(
TreeViewItemVariant({
@@ -20,6 +20,7 @@ export function CollapseButton({ hideTabs }: { hideTabs: boolean }) {
<Tooltip>
<TooltipTrigger asChild>
<button
tabIndex={0}
className={cn(
'hidden md:flex items-center justify-center w-10 h-(--header-height) hover:bg-surface-100 shrink-0',
!hideTabs && 'border-b border-b-default'
@@ -82,9 +82,7 @@ export const ModelSelector = ({ selectedModel, onSelectModel }: ModelSelectorPro
<Tooltip>
<TooltipTrigger asChild>
<div>
<Badge role="button" variant="warning">
Upgrade
</Badge>
<Badge variant="warning">Upgrade</Badge>
</div>
</TooltipTrigger>
<TooltipContent side="right">
@@ -134,6 +134,7 @@ export const ChartHighlightActions = ({
<DropdownMenuItem asChild key={action.id} disabled={disabled} className={cn('group')}>
<button
disabled={disabled}
tabIndex={-1}
onClick={() => ctx && action.onSelect({ ...ctx })}
className="w-full flex items-center gap-1.5"
>
@@ -368,6 +368,7 @@ export const CustomLabel = ({
return (
<button
key={entry.name}
tabIndex={0}
className="flex md:flex-col gap-1 md:gap-0 w-fit text-foreground rounded-lg hover:bg-background-overlay-hover"
onMouseOver={() => handleMouseEnter(entry.name)}
onMouseOutCapture={handleMouseLeave}
@@ -189,8 +189,6 @@ export const ComputeBadgeWrapper = ({
<Button
asChild
variant="default"
type="button"
role="button"
onClick={() => {
track('compute_badge_upgrade_clicked', {
computeSize: computeSize ?? 'unknown',
@@ -123,6 +123,7 @@ export function DataTableFilterCheckbox<TData>({
{hasNested && (
<button
type="button"
tabIndex={0}
aria-label={isExpanded ? 'Collapse' : 'Expand'}
aria-expanded={isExpanded}
onClick={() => toggleExpanded(optionKey)}
@@ -147,10 +148,11 @@ export function DataTableFilterCheckbox<TData>({
<button
type="button"
tabIndex={0}
onClick={() => column?.setFilterValue([option.value])}
className={cn(
'text-xs text-muted-foreground hover:text-foreground',
'absolute inset-y-0 right-0 hidden bg-surface-100 group-hover:flex items-center cursor-pointer',
'absolute inset-y-0 right-0 hidden bg-surface-100 group-hover:flex group-focus-within:flex items-center cursor-pointer',
'ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
)}
>
@@ -118,10 +118,11 @@ export function DataTableFilterCheckboxAsync<TData>({
</span>
<button
type="button"
tabIndex={0}
onClick={() => column?.setFilterValue([option.value])}
className={cn(
'text-xs text-muted-foreground hover:text-foreground',
'absolute inset-y-0 right-0 hidden bg-surface-100 group-hover:flex items-center cursor-pointer',
'absolute inset-y-0 right-0 hidden bg-surface-100 group-hover:flex group-focus-within:flex items-center cursor-pointer',
'ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
)}
>
@@ -124,6 +124,7 @@ export const ProjectStatusTab = () => {
<button
onClick={handleReset}
disabled={isDisabled || !hasOverride}
tabIndex={isDisabled || !hasOverride ? -1 : 0}
className="text-xs text-foreground-lighter hover:text-foreground transition underline disabled:opacity-50 disabled:cursor-not-allowed"
>
Reset to real data
@@ -138,6 +138,7 @@ export const ResourceWarningsTab = () => {
<button
onClick={handleReset}
disabled={isDisabled}
tabIndex={isDisabled ? -1 : 0}
className="text-xs text-foreground-lighter hover:text-foreground transition underline disabled:opacity-50 disabled:cursor-not-allowed"
>
Reset to real data
@@ -157,6 +158,7 @@ export const ResourceWarningsTab = () => {
<SeverityButton
active={!isReadOnly}
variant="off"
disabled={isDisabled}
onClick={() => handleReadOnlyChange(false)}
>
Off
@@ -164,6 +166,7 @@ export const ResourceWarningsTab = () => {
<SeverityButton
active={isReadOnly}
variant="critical"
disabled={isDisabled}
onClick={() => handleReadOnlyChange(true)}
>
On
@@ -178,6 +181,7 @@ export const ResourceWarningsTab = () => {
<SeverityButton
active={severities[key] === null}
variant="off"
disabled={isDisabled}
onClick={() => handleSeverityChange(key, null)}
>
Off
@@ -185,6 +189,7 @@ export const ResourceWarningsTab = () => {
<SeverityButton
active={severities[key] === 'warning'}
variant="warning"
disabled={isDisabled}
onClick={() => handleSeverityChange(key, 'warning')}
>
Warn
@@ -193,6 +198,7 @@ export const ResourceWarningsTab = () => {
<SeverityButton
active={severities[key] === 'critical'}
variant="critical"
disabled={isDisabled}
onClick={() => handleSeverityChange(key, 'critical')}
>
Crit
@@ -209,13 +215,17 @@ export const ResourceWarningsTab = () => {
interface SeverityButtonProps {
active: boolean
variant: 'off' | 'warning' | 'critical'
disabled?: boolean
onClick: () => void
children: React.ReactNode
}
const SeverityButton = ({ active, variant, onClick, children }: SeverityButtonProps) => (
const SeverityButton = ({ active, variant, disabled, onClick, children }: SeverityButtonProps) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
tabIndex={disabled ? -1 : 0}
className={cn(
'px-1.5 py-0.5 rounded-sm text-xs font-mono transition border',
active
@@ -224,7 +234,8 @@ const SeverityButton = ({ active, variant, onClick, children }: SeverityButtonPr
: variant === 'warning'
? 'bg-warning/20 text-warning border-warning'
: 'bg-destructive/20 text-destructive border-destructive'
: 'bg-transparent text-foreground-muted border-transparent hover:border-border'
: 'bg-transparent text-foreground-muted border-transparent hover:border-border',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
{children}
@@ -14,6 +14,7 @@ export const FeaturePreviewBadge = ({ featureKey, className }: FeaturePreviewBad
return (
<button
type="button"
tabIndex={0}
onClick={() => selectFeaturePreview(featureKey)}
className="group flex items-center"
title="Feature preview — click to manage"
+2 -1
View File
@@ -121,7 +121,8 @@ export const FilterPopover = <T extends Record<string, any>>({
<div className="flex-1">{label}</div>
{showOnlyButton && (
<button
className="text-xs text-foreground-lighter hover:text-foreground-muted opacity-0 group-hover:opacity-100 transition-opacity"
tabIndex={0}
className="text-xs text-foreground-lighter hover:text-foreground-muted opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
onClick={(e) => {
e.preventDefault()
setSelectedOptions([value])
@@ -41,7 +41,12 @@ export const PasswordStrengthBar = ({
? passwordStrengthMessage
: 'This is the password to your Postgres database, so it must be strong and hard to guess.') +
' '}
<button type="button" className={InlineLinkClassName} onClick={generateStrongPassword}>
<button
type="button"
tabIndex={0}
className={InlineLinkClassName}
onClick={generateStrongPassword}
>
Generate a password
</button>
.
+1 -1
View File
@@ -59,7 +59,7 @@ export const SingleStat = ({
if (onClick) {
return (
<button className="group" onClick={onClick}>
<button type="button" className="group" tabIndex={0} onClick={onClick}>
{content}
</button>
)
+1 -2
View File
@@ -18,6 +18,5 @@
"jsx-a11y/tabindex-no-positive",
"jsx-a11y/anchor-is-valid",
"jsx-a11y/heading-has-content",
"jsx-a11y/no-distracting-elements",
"supabase/require-explicit-tabindex"
"jsx-a11y/no-distracting-elements"
]
@@ -224,7 +224,12 @@ function DialogView({ projectRef, isMobile }: { projectRef: string; isMobile?: b
{index === stack.length - 1 ? (
<span className="font-semibold text-foreground">{item.title}</span>
) : (
<button onClick={() => popTo(index)} className="hover:underline">
<button
type="button"
tabIndex={0}
onClick={() => popTo(index)}
className="hover:underline"
>
{item.title}
</button>
)}
+3 -2
View File
@@ -1,5 +1,7 @@
import fs from 'fs'
import path from 'path'
import { expect, Page } from '@playwright/test'
import { env } from '../env.config.js'
import { expectClipboardValue } from '../utils/clipboard.js'
import { dropTable, query } from '../utils/db/index.js'
@@ -14,7 +16,6 @@ import {
waitForGridDataToLoad,
waitForTableToLoad,
} from '../utils/wait-for-response.js'
import { expect, Page } from '@playwright/test'
const deleteTable = async (page: Page, ref: string, tableName: string) => {
const viewLocator = page.getByLabel(`View ${tableName}`)
@@ -1254,7 +1255,7 @@ testRunner('table editor', () => {
await expect(page.getByRole('link', { name: 'public.pw_table_fk_target' })).toBeVisible()
// Remove the foreign key relation
await page.getByRole('button', { name: 'Remove' }).click()
await page.getByRole('button', { name: 'Remove', exact: true }).click()
// Save the table changes after removing foreign key
const removeFkPromise = waitForApiResponseWithTimeout(