diff --git a/apps/v4/components/csv-viewer-docs.tsx b/apps/v4/components/csv-viewer-docs.tsx index acefee2..a80d413 100644 --- a/apps/v4/components/csv-viewer-docs.tsx +++ b/apps/v4/components/csv-viewer-docs.tsx @@ -4,10 +4,7 @@ import * as React from "react" import dynamic from "next/dynamic" import { Spinner } from "@/components/ui/spinner" -import { - DocsSourceCodeBlock, - DocsViewCodeBlock, -} from "@/components/docs-code-block" +import { DocsViewCodeBlock } from "@/components/docs-code-block" const SAMPLE_CSV = `Invoice,Customer,Status,Amount,Submitted INV-1001,Northstar Supply,Approved,4280.50,2026-04-28 @@ -41,18 +38,6 @@ export function CsvViewerPreviewClient() { return } -export function CsvViewerDemo() { - return ( -
- - -
- ) -} - const csvViewerUsageCode = `"use client"; import { CsvViewer } from "@/components/ui/csv-viewer"; @@ -65,14 +50,14 @@ export function CsvViewerExample() { return ; }` -const csvViewerSourceCode = - '"use client"\n\nimport * as React from "react"\nimport type * as GlideDataGrid from "@glideapps/glide-data-grid"\nimport type {\n DataEditorRef,\n GridCell,\n GridCellKind,\n GridColumn,\n GridSelection,\n Item,\n Theme,\n} from "@glideapps/glide-data-grid"\nimport {\n CompactSelection,\n emptyGridSelection,\n} from "@glideapps/glide-data-grid"\n\nimport "@glideapps/glide-data-grid/dist/index.css"\n\nimport {\n ArrowLeft01Icon,\n ArrowRight01Icon,\n Download01Icon,\n MinusSignCircleIcon,\n MoreHorizontalIcon,\n PlusSignCircleIcon,\n Search01Icon,\n Upload01Icon,\n} from "@hugeicons/core-free-icons"\nimport { HugeiconsIcon } from "@hugeicons/react"\nimport Papa from "papaparse"\n\nimport { cn } from "@/lib/utils"\nimport { Button } from "@/components/ui/button"\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from "@/components/ui/dropdown-menu"\nimport { Input } from "@/components/ui/input"\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from "@/components/ui/popover"\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from "@/components/ui/select"\nimport { Separator } from "@/components/ui/separator"\nimport { Spinner } from "@/components/ui/spinner"\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from "@/components/ui/tooltip"\n\nconst ZOOM_OPTIONS = [0.75, 1, 1.25, 1.5, 2] as const\nconst CSV_SEARCH_BATCH_ROW_COUNT = 500\nconst CSV_SEARCH_DEBOUNCE_MS = 300\n\ntype GlideDataGridModule = typeof GlideDataGrid\ntype CsvViewerProps = {\n className?: string\n data?: string\n search?: boolean\n}\n\ntype CsvSearchResult = {\n col: number\n row: number\n displayValue: string\n columnTitle: string\n}\n\nfunction toDisplayString(value: unknown): string {\n return value === null || value === undefined ? "" : String(value)\n}\n\nfunction normalizeHeaderTitle(header: string, index: number): string {\n const trimmed = header.trim()\n return trimmed.length > 0 ? trimmed : `Column ${index + 1}`\n}\n\nfunction columnIndexToA1(col: number) {\n let columnNumber = col + 1\n let columnName = ""\n\n while (columnNumber > 0) {\n const remainder = (columnNumber - 1) % 26\n columnName = String.fromCharCode(65 + remainder) + columnName\n columnNumber = Math.floor((columnNumber - 1) / 26)\n }\n\n return columnName\n}\n\nfunction cellAddressToA1(col: number, row: number) {\n return `${columnIndexToA1(col)}${row + 1}`\n}\n\nfunction cellMatchesQuery(displayValue: string, query: string) {\n return displayValue.toLowerCase().includes(query)\n}\n\nfunction createSingleCellSelection(cell: Item): GridSelection {\n const [col, row] = cell\n\n return {\n columns: CompactSelection.empty(),\n rows: CompactSelection.empty(),\n current: {\n cell,\n range: { x: col, y: row, width: 1, height: 1 },\n rangeStack: [],\n },\n }\n}\n\nasync function findCsvSearchResults(\n headers: string[],\n rows: string[][],\n rawQuery: string\n) {\n const query = rawQuery.trim().toLowerCase()\n if (!query) return []\n\n const results: CsvSearchResult[] = []\n const columnCount = Math.max(\n headers.length,\n rows.reduce((maxCount, row) => Math.max(maxCount, row.length), 0)\n )\n\n for (\n let batchStartRow = 0;\n batchStartRow < rows.length;\n batchStartRow += CSV_SEARCH_BATCH_ROW_COUNT\n ) {\n const batchEndRow = Math.min(\n batchStartRow + CSV_SEARCH_BATCH_ROW_COUNT,\n rows.length\n )\n\n for (let row = batchStartRow; row < batchEndRow; row += 1) {\n const rowValues = rows[row] ?? []\n\n for (let col = 0; col < columnCount; col += 1) {\n const displayValue = rowValues[col] ?? ""\n if (!cellMatchesQuery(displayValue, query)) continue\n\n results.push({\n col,\n row,\n displayValue,\n columnTitle: headers[col] ?? `Column ${col + 1}`,\n })\n }\n }\n\n if (batchEndRow < rows.length) {\n await new Promise((resolve) => {\n window.setTimeout(resolve, 0)\n })\n }\n }\n\n return results\n}\n\nfunction parseDelimitedText(text: string): {\n headers: string[]\n rows: string[][]\n error: string | null\n} {\n const results = Papa.parse>(text, {\n header: true,\n skipEmptyLines: "greedy",\n })\n\n const objectRows = Array.isArray(results.data)\n ? results.data.filter(\n (row): row is Record =>\n !!row && typeof row === "object" && !Array.isArray(row)\n )\n : []\n const metaFields = Array.isArray(results.meta.fields)\n ? results.meta.fields.map((field) => String(field))\n : []\n const fieldKeys =\n metaFields.length > 0\n ? metaFields\n : Object.keys(objectRows[0] ?? {}).filter(\n (key) => key !== "__parsed_extra"\n )\n const extraColumnCount = objectRows.reduce((maxCount, row) => {\n const extras = row.__parsed_extra\n return Array.isArray(extras) ? Math.max(maxCount, extras.length) : maxCount\n }, 0)\n const headers = [\n ...fieldKeys.map((field, index) => normalizeHeaderTitle(field, index)),\n ...Array.from(\n { length: extraColumnCount },\n (_, index) => `Extra ${index + 1}`\n ),\n ]\n\n const rows = objectRows.map((row) => {\n const baseValues = fieldKeys.map((fieldKey) =>\n toDisplayString(row[fieldKey])\n )\n const extras = Array.isArray(row.__parsed_extra)\n ? row.__parsed_extra.map((value) => toDisplayString(value))\n : []\n const paddedExtras =\n extras.length >= extraColumnCount\n ? extras.slice(0, extraColumnCount)\n : [\n ...extras,\n ...Array.from(\n { length: extraColumnCount - extras.length },\n () => ""\n ),\n ]\n\n return [...baseValues, ...paddedExtras]\n })\n\n const firstError =\n Array.isArray(results.errors) && results.errors.length > 0\n ? results.errors[0]\n : null\n\n return {\n headers,\n rows,\n error:\n rows.length === 0 && firstError\n ? String(firstError.message ?? "Could not parse CSV file.")\n : null,\n }\n}\n\nfunction ensureCsvExtension(fileName: string) {\n const lowerFileName = fileName.toLowerCase()\n return lowerFileName.endsWith(".csv") || lowerFileName.endsWith(".tsv")\n ? fileName\n : `${fileName}.csv`\n}\n\nfunction downloadTextFile(text: string, fileName: string, type: string) {\n const url = URL.createObjectURL(new Blob([text], { type }))\n const anchor = document.createElement("a")\n\n anchor.href = url\n anchor.download = fileName\n anchor.rel = "noopener"\n document.body.append(anchor)\n anchor.click()\n anchor.remove()\n window.setTimeout(() => URL.revokeObjectURL(url), 0)\n}\n\nfunction CsvFileActionsMenu({\n downloadDisabled,\n isPending,\n onDownload,\n onUploadClick,\n}: {\n downloadDisabled: boolean\n isPending: boolean\n onDownload: () => void\n onUploadClick: () => void\n}) {\n return (\n \n \n \n \n \n \n \n \n \n Download\n \n \n {isPending ? (\n \n ) : (\n \n )}\n Upload\n \n \n \n )\n}\n\nfunction ToolbarTooltip({\n label,\n children,\n}: {\n label: string\n children: React.ReactNode\n}) {\n return (\n \n \n {children}\n \n {label}\n \n )\n}\n\nfunction CsvSearchPopover({\n headers,\n rows,\n gridRef,\n dataIdentity,\n controlsDisabled,\n onGridSelectionChange,\n}: {\n headers: string[]\n rows: string[][]\n gridRef: React.RefObject\n dataIdentity: string\n controlsDisabled: boolean\n onGridSelectionChange: (selection: GridSelection) => void\n}) {\n const [searchDraft, setSearchDraft] = React.useState("")\n const [searchQuery, setSearchQuery] = React.useState("")\n const [searchResults, setSearchResults] = React.useState(\n []\n )\n const [activeResultIndex, setActiveResultIndex] = React.useState(0)\n const [isSearching, setIsSearching] = React.useState(false)\n const searchRequestIdRef = React.useRef(0)\n const appliedResultKeyRef = React.useRef("")\n const activeResult = searchResults[activeResultIndex] ?? null\n const activeResultKey = activeResult\n ? `${activeResult.row}:${activeResult.col}`\n : ""\n const hasActiveQuery = Boolean(searchQuery.trim())\n const resultLabel = isSearching\n ? "Searching"\n : !hasActiveQuery\n ? "No search"\n : searchResults.length\n ? `${activeResultIndex + 1} / ${searchResults.length}`\n : "No results"\n\n const runSearch = React.useCallback(\n (rawQuery: string) => {\n const nextQuery = rawQuery.trim()\n const requestId = searchRequestIdRef.current + 1\n searchRequestIdRef.current = requestId\n appliedResultKeyRef.current = ""\n setSearchQuery(nextQuery)\n setActiveResultIndex(0)\n\n if (!nextQuery) {\n setSearchResults([])\n setIsSearching(false)\n return\n }\n\n setIsSearching(true)\n void findCsvSearchResults(headers, rows, nextQuery)\n .then((nextResults) => {\n if (searchRequestIdRef.current !== requestId) return\n setSearchResults(nextResults)\n })\n .catch(() => {\n if (searchRequestIdRef.current !== requestId) return\n setSearchResults([])\n })\n .finally(() => {\n if (searchRequestIdRef.current !== requestId) return\n setIsSearching(false)\n })\n },\n [headers, rows]\n )\n\n React.useEffect(() => {\n const trimmedDraft = searchDraft.trim()\n\n if (!trimmedDraft) {\n runSearch("")\n return\n }\n\n setIsSearching(true)\n const timeoutId = window.setTimeout(() => {\n runSearch(searchDraft)\n }, CSV_SEARCH_DEBOUNCE_MS)\n\n return () => window.clearTimeout(timeoutId)\n }, [runSearch, searchDraft])\n\n const clearSearch = React.useCallback(() => {\n searchRequestIdRef.current += 1\n setSearchDraft("")\n setSearchQuery("")\n setSearchResults([])\n setActiveResultIndex(0)\n setIsSearching(false)\n appliedResultKeyRef.current = ""\n onGridSelectionChange(emptyGridSelection)\n }, [onGridSelectionChange])\n\n const goToRelativeResult = React.useCallback(\n (direction: 1 | -1) => {\n if (!searchResults.length) return\n\n setActiveResultIndex((currentIndex) => {\n return (\n (currentIndex + direction + searchResults.length) %\n searchResults.length\n )\n })\n },\n [searchResults.length]\n )\n\n React.useEffect(() => {\n searchRequestIdRef.current += 1\n setSearchDraft("")\n setSearchQuery("")\n setSearchResults([])\n setActiveResultIndex(0)\n setIsSearching(false)\n appliedResultKeyRef.current = ""\n onGridSelectionChange(emptyGridSelection)\n }, [dataIdentity, onGridSelectionChange])\n\n React.useEffect(() => {\n if (!activeResult) return\n\n if (appliedResultKeyRef.current === activeResultKey) return\n appliedResultKeyRef.current = activeResultKey\n\n const cell: Item = [activeResult.col, activeResult.row]\n onGridSelectionChange(createSingleCellSelection(cell))\n\n const frame = window.requestAnimationFrame(() => {\n gridRef.current?.scrollTo(\n activeResult.col,\n activeResult.row,\n "both",\n 48,\n 48,\n {\n vAlign: "center",\n hAlign: "center",\n }\n )\n })\n\n return () => window.cancelAnimationFrame(frame)\n }, [activeResult, activeResultKey, gridRef, onGridSelectionChange])\n\n return (\n \n \n \n \n \n \n \n \n \n
\n setSearchDraft(event.target.value)}\n onKeyDown={(event) => {\n if (event.key !== "Enter") return\n\n event.preventDefault()\n if (event.shiftKey && searchResults.length) {\n goToRelativeResult(-1)\n } else if (searchResults.length) {\n goToRelativeResult(1)\n } else if (searchDraft.trim()) {\n runSearch(searchDraft)\n }\n }}\n />\n
\n
\n
\n {searchResults.length ? (\n <>\n \n {activeResultIndex + 1}\n \n {` / ${searchResults.length}`}\n \n ) : (\n resultLabel\n )}\n
\n {activeResult ? (\n
\n {activeResult.columnTitle}!\n {cellAddressToA1(activeResult.col, activeResult.row)}\n
\n ) : null}\n
\n
\n goToRelativeResult(-1)}\n >\n \n \n goToRelativeResult(1)}\n >\n \n \n
\n
\n
\n \n Clear\n \n
\n
\n
\n
\n )\n}\n\nfunction readIsDarkTheme() {\n return (\n typeof document !== "undefined" &&\n document.documentElement.classList.contains("dark")\n )\n}\n\nfunction useIsDarkTheme() {\n const [isDark, setIsDark] = React.useState(readIsDarkTheme)\n\n React.useEffect(() => {\n if (typeof document === "undefined") return\n\n const updateTheme = () => setIsDark(readIsDarkTheme())\n\n updateTheme()\n\n if (typeof MutationObserver === "undefined") return\n\n const observer = new MutationObserver(updateTheme)\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: ["class"],\n })\n\n return () => observer.disconnect()\n }, [])\n\n return isDark\n}\n\nexport function CsvViewer({ className, data, search = false }: CsvViewerProps) {\n const inputRef = React.useRef(null)\n const gridRef = React.useRef(null)\n const isDark = useIsDarkTheme()\n const [glide, setGlide] = React.useState(null)\n const [zoom, setZoom] = React.useState<(typeof ZOOM_OPTIONS)[number]>(1)\n const [gridSelection, setGridSelection] =\n React.useState(emptyGridSelection)\n const [parsed, setParsed] = React.useState(() =>\n data ? parseDelimitedText(data) : { headers: [], rows: [], error: null }\n )\n const [uploadedFileName, setUploadedFileName] = React.useState(\n null\n )\n const [isPending, setIsPending] = React.useState(false)\n const [dataRevision, setDataRevision] = React.useState(0)\n\n const dataIdentity = React.useMemo(\n () =>\n `${dataRevision}:${parsed.headers.join("\\u0001")}:${parsed.rows.length}:${parsed.error ?? ""}`,\n [dataRevision, parsed.error, parsed.headers, parsed.rows.length]\n )\n\n const handleGridSelectionChange = React.useCallback(\n (selection: GridSelection) => {\n setGridSelection(selection)\n },\n []\n )\n\n React.useEffect(() => {\n if (data) {\n setParsed(parseDelimitedText(data))\n setUploadedFileName(null)\n setDataRevision((revision) => revision + 1)\n }\n }, [data])\n\n React.useEffect(() => {\n if (!search) {\n setGridSelection(emptyGridSelection)\n }\n }, [search])\n\n React.useEffect(() => {\n let mounted = true\n\n void import("@glideapps/glide-data-grid").then((module) => {\n if (mounted) {\n setGlide(module)\n }\n })\n\n return () => {\n mounted = false\n }\n }, [])\n\n const columnCount = Math.max(1, parsed.headers.length)\n const scale = React.useCallback(\n (value: number) => Math.round(value * zoom),\n [zoom]\n )\n const searchDisabled =\n Boolean(parsed.error) || parsed.rows.length === 0 || isPending\n\n const theme = React.useMemo>(\n () => ({\n accentColor: isDark ? "#60a5fa" : "#2563eb",\n accentLight: isDark ? "#1d4ed826" : "#dbeafe",\n accentFg: "#ffffff",\n textDark: isDark ? "#e5e5e5" : "#171717",\n textMedium: isDark ? "#a3a3a3" : "#525252",\n textLight: isDark ? "#737373" : "#a3a3a3",\n textBubble: isDark ? "#f5f5f5" : "#171717",\n textHeader: isDark ? "#f5f5f5" : "#171717",\n textGroupHeader: isDark ? "#a3a3a3" : "#525252",\n bgCell: isDark ? "#0a0a0a" : "#ffffff",\n bgCellMedium: isDark ? "#171717" : "#fafafa",\n bgHeader: isDark ? "#171717" : "#fafafa",\n bgHeaderHasFocus: isDark ? "#262626" : "#f5f5f5",\n bgHeaderHovered: isDark ? "#262626" : "#f5f5f5",\n borderColor: isDark ? "#262626" : "#e5e5e5",\n horizontalBorderColor: isDark ? "#262626" : "#e5e5e5",\n cellHorizontalPadding: scale(8),\n cellVerticalPadding: Math.max(2, scale(3)),\n headerIconSize: scale(18),\n baseFontStyle: `${scale(13)}px`,\n headerFontStyle: `600 ${scale(13)}px`,\n markerFontStyle: `${scale(11)}px`,\n fontFamily: \'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif\',\n editorFontSize: `${scale(13)}px`,\n }),\n [isDark, scale]\n )\n\n const columns = React.useMemo(\n () =>\n Array.from({ length: columnCount }, (_, index) => ({\n id: `column-${index}`,\n title: parsed.headers[index] ?? `Column ${index + 1}`,\n width: scale(index === 0 ? 180 : 160),\n })),\n [columnCount, parsed.headers, scale]\n )\n\n const getCellContent = React.useCallback(\n ([col, row]: Item): GridCell => {\n const value = parsed.rows[row]?.[col] ?? ""\n const textKind = glide?.GridCellKind.Text as GridCellKind.Text\n\n return {\n kind: textKind,\n data: value,\n displayData: value,\n allowOverlay: true,\n readonly: true,\n }\n },\n [glide, parsed.rows]\n )\n\n async function handleUpload(event: React.ChangeEvent) {\n const file = event.target.files?.[0]\n if (!file) return\n\n setIsPending(true)\n try {\n const text = await file.text()\n setParsed(parseDelimitedText(text))\n setUploadedFileName(file.name)\n setDataRevision((revision) => revision + 1)\n } catch (error) {\n setParsed({\n headers: [],\n rows: [],\n error:\n error instanceof Error ? error.message : "Could not read CSV file.",\n })\n setDataRevision((revision) => revision + 1)\n } finally {\n event.target.value = ""\n setIsPending(false)\n }\n }\n\n function stepZoom(direction: -1 | 1) {\n const index = ZOOM_OPTIONS.indexOf(zoom)\n const nextIndex = Math.min(\n ZOOM_OPTIONS.length - 1,\n Math.max(0, index + direction)\n )\n setZoom(ZOOM_OPTIONS[nextIndex])\n }\n\n function handleDownload() {\n const text = Papa.unparse({\n fields: parsed.headers,\n data: parsed.rows,\n })\n\n downloadTextFile(\n text,\n ensureCsvExtension(uploadedFileName ?? "data.csv"),\n "text/csv;charset=utf-8"\n )\n }\n\n return (\n \n
\n \n
\n
\n \n stepZoom(-1)}\n >\n \n \n \n \n setZoom(Number(value) as (typeof ZOOM_OPTIONS)[number])\n }\n modal={false}\n >\n \n {Math.round(zoom * 100)}%\n \n \n {ZOOM_OPTIONS.map((option) => (\n \n {Math.round(option * 100)}%\n \n ))}\n \n \n \n = ZOOM_OPTIONS[ZOOM_OPTIONS.length - 1]}\n onClick={() => stepZoom(1)}\n >\n \n \n \n
\n {search ? (\n <>\n \n \n \n ) : null}\n \n \n inputRef.current?.click()}\n />\n
\n
\n
\n
\n {parsed.error ? (\n
\n {parsed.error}\n
\n ) : parsed.rows.length === 0 ? (\n
\n
\n

Upload a CSV to preview

\n

\n Pass delimited text with the data prop or upload a\n CSV file.\n

\n inputRef.current?.click()}\n >\n \n Upload CSV\n \n
\n
\n ) : !glide ? (\n
\n \n
\n ) : (\n \n )}\n
\n \n )\n}\n' - -export function CsvViewerSource() { +export function CsvViewerDemo() { return ( - +
+ + +
) } diff --git a/apps/v4/components/csv-viewer-source.tsx b/apps/v4/components/csv-viewer-source.tsx new file mode 100644 index 0000000..c61df26 --- /dev/null +++ b/apps/v4/components/csv-viewer-source.tsx @@ -0,0 +1,15 @@ +import { readFileSync } from "node:fs" +import { join } from "node:path" + +import { DocsSourceCodeBlock } from "@/components/docs-code-block" + +export function CsvViewerSource() { + const code = readFileSync( + join(process.cwd(), "components/ui/csv-viewer.tsx"), + "utf8" + ) + + return ( + + ) +} diff --git a/apps/v4/components/ui/csv-viewer.tsx b/apps/v4/components/ui/csv-viewer.tsx index 1b12f0f..ca1c11c 100644 --- a/apps/v4/components/ui/csv-viewer.tsx +++ b/apps/v4/components/ui/csv-viewer.tsx @@ -19,6 +19,7 @@ import { import "@glideapps/glide-data-grid/dist/index.css" import { + ArrowExpandDiagonal02Icon, ArrowLeft01Icon, ArrowRight01Icon, Download01Icon, @@ -26,6 +27,7 @@ import { MoreHorizontalIcon, PlusSignCircleIcon, Search01Icon, + Undo02Icon, Upload01Icon, } from "@hugeicons/core-free-icons" import { HugeiconsIcon } from "@hugeicons/react" @@ -62,14 +64,36 @@ import { } from "@/components/ui/tooltip" const ZOOM_OPTIONS = [0.75, 1, 1.25, 1.5, 2] as const -const CSV_SEARCH_BATCH_ROW_COUNT = 500 +const ROW_HEIGHT_PRESETS = [ + { value: 28, label: "Compact" }, + { value: 34, label: "Default" }, + { value: 42, label: "Relaxed" }, + { value: 52, label: "Wide" }, +] as const +type RowHeight = (typeof ROW_HEIGHT_PRESETS)[number]["value"] +const CSV_ROW_BATCH_SIZE = 500 const CSV_SEARCH_DEBOUNCE_MS = 300 +const CSV_DEFAULT_ROW_HEIGHT: RowHeight = ROW_HEIGHT_PRESETS[1].value +const CSV_DEFAULT_FIRST_COL_WIDTH = 180 +const CSV_DEFAULT_COL_WIDTH = 160 +const CSV_MIN_COL_WIDTH = 60 +const CSV_MAX_COL_WIDTH = 1200 +const CSV_MAX_COL_AUTO_WIDTH = 640 +const CSV_GRID_FONT_FAMILY = + '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' type GlideDataGridModule = typeof GlideDataGrid +type ColumnWidthMap = Record type CsvViewerProps = { className?: string data?: string search?: boolean + columnWidths?: ColumnWidthMap + defaultColumnWidths?: ColumnWidthMap + onColumnWidthsChange?: (columnWidths: ColumnWidthMap) => void + rowHeight?: RowHeight + defaultRowHeight?: RowHeight + onRowHeightChange?: (rowHeight: RowHeight) => void } type CsvSearchResult = { @@ -109,6 +133,70 @@ function cellMatchesQuery(displayValue: string, query: string) { return displayValue.toLowerCase().includes(query) } +function getDefaultColumnWidth(index: number) { + return index === 0 ? CSV_DEFAULT_FIRST_COL_WIDTH : CSV_DEFAULT_COL_WIDTH +} + +function getRowHeightLabel(height: number) { + return ( + ROW_HEIGHT_PRESETS.find((preset) => preset.value === height)?.label ?? + `${height}px` + ) +} + +function hasCustomColumnWidths(columnWidths: ColumnWidthMap) { + return Object.keys(columnWidths).length > 0 +} + +let measureCanvasContext: CanvasRenderingContext2D | null = null + +function measureTextWidth(text: string, font: string) { + if (typeof document === "undefined") return text.length * 8 + + measureCanvasContext ??= document.createElement("canvas").getContext("2d") + if (!measureCanvasContext) return text.length * 8 + + measureCanvasContext.font = font + return measureCanvasContext.measureText(text).width +} + +function computeFitColumnWidths( + headers: string[], + rows: string[][], + options: { + cellFont: string + headerFont: string + horizontalPadding: number + maxWidth: number + minWidth: number + } +) { + const widths: ColumnWidthMap = {} + const sampleRows = + rows.length <= CSV_ROW_BATCH_SIZE ? rows : rows.slice(0, CSV_ROW_BATCH_SIZE) + + for (let col = 0; col < headers.length; col += 1) { + let contentWidth = measureTextWidth(headers[col] ?? "", options.headerFont) + + for (const row of sampleRows) { + contentWidth = Math.max( + contentWidth, + measureTextWidth(row[col] ?? "", options.cellFont) + ) + } + + widths[col] = Math.min( + options.maxWidth, + Math.max( + options.minWidth, + Math.ceil(contentWidth + options.horizontalPadding) + ) + ) + } + + return widths +} + function createSingleCellSelection(cell: Item): GridSelection { const [col, row] = cell @@ -140,10 +228,10 @@ async function findCsvSearchResults( for ( let batchStartRow = 0; batchStartRow < rows.length; - batchStartRow += CSV_SEARCH_BATCH_ROW_COUNT + batchStartRow += CSV_ROW_BATCH_SIZE ) { const batchEndRow = Math.min( - batchStartRow + CSV_SEARCH_BATCH_ROW_COUNT, + batchStartRow + CSV_ROW_BATCH_SIZE, rows.length ) @@ -598,7 +686,17 @@ function useIsDarkTheme() { return isDark } -export function CsvViewer({ className, data, search = false }: CsvViewerProps) { +export function CsvViewer({ + className, + data, + search = false, + columnWidths: columnWidthsProp, + defaultColumnWidths, + onColumnWidthsChange, + rowHeight: rowHeightProp, + defaultRowHeight = CSV_DEFAULT_ROW_HEIGHT, + onRowHeightChange, +}: CsvViewerProps) { const inputRef = React.useRef(null) const gridRef = React.useRef(null) const isDark = useIsDarkTheme() @@ -606,6 +704,24 @@ export function CsvViewer({ className, data, search = false }: CsvViewerProps) { const [zoom, setZoom] = React.useState<(typeof ZOOM_OPTIONS)[number]>(1) const [gridSelection, setGridSelection] = React.useState(emptyGridSelection) + const [internalColumnWidths, setInternalColumnWidths] = + React.useState(defaultColumnWidths ?? {}) + const [internalRowHeight, setInternalRowHeight] = + React.useState(defaultRowHeight) + const isColumnWidthsControlled = columnWidthsProp !== undefined + const isRowHeightControlled = rowHeightProp !== undefined + const columnWidths = isColumnWidthsControlled + ? columnWidthsProp + : internalColumnWidths + const baseRowHeight = isRowHeightControlled + ? rowHeightProp + : internalRowHeight + const columnWidthsRef = React.useRef(columnWidths) + + React.useEffect(() => { + columnWidthsRef.current = columnWidths + }, [columnWidths]) + const [parsed, setParsed] = React.useState(() => data ? parseDelimitedText(data) : { headers: [], rows: [], error: null } ) @@ -642,6 +758,50 @@ export function CsvViewer({ className, data, search = false }: CsvViewerProps) { } }, [search]) + React.useEffect(() => { + if (!isColumnWidthsControlled) { + setInternalColumnWidths(defaultColumnWidths ?? {}) + } + if (!isRowHeightControlled) { + setInternalRowHeight(defaultRowHeight) + } + }, [ + dataIdentity, + defaultColumnWidths, + defaultRowHeight, + isColumnWidthsControlled, + isRowHeightControlled, + ]) + + const updateColumnWidths = React.useCallback( + ( + updater: ColumnWidthMap | ((current: ColumnWidthMap) => ColumnWidthMap) + ) => { + const next = + typeof updater === "function" + ? updater(columnWidthsRef.current) + : updater + + columnWidthsRef.current = next + onColumnWidthsChange?.(next) + + if (!isColumnWidthsControlled) { + setInternalColumnWidths(next) + } + }, + [isColumnWidthsControlled, onColumnWidthsChange] + ) + + const updateRowHeight = React.useCallback( + (nextRowHeight: RowHeight) => { + onRowHeightChange?.(nextRowHeight) + if (!isRowHeightControlled) { + setInternalRowHeight(nextRowHeight) + } + }, + [isRowHeightControlled, onRowHeightChange] + ) + React.useEffect(() => { let mounted = true @@ -656,14 +816,65 @@ export function CsvViewer({ className, data, search = false }: CsvViewerProps) { } }, []) - const columnCount = Math.max(1, parsed.headers.length) + const columnCount = parsed.headers.length + const scale = React.useCallback( (value: number) => Math.round(value * zoom), [zoom] ) - const searchDisabled = + + const gridToolbarDisabled = Boolean(parsed.error) || parsed.rows.length === 0 || isPending + const getBaseColumnWidth = React.useCallback( + (index: number) => columnWidths[index] ?? getDefaultColumnWidth(index), + [columnWidths] + ) + + const handleColumnResize = React.useCallback( + (_column: GridColumn, newSize: number, columnIndex: number) => { + updateColumnWidths((current) => ({ + ...current, + [columnIndex]: Math.round(newSize / zoom), + })) + }, + [updateColumnWidths, zoom] + ) + + const handleFitColumns = React.useCallback(() => { + if (columnCount === 0) return + + const fitted = computeFitColumnWidths(parsed.headers, parsed.rows, { + cellFont: `${scale(13)}px ${CSV_GRID_FONT_FAMILY}`, + headerFont: `600 ${scale(13)}px ${CSV_GRID_FONT_FAMILY}`, + horizontalPadding: scale(16), + minWidth: scale(CSV_MIN_COL_WIDTH), + maxWidth: scale(CSV_MAX_COL_AUTO_WIDTH), + }) + + updateColumnWidths( + Object.fromEntries( + Object.entries(fitted).map(([col, width]) => [ + Number(col), + Math.round(Number(width) / zoom), + ]) + ) + ) + }, [ + columnCount, + parsed.headers, + parsed.rows, + scale, + updateColumnWidths, + zoom, + ]) + + const handleResetColumns = React.useCallback(() => { + updateColumnWidths({}) + }, [updateColumnWidths]) + + const hasCustomWidths = hasCustomColumnWidths(columnWidths) + const theme = React.useMemo>( () => ({ accentColor: isDark ? "#60a5fa" : "#2563eb", @@ -688,7 +899,7 @@ export function CsvViewer({ className, data, search = false }: CsvViewerProps) { baseFontStyle: `${scale(13)}px`, headerFontStyle: `600 ${scale(13)}px`, markerFontStyle: `${scale(11)}px`, - fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + fontFamily: CSV_GRID_FONT_FAMILY, editorFontSize: `${scale(13)}px`, }), [isDark, scale] @@ -696,12 +907,12 @@ export function CsvViewer({ className, data, search = false }: CsvViewerProps) { const columns = React.useMemo( () => - Array.from({ length: columnCount }, (_, index) => ({ + Array.from({ length: Math.max(1, columnCount) }, (_, index) => ({ id: `column-${index}`, title: parsed.headers[index] ?? `Column ${index + 1}`, - width: scale(index === 0 ? 180 : 160), + width: scale(getBaseColumnWidth(index)), })), - [columnCount, parsed.headers, scale] + [columnCount, getBaseColumnWidth, parsed.headers, scale] ) const getCellContent = React.useCallback( @@ -825,6 +1036,65 @@ export function CsvViewer({ className, data, search = false }: CsvViewerProps) { + +
+ + + + + + + +
{search ? ( <> @@ -898,7 +1168,6 @@ export function CsvViewer({ className, data, search = false }: CsvViewerProps) { ) : ( )} diff --git a/apps/v4/content/docs/components/csv-tsv-viewer.mdx b/apps/v4/content/docs/components/csv-tsv-viewer.mdx index 03a41eb..b3ee4c9 100644 --- a/apps/v4/content/docs/components/csv-tsv-viewer.mdx +++ b/apps/v4/content/docs/components/csv-tsv-viewer.mdx @@ -78,6 +78,9 @@ npm install @glideapps/glide-data-grid@6.0.4-alpha24 @hugeicons/core-free-icons@ - Optional toolbar search via the `search` prop. When enabled, results update as you type with debounced matching, result navigation, cell selection, and scroll-into-view. - Header controls for upload, row and column counts, and zoom. - Grid zoom that scales row height, header height, column width, and cell text. +- Column resize by dragging header dividers. Double-click a divider to auto-fit that column. +- Toolbar layout controls: row size (`Compact`, `Default`, `Relaxed`, `Wide`), fit columns, and reset columns. +- Optional controlled `columnWidths`, `rowHeight`, and matching change callbacks for parent-managed layouts. ## API Reference @@ -85,8 +88,14 @@ npm install @glideapps/glide-data-grid@6.0.4-alpha24 @hugeicons/core-free-icons@ The CsvViewer component renders its built-in CSV/TSV upload flow, parser state, zoom controls, and virtualized grid. -| Prop | Type | Default | Required | -| ----------- | --------- | ------- | -------- | -| `className` | `string` | `-` | No | -| `data` | `string` | `-` | No | -| `search` | `boolean` | `false` | No | +| Prop | Type | Default | Required | +| ---------------------- | ---------------------------- | ------- | -------- | +| `className` | `string` | `-` | No | +| `data` | `string` | `-` | No | +| `search` | `boolean` | `false` | No | +| `columnWidths` | `Record` | `-` | No | +| `defaultColumnWidths` | `Record` | `-` | No | +| `onColumnWidthsChange` | `(widths) => void` | `-` | No | +| `rowHeight` | `28 \| 34 \| 42 \| 52` | `-` | No | +| `defaultRowHeight` | `28 \| 34 \| 42 \| 52` | `34` | No | +| `onRowHeightChange` | `(height) => void` | `-` | No | diff --git a/apps/v4/mdx-components.tsx b/apps/v4/mdx-components.tsx index d1bdc8c..534a273 100644 --- a/apps/v4/mdx-components.tsx +++ b/apps/v4/mdx-components.tsx @@ -26,7 +26,8 @@ import { CodeCollapsibleWrapper } from "@/components/code-collapsible-wrapper" import { CodeTabs } from "@/components/code-tabs" import { ComponentsList } from "@/components/components-list" import { CopyButton } from "@/components/copy-button" -import { CsvViewerDemo, CsvViewerSource } from "@/components/csv-viewer-docs" +import { CsvViewerDemo } from "@/components/csv-viewer-docs" +import { CsvViewerSource } from "@/components/csv-viewer-source" import { DocsMdxCodeBlock } from "@/components/docs-code-block" import { DocumentSplitsDemo,