diff --git a/packages/sanity/.eslintrc.cjs b/packages/sanity/.eslintrc.cjs index 0a4238067d0..8feec3427a6 100644 --- a/packages/sanity/.eslintrc.cjs +++ b/packages/sanity/.eslintrc.cjs @@ -195,5 +195,11 @@ module.exports = { ], }, }, + { + files: ['**/*.test.*'], + rules: { + 'max-nested-callbacks': 'off', + }, + }, ], } diff --git a/packages/sanity/src/_singletons/index.ts b/packages/sanity/src/_singletons/index.ts index 5f09007f7d0..d3fd2b6dd0a 100644 --- a/packages/sanity/src/_singletons/index.ts +++ b/packages/sanity/src/_singletons/index.ts @@ -61,4 +61,5 @@ export * from './structure/components/pane/PaneContext' export * from './structure/components/pane/PaneLayoutContext' export * from './structure/components/paneRouter/PaneRouterContext' export * from './structure/panes/document/DocumentPaneContext' +export * from './structure/panes/document/DocumentSheetListContext' export * from './structure/StructureToolContext' diff --git a/packages/sanity/src/_singletons/structure/panes/document/DocumentSheetListContext.ts b/packages/sanity/src/_singletons/structure/panes/document/DocumentSheetListContext.ts new file mode 100644 index 00000000000..8011b87d672 --- /dev/null +++ b/packages/sanity/src/_singletons/structure/panes/document/DocumentSheetListContext.ts @@ -0,0 +1,8 @@ +import {createContext} from 'react' + +import type {DocumentSheetListContextValue} from '../../../../structure/panes/documentList/sheetList/DocumentSheetListProvider' + +/** @internal */ +export const DocumentSheetListContext = createContext( + undefined, +) diff --git a/packages/sanity/src/structure/panes/documentList/sheetList/DocumentSheetListPane.tsx b/packages/sanity/src/structure/panes/documentList/sheetList/DocumentSheetListPane.tsx index 41e68a0af72..4f2f7633c1b 100644 --- a/packages/sanity/src/structure/panes/documentList/sheetList/DocumentSheetListPane.tsx +++ b/packages/sanity/src/structure/panes/documentList/sheetList/DocumentSheetListPane.tsx @@ -16,6 +16,7 @@ import {type BaseStructureToolPaneProps} from '../../types' import {ColumnsControl} from './ColumnsControl' import {DocumentSheetListFilter} from './DocumentSheetListFilter' import {DocumentSheetListPaginator} from './DocumentSheetListPaginator' +import {DocumentSheetListProvider} from './DocumentSheetListProvider' import {useDocumentSheetColumns} from './useDocumentSheetColumns' import {useDocumentSheetList} from './useDocumentSheetList' @@ -87,9 +88,11 @@ function DocumentSheetListPaneInner({ pagination: {pageSize: 25}, columnVisibility: initialColumnsVisibility, }, + getRowId: (row) => row._id, meta: { selectedAnchor, setSelectedAnchor, + patchDocument: (documentId, fieldId, value) => null, }, }) @@ -139,27 +142,29 @@ function DocumentSheetListPaneInner({ - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - {table.getRowModel().rows.map(renderRow)} -
- {headerGroup.depth > 0 && !header.column.parent ? null : ( -
{flexRender(header.column.columnDef.header, header.getContext())}
- )} -
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + {table.getRowModel().rows.map(renderRow)} +
+ {headerGroup.depth > 0 && !header.column.parent ? null : ( +
{flexRender(header.column.columnDef.header, header.getContext())}
+ )} +
+
diff --git a/packages/sanity/src/structure/panes/documentList/sheetList/DocumentSheetListProvider.tsx b/packages/sanity/src/structure/panes/documentList/sheetList/DocumentSheetListProvider.tsx new file mode 100644 index 00000000000..690b3fb8632 --- /dev/null +++ b/packages/sanity/src/structure/panes/documentList/sheetList/DocumentSheetListProvider.tsx @@ -0,0 +1,284 @@ +import {type SanityDocument} from '@sanity/types' +import {type Table} from '@tanstack/react-table' +import {type ReactNode, useCallback, useContext, useEffect, useMemo, useState} from 'react' +import {DocumentSheetListContext} from 'sanity/_singletons' + +interface DocumentSheetListProviderProps { + children?: ReactNode + table: Table +} + +type SelectedCellDetails = { + colId: string + rowIndex: number + state: 'focused' | 'selected' +} | null + +/** @internal */ +export interface DocumentSheetListContextValue { + focusAnchorCell: () => void + resetFocusSelection: () => void + setSelectedAnchorCell: (colId: string, rowIndex: number) => void + getStateByCellId: ( + colId: string, + rowIndex: number, + ) => 'focused' | 'selectedAnchor' | 'selectedRange' | null + submitFocusedCell: () => void +} + +/** @internal */ +export const useDocumentSheetListContext = (): DocumentSheetListContextValue => { + const context = useContext(DocumentSheetListContext) + + if (context === undefined) { + throw new Error('useDocumentSheetListContext must be used within an DocumentSheetListProvider') + } + return context +} + +/** @internal */ +export function DocumentSheetListProvider({ + children, + table, +}: DocumentSheetListProviderProps): ReactNode { + const [selectedAnchorCellDetails, setSelectedAnchorCellDetails] = + useState(null) + const [selectedRangeCellIndexes, setSelectedRangeCellIndexes] = useState([]) + + const clearAndSetFocusSelection = useCallback( + (nextAnchorDetails: SelectedCellDetails = null) => { + if ( + selectedAnchorCellDetails?.state === 'focused' && + document.activeElement instanceof HTMLElement + ) { + document.activeElement.blur() + } + + setSelectedAnchorCellDetails(nextAnchorDetails) + setSelectedRangeCellIndexes([]) + }, + [selectedAnchorCellDetails], + ) + + const resetFocusSelection = useCallback( + () => clearAndSetFocusSelection(), + [clearAndSetFocusSelection], + ) + + const changeSelectionColumn = useCallback( + (direction: 'left' | 'right') => { + if (!selectedAnchorCellDetails) return + + const visibleColumns = table.getVisibleLeafColumns() + const columnIndexAfterMove = + visibleColumns.findIndex((col) => col.id === selectedAnchorCellDetails.colId) + + (direction === 'left' ? -1 : 1) + + if (columnIndexAfterMove < 0 || columnIndexAfterMove >= visibleColumns.length) return + + clearAndSetFocusSelection({ + colId: visibleColumns[columnIndexAfterMove].id, + rowIndex: selectedAnchorCellDetails.rowIndex, + state: 'selected', + }) + }, + [clearAndSetFocusSelection, selectedAnchorCellDetails, table], + ) + + const changeSelectionRange = useCallback( + (direction: 'up' | 'down') => { + if (!selectedAnchorCellDetails) return + + setSelectedRangeCellIndexes((previousSelection) => { + const {rowIndex: anchorIndex} = selectedAnchorCellDetails + const getNextIndex = (startingIndex: number) => + startingIndex + (direction === 'down' ? 1 : -1) + // if no cells are selected, select the cell in the direction + if (!previousSelection.length) { + const firstSelectedIndex = getNextIndex(anchorIndex) + if (firstSelectedIndex < 0) return [] + return [firstSelectedIndex] + } + const lastIndexSelected = previousSelection[previousSelection.length - 1] + const nextIndex = getNextIndex(lastIndexSelected) + + // if the cell in the direction is out of bounds, return the previous selection + if (nextIndex < 0) return previousSelection + + // if the cell in the direction is the same as the focused cell, deselect all cells + if (nextIndex === anchorIndex) return [] + + // if the cell in the direction is already selected, deselect the last selected cell + if (previousSelection.includes(nextIndex)) { + return previousSelection.slice(0, -1) + } + + return [...previousSelection, nextIndex] + }) + }, + [selectedAnchorCellDetails], + ) + + const setSelectedAnchorCell = useCallback( + (colId: string, rowIndex: number) => { + clearAndSetFocusSelection({colId, rowIndex, state: 'selected'}) + }, + [clearAndSetFocusSelection], + ) + + const handleEscapePress = useCallback(() => { + if (!selectedAnchorCellDetails) return + if (selectedRangeCellIndexes.length) { + // only clear selected range if it exists + setSelectedRangeCellIndexes([]) + } else { + const nextAnchorCellDetails: SelectedCellDetails = + selectedAnchorCellDetails.state === 'selected' + ? null + : { + ...selectedAnchorCellDetails, + state: 'selected', + } + clearAndSetFocusSelection(nextAnchorCellDetails) + } + }, [clearAndSetFocusSelection, selectedAnchorCellDetails, selectedRangeCellIndexes.length]) + + const handleUpDownKey = useCallback( + (isShiftKey: boolean, key: string) => { + if (!selectedAnchorCellDetails) return + + const direction = key === 'ArrowDown' ? 'down' : 'up' + const offset = direction === 'down' ? 1 : -1 + + if (isShiftKey) { + changeSelectionRange(direction) + } else { + const newSelectedCellRowIndex = selectedAnchorCellDetails.rowIndex + offset + if (newSelectedCellRowIndex < 0) return + + setSelectedAnchorCell(selectedAnchorCellDetails.colId, newSelectedCellRowIndex) + } + }, + [changeSelectionRange, selectedAnchorCellDetails, setSelectedAnchorCell], + ) + + const handleAnchorKeydown = useCallback( + (event: KeyboardEvent) => { + if (!selectedAnchorCellDetails) return + + const {key, shiftKey} = event + + switch (key) { + case 'Shift': + break // shift allow should do nothing + + case 'Escape': + handleEscapePress() + break + + case 'ArrowDown': + case 'ArrowUp': + event.preventDefault() + handleUpDownKey(shiftKey, key) + break + + case 'ArrowLeft': + case 'ArrowRight': + // when cell is focused, arrows should have default behavior + // only prevent default when cell is selected + if (selectedAnchorCellDetails.state === 'selected') { + event.preventDefault() + changeSelectionColumn(key === 'ArrowLeft' ? 'left' : 'right') + } + break + + default: + break + } + }, + [selectedAnchorCellDetails, handleEscapePress, handleUpDownKey, changeSelectionColumn], + ) + + const handleAnchorClick = useCallback( + (event: MouseEvent) => { + if (!selectedAnchorCellDetails) return + const isClickInAnchorCell = document + .getElementById( + `cell-${selectedAnchorCellDetails.colId}-${selectedAnchorCellDetails.rowIndex}`, + ) + ?.contains(event.target as Node) + + if (!isClickInAnchorCell) resetFocusSelection() + }, + [resetFocusSelection, selectedAnchorCellDetails], + ) + + useEffect(() => { + if (selectedAnchorCellDetails) { + document.addEventListener('keydown', handleAnchorKeydown) + document.addEventListener('click', handleAnchorClick) + } + + return () => { + if (selectedAnchorCellDetails) { + document.removeEventListener('keydown', handleAnchorKeydown) + document.removeEventListener('click', handleAnchorClick) + } + } + }, [handleAnchorClick, handleAnchorKeydown, selectedAnchorCellDetails]) + + const focusAnchorCell = useCallback( + () => + setSelectedAnchorCellDetails((anchorCellDetails) => { + if (!anchorCellDetails) return null + + return {...anchorCellDetails, state: 'focused'} + }), + [], + ) + + const getStateByCellId = useCallback( + (colId: string, rowIndex: number) => { + if (selectedAnchorCellDetails?.colId !== colId) return null + + if (selectedAnchorCellDetails.rowIndex === rowIndex) + return selectedAnchorCellDetails.state === 'focused' ? 'focused' : 'selectedAnchor' + + if (selectedRangeCellIndexes.includes(rowIndex)) return 'selectedRange' + + return null + }, + [selectedAnchorCellDetails, selectedRangeCellIndexes], + ) + + const submitFocusedCell = useCallback(() => { + if (!selectedAnchorCellDetails) return + + clearAndSetFocusSelection({ + colId: selectedAnchorCellDetails.colId, + rowIndex: selectedAnchorCellDetails.rowIndex + 1, + state: 'selected', + }) + }, [clearAndSetFocusSelection, selectedAnchorCellDetails]) + + const value = useMemo( + () => ({ + focusAnchorCell, + resetFocusSelection, + setSelectedAnchorCell, + getStateByCellId, + submitFocusedCell, + }), + [ + focusAnchorCell, + resetFocusSelection, + setSelectedAnchorCell, + getStateByCellId, + submitFocusedCell, + ], + ) + + return ( + {children} + ) +} diff --git a/packages/sanity/src/structure/panes/documentList/sheetList/SheetListCell.tsx b/packages/sanity/src/structure/panes/documentList/sheetList/SheetListCell.tsx index 8229c646fba..fe9911b1ddc 100644 --- a/packages/sanity/src/structure/panes/documentList/sheetList/SheetListCell.tsx +++ b/packages/sanity/src/structure/panes/documentList/sheetList/SheetListCell.tsx @@ -2,36 +2,149 @@ import {type ObjectFieldType} from '@sanity/types' import {Select, TextInput} from '@sanity/ui' import {type CellContext} from '@tanstack/react-table' -import {type FormEvent, useCallback, useEffect, useState} from 'react' +import {type MouseEventHandler, useCallback, useEffect, useRef, useState} from 'react' import {type SanityDocument} from 'sanity' +import {useDocumentSheetListContext} from './DocumentSheetListProvider' + interface SheetListCellProps extends CellContext { fieldType: ObjectFieldType } +type InputRef = HTMLInputElement | HTMLSelectElement | null + +/** @internal */ export function SheetListCell(props: SheetListCellProps) { const {getValue, column, row, fieldType} = props - const initialValue = getValue() || '' - // We need to keep and update the state of the cell normally - const [value, setValue] = useState(initialValue) + const cellId = `cell-${column.id}-${row.index}` + const [renderValue, setRenderValue] = useState(getValue() as string) + const [isDirty, setIsDirty] = useState(false) + const inputRef = useRef(null) + const { + focusAnchorCell, + resetFocusSelection, + setSelectedAnchorCell, + getStateByCellId, + submitFocusedCell, + } = useDocumentSheetListContext() + const cellState = getStateByCellId(column.id, row.index) + + const handleOnFocus = useCallback(() => { + // reselect in cases where focus achieved without initial mousedown + setSelectedAnchorCell(column.id, row.index) + focusAnchorCell() + }, [column.id, focusAnchorCell, row.index, setSelectedAnchorCell]) + const {patchDocument} = props.table.options.meta || {} + + const handleOnMouseDown: MouseEventHandler = (event) => { + if (event.detail === 2) { + inputRef.current?.focus() + } else { + event.preventDefault() + setSelectedAnchorCell(column.id, row.index) + } + } + + const handleOnEnterDown = useCallback( + (event: KeyboardEvent) => { + const {key} = event + if (key === 'Enter') { + if (cellState === 'selectedAnchor') inputRef.current?.focus() + if (cellState === 'focused') submitFocusedCell() + } + }, + [cellState, submitFocusedCell], + ) + + const handleOnChange = (event: React.ChangeEvent) => { + setIsDirty(true) + setRenderValue(event.target.value) + } - const handleOnChange = useCallback((e: FormEvent) => { - setValue(e.currentTarget.value) - }, []) + const handleOnBlur = () => { + if (isDirty) { + patchDocument?.(row.id, column.id, renderValue) + setIsDirty(false) + } + resetFocusSelection() + } + + const handlePaste = useCallback( + (event: ClipboardEvent) => { + const clipboardData = event.clipboardData?.getData('Text') + + if (typeof clipboardData === 'string' || typeof clipboardData === 'number') { + setRenderValue(clipboardData) + // patch immediately when pasting + patchDocument?.(row.id, column.id, clipboardData) + } + }, + [column.id, patchDocument, row.id], + ) + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(renderValue.toString()) + }, [renderValue]) useEffect(() => { - setValue(initialValue || '') - }, [initialValue]) + if (cellState === 'selectedAnchor' || cellState === 'focused') + // only listen for enter key when cell is focused or anchor + document.addEventListener('keydown', handleOnEnterDown) + if (cellState === 'selectedAnchor' || cellState === 'selectedRange') + // if cell is selected, paste events should be handled + document.addEventListener('paste', handlePaste) + + if (cellState === 'selectedAnchor') + // only allow copying when cell is selected anchor + document.addEventListener('copy', handleCopy) + + return () => { + if (cellState === 'selectedAnchor' || cellState === 'focused') + document.removeEventListener('keydown', handleOnEnterDown) + if (cellState === 'selectedAnchor' || cellState === 'selectedRange') + document.removeEventListener('paste', handlePaste) + if (cellState === 'selectedAnchor') document.removeEventListener('copy', handleCopy) + } + }, [ + cellId, + cellState, + column.id, + getStateByCellId, + handleCopy, + handleOnEnterDown, + handlePaste, + row.index, + ]) + + const getBorderStyle = () => { + if (cellState === 'focused') return '2px solid blue' + if (cellState === 'selectedRange') return '1px solid green' + if (cellState === 'selectedAnchor') return '1px solid blue' + + return '1px solid transparent' + } + + const inputProps = { + 'onFocus': handleOnFocus, + 'onBlur': handleOnBlur, + 'onMouseDown': handleOnMouseDown, + 'aria-selected': !!cellState, + 'data-testid': cellId, + 'id': cellId, + 'ref': (ref: InputRef) => (inputRef.current = ref), + } if (fieldType.name === 'boolean') { return (