mirror of
https://github.com/supabase/supabase.git
synced 2026-05-06 08:56:46 -04:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user