| 1 | import type React from "react"; |
| 2 | import { useCallback, useEffect, useRef } from "react"; |
| 3 | |
| 4 | import { GridHeader } from "./grid-header"; |
| 5 | import { GridRow } from "./grid-row"; |
| 6 | import { |
| 7 | type ChartDataField, |
| 8 | type ChartDataRow, |
| 9 | type ChartEditorSchema, |
| 10 | } from "./schemas"; |
| 11 | import { type ChartDataType, type SeriesChartType } from "./types"; |
| 12 | |
| 13 | const EMPTY_SERIES_CHART_TYPES: Record<string, SeriesChartType> = {}; |
| 14 | |
| 15 | interface GridProps { |
| 16 | data: ChartDataType; |
| 17 | fields: ChartDataField[]; |
| 18 | schema: ChartEditorSchema; |
| 19 | seriesNames: string[]; |
| 20 | focusedCell: { row: number; col: number } | null; |
| 21 | onUpdateCell: (rowIndex: number, field: string, value: string) => void; |
| 22 | onRemoveRow: (rowIndex: number) => void; |
| 23 | onRenameSeries: (oldName: string, newName: string) => void; |
| 24 | onRemoveSeries: (name: string) => void; |
| 25 | onAddRow: () => void; |
| 26 | onFocusCell: (row: number, col: number) => void; |
| 27 | setFocusedCell: (cell: { row: number; col: number } | null) => void; |
| 28 | seriesChartTypes?: Record<string, SeriesChartType>; |
| 29 | onSeriesChartTypeChange?: ( |
| 30 | seriesName: string, |
| 31 | chartType: SeriesChartType, |
| 32 | ) => void; |
| 33 | isComposedChart?: boolean; |
| 34 | labelKey?: string; |
| 35 | } |
| 36 | |
| 37 | function toRows(data: ChartDataType): ChartDataRow[] { |
| 38 | return Array.isArray(data) ? (data as ChartDataRow[]) : []; |
| 39 | } |
| 40 | |
| 41 | export function Grid({ |
| 42 | data, |
| 43 | fields, |
| 44 | schema, |
| 45 | seriesNames, |
| 46 | focusedCell, |
| 47 | onUpdateCell, |
| 48 | onRemoveRow, |
| 49 | onRenameSeries, |
| 50 | onRemoveSeries, |
| 51 | onAddRow, |
| 52 | onFocusCell, |
| 53 | setFocusedCell, |
| 54 | seriesChartTypes = EMPTY_SERIES_CHART_TYPES, |
| 55 | onSeriesChartTypeChange, |
| 56 | isComposedChart = false, |
| 57 | }: GridProps) { |
| 58 | const cellRefs = useRef<Map<string, HTMLInputElement> | null>(null); |
| 59 | if (!cellRefs.current) { |
| 60 | cellRefs.current = new Map(); |
| 61 | } |
| 62 | const cellRefsCurrent = cellRefs.current; |
| 63 | const rows = toRows(data); |
| 64 | |
| 65 | const registerCell = useCallback( |
| 66 | (row: number, col: number, el: HTMLInputElement | null) => { |
| 67 | const key = `${row}-${col}`; |
| 68 | if (el) { |
| 69 | cellRefsCurrent.set(key, el); |
| 70 | } else { |
| 71 | cellRefsCurrent.delete(key); |
| 72 | } |
| 73 | }, |
| 74 | [], |
| 75 | ); |
| 76 | |
| 77 | const focusCellElement = useCallback((row: number, col: number) => { |
| 78 | const key = `${row}-${col}`; |
| 79 | const cell = cellRefsCurrent.get(key); |
| 80 | if (cell) { |
| 81 | cell.focus(); |
| 82 | cell.select(); |
| 83 | } |
| 84 | }, []); |
| 85 | |
| 86 | useEffect(() => { |
| 87 | if (focusedCell) { |
| 88 | focusCellElement(focusedCell.row, focusedCell.col); |
| 89 | } |
| 90 | }, [focusedCell, focusCellElement]); |
| 91 | |
| 92 | const handleKeyDown = useCallback( |
| 93 | (e: React.KeyboardEvent<HTMLInputElement>, row: number, col: number) => { |
| 94 | const totalCols = fields.length; |
| 95 | const totalRows = rows.length; |
| 96 | |
| 97 | switch (e.key) { |
| 98 | case "Tab": |
| 99 | e.preventDefault(); |
| 100 | if (e.shiftKey) { |
| 101 | if (col > 0) { |
| 102 | onFocusCell(row, col - 1); |
| 103 | } else if (row > 0) { |
| 104 | onFocusCell(row - 1, totalCols - 1); |
| 105 | } |
| 106 | } else if (col < totalCols - 1) { |
| 107 | onFocusCell(row, col + 1); |
| 108 | } else if (row < totalRows - 1) { |
| 109 | onFocusCell(row + 1, 0); |
| 110 | } else { |
| 111 | onAddRow(); |
| 112 | setTimeout(() => onFocusCell(totalRows, 0), 50); |
| 113 | } |
| 114 | break; |
| 115 | |
| 116 | case "Enter": |
| 117 | e.preventDefault(); |
| 118 | if (row < totalRows - 1) { |
| 119 | onFocusCell(row + 1, col); |
| 120 | } else { |
| 121 | onAddRow(); |
| 122 | setTimeout(() => onFocusCell(totalRows, col), 50); |
| 123 | } |
| 124 | break; |
| 125 | |
| 126 | case "ArrowUp": |
| 127 | e.preventDefault(); |
| 128 | if (row > 0) onFocusCell(row - 1, col); |
| 129 | break; |
| 130 | |
| 131 | case "ArrowDown": |
| 132 | e.preventDefault(); |
| 133 | if (row < totalRows - 1) onFocusCell(row + 1, col); |
| 134 | break; |
| 135 | |
| 136 | case "ArrowLeft": |
| 137 | if (e.currentTarget.selectionStart === 0) { |
| 138 | e.preventDefault(); |
| 139 | if (col > 0) onFocusCell(row, col - 1); |
| 140 | } |
| 141 | break; |
| 142 | |
| 143 | case "ArrowRight": |
| 144 | if (e.currentTarget.selectionStart === e.currentTarget.value.length) { |
| 145 | e.preventDefault(); |
| 146 | if (col < totalCols - 1) onFocusCell(row, col + 1); |
| 147 | } |
| 148 | break; |
| 149 | } |
| 150 | }, |
| 151 | [fields.length, rows.length, onFocusCell, onAddRow], |
| 152 | ); |
| 153 | |
| 154 | return ( |
| 155 | <div className="overflow-hidden rounded-md border border-border bg-background"> |
| 156 | <div className="overflow-x-auto"> |
| 157 | <table className="w-full border-collapse"> |
| 158 | <GridHeader |
| 159 | fields={fields} |
| 160 | schema={schema} |
| 161 | seriesNames={seriesNames} |
| 162 | onRenameSeries={onRenameSeries} |
| 163 | onRemoveSeries={onRemoveSeries} |
| 164 | seriesChartTypes={seriesChartTypes} |
| 165 | onSeriesChartTypeChange={onSeriesChartTypeChange} |
| 166 | isComposedChart={isComposedChart} |
| 167 | /> |
| 168 | <tbody> |
| 169 | {rows.map((row, rowIndex) => ( |
| 170 | <GridRow |
| 171 | key={rowIndex} |
| 172 | row={row} |
| 173 | fields={fields} |
| 174 | rowIndex={rowIndex} |
| 175 | focusedCol={ |
| 176 | focusedCell?.row === rowIndex ? focusedCell.col : null |
| 177 | } |
| 178 | canDelete={rows.length > 1} |
| 179 | onUpdateCell={(field, value) => |
| 180 | onUpdateCell(rowIndex, field, value) |
| 181 | } |
| 182 | onRemoveRow={() => onRemoveRow(rowIndex)} |
| 183 | onKeyDown={(e, col) => handleKeyDown(e, rowIndex, col)} |
| 184 | onFocus={(col) => setFocusedCell({ row: rowIndex, col })} |
| 185 | registerCell={(col, el) => registerCell(rowIndex, col, el)} |
| 186 | /> |
| 187 | ))} |
| 188 | </tbody> |
| 189 | </table> |
| 190 | </div> |
| 191 | </div> |
| 192 | ); |
| 193 | } |
| 194 |