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 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
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 \n \n
\n
\n
\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 \n \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
\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,