fix(studio): restore Safari table editor cell copy and context menu (#45353)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Bug fix.

## What is the current behavior?

- Safari Table Editor cells fail to copy from a focused cell with `⌘C`.
- Safari right-click can show the browser menu instead of the custom
cell menu.
- Copy can leave RDG's copied-cell fill behind.

## What is the new behavior?

- Reuses the existing shared `copyToClipboard(value, onSuccess)`
pattern, with the Safari clipboard fix inside that util.
- Handles selected-cell `⌘C` in the RDG keydown path, preventing
browser/RDG defaults and showing the success toast only after copy.
- Replaces the row-level synthetic context-menu shim with RDG's
`onCellContextMenu`, so we prevent Safari's browser menu at the source
and select/focus the target cell.
- Keeps the selected-cell outline while the controlled menu is open.

## Additional context

- `RowRenderer` was only supporting the old context-menu shim; removing
it is part of moving to RDG's cell event path.

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

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Context menu now provides feedback with toast notifications when
copying cells or rows.
* Selected cells retain their visual styling when context menu is open.

* **Bug Fixes**
  * Improved keyboard shortcut handling for copy functionality.
  * Enhanced clipboard error handling with user-friendly error messages.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Ali Waseem <waseema393@gmail.com>
This commit is contained in:
Danny White
2026-05-04 11:34:28 +10:00
committed by GitHub
parent aeda6a88a8
commit e540f9089f
8 changed files with 251 additions and 131 deletions
@@ -9,6 +9,7 @@ import {
CellKeyDownArgs,
RowsChangeData,
} from 'react-data-grid'
import { toast } from 'sonner'
import { copyToClipboard } from 'ui'
import { FilterOperatorOptions } from './components/header/filter/Filter.constants'
@@ -283,11 +284,18 @@ export const handleCellKeyDown = <TRow extends SupaRow = SupaRow>(
) => {
const { mode, column, row, rowIdx } = args
if (mode !== 'SELECT') return
const key = event.key.toLowerCase()
if (event.code === 'KeyC' && (event.metaKey || event.ctrlKey)) {
const cellValue = row[column.key] ?? ''
const value = formatClipboardValue(cellValue)
copyToClipboard(value)
if (key === 'c' && (event.metaKey || event.ctrlKey)) {
if (window.getSelection()?.isCollapsed === false) return
const value = formatClipboardValue(row[column.key] ?? '')
event.preventDefault()
event.preventGridDefault()
void copyToClipboard(value, () => {
toast.success('Copied cell value to clipboard')
})
return
}
// Let registered shortcuts win over rdg's "type a key to enter edit mode" default,
@@ -312,7 +320,6 @@ export const handleCellKeyDown = <TRow extends SupaRow = SupaRow>(
// Toggle boolean cells with T/F when no modifier keys are pressed.
if (context === undefined) return
const key = event.key.toLowerCase()
if (event.altKey || event.ctrlKey || event.metaKey || (key !== 't' && key !== 'f')) return
const supaColumn = context.columns.find((c) => c.name === column.key)
@@ -2,17 +2,23 @@ import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from '@
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable'
import type { PostgresColumn } from '@supabase/postgres-meta'
import { forwardRef, memo, Ref, useCallback, useMemo, useRef, useState } from 'react'
import DataGrid, { CalculatedColumn, DataGridHandle } from 'react-data-grid'
import { Button, cn } from 'ui'
import DataGrid, {
CalculatedColumn,
CellClickArgs,
CellMouseEvent,
DataGridHandle,
} from 'react-data-grid'
import { flushSync } from 'react-dom'
import { Button, cn, DropdownMenu, DropdownMenuTrigger } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { ref as valtioRef } from 'valtio'
import type { GridProps, SupaColumn, SupaRow } from '../../types'
import { isPendingAddRow, isPendingDeleteRow } from '../../types'
import { RowContextMenuContent } from '../menu/RowContextMenu'
import { ColumnOverlayItem } from './ColumnOverlayItem'
import { useOnRowsChange } from './Grid.utils'
import { GridError } from './GridError'
import { RowContextMenuProvider, RowRenderer } from './RowRenderer'
import { useTableFilter } from '@/components/grid/hooks/useTableFilter'
import { handleCellKeyDown } from '@/components/grid/SupabaseGrid.utils'
import { formatForeignKeys } from '@/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.utils'
@@ -72,18 +78,11 @@ export const Grid = memo(
snap.setSelectedRows(selectedRows)
}
const selectedCellRef = useRef<{
rowIdx: number
row: SupaRow
column: CalculatedColumn<SupaRow, unknown>
} | null>(null)
function onSelectedCellChange(args: {
rowIdx: number
row: SupaRow
column: CalculatedColumn<SupaRow, unknown>
}) {
selectedCellRef.current = args
snap.setSelectedCellPosition({ idx: args.column.idx, rowIdx: args.rowIdx })
}
@@ -217,6 +216,65 @@ export const Grid = memo(
})
)
const [draggedColumn, setDraggedColumn] = useState<SupaColumn | undefined>(undefined)
const contextMenuTriggerRef = useRef<HTMLDivElement>(null)
const [contextMenuKey, setContextMenuKey] = useState(0)
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const [contextMenuRow, setContextMenuRow] = useState<SupaRow | null>(null)
const [contextMenuCellPosition, setContextMenuCellPosition] = useState<{
idx: number
rowIdx: number
} | null>(null)
const openContextMenu = useCallback(
(
event: React.MouseEvent,
row: SupaRow,
position: {
idx: number
rowIdx: number
}
) => {
const trigger = contextMenuTriggerRef.current
if (!trigger) return
trigger.style.left = `${event.clientX}px`
trigger.style.top = `${event.clientY}px`
flushSync(() => {
setContextMenuRow(row)
setContextMenuCellPosition(position)
setContextMenuKey((prev) => prev + 1)
setIsContextMenuOpen(true)
})
},
[]
)
const handleCellClick = useCallback(
(_: CellClickArgs<SupaRow, unknown>, event: React.MouseEvent<HTMLElement>) => {
event.currentTarget.focus()
},
[]
)
const handleCellContextMenu = useCallback(
(args: CellClickArgs<SupaRow, unknown>, event: CellMouseEvent) => {
event.preventDefault()
event.currentTarget.focus()
args.selectCell()
event.preventGridDefault()
const rowIdx = rows.findIndex(
(candidate) => candidate === args.row || candidate.idx === args.row.idx
)
if (rowIdx === -1) return
const position = { idx: args.column.idx, rowIdx }
snap.setSelectedCellPosition(position)
openContextMenu(event, args.row, position)
},
[openContextMenu, rows, snap]
)
return (
<div
@@ -343,20 +401,35 @@ export const Grid = memo(
items={columnsWithDirtyCellClass.map((column) => column.key)}
strategy={horizontalListSortingStrategy}
>
<RowContextMenuProvider>
<DropdownMenu
modal={false}
open={isContextMenuOpen}
onOpenChange={setIsContextMenuOpen}
>
<DropdownMenuTrigger asChild>
<div ref={contextMenuTriggerRef} className="fixed pointer-events-none w-0 h-0" />
</DropdownMenuTrigger>
{contextMenuRow && (
<RowContextMenuContent
key={contextMenuKey}
row={contextMenuRow}
selectedCellPosition={contextMenuCellPosition}
/>
)}
<DataGrid
ref={ref}
className={`${gridClass} grow`}
className={cn(gridClass, 'grow', isContextMenuOpen && 'rdg-context-menu-open')}
rowClass={computedRowClass}
columns={columnsWithDirtyCellClass}
rows={rows ?? []}
renderers={{ renderRow: RowRenderer }}
rowKeyGetter={rowKeyGetter}
selectedRows={snap.selectedRows}
onColumnResize={snap.updateColumnSize}
onRowsChange={onRowsChange}
onSelectedCellChange={onSelectedCellChange}
onSelectedRowsChange={onSelectedRowsChange}
onCellClick={handleCellClick}
onCellContextMenu={handleCellContextMenu}
onCellDoubleClick={(props) => {
if (typeof props.column.name === 'string') {
onRowDoubleClick(props.row, { name: props.column.name })
@@ -370,7 +443,7 @@ export const Grid = memo(
})
}
/>
</RowContextMenuProvider>
</DropdownMenu>
{/* The DragOverlay is necessary to avoid styling issues while dragging a column */}
<DragOverlay>
{draggedColumn ? <ColumnOverlayItem column={draggedColumn} /> : null}
@@ -1,66 +0,0 @@
import { createContext, useCallback, useContext, useRef, useState } from 'react'
import type { Key, ReactNode } from 'react'
import { RenderRowProps, Row } from 'react-data-grid'
import { flushSync } from 'react-dom'
import { ContextMenu_Shadcn_, ContextMenuTrigger_Shadcn_ } from 'ui'
import { RowContextMenuContent } from '../menu/RowContextMenu'
import { SupaRow } from '@/components/grid/types'
type RowContextMenuContextValue = {
onRowContextMenu: (e: React.MouseEvent, row: SupaRow) => void
}
const RowContextMenuContext = createContext<RowContextMenuContextValue | null>(null)
export function RowContextMenuProvider({ children }: { children: ReactNode }) {
const triggerRef = useRef<HTMLDivElement>(null)
const [menuKey, setMenuKey] = useState(0)
const [activeRow, setActiveRow] = useState<SupaRow | null>(null)
const onRowContextMenu = useCallback((e: React.MouseEvent, row: SupaRow) => {
e.preventDefault()
flushSync(() => setActiveRow(row))
setMenuKey((prev) => prev + 1)
const trigger = triggerRef.current
if (!trigger) return
trigger.style.left = `${e.clientX}px`
trigger.style.top = `${e.clientY}px`
trigger.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
clientX: e.clientX,
clientY: e.clientY,
})
)
}, [])
return (
<RowContextMenuContext.Provider value={{ onRowContextMenu }}>
<ContextMenu_Shadcn_ modal={false}>
<ContextMenuTrigger_Shadcn_ asChild>
<div ref={triggerRef} className="fixed pointer-events-none w-0 h-0" />
</ContextMenuTrigger_Shadcn_>
{activeRow && <RowContextMenuContent key={menuKey} row={activeRow} />}
</ContextMenu_Shadcn_>
{children}
</RowContextMenuContext.Provider>
)
}
function RowWithContextMenu({ row, ...props }: RenderRowProps<SupaRow>) {
const ctx = useContext(RowContextMenuContext)
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
ctx?.onRowContextMenu(e, row)
},
[ctx, row]
)
return <Row row={row} {...props} onContextMenu={handleContextMenu} />
}
export function RowRenderer(key: Key, props: RenderRowProps<SupaRow>) {
return <RowWithContextMenu key={key} {...props} />
}
@@ -1,8 +1,7 @@
import { ContextMenuContent } from '@ui/components/shadcn/ui/context-menu'
import { Copy, Edit, ListFilter, Trash } from 'lucide-react'
import { useCallback } from 'react'
import { toast } from 'sonner'
import { ContextMenuItem_Shadcn_, ContextMenuSeparator_Shadcn_, copyToClipboard } from 'ui'
import { copyToClipboard, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from 'ui'
import { useTableRowOperations } from '../../hooks/useTableRowOperations'
import { formatClipboardValue } from '../../utils/common'
@@ -13,12 +12,17 @@ import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table'
type RowContextMenuContentProps = {
row: SupaRow
selectedCellPosition?: { idx: number; rowIdx: number } | null
}
export const RowContextMenuContent = ({ row }: RowContextMenuContentProps) => {
export const RowContextMenuContent = ({
row,
selectedCellPosition,
}: RowContextMenuContentProps) => {
const tableEditorSnap = useTableEditorStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
const { deleteRows } = useTableRowOperations()
const activeCellPosition = selectedCellPosition ?? snap.selectedCellPosition
const onDeleteRow = useCallback(() => {
if (!row) {
@@ -33,29 +37,33 @@ export const RowContextMenuContent = ({ row }: RowContextMenuContentProps) => {
}, [row, tableEditorSnap])
const onCopyCellContent = useCallback(() => {
if (!snap.selectedCellPosition) return
if (!activeCellPosition) return
const columnKey = snap.gridColumns[snap.selectedCellPosition.idx as number].key
const value = row[columnKey]
const column = snap.gridColumns[activeCellPosition.idx]
if (!column) return
const value = row[column.key]
const text = formatClipboardValue(value)
copyToClipboard(text)
toast.success('Copied cell value to clipboard')
}, [row, snap.gridColumns, snap.selectedCellPosition])
void copyToClipboard(text, () => {
toast.success('Copied cell value to clipboard')
})
}, [activeCellPosition, row, snap.gridColumns])
const onCopyRowContent = useCallback(() => {
copyToClipboard(JSON.stringify(row))
toast.success('Copied row to clipboard')
void copyToClipboard(JSON.stringify(row), () => {
toast.success('Copied row to clipboard')
})
}, [row])
const getRowAndColumn = useCallback(() => {
if (!snap.selectedCellPosition) return null
if (!activeCellPosition) return null
const column = snap.gridColumns[snap.selectedCellPosition.idx as number]
const column = snap.gridColumns[activeCellPosition.idx as number]
if (!row || !column) return null
return { row, column }
}, [row, snap.selectedCellPosition, snap.gridColumns])
}, [activeCellPosition, row, snap.gridColumns])
const onFilterByValue = useCallback(() => {
const result = getRowAndColumn()
@@ -77,35 +85,35 @@ export const RowContextMenuContent = ({ row }: RowContextMenuContentProps) => {
}, [getRowAndColumn])
return (
<ContextMenuContent className="min-w-36!">
<ContextMenuItem_Shadcn_ className="gap-x-2" onSelect={onCopyCellContent}>
<DropdownMenuContent align="start" side="right" sideOffset={0} className="w-36 min-w-36!">
<DropdownMenuItem className="gap-x-2" onSelect={onCopyCellContent}>
<Copy size={12} />
<span className="text-xs">Copy cell</span>
</ContextMenuItem_Shadcn_>
<ContextMenuItem_Shadcn_ className="gap-x-2" onSelect={onCopyRowContent}>
</DropdownMenuItem>
<DropdownMenuItem className="gap-x-2" onSelect={onCopyRowContent}>
<Copy size={12} />
<span className="text-xs">Copy row</span>
</ContextMenuItem_Shadcn_>
</DropdownMenuItem>
{isFilterByValueVisible() && (
<ContextMenuItem_Shadcn_ className="gap-x-2" onSelect={onFilterByValue}>
<DropdownMenuItem className="gap-x-2" onSelect={onFilterByValue}>
<ListFilter size={12} />
<span className="text-xs">Filter by value</span>
</ContextMenuItem_Shadcn_>
</DropdownMenuItem>
)}
{snap.editable && (
<>
<ContextMenuSeparator_Shadcn_ />
<ContextMenuItem_Shadcn_ className="gap-x-2" onSelect={onEditRowClick}>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-x-2" onSelect={onEditRowClick}>
<Edit size={12} />
<span className="text-xs">Edit row</span>
</ContextMenuItem_Shadcn_>
<ContextMenuSeparator_Shadcn_ />
<ContextMenuItem_Shadcn_ className="gap-x-2" onSelect={onDeleteRow}>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-x-2" onSelect={onDeleteRow}>
<Trash size={12} />
<span className="text-xs">Delete row</span>
</ContextMenuItem_Shadcn_>
</DropdownMenuItem>
</>
)}
</ContextMenuContent>
</DropdownMenuContent>
)
}
+3 -3
View File
@@ -1,7 +1,7 @@
export function formatClipboardValue(value: any) {
export function formatClipboardValue(value: unknown) {
if (!value) return ''
if (typeof value == 'object' || Array.isArray(value)) {
if (typeof value === 'object') {
return JSON.stringify(value)
}
return value
return String(value)
}
+1 -1
View File
@@ -39,7 +39,7 @@
box-shadow: inset 0 0 0 1px #24b47e;
}
.rdg:not(:focus-within) .rdg-cell[aria-selected='true'] {
.rdg:not(:focus-within):not(.rdg-context-menu-open) .rdg-cell[aria-selected='true'] {
box-shadow: none;
outline: none;
}
@@ -1,6 +1,23 @@
import { describe, expect, test } from 'vitest'
import { copyToClipboard } from 'ui'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { formatFilterURLParams, formatSortURLParams } from '@/components/grid/SupabaseGrid.utils'
import {
formatFilterURLParams,
formatSortURLParams,
handleCellKeyDown,
} from '@/components/grid/SupabaseGrid.utils'
const { toastError, toastSuccess } = vi.hoisted(() => ({
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('sonner', () => ({
toast: {
error: toastError,
success: toastSuccess,
},
}))
// Sort URL syntax: `column:order`
describe('SupabaseGrid.utils: formatSortURLParams', () => {
@@ -72,3 +89,78 @@ describe('SupabaseGrid.utils: formatFilterURLParams', () => {
})
})
})
describe('SupabaseGrid.utils: handleCellKeyDown', () => {
beforeEach(() => {
toastError.mockReset()
toastSuccess.mockReset()
vi.unstubAllGlobals()
vi.spyOn(window.document, 'hasFocus').mockReturnValue(true)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
test('should copy the selected cell value when Meta+C is pressed', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('navigator', {
clipboard: { writeText },
})
const args = {
mode: 'SELECT',
column: { key: 'name' },
row: { name: 'hello from safari' },
rowIdx: 0,
selectCell: vi.fn(),
} as unknown as Parameters<typeof handleCellKeyDown>[0]
const event = {
key: 'C',
metaKey: true,
ctrlKey: false,
altKey: false,
nativeEvent: new KeyboardEvent('keydown', { key: 'C', metaKey: true }),
preventDefault: vi.fn(),
preventGridDefault: vi.fn(),
} as unknown as Parameters<typeof handleCellKeyDown>[1]
handleCellKeyDown(args, event)
await vi.waitFor(() => {
expect(writeText).toHaveBeenCalledWith('hello from safari')
})
expect(event.preventDefault).toHaveBeenCalled()
expect(event.preventGridDefault).toHaveBeenCalled()
await vi.waitFor(() => {
expect(toastSuccess).toHaveBeenCalledWith('Copied cell value to clipboard')
})
})
})
describe('shared clipboard util', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.spyOn(window.document, 'hasFocus').mockReturnValue(true)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
test('should invoke the callback after writing text to the clipboard', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
const onCopy = vi.fn()
vi.stubGlobal('navigator', {
clipboard: { writeText },
})
await copyToClipboard('hello from safari', onCopy)
expect(writeText).toHaveBeenCalledWith('hello from safari')
expect(onCopy).toHaveBeenCalled()
})
})
+18 -12
View File
@@ -1,6 +1,8 @@
import { noop } from 'lodash'
import { toast } from 'sonner'
type ClipboardText = string | Promise<string>
/**
* Copy text content (string or Promise<string>) into Clipboard. Safari doesn't support write text into clipboard async,
* so if you need to load text content async before coping, please use Promise<string> for the 1st arg.
@@ -10,10 +12,15 @@ import { toast } from 'sonner'
*
* Copied code from https://wolfgangrittner.dev/how-to-use-clipboard-api-in-firefox/
*/
export const copyToClipboard = async (str: string | Promise<string>, callback = noop) => {
export const copyToClipboard = async (str: ClipboardText, callback = noop) => {
const focused = window.document.hasFocus()
if (focused) {
if (typeof ClipboardItem && navigator.clipboard?.write) {
if (!focused) {
toast.error('Unable to copy to clipboard')
return
}
try {
if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) {
// NOTE: Safari locks down the clipboard API to only work when triggered
// by a direct user interaction. You can't use it async in a promise.
// But! You can wrap the promise in a ClipboardItem, and give that to
@@ -37,16 +44,15 @@ export const copyToClipboard = async (str: string | Promise<string>, callback =
navigator.clipboard.write([text]).then(callback).then(resolve).catch(reject)
}, 0)
return promise
} else {
// NOTE: Firefox has support for ClipboardItem and navigator.clipboard.write,
// but those are behind `dom.events.asyncClipboard.clipboardItem` preference.
// Good news is that other than Safari, Firefox does not care about
// Clipboard API being used async in a Promise.
return Promise.resolve(str)
.then((text) => navigator.clipboard?.writeText(text))
.then(callback)
}
} else {
// NOTE: Firefox has support for ClipboardItem and navigator.clipboard.write,
// but those are behind `dom.events.asyncClipboard.clipboardItem` preference.
// Good news is that other than Safari, Firefox does not care about
// Clipboard API being used async in a Promise.
await Promise.resolve(str).then((text) => navigator.clipboard?.writeText(text))
callback()
} catch {
toast.error('Unable to copy to clipboard')
}
}