├── .eslintrc.json ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── example ├── .gitignore ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── src │ ├── App.tsx │ ├── index.css │ ├── main.tsx │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── images └── preview.png ├── jest.config.ts ├── package-lock.json ├── package.json ├── src ├── columns │ ├── checkboxColumn.tsx │ ├── dateColumn.tsx │ ├── floatColumn.tsx │ ├── intColumn.tsx │ ├── isoDateColumn.tsx │ ├── keyColumn.tsx │ ├── percentColumn.tsx │ └── textColumn.tsx ├── components │ ├── AddRows.test.tsx │ ├── AddRows.tsx │ ├── Cell.tsx │ ├── ContextMenu.test.tsx │ ├── ContextMenu.tsx │ ├── DataSheetGrid.tsx │ ├── Grid.tsx │ ├── SelectionRect.tsx │ └── StaticDataSheetGrid.tsx ├── hooks │ ├── useColumnWidths.ts │ ├── useColumns.tsx │ ├── useDebounceState.ts │ ├── useDeepEqualState.ts │ ├── useDocumentEventListener.ts │ ├── useEdges.ts │ ├── useFirstRender.ts │ ├── useGetBoundingClientRect.ts │ ├── useMemoizedIndexCallback.ts │ ├── useRowHeights.test.ts │ └── useRowHeights.ts ├── index.tsx ├── style.css ├── types.ts └── utils │ ├── copyPasting.test.ts │ ├── copyPasting.ts │ ├── domParser.ts │ ├── tab.ts │ └── typeCheck.ts ├── tests ├── addRows.test.tsx ├── arrows.test.tsx ├── copy.test.tsx ├── deleteContent.test.tsx ├── duplicateRow.test.tsx ├── editTextCell.test.tsx ├── escape.test.tsx ├── helpers │ ├── DataWrapper.tsx │ └── styleMock.ts ├── insertRow.test.tsx ├── paste.test.tsx ├── selectAll.test.tsx ├── setup.ts └── tab.test.tsx ├── tsconfig.json └── website ├── .gitignore ├── .nvmrc ├── README.md ├── babel.config.js ├── docs ├── api-reference │ ├── _category_.json │ ├── cell-components.mdx │ ├── columns.mdx │ ├── props.mdx │ └── ref.mdx ├── columns.mdx ├── controlling-the-grid.mdx ├── examples │ ├── _category_.json │ ├── collapsible-rows.mdx │ ├── default-values.mdx │ ├── implementing-select.mdx │ ├── infinite-scroll.mdx │ ├── tracking-rows-changes.mdx │ └── unique-ids.mdx ├── features.mdx ├── getting-started.mdx ├── i18n.mdx ├── performance │ ├── _category_.json │ ├── optimization-guidelines.mdx │ └── static-vs-dynamic.mdx ├── styling.mdx └── typescript.mdx ├── docusaurus.config.js ├── package-lock.json ├── package.json ├── sidebars.js ├── src ├── components │ ├── HomepageFeatures.js │ └── HomepageFeatures.module.css ├── css │ └── custom.css ├── demos │ ├── activeFocus.tsx │ ├── builtInColumns.tsx │ ├── collapsibleRows.tsx │ ├── defaultValues.tsx │ ├── disableColumns.tsx │ ├── infiniteScroll.tsx │ ├── lockRows.tsx │ ├── options.tsx │ ├── random.tsx │ ├── ref.tsx │ ├── reponsive.tsx │ ├── simple.tsx │ ├── sizing.css │ ├── sizing.tsx │ ├── stickyRight.tsx │ ├── styleGenerator.css │ ├── styleGenerator.tsx │ ├── trackRows.css │ ├── trackRows.tsx │ ├── underlyingData.tsx │ ├── uniqueIds.tsx │ └── xxl.tsx ├── examples │ └── selectColumn.tsx └── pages │ ├── index.js │ ├── index.module.css │ ├── markdown-page.md │ └── style.css ├── static ├── .nojekyll └── img │ ├── copy-paste.gif │ ├── custom-widgets.png │ ├── expand-selection.gif │ ├── favicon.ico │ └── logos.png └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:react-hooks/recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "prettier" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaFeatures": { 16 | "jsx": true 17 | }, 18 | "ecmaVersion": 12, 19 | "sourceType": "module" 20 | }, 21 | "plugins": [ 22 | "react", 23 | "@typescript-eslint" 24 | ], 25 | "rules": { 26 | "react/prop-types": [0], 27 | "@typescript-eslint/no-explicit-any": [0], 28 | "@typescript-eslint/explicit-module-boundary-types": [0], 29 | "@typescript-eslint/no-inferrable-types": [0], 30 | "react/no-children-prop": [0] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [ master ] 5 | pull_request: 6 | branches: [ master ] 7 | jobs: 8 | jest: 9 | name: Unit tests 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Use Node.js 14 | uses: actions/setup-node@v2 15 | with: 16 | node-version: '12' 17 | cache: 'npm' 18 | - run: npm ci 19 | - run: npm test -- --coverage 20 | - name: Repport coverage 21 | uses: coverallsapp/github-action@1.1.3 22 | with: 23 | github-token: ${{ secrets.GITHUB_TOKEN }} 24 | 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | /example/node_modules 4 | /.idea 5 | tsconfig.tsbuildinfo 6 | /coverage 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "tabWidth": 2, 5 | "bracketSpacing": true, 6 | "arrowParens": "always", 7 | "trailingComma": "es5" 8 | } 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | ## Local development 3 | You can use the [example app](example) for locale development. 4 | 5 | ## Documentation 6 | The documentation is located in the [website](website) directory. 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Nicolas Keller 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-datasheet-grid 2 | 3 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/nick-keller/react-datasheet-grid/tests.yml?branch=master) 4 | [![Coveralls](https://img.shields.io/coveralls/github/nick-keller/react-datasheet-grid)](https://coveralls.io/github/nick-keller/react-datasheet-grid) 5 | [![npm](https://img.shields.io/npm/dm/react-datasheet-grid)](https://www.npmjs.com/package/react-datasheet-grid) 6 | [![GitHub last commit](https://img.shields.io/github/last-commit/nick-keller/react-datasheet-grid)](https://github.com/nick-keller/react-datasheet-grid) 7 | ![npm bundle size](https://img.shields.io/bundlephobia/min/react-datasheet-grid) 8 | [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 9 | 10 | View [demo and documentation](https://react-datasheet-grid.netlify.app/) 11 | 12 | An Airtable-like / Excel-like component to create beautiful spreadsheets. 13 | 14 | ![Preview](./images/preview.png) 15 | 16 | Feature rich: 17 | - Dead simple to set up and to use 18 | - Supports copy / pasting to and from Excel, Google-sheet... 19 | - Keyboard navigation and shortcuts fully-supported 20 | - Supports right-clicking and custom context menu 21 | - Supports dragging corner to expand selection 22 | - Easy to extend and implement custom widgets 23 | - Blazing fast, optimized for speed, minimal renders count 24 | - Smooth animations 25 | - Virtualized rows and columns, supports hundreds of thousands of rows 26 | - Extensively customizable, controllable behaviors 27 | - Built with Typescript 28 | 29 | ## Install 30 | 31 | ```bash 32 | npm i react-datasheet-grid 33 | ``` 34 | 35 | ## Usage 36 | 37 | ```tsx 38 | import { 39 | DataSheetGrid, 40 | checkboxColumn, 41 | textColumn, 42 | keyColumn, 43 | } from 'react-datasheet-grid' 44 | 45 | // Import the style only once in your app! 46 | import 'react-datasheet-grid/dist/style.css' 47 | 48 | const Example = () => { 49 | const [ data, setData ] = useState([ 50 | { active: true, firstName: 'Elon', lastName: 'Musk' }, 51 | { active: false, firstName: 'Jeff', lastName: 'Bezos' }, 52 | ]) 53 | 54 | const columns = [ 55 | { 56 | ...keyColumn('active', checkboxColumn), 57 | title: 'Active', 58 | }, 59 | { 60 | ...keyColumn('firstName', textColumn), 61 | title: 'First name', 62 | }, 63 | { 64 | ...keyColumn('lastName', textColumn), 65 | title: 'Last name', 66 | }, 67 | ] 68 | 69 | return ( 70 | 75 | ) 76 | } 77 | ``` 78 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Example app 2 | To run the example app open two terminals: 3 | 4 | ### 1: compile in watch mode 5 | ``` 6 | npm i 7 | npm start 8 | ``` 9 | 10 | ### 2: run the example app 11 | ``` 12 | cd example 13 | npm i 14 | npm run dev 15 | ``` 16 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Example 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "react": "^18.2.0", 13 | "react-dom": "^18.2.0" 14 | }, 15 | "devDependencies": { 16 | "@types/react": "^18.0.17", 17 | "@types/react-dom": "^18.0.6", 18 | "@vitejs/plugin-react": "^2.1.0", 19 | "typescript": "^4.6.4", 20 | "vite": "^3.1.0" 21 | } 22 | } -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { 3 | checkboxColumn, 4 | Column, 5 | DataSheetGrid, 6 | keyColumn, 7 | textColumn, 8 | } from '../../src' 9 | import '../../src/style.css' 10 | 11 | type Row = { 12 | active: boolean 13 | firstName: string | null 14 | lastName: string | null 15 | } 16 | 17 | function App() { 18 | const [data, setData] = useState([ 19 | { active: true, firstName: 'Elon', lastName: 'Musk' }, 20 | { active: false, firstName: 'Jeff', lastName: 'Bezos' }, 21 | ]) 22 | 23 | const columns: Column[] = [ 24 | { 25 | ...keyColumn('active', checkboxColumn), 26 | title: 'Active', 27 | grow: 0.5, 28 | }, 29 | { 30 | ...keyColumn('firstName', textColumn), 31 | title: 'First name', 32 | }, 33 | { 34 | ...keyColumn('lastName', textColumn), 35 | title: 'Last name', 36 | grow: 2, 37 | }, 38 | ] 39 | 40 | return ( 41 |
49 | 50 |
51 | ) 52 | } 53 | 54 | export default App 55 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Helvetica, sans-serif; 3 | } 4 | -------------------------------------------------------------------------------- /example/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 7 | 8 | 9 | 10 | ) 11 | -------------------------------------------------------------------------------- /example/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /example/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /example/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | optimizeDeps: { 8 | include: [ 9 | "react-datasheet-grid" 10 | ] 11 | } 12 | }) 13 | -------------------------------------------------------------------------------- /images/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nick-keller/react-datasheet-grid/779bd227711afa89ee4901cee5de3bb0082dac4b/images/preview.png -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types' 2 | 3 | const config: Config.InitialOptions = { 4 | verbose: true, 5 | preset: 'ts-jest', 6 | testEnvironment: 'jsdom', 7 | setupFiles: ['./tests/setup.ts'], 8 | testPathIgnorePatterns: ['./dist'], 9 | collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}'], 10 | moduleNameMapper: { 11 | '\\.css$': '/tests/helpers/styleMock.ts', 12 | }, 13 | } 14 | export default config 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-datasheet-grid", 3 | "version": "4.11.5", 4 | "description": "An Excel-like React component to create beautiful spreadsheets.", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "build": "rm -f tsconfig.tsbuildinfo && rimraf ./dist && tsc && cpx \"src/**/*.css\" dist", 9 | "start": "concurrently \"tsc --watch\" \"cpx \\\"src/**/*.css\\\" dist --watch\"", 10 | "format": "prettier --write src/. && prettier --write example/src/.", 11 | "lint": "eslint src/.", 12 | "test": "jest" 13 | }, 14 | "keywords": [ 15 | "react", 16 | "reactjs", 17 | "spreadsheet", 18 | "grid", 19 | "datasheet", 20 | "excel", 21 | "airtable", 22 | "notion", 23 | "table" 24 | ], 25 | "author": "Nicolas Keller (https://github.com/nick-keller)", 26 | "license": "MIT", 27 | "repository": "nick-keller/react-datasheet-grid", 28 | "homepage": "https://react-datasheet-grid.netlify.app/", 29 | "devDependencies": { 30 | "@jest/types": "^27.4.2", 31 | "@testing-library/jest-dom": "^5.15.1", 32 | "@testing-library/react": "^13.4.0", 33 | "@testing-library/user-event": "^13.5.0", 34 | "@types/jest": "^27.4.0", 35 | "@types/jsdom": "^16.2.13", 36 | "@types/react": "^18.0.8", 37 | "@types/throttle-debounce": "^2.1.0", 38 | "@typescript-eslint/eslint-plugin": "^4.28.0", 39 | "@typescript-eslint/parser": "^4.28.0", 40 | "concurrently": "^6.2.0", 41 | "cpx": "^1.5.0", 42 | "eslint": "^7.29.0", 43 | "eslint-config-prettier": "^8.3.0", 44 | "eslint-plugin-react": "^7.29.4", 45 | "eslint-plugin-react-hooks": "^4.5.0", 46 | "jest": "^27.4.7", 47 | "jsdom": "^16.6.0", 48 | "prettier": "^2.3.1", 49 | "react": "^18.1.0", 50 | "react-dom": "^18.1.0", 51 | "resize-observer-polyfill": "^1.5.1", 52 | "rimraf": "^3.0.2", 53 | "ts-jest": "^27.0.3", 54 | "ts-node": "^10.9.1", 55 | "typescript": "^4.9.4" 56 | }, 57 | "files": [ 58 | "dist/**/*" 59 | ], 60 | "peerDependencies": { 61 | "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" 62 | }, 63 | "dependencies": { 64 | "@tanstack/react-virtual": "^3.0.0-beta.18", 65 | "classnames": "^2.3.1", 66 | "fast-deep-equal": "^3.1.3", 67 | "react-resize-detector": "^7.1.2", 68 | "throttle-debounce": "^3.0.1" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/columns/checkboxColumn.tsx: -------------------------------------------------------------------------------- 1 | import React, { useLayoutEffect, useRef } from 'react' 2 | import { CellComponent, CellProps, Column } from '../types' 3 | 4 | // Those values are used when pasting values, all those values will be considered false, any other true 5 | const FALSY = [ 6 | '', 7 | 'false', 8 | 'no', 9 | 'off', 10 | 'disabled', 11 | '0', 12 | 'n', 13 | 'f', 14 | 'unchecked', 15 | 'undefined', 16 | 'null', 17 | 'wrong', 18 | 'negative', 19 | ] 20 | 21 | const CheckboxComponent = React.memo>( 22 | ({ focus, rowData, setRowData, active, stopEditing, disabled }) => { 23 | const ref = useRef(null) 24 | 25 | // When cell becomes focus we immediately toggle the checkbox and blur the cell by calling `stopEditing` 26 | // Notice the `nextRow: false` to make sure the active cell does not go to the cell below and stays on this cell 27 | // This way the user can keep pressing Enter to toggle the checkbox on and off multiple times 28 | useLayoutEffect(() => { 29 | if (focus) { 30 | setRowData(!rowData) 31 | stopEditing({ nextRow: false }) 32 | } 33 | // eslint-disable-next-line react-hooks/exhaustive-deps 34 | }, [focus, stopEditing]) 35 | 36 | return ( 37 | !active && setRowData(!rowData)} 48 | onChange={() => null} 49 | /> 50 | ) 51 | } 52 | ) 53 | 54 | CheckboxComponent.displayName = 'CheckboxComponent' 55 | 56 | export const checkboxColumn: Partial> = { 57 | component: CheckboxComponent as CellComponent, 58 | deleteValue: () => false, 59 | // We can customize what value is copied: when the checkbox is checked we copy YES, otherwise we copy NO 60 | copyValue: ({ rowData }) => (rowData ? 'YES' : 'NO'), 61 | // Since we copy custom values, we have to make sure pasting gives us the expected result 62 | // Here NO is included in the FALSY array, so it will be converted to false, YES is not, so it will be converted to true 63 | pasteValue: ({ value }) => !FALSY.includes(value.toLowerCase()), 64 | isCellEmpty: ({ rowData }) => !rowData, 65 | } 66 | -------------------------------------------------------------------------------- /src/columns/dateColumn.tsx: -------------------------------------------------------------------------------- 1 | import React, { useLayoutEffect, useRef } from 'react' 2 | import { CellComponent, CellProps, Column } from '../types' 3 | import cx from 'classnames' 4 | 5 | const DateComponent = React.memo>( 6 | ({ focus, active, rowData, setRowData }) => { 7 | const ref = useRef(null) 8 | 9 | // This is the same trick as in `textColumn` 10 | useLayoutEffect(() => { 11 | if (focus) { 12 | ref.current?.select() 13 | } else { 14 | ref.current?.blur() 15 | } 16 | }, [focus]) 17 | 18 | return ( 19 | { 36 | const date = new Date(e.target.value) 37 | setRowData(isNaN(date.getTime()) ? null : date) 38 | }} 39 | /> 40 | ) 41 | } 42 | ) 43 | 44 | DateComponent.displayName = 'DateComponent' 45 | 46 | export const dateColumn: Partial> = { 47 | component: DateComponent as CellComponent, 48 | deleteValue: () => null, 49 | // We convert the date to a string for copying using toISOString 50 | copyValue: ({ rowData }) => 51 | rowData ? rowData.toISOString().substr(0, 10) : null, 52 | // Because the Date constructor works using iso format, we can use it to parse ISO string back to a Date object 53 | pasteValue: ({ value }) => { 54 | const date = new Date(value.replace(/\.\s?|\//g, '-')) 55 | return isNaN(date.getTime()) ? null : date 56 | }, 57 | minWidth: 170, 58 | isCellEmpty: ({ rowData }) => !rowData, 59 | } 60 | -------------------------------------------------------------------------------- /src/columns/floatColumn.tsx: -------------------------------------------------------------------------------- 1 | import { createTextColumn } from './textColumn' 2 | 3 | export const floatColumn = createTextColumn({ 4 | alignRight: true, 5 | formatBlurredInput: (value) => 6 | typeof value === 'number' ? new Intl.NumberFormat().format(value) : '', 7 | parseUserInput: (value) => { 8 | const number = parseFloat(value) 9 | return !isNaN(number) ? number : null 10 | }, 11 | parsePastedValue: (value) => { 12 | const number = parseFloat(value) 13 | return !isNaN(number) ? number : null 14 | }, 15 | }) 16 | -------------------------------------------------------------------------------- /src/columns/intColumn.tsx: -------------------------------------------------------------------------------- 1 | import { createTextColumn } from './textColumn' 2 | 3 | export const intColumn = createTextColumn({ 4 | alignRight: true, 5 | formatBlurredInput: (value) => 6 | typeof value === 'number' ? new Intl.NumberFormat().format(value) : '', 7 | parseUserInput: (value) => { 8 | const number = parseFloat(value) 9 | return !isNaN(number) ? Math.round(number) : null 10 | }, 11 | parsePastedValue: (value) => { 12 | const number = parseFloat(value) 13 | return !isNaN(number) ? Math.round(number) : null 14 | }, 15 | }) 16 | -------------------------------------------------------------------------------- /src/columns/isoDateColumn.tsx: -------------------------------------------------------------------------------- 1 | import React, { useLayoutEffect, useRef } from 'react' 2 | import { CellComponent, CellProps, Column } from '../types' 3 | import cx from 'classnames' 4 | 5 | const IsoDateComponent = React.memo>( 6 | ({ focus, active, rowData, setRowData }) => { 7 | const ref = useRef(null) 8 | 9 | // This is the same trick as in `textColumn` 10 | useLayoutEffect(() => { 11 | if (focus) { 12 | ref.current?.select() 13 | } else { 14 | ref.current?.blur() 15 | } 16 | }, [focus]) 17 | 18 | return ( 19 | { 36 | const date = new Date(e.target.value) 37 | setRowData( 38 | isNaN(date.getTime()) ? null : date.toISOString().substr(0, 10) 39 | ) 40 | }} 41 | /> 42 | ) 43 | } 44 | ) 45 | 46 | IsoDateComponent.displayName = 'IsoDateComponent' 47 | 48 | export const isoDateColumn: Partial> = { 49 | component: IsoDateComponent as CellComponent, 50 | deleteValue: () => null, 51 | copyValue: ({ rowData }) => rowData, 52 | // Because the Date constructor works using iso format, we can use it to parse ISO string back to a Date object 53 | pasteValue: ({ value }) => { 54 | const date = new Date(value.replace(/\.\s?|\//g, '-')) 55 | return isNaN(date.getTime()) ? null : date.toISOString().substr(0, 10) 56 | }, 57 | minWidth: 170, 58 | isCellEmpty: ({ rowData }) => !rowData, 59 | } 60 | -------------------------------------------------------------------------------- /src/columns/keyColumn.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useRef } from 'react' 2 | import { CellComponent, Column } from '../types' 3 | 4 | type ColumnData = { key: string; original: Partial> } 5 | 6 | const KeyComponent: CellComponent = ({ 7 | columnData: { key, original }, 8 | rowData, 9 | setRowData, 10 | ...rest 11 | }) => { 12 | // We use a ref so useCallback does not produce a new setKeyData function every time the rowData changes 13 | const rowDataRef = useRef(rowData) 14 | rowDataRef.current = rowData 15 | 16 | // We wrap the setRowData function to assign the value to the desired key 17 | const setKeyData = useCallback( 18 | (value: any) => { 19 | setRowData({ ...rowDataRef.current, [key]: value }) 20 | }, 21 | [key, setRowData] 22 | ) 23 | 24 | if (!original.component) { 25 | return <> 26 | } 27 | 28 | const Component = original.component 29 | 30 | return ( 31 | 39 | ) 40 | } 41 | 42 | export const keyColumn = < 43 | T extends Record, 44 | K extends keyof T = keyof T, 45 | PasteValue = string 46 | >( 47 | key: K, 48 | column: Partial> 49 | ): Partial> => ({ 50 | id: key as string, 51 | ...column, 52 | // We pass the key and the original column as columnData to be able to retrieve them in the cell component 53 | columnData: { key: key as string, original: column }, 54 | component: KeyComponent, 55 | // Here we simply wrap all functions to only pass the value of the desired key to the column, and not the entire row 56 | copyValue: ({ rowData, rowIndex }) => 57 | column.copyValue?.({ rowData: rowData[key], rowIndex }) ?? null, 58 | deleteValue: ({ rowData, rowIndex }) => ({ 59 | ...rowData, 60 | [key]: column.deleteValue?.({ rowData: rowData[key], rowIndex }) ?? null, 61 | }), 62 | pasteValue: ({ rowData, value, rowIndex }) => ({ 63 | ...rowData, 64 | [key]: 65 | column.pasteValue?.({ rowData: rowData[key], value, rowIndex }) ?? null, 66 | }), 67 | disabled: 68 | typeof column.disabled === 'function' 69 | ? ({ rowData, rowIndex }) => { 70 | return typeof column.disabled === 'function' 71 | ? column.disabled({ rowData: rowData[key], rowIndex }) 72 | : column.disabled ?? false 73 | } 74 | : column.disabled, 75 | cellClassName: 76 | typeof column.cellClassName === 'function' 77 | ? ({ rowData, rowIndex, columnId }) => { 78 | return typeof column.cellClassName === 'function' 79 | ? column.cellClassName({ rowData: rowData[key], rowIndex, columnId }) 80 | : column.cellClassName ?? undefined 81 | } 82 | : column.cellClassName, 83 | isCellEmpty: ({ rowData, rowIndex }) => 84 | column.isCellEmpty?.({ rowData: rowData[key], rowIndex }) ?? false, 85 | }) 86 | -------------------------------------------------------------------------------- /src/columns/percentColumn.tsx: -------------------------------------------------------------------------------- 1 | import { createTextColumn } from './textColumn' 2 | 3 | const TEN_TO_THE_12 = 1000000000000 4 | const TEN_TO_THE_10 = 10000000000 5 | 6 | export const percentColumn = createTextColumn({ 7 | alignRight: true, 8 | formatBlurredInput: (value) => 9 | typeof value === 'number' 10 | ? new Intl.NumberFormat(undefined, { style: 'percent' }).format(value) 11 | : '', 12 | // We turn percentages (numbers between 0 and 1) into string (between 0 and 100) 13 | // We could have just multiply percentages by 100, but floating point arithmetic won't work as expected: 0.29 * 100 === 28.999999999999996 14 | // So we have to round those numbers to 10 decimals before turning them into strings 15 | formatInputOnFocus: (value) => 16 | typeof value === 'number' && !isNaN(value) 17 | ? String(Math.round(value * TEN_TO_THE_12) / TEN_TO_THE_10) 18 | : '', 19 | parseUserInput: (value) => { 20 | const number = parseFloat(value) 21 | return !isNaN(number) ? number / 100 : null 22 | }, 23 | parsePastedValue: (value) => { 24 | const number = parseFloat(value) 25 | return !isNaN(number) ? number : null 26 | }, 27 | }) 28 | -------------------------------------------------------------------------------- /src/columns/textColumn.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useLayoutEffect, useRef } from 'react' 2 | import { CellComponent, CellProps, Column } from '../types' 3 | import cx from 'classnames' 4 | import { useFirstRender } from '../hooks/useFirstRender' 5 | 6 | type TextColumnOptions = { 7 | placeholder?: string 8 | alignRight?: boolean 9 | // When true, data is updated as the user types, otherwise it is only updated on blur. Default to true 10 | continuousUpdates?: boolean 11 | // Value to use when deleting the cell 12 | deletedValue?: T 13 | // Parse what the user types 14 | parseUserInput?: (value: string) => T 15 | // Format the value of the input when it is blurred 16 | formatBlurredInput?: (value: T) => string 17 | // Format the value of the input when it gets focused 18 | formatInputOnFocus?: (value: T) => string 19 | // Format the value when copy 20 | formatForCopy?: (value: T) => string 21 | // Parse the pasted value 22 | parsePastedValue?: (value: string) => T 23 | } 24 | 25 | type TextColumnData = { 26 | placeholder?: string 27 | alignRight: boolean 28 | continuousUpdates: boolean 29 | parseUserInput: (value: string) => T 30 | formatBlurredInput: (value: T) => string 31 | formatInputOnFocus: (value: T) => string 32 | } 33 | 34 | const TextComponent = React.memo< 35 | CellProps> 36 | >( 37 | ({ 38 | active, 39 | focus, 40 | rowData, 41 | setRowData, 42 | columnData: { 43 | placeholder, 44 | alignRight, 45 | formatInputOnFocus, 46 | formatBlurredInput, 47 | parseUserInput, 48 | continuousUpdates, 49 | }, 50 | }) => { 51 | const ref = useRef(null) 52 | const firstRender = useFirstRender() 53 | 54 | // We create refs for async access so we don't have to add it to the useEffect dependencies 55 | const asyncRef = useRef({ 56 | rowData, 57 | formatInputOnFocus, 58 | formatBlurredInput, 59 | setRowData, 60 | parseUserInput, 61 | continuousUpdates, 62 | firstRender, 63 | // Timestamp of last focus (when focus becomes true) and last change (input change) 64 | // used to prevent un-necessary updates when value was not changed 65 | focusedAt: 0, 66 | changedAt: 0, 67 | // This allows us to keep track of whether or not the user blurred the input using the Esc key 68 | // If the Esc key is used we do not update the row's value (only relevant when continuousUpdates is false) 69 | escPressed: false, 70 | }) 71 | asyncRef.current = { 72 | rowData, 73 | formatInputOnFocus, 74 | formatBlurredInput, 75 | setRowData, 76 | parseUserInput, 77 | continuousUpdates, 78 | firstRender, 79 | // Keep the same values across renders 80 | focusedAt: asyncRef.current.focusedAt, 81 | changedAt: asyncRef.current.changedAt, 82 | escPressed: asyncRef.current.escPressed, 83 | } 84 | 85 | useLayoutEffect(() => { 86 | // When the cell gains focus we make sure to immediately select the text in the input: 87 | // - If the user gains focus by typing, it will replace the existing text, as expected 88 | // - If the user gains focus by clicking or pressing Enter, the text will be preserved and selected 89 | if (focus) { 90 | if (ref.current) { 91 | // Make sure to first format the input 92 | ref.current.value = asyncRef.current.formatInputOnFocus( 93 | asyncRef.current.rowData 94 | ) 95 | ref.current.focus() 96 | ref.current.select() 97 | } 98 | 99 | // We immediately reset the escPressed 100 | asyncRef.current.escPressed = false 101 | // Save current timestamp 102 | asyncRef.current.focusedAt = Date.now() 103 | } 104 | // When the cell looses focus (by pressing Esc, Enter, clicking away...) we make sure to blur the input 105 | // Otherwise the user would still see the cursor blinking 106 | else { 107 | if (ref.current) { 108 | // Update the row's value on blur only if the user did not press escape (only relevant when continuousUpdates is false) 109 | if ( 110 | !asyncRef.current.escPressed && 111 | !asyncRef.current.continuousUpdates && 112 | !asyncRef.current.firstRender && 113 | // Make sure that focus was gained more than 10 ms ago, used to prevent flickering 114 | asyncRef.current.changedAt >= asyncRef.current.focusedAt 115 | ) { 116 | asyncRef.current.setRowData( 117 | asyncRef.current.parseUserInput(ref.current.value) 118 | ) 119 | } 120 | ref.current.blur() 121 | } 122 | } 123 | }, [focus]) 124 | 125 | useEffect(() => { 126 | if (!focus && ref.current) { 127 | // On blur or when the data changes, format it for display 128 | ref.current.value = asyncRef.current.formatBlurredInput(rowData) 129 | } 130 | }, [focus, rowData]) 131 | 132 | return ( 133 | { 146 | asyncRef.current.changedAt = Date.now() 147 | 148 | // Only update the row's value as the user types if continuousUpdates is true 149 | if (continuousUpdates) { 150 | setRowData(parseUserInput(e.target.value)) 151 | } 152 | }} 153 | onKeyDown={(e) => { 154 | // Track when user presses the Esc key 155 | if (e.key === 'Escape') { 156 | asyncRef.current.escPressed = true 157 | } 158 | }} 159 | /> 160 | ) 161 | } 162 | ) 163 | 164 | TextComponent.displayName = 'TextComponent' 165 | 166 | export const textColumn = createTextColumn() 167 | 168 | export function createTextColumn({ 169 | placeholder, 170 | alignRight = false, 171 | continuousUpdates = true, 172 | deletedValue = null as unknown as T, 173 | parseUserInput = (value) => (value.trim() || null) as unknown as T, 174 | formatBlurredInput = (value) => String(value ?? ''), 175 | formatInputOnFocus = (value) => String(value ?? ''), 176 | formatForCopy = (value) => String(value ?? ''), 177 | parsePastedValue = (value) => 178 | (value.replace(/[\n\r]+/g, ' ').trim() || (null as unknown)) as T, 179 | }: TextColumnOptions = {}): Partial, string>> { 180 | return { 181 | component: TextComponent as unknown as CellComponent>, 182 | columnData: { 183 | placeholder, 184 | alignRight, 185 | continuousUpdates, 186 | formatInputOnFocus, 187 | formatBlurredInput, 188 | parseUserInput, 189 | }, 190 | deleteValue: () => deletedValue, 191 | copyValue: ({ rowData }) => formatForCopy(rowData), 192 | pasteValue: ({ value }) => parsePastedValue(value), 193 | isCellEmpty: ({ rowData }) => rowData === null || rowData === undefined, 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/components/AddRows.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import '@testing-library/jest-dom' 3 | import userEvent from '@testing-library/user-event' 4 | import { act, render, screen } from '@testing-library/react' 5 | import { AddRows } from './AddRows' 6 | 7 | test('Has correct classes', () => { 8 | render( null} />) 9 | const button = screen.getByRole('button') 10 | const input = screen.getByRole('spinbutton') 11 | 12 | expect(button).toHaveClass('dsg-add-row-btn') 13 | expect(input).toHaveClass('dsg-add-row-input') 14 | }) 15 | 16 | test('Calls addRows', () => { 17 | const addRows = jest.fn() 18 | render() 19 | const button = screen.getByRole('button') 20 | const input = screen.getByRole('spinbutton') 21 | 22 | userEvent.click(button) 23 | expect(addRows).toHaveBeenLastCalledWith(1) 24 | 25 | userEvent.type(input, '{selectall}5') 26 | userEvent.click(button) 27 | expect(addRows).toHaveBeenLastCalledWith(5) 28 | 29 | userEvent.type(input, '{selectall}{backspace}{enter}') 30 | expect(addRows).toHaveBeenLastCalledWith(1) 31 | }) 32 | 33 | test('Resets on blur when value is invalid', () => { 34 | render( null} />) 35 | const input = screen.getByRole('spinbutton') as HTMLInputElement 36 | // Force the input to be of type "text" to test what happens if the user types in non-number characters 37 | input.type = 'text' 38 | 39 | act(() => { 40 | userEvent.type(input, '{selectall}{backspace}') 41 | input.blur() 42 | }) 43 | expect(input.value).toBe('1') 44 | 45 | act(() => { 46 | userEvent.type(input, '{selectall}456xyz') 47 | input.blur() 48 | }) 49 | expect(input.value).toBe('456') 50 | 51 | act(() => { 52 | userEvent.type(input, '{selectall}abc') 53 | input.blur() 54 | }) 55 | expect(input.value).toBe('1') 56 | }) 57 | -------------------------------------------------------------------------------- /src/components/AddRows.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState } from 'react' 2 | import { AddRowsComponentProps } from '../types' 3 | 4 | export const createAddRowsComponent = 5 | ( 6 | translationKeys: { button?: string; unit?: string } = {} 7 | ): FC => 8 | // eslint-disable-next-line react/display-name 9 | ({ addRows }) => { 10 | const [value, setValue] = useState(1) 11 | const [rawValue, setRawValue] = useState(String(value)) 12 | 13 | return ( 14 |
15 | {' '} 22 | setRawValue(String(value))} 26 | type="number" 27 | min={1} 28 | onChange={(e) => { 29 | setRawValue(e.target.value) 30 | setValue(Math.max(1, Math.round(parseInt(e.target.value) || 0))) 31 | }} 32 | onKeyDown={(event) => { 33 | if (event.key === 'Enter') { 34 | addRows(value) 35 | } 36 | }} 37 | />{' '} 38 | {translationKeys.unit ?? 'rows'} 39 |
40 | ) 41 | } 42 | 43 | export const AddRows = createAddRowsComponent() 44 | 45 | AddRows.displayName = 'AddRows' 46 | -------------------------------------------------------------------------------- /src/components/Cell.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import cx from 'classnames' 3 | 4 | export const Cell: FC<{ 5 | gutter: boolean 6 | stickyRight: boolean 7 | disabled?: boolean 8 | className?: string 9 | active?: boolean 10 | children?: any 11 | width: number 12 | left: number 13 | }> = ({ 14 | children, 15 | gutter, 16 | stickyRight, 17 | active, 18 | disabled, 19 | className, 20 | width, 21 | left, 22 | }) => { 23 | return ( 24 |
38 | {children} 39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/components/ContextMenu.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import '@testing-library/jest-dom' 3 | import userEvent from '@testing-library/user-event' 4 | import { render, screen } from '@testing-library/react' 5 | import { ContextMenu } from './ContextMenu' 6 | import { MatcherFunction } from '@testing-library/dom/types/matches' 7 | 8 | test('Closes properly', () => { 9 | const onClose = jest.fn() 10 | const { container } = render( 11 | 18 | ) 19 | userEvent.click(container) 20 | expect(onClose).toHaveBeenCalled() 21 | }) 22 | 23 | test('Click on item', () => { 24 | const onClose = jest.fn() 25 | const onInsertRowBelow = jest.fn() 26 | render( 27 | 34 | ) 35 | userEvent.click(screen.getByText('Insert row below')) 36 | expect(onInsertRowBelow).toHaveBeenCalled() 37 | expect(onClose).not.toHaveBeenCalled() 38 | }) 39 | 40 | const textContentMatcher = (text: string): MatcherFunction => { 41 | const hasText = (node: Element | null) => node?.textContent === text 42 | 43 | return function (_, node) { 44 | const nodeHasText = hasText(node) 45 | const childrenDontHaveText = Array.from(node?.children ?? []).every( 46 | (child) => !hasText(child) 47 | ) 48 | return nodeHasText && childrenDontHaveText 49 | } 50 | } 51 | 52 | test('Check all items', () => { 53 | render( 54 | null }, 60 | { type: 'DELETE_ROW', action: () => null }, 61 | { type: 'DUPLICATE_ROW', action: () => null }, 62 | { type: 'DELETE_ROWS', fromRow: 1, toRow: 3, action: () => null }, 63 | { type: 'DUPLICATE_ROWS', fromRow: 5, toRow: 7, action: () => null }, 64 | ]} 65 | close={() => null} 66 | /> 67 | ) 68 | expect(screen.getByText('Insert row below')).toBeInTheDocument() 69 | expect(screen.getByText('Delete row')).toBeInTheDocument() 70 | expect(screen.getByText('Duplicate row')).toBeInTheDocument() 71 | expect( 72 | screen.getByText(textContentMatcher('Delete rows 1 to 3')) 73 | ).toBeInTheDocument() 74 | expect( 75 | screen.getByText(textContentMatcher('Duplicate rows 5 to 7')) 76 | ).toBeInTheDocument() 77 | }) 78 | 79 | test('Fallback for unknown item', () => { 80 | render( 81 | null }, 89 | ]} 90 | close={() => null} 91 | /> 92 | ) 93 | expect(screen.getByText('UNKNOWN_ITEM')).toBeInTheDocument() 94 | }) 95 | -------------------------------------------------------------------------------- /src/components/ContextMenu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { FC, useCallback, useRef } from 'react' 3 | import { useDocumentEventListener } from '../hooks/useDocumentEventListener' 4 | import { ContextMenuItem, ContextMenuComponentProps } from '../types' 5 | 6 | export const defaultRenderItem = (item: ContextMenuItem) => { 7 | if (item.type === 'CUT') { 8 | return <>Cut 9 | } 10 | 11 | if (item.type === 'COPY') { 12 | return <>Copy 13 | } 14 | 15 | if (item.type === 'PASTE') { 16 | return <>Paste 17 | } 18 | 19 | if (item.type === 'DELETE_ROW') { 20 | return <>Delete row 21 | } 22 | 23 | if (item.type === 'DELETE_ROWS') { 24 | return ( 25 | <> 26 | Delete rows {item.fromRow} to {item.toRow} 27 | 28 | ) 29 | } 30 | 31 | if (item.type === 'INSERT_ROW_BELLOW') { 32 | return <>Insert row below 33 | } 34 | 35 | if (item.type === 'DUPLICATE_ROW') { 36 | return <>Duplicate row 37 | } 38 | 39 | if (item.type === 'DUPLICATE_ROWS') { 40 | return ( 41 | <> 42 | Duplicate rows {item.fromRow} to {item.toRow} 43 | 44 | ) 45 | } 46 | 47 | return item.type 48 | } 49 | 50 | export const createContextMenuComponent = 51 | ( 52 | renderItem: (item: ContextMenuItem) => JSX.Element = defaultRenderItem 53 | ): FC => 54 | // eslint-disable-next-line react/display-name 55 | ({ clientX, clientY, items, close }) => { 56 | const containerRef = useRef(null) 57 | 58 | const onClickOutside = useCallback( 59 | (event: MouseEvent) => { 60 | const clickInside = containerRef.current?.contains(event.target as Node) 61 | 62 | if (!clickInside) { 63 | close() 64 | } 65 | }, 66 | [close] 67 | ) 68 | useDocumentEventListener('mousedown', onClickOutside) 69 | 70 | return ( 71 |
76 | {items.map((item) => ( 77 |
82 | {renderItem(item)} 83 |
84 | ))} 85 |
86 | ) 87 | } 88 | 89 | export const ContextMenu = createContextMenuComponent(defaultRenderItem) 90 | 91 | ContextMenu.displayName = 'ContextMenu' 92 | -------------------------------------------------------------------------------- /src/components/StaticDataSheetGrid.tsx: -------------------------------------------------------------------------------- 1 | import { DataSheetGridProps, DataSheetGridRef } from '../types' 2 | import { useState } from 'react' 3 | import { DataSheetGrid } from './DataSheetGrid' 4 | import React from 'react' 5 | 6 | export const StaticDataSheetGrid = React.forwardRef< 7 | DataSheetGridRef, 8 | DataSheetGridProps 9 | >( 10 | ( 11 | { 12 | columns, 13 | gutterColumn, 14 | stickyRightColumn, 15 | addRowsComponent, 16 | createRow, 17 | duplicateRow, 18 | style, 19 | rowKey, 20 | onFocus, 21 | onBlur, 22 | onActiveCellChange, 23 | onSelectionChange, 24 | rowClassName, 25 | rowHeight, 26 | ...rest 27 | }: DataSheetGridProps, 28 | ref: React.ForwardedRef 29 | ) => { 30 | const [staticProps] = useState({ 31 | columns, 32 | gutterColumn, 33 | stickyRightColumn, 34 | addRowsComponent, 35 | createRow, 36 | duplicateRow, 37 | style, 38 | rowKey, 39 | onFocus, 40 | onBlur, 41 | onActiveCellChange, 42 | onSelectionChange, 43 | rowClassName, 44 | rowHeight, 45 | }) 46 | 47 | return ( 48 | 56 | ) 57 | } 58 | ) as ( 59 | props: DataSheetGridProps & { ref?: React.ForwardedRef } 60 | ) => JSX.Element 61 | -------------------------------------------------------------------------------- /src/hooks/useColumnWidths.ts: -------------------------------------------------------------------------------- 1 | import { Column } from '../types' 2 | import { useMemo } from 'react' 3 | 4 | export const getColumnWidths = ( 5 | containerWidth: number, 6 | columns: Pick< 7 | Column, 8 | 'basis' | 'grow' | 'shrink' | 'minWidth' | 'maxWidth' 9 | >[] 10 | ) => { 11 | const items = columns.map(({ basis, minWidth, maxWidth }) => ({ 12 | basis, 13 | minWidth, 14 | maxWidth, 15 | size: basis, 16 | violation: 0, 17 | frozen: false, 18 | factor: 0, 19 | })) 20 | 21 | let availableWidth = items.reduce( 22 | (acc, cur) => acc - cur.size, 23 | containerWidth 24 | ) 25 | 26 | if (availableWidth > 0) { 27 | columns.forEach(({ grow }, i) => { 28 | items[i].factor = grow 29 | }) 30 | } else if (availableWidth < 0) { 31 | columns.forEach(({ shrink }, i) => { 32 | items[i].factor = shrink 33 | }) 34 | } 35 | 36 | for (const item of items) { 37 | if (item.factor === 0) { 38 | item.frozen = true 39 | } 40 | } 41 | 42 | while (items.some(({ frozen }) => !frozen)) { 43 | const sumFactors = items.reduce( 44 | (acc, cur) => acc + (cur.frozen ? 0 : cur.factor), 45 | 0 46 | ) 47 | 48 | let totalViolation = 0 49 | 50 | for (const item of items) { 51 | if (!item.frozen) { 52 | item.size += (availableWidth * item.factor) / sumFactors 53 | 54 | if (item.size < item.minWidth) { 55 | item.violation = item.minWidth - item.size 56 | } else if (item.maxWidth !== undefined && item.size > item.maxWidth) { 57 | item.violation = item.maxWidth - item.size 58 | } else { 59 | item.violation = 0 60 | } 61 | 62 | item.size += item.violation 63 | totalViolation += item.violation 64 | } 65 | } 66 | 67 | if (totalViolation > 0) { 68 | for (const item of items) { 69 | if (item.violation > 0) { 70 | item.frozen = true 71 | } 72 | } 73 | } else if (totalViolation < 0) { 74 | for (const item of items) { 75 | if (item.violation < 0) { 76 | item.frozen = true 77 | } 78 | } 79 | } else { 80 | break 81 | } 82 | 83 | availableWidth = items.reduce((acc, cur) => acc - cur.size, containerWidth) 84 | } 85 | 86 | return items.map(({ size }) => size) 87 | } 88 | 89 | export const useColumnWidths = ( 90 | columns: Column[], 91 | width?: number 92 | ) => { 93 | const columnsHash = columns 94 | .map(({ basis, minWidth, maxWidth, grow, shrink }) => 95 | [basis, minWidth, maxWidth, grow, shrink].join(',') 96 | ) 97 | .join('|') 98 | 99 | return useMemo(() => { 100 | if (width === undefined) { 101 | return { 102 | fullWidth: false, 103 | columnWidths: undefined, 104 | columnRights: undefined, 105 | totalWidth: undefined, 106 | } 107 | } 108 | 109 | const columnWidths = getColumnWidths(width, columns) 110 | 111 | let totalWidth = 0 112 | 113 | const columnRights = columnWidths.map((w, i) => { 114 | totalWidth += w 115 | return i === columnWidths.length - 1 ? Infinity : totalWidth 116 | }) 117 | 118 | return { 119 | fullWidth: Math.abs(width - totalWidth) < 0.1, 120 | columnWidths, 121 | columnRights, 122 | totalWidth, 123 | } 124 | // eslint-disable-next-line react-hooks/exhaustive-deps 125 | }, [width, columnsHash]) 126 | } 127 | -------------------------------------------------------------------------------- /src/hooks/useColumns.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react' 2 | import { CellProps, Column, SimpleColumn } from '../types' 3 | 4 | const defaultComponent = () => <> 5 | const defaultIsCellEmpty = () => false 6 | const identityRow = ({ rowData }: { rowData: T }) => rowData 7 | const defaultCopyValue = () => null 8 | const defaultGutterComponent = ({ rowIndex }: CellProps) => ( 9 | <>{rowIndex + 1} 10 | ) 11 | const cellAlwaysEmpty = () => true 12 | const defaultPrePasteValues = (values: string[]) => values 13 | 14 | export const parseFlexValue = (value: string | number) => { 15 | if (typeof value === 'number') { 16 | return { 17 | basis: 0, 18 | grow: value, 19 | shrink: 1, 20 | } 21 | } 22 | 23 | if (value.match(/^ *\d+(\.\d*)? *$/)) { 24 | return { 25 | basis: 0, 26 | grow: parseFloat(value.trim()), 27 | shrink: 1, 28 | } 29 | } 30 | 31 | if (value.match(/^ *\d+(\.\d*)? *px *$/)) { 32 | return { 33 | basis: parseFloat(value.trim()), 34 | grow: 1, 35 | shrink: 1, 36 | } 37 | } 38 | 39 | if (value.match(/^ *\d+(\.\d*)? \d+(\.\d*)? *$/)) { 40 | const [grow, shrink] = value.trim().split(' ') 41 | return { 42 | basis: 0, 43 | grow: parseFloat(grow), 44 | shrink: parseFloat(shrink), 45 | } 46 | } 47 | 48 | if (value.match(/^ *\d+(\.\d*)? \d+(\.\d*)? *px *$/)) { 49 | const [grow, basis] = value.trim().split(' ') 50 | return { 51 | basis: parseFloat(basis), 52 | grow: parseFloat(grow), 53 | shrink: 1, 54 | } 55 | } 56 | 57 | if (value.match(/^ *\d+(\.\d*)? \d+(\.\d*)? \d+(\.\d*)? *px *$/)) { 58 | const [grow, shrink, basis] = value.trim().split(' ') 59 | return { 60 | basis: parseFloat(basis), 61 | grow: parseFloat(grow), 62 | shrink: parseFloat(shrink), 63 | } 64 | } 65 | 66 | return { 67 | basis: 0, 68 | grow: 1, 69 | shrink: 1, 70 | } 71 | } 72 | 73 | export const useColumns = ( 74 | columns: Partial>[], 75 | gutterColumn?: SimpleColumn | false, 76 | stickyRightColumn?: SimpleColumn 77 | ): Column[] => { 78 | return useMemo[]>(() => { 79 | const partialColumns: Partial>[] = [ 80 | gutterColumn === false 81 | ? { 82 | basis: 0, 83 | grow: 0, 84 | shrink: 0, 85 | minWidth: 0, 86 | // eslint-disable-next-line react/display-name 87 | component: () => <>, 88 | headerClassName: 'dsg-hidden-cell', 89 | cellClassName: 'dsg-hidden-cell', 90 | isCellEmpty: cellAlwaysEmpty, 91 | } 92 | : { 93 | ...gutterColumn, 94 | basis: gutterColumn?.basis ?? 40, 95 | grow: gutterColumn?.grow ?? 0, 96 | shrink: gutterColumn?.shrink ?? 0, 97 | minWidth: gutterColumn?.minWidth ?? 0, 98 | title: gutterColumn?.title ?? ( 99 |
100 | ), 101 | component: gutterColumn?.component ?? defaultGutterComponent, 102 | isCellEmpty: cellAlwaysEmpty, 103 | }, 104 | ...columns, 105 | ] 106 | 107 | if (stickyRightColumn) { 108 | partialColumns.push({ 109 | ...stickyRightColumn, 110 | basis: stickyRightColumn?.basis ?? 40, 111 | grow: stickyRightColumn?.grow ?? 0, 112 | shrink: stickyRightColumn?.shrink ?? 0, 113 | minWidth: stickyRightColumn.minWidth ?? 0, 114 | isCellEmpty: cellAlwaysEmpty, 115 | }) 116 | } 117 | 118 | return partialColumns.map>((column) => { 119 | const legacyWidth = 120 | column.width !== undefined 121 | ? parseFlexValue(column.width) 122 | : { 123 | basis: undefined, 124 | grow: undefined, 125 | shrink: undefined, 126 | } 127 | 128 | return { 129 | ...column, 130 | basis: column.basis ?? legacyWidth.basis ?? 0, 131 | grow: column.grow ?? legacyWidth.grow ?? 1, 132 | shrink: column.shrink ?? legacyWidth.shrink ?? 1, 133 | minWidth: column.minWidth ?? 100, 134 | component: column.component ?? defaultComponent, 135 | disableKeys: column.disableKeys ?? false, 136 | disabled: column.disabled ?? false, 137 | keepFocus: column.keepFocus ?? false, 138 | deleteValue: column.deleteValue ?? identityRow, 139 | copyValue: column.copyValue ?? defaultCopyValue, 140 | pasteValue: column.pasteValue ?? identityRow, 141 | prePasteValues: column.prePasteValues ?? defaultPrePasteValues, 142 | isCellEmpty: column.isCellEmpty ?? defaultIsCellEmpty, 143 | } 144 | }) 145 | }, [gutterColumn, stickyRightColumn, columns]) 146 | } 147 | -------------------------------------------------------------------------------- /src/hooks/useDebounceState.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useRef, useState } from 'react' 2 | import { debounce } from 'throttle-debounce' 3 | 4 | export const useDebounceState = ( 5 | defaultValue: T, 6 | delay: number 7 | ): [T, (nextVal: T) => void] => { 8 | const [debouncedValue, setDebouncedValue] = useState(defaultValue) 9 | const cancelRef = useRef void>>() 10 | 11 | useEffect(() => () => cancelRef.current?.cancel(), []) 12 | 13 | const setValue = useMemo( 14 | () => 15 | (cancelRef.current = debounce(delay, (newValue: T) => { 16 | setDebouncedValue(newValue) 17 | })), 18 | [delay] 19 | ) 20 | 21 | return [debouncedValue, setValue] 22 | } 23 | -------------------------------------------------------------------------------- /src/hooks/useDeepEqualState.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useCallback, useState } from 'react' 2 | import deepEqual from 'fast-deep-equal' 3 | 4 | export const useDeepEqualState = ( 5 | defaultValue: T 6 | ): [T, Dispatch>] => { 7 | const [value, setValue] = useState(defaultValue) 8 | 9 | const customSetValue = useCallback( 10 | (newValue: SetStateAction) => { 11 | setValue((prevValue) => { 12 | const nextValue = 13 | typeof newValue === 'function' 14 | ? (newValue as (prev: T) => T)(prevValue) 15 | : newValue 16 | 17 | return deepEqual(nextValue, prevValue) ? prevValue : nextValue 18 | }) 19 | }, 20 | [setValue] 21 | ) 22 | 23 | return [value, customSetValue] 24 | } 25 | -------------------------------------------------------------------------------- /src/hooks/useDocumentEventListener.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | export const useDocumentEventListener = ( 4 | type: string, 5 | listener: (...args: any[]) => void 6 | ) => { 7 | useEffect(() => { 8 | document.addEventListener(type, listener) 9 | 10 | return () => { 11 | document.removeEventListener(type, listener) 12 | } 13 | }, [listener, type]) 14 | } 15 | -------------------------------------------------------------------------------- /src/hooks/useEdges.ts: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { throttle } from 'throttle-debounce' 3 | import { useDeepEqualState } from './useDeepEqualState' 4 | 5 | export const useEdges = ( 6 | ref: React.RefObject, 7 | width?: number, 8 | height?: number 9 | ) => { 10 | const [edges, setEdges] = useDeepEqualState({ 11 | top: true, 12 | right: true, 13 | bottom: true, 14 | left: true, 15 | }) 16 | 17 | useEffect(() => { 18 | const onScroll = throttle(100, () => { 19 | setEdges({ 20 | top: ref.current?.scrollTop === 0, 21 | right: 22 | (ref.current?.scrollLeft ?? 0) >= 23 | (ref.current?.scrollWidth ?? 0) - (width ?? 0) - 1, 24 | bottom: 25 | (ref.current?.scrollTop ?? 0) >= 26 | (ref.current?.scrollHeight ?? 0) - (height ?? 0) - 1, 27 | left: ref.current?.scrollLeft === 0, 28 | }) 29 | }) 30 | 31 | const current = ref.current 32 | current?.addEventListener('scroll', onScroll) 33 | setTimeout(onScroll, 100) 34 | 35 | return () => { 36 | current?.removeEventListener('scroll', onScroll) 37 | onScroll.cancel() 38 | } 39 | }, [height, width, ref, setEdges]) 40 | 41 | return edges 42 | } 43 | -------------------------------------------------------------------------------- /src/hooks/useFirstRender.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react' 2 | 3 | export const useFirstRender = () => { 4 | const firstRenderRef = useRef(true) 5 | const firstRender = firstRenderRef.current 6 | firstRenderRef.current = false 7 | 8 | return firstRender 9 | } 10 | -------------------------------------------------------------------------------- /src/hooks/useGetBoundingClientRect.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useCallback, useMemo, useRef } from 'react' 2 | import { throttle } from 'throttle-debounce' 3 | 4 | // Cache bounding rect in a ref and only recompute every ms 5 | export const useGetBoundingClientRect = ( 6 | ref: RefObject, 7 | delay = 200 8 | ) => { 9 | const boundingRect = useRef(null) 10 | 11 | const throttledCompute = useMemo( 12 | () => 13 | throttle(delay, true, () => { 14 | setTimeout( 15 | () => 16 | (boundingRect.current = 17 | ref.current?.getBoundingClientRect() || null), 18 | 0 19 | ) 20 | }), 21 | [ref, delay] 22 | ) 23 | 24 | return useCallback( 25 | (force = false) => { 26 | if (force) { 27 | boundingRect.current = ref.current?.getBoundingClientRect() || null 28 | } else { 29 | throttledCompute() 30 | } 31 | return boundingRect.current 32 | }, 33 | [ref, throttledCompute] 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/hooks/useMemoizedIndexCallback.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | 3 | export const useMemoizedIndexCallback = >( 4 | callbackFn: (index: number, ...args: T) => void, 5 | argsLength: number 6 | ) => { 7 | return useMemo(() => { 8 | const cache = new Map void>() 9 | 10 | return (index: number) => { 11 | if (!cache.has(index)) { 12 | cache.set(index, (...args) => { 13 | callbackFn(index, ...(args.slice(0, argsLength) as T)) 14 | }) 15 | } 16 | 17 | return cache.get(index) as (...args: T) => void 18 | } 19 | }, [argsLength, callbackFn]) 20 | } 21 | -------------------------------------------------------------------------------- /src/hooks/useRowHeights.test.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useState, useMemo } from 'react' 2 | import { useRowHeights } from './useRowHeights' 3 | jest.mock('react') 4 | 5 | const useRefMock = useRef as unknown as jest.Mock<{ 6 | current: { height: number; top: number }[] 7 | }> 8 | const useStateMock = useState as unknown as jest.Mock 9 | const useMemoMock = useMemo as unknown as jest.Mock 10 | 11 | const createSizes = (...heights: number[]) => { 12 | let bottom = 0 13 | 14 | return heights.map((height) => { 15 | bottom += height 16 | return { height, top: bottom - height } 17 | }) 18 | } 19 | 20 | const fromSizes = (heights: number[], calculated = heights.length) => { 21 | useRefMock.mockReturnValue({ 22 | current: createSizes(...heights.slice(0, calculated)), 23 | }) 24 | 25 | // eslint-disable-next-line react-hooks/rules-of-hooks 26 | return useRowHeights({ 27 | value: new Array(heights.length).fill(0), 28 | rowHeight: ({ rowIndex }) => heights[rowIndex], 29 | }) 30 | } 31 | 32 | beforeEach(() => { 33 | useStateMock.mockReturnValue([null, () => null]) 34 | useMemoMock.mockImplementation((cb) => cb()) 35 | }) 36 | 37 | describe('getRowIndex', () => { 38 | test('Right on top', () => { 39 | const { getRowIndex } = fromSizes([2, 3, 4]) 40 | 41 | expect(getRowIndex(0)).toBe(0) 42 | expect(getRowIndex(2)).toBe(1) 43 | expect(getRowIndex(5)).toBe(2) 44 | }) 45 | 46 | test('Between tops', () => { 47 | const { getRowIndex } = fromSizes([2, 3, 4]) 48 | 49 | expect(getRowIndex(1)).toBe(0) 50 | expect(getRowIndex(4)).toBe(1) 51 | }) 52 | 53 | test('Negative', () => { 54 | const { getRowIndex } = fromSizes([2, 3, 4]) 55 | 56 | expect(getRowIndex(-1)).toBe(-1) 57 | expect(getRowIndex(-100)).toBe(-1) 58 | }) 59 | 60 | test('Beyond', () => { 61 | const { getRowIndex } = fromSizes([2, 3, 4]) 62 | 63 | expect(getRowIndex(6)).toBe(2) 64 | expect(getRowIndex(100)).toBe(2) 65 | }) 66 | 67 | test('No row calculated right on top', () => { 68 | const { getRowIndex } = fromSizes([2, 3, 4], 0) 69 | 70 | expect(getRowIndex(0)).toBe(0) 71 | expect(getRowIndex(2)).toBe(1) 72 | expect(getRowIndex(5)).toBe(2) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /src/hooks/useRowHeights.ts: -------------------------------------------------------------------------------- 1 | import { DataSheetGridProps } from '../types' 2 | import { useMemo, useRef, useState } from 'react' 3 | 4 | type RowSize = { height: number; top: number } 5 | 6 | export const useRowHeights = ({ 7 | value, 8 | rowHeight, 9 | }: Required, 'value' | 'rowHeight'>>) => { 10 | const calculatedHeights = useRef([]) 11 | const [, rerender] = useState(0) 12 | 13 | return useMemo(() => { 14 | const getRowIndex = (top: number): number => { 15 | if (typeof rowHeight === 'number') { 16 | return Math.min( 17 | value.length - 1, 18 | Math.max(-1, Math.floor(top / rowHeight)) 19 | ) 20 | } 21 | 22 | let l = 0 23 | let r = calculatedHeights.current.length - 1 24 | 25 | while (l <= r) { 26 | const m = Math.floor((l + r) / 2) 27 | 28 | if (calculatedHeights.current[m].top < top) { 29 | l = m + 1 30 | } else if (calculatedHeights.current[m].top > top) { 31 | r = m - 1 32 | } else { 33 | return m 34 | } 35 | } 36 | 37 | if ( 38 | r === calculatedHeights.current.length - 1 && 39 | value.length > calculatedHeights.current.length && 40 | (!calculatedHeights.current.length || 41 | top >= 42 | calculatedHeights.current[r].top + 43 | calculatedHeights.current[r].height) 44 | ) { 45 | let lastBottom = 46 | r === -1 47 | ? 0 48 | : calculatedHeights.current[r].top + 49 | calculatedHeights.current[r].height 50 | 51 | do { 52 | r++ 53 | const height = rowHeight({ rowIndex: r, rowData: value[r] }) 54 | calculatedHeights.current.push({ 55 | height, 56 | top: lastBottom, 57 | }) 58 | lastBottom += height 59 | } while (lastBottom <= top && r < calculatedHeights.current.length - 1) 60 | } 61 | 62 | return r 63 | } 64 | 65 | return { 66 | resetAfter: (index: number) => { 67 | calculatedHeights.current = calculatedHeights.current.slice(0, index) 68 | rerender((x) => x + 1) 69 | }, 70 | getRowSize: (index: number): RowSize => { 71 | if (typeof rowHeight === 'number') { 72 | return { height: rowHeight, top: rowHeight * index } 73 | } 74 | 75 | if (index >= value.length) { 76 | return { height: 0, top: 0 } 77 | } 78 | 79 | if (index < calculatedHeights.current.length) { 80 | return calculatedHeights.current[index] 81 | } 82 | 83 | let lastBottom = 84 | calculatedHeights.current[calculatedHeights.current.length - 1].top + 85 | calculatedHeights.current[calculatedHeights.current.length - 1].height 86 | 87 | for (let i = calculatedHeights.current.length; i <= index; i++) { 88 | const height = rowHeight({ rowIndex: i, rowData: value[i] }) 89 | 90 | calculatedHeights.current.push({ height, top: lastBottom }) 91 | lastBottom += height 92 | } 93 | 94 | return calculatedHeights.current[index] 95 | }, 96 | getRowIndex, 97 | totalSize: (maxHeight: number) => { 98 | if (typeof rowHeight === 'number') { 99 | return value.length * rowHeight 100 | } 101 | 102 | const index = getRowIndex(maxHeight) 103 | 104 | return ( 105 | calculatedHeights.current[index].top + 106 | calculatedHeights.current[index].height 107 | ) 108 | }, 109 | } 110 | }, [rowHeight, value]) 111 | } 112 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Column as ColumnBase, 3 | CellComponent as CellComponentBase, 4 | CellProps as CellPropsBase, 5 | DataSheetGridProps as DataSheetGridPropsBase, 6 | AddRowsComponentProps as AddRowsComponentPropsBase, 7 | SimpleColumn as SimpleColumnBase, 8 | ContextMenuComponentProps as ContextMenuComponentPropsBase, 9 | ContextMenuItem as ContextMenuItemBase, 10 | DataSheetGridRef as DataSheetGridRefBase, 11 | } from './types' 12 | import { DataSheetGrid as DataSheetGridBase } from './components/DataSheetGrid' 13 | import { StaticDataSheetGrid as StaticDataSheetGridBase } from './components/StaticDataSheetGrid' 14 | 15 | export type Column = Partial< 16 | ColumnBase 17 | > 18 | export type CellComponent = CellComponentBase 19 | export type CellProps = CellPropsBase 20 | export type DataSheetGridProps = DataSheetGridPropsBase 21 | export type AddRowsComponentProps = AddRowsComponentPropsBase 22 | export type SimpleColumn = SimpleColumnBase 23 | export type ContextMenuComponentProps = ContextMenuComponentPropsBase 24 | export type ContextMenuItem = ContextMenuItemBase 25 | export type DataSheetGridRef = DataSheetGridRefBase 26 | export const DynamicDataSheetGrid = DataSheetGridBase 27 | export const DataSheetGrid = StaticDataSheetGridBase 28 | export { textColumn, createTextColumn } from './columns/textColumn' 29 | export { checkboxColumn } from './columns/checkboxColumn' 30 | export { floatColumn } from './columns/floatColumn' 31 | export { intColumn } from './columns/intColumn' 32 | export { percentColumn } from './columns/percentColumn' 33 | export { dateColumn } from './columns/dateColumn' 34 | export { isoDateColumn } from './columns/isoDateColumn' 35 | export { keyColumn } from './columns/keyColumn' 36 | export { createAddRowsComponent } from './components/AddRows' 37 | export { 38 | createContextMenuComponent, 39 | defaultRenderItem as renderContextMenuItem, 40 | } from './components/ContextMenu' 41 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export type Cell = { 4 | col: number 5 | row: number 6 | } 7 | 8 | export type Selection = { min: Cell; max: Cell } 9 | 10 | export type CellProps = { 11 | rowData: T 12 | rowIndex: number 13 | columnIndex: number 14 | active: boolean 15 | focus: boolean 16 | disabled: boolean 17 | columnData: C 18 | setRowData: (rowData: T) => void 19 | stopEditing: (opts?: { nextRow?: boolean }) => void 20 | insertRowBelow: () => void 21 | duplicateRow: () => void 22 | deleteRow: () => void 23 | getContextMenuItems: () => ContextMenuItem[] 24 | } 25 | 26 | export type CellComponent = (props: CellProps) => JSX.Element 27 | 28 | export type Column = { 29 | id?: string 30 | headerClassName?: string 31 | title?: React.ReactNode 32 | /** @deprecated Use `basis`, `grow`, and `shrink` instead */ 33 | width?: string | number 34 | basis: number 35 | grow: number 36 | shrink: number 37 | minWidth: number 38 | maxWidth?: number 39 | component: CellComponent 40 | columnData?: C 41 | disableKeys: boolean 42 | disabled: boolean | ((opt: { rowData: T; rowIndex: number }) => boolean) 43 | cellClassName?: 44 | | string 45 | | ((opt: { 46 | rowData: T 47 | rowIndex: number 48 | columnId?: string 49 | }) => string | undefined) 50 | keepFocus: boolean 51 | deleteValue: (opt: { rowData: T; rowIndex: number }) => T 52 | copyValue: (opt: { rowData: T; rowIndex: number }) => number | string | null 53 | pasteValue: (opt: { rowData: T; value: PasteValue; rowIndex: number }) => T 54 | prePasteValues: (values: string[]) => PasteValue[] | Promise 55 | isCellEmpty: (opt: { rowData: T; rowIndex: number }) => boolean 56 | } 57 | 58 | export type SelectionContextType = { 59 | columnRights?: number[] 60 | columnWidths?: number[] 61 | activeCell: Cell | null 62 | selection: Selection | null 63 | dataLength: number 64 | rowHeight: (index: number) => { height: number; top: number } 65 | hasStickyRightColumn: boolean 66 | editing: boolean 67 | isCellDisabled: (cell: Cell) => boolean 68 | headerRowHeight: number 69 | viewWidth?: number 70 | viewHeight?: number 71 | contentWidth?: number 72 | edges: { top: boolean; right: boolean; bottom: boolean; left: boolean } 73 | expandSelection: number | null 74 | } 75 | 76 | export type SimpleColumn = Partial< 77 | Pick< 78 | Column, 79 | | 'title' 80 | | 'maxWidth' 81 | | 'minWidth' 82 | | 'basis' 83 | | 'grow' 84 | | 'shrink' 85 | | 'component' 86 | | 'columnData' 87 | > 88 | > 89 | 90 | export type AddRowsComponentProps = { 91 | addRows: (count?: number) => void 92 | } 93 | 94 | export type ContextMenuItem = 95 | | { 96 | type: 'INSERT_ROW_BELLOW' | 'DELETE_ROW' | 'DUPLICATE_ROW' | 'COPY' | 'CUT' | 'PASTE' 97 | action: () => void 98 | } 99 | | { 100 | type: 'DELETE_ROWS' | 'DUPLICATE_ROWS' 101 | action: () => void 102 | fromRow: number 103 | toRow: number 104 | } 105 | 106 | export type ContextMenuComponentProps = { 107 | clientX: number 108 | clientY: number 109 | items: ContextMenuItem[] 110 | cursorIndex: Cell 111 | close: () => void 112 | } 113 | 114 | export type Operation = { 115 | type: 'UPDATE' | 'DELETE' | 'CREATE' 116 | fromRowIndex: number 117 | toRowIndex: number 118 | } 119 | 120 | export type DataSheetGridProps = { 121 | value?: T[] 122 | style?: React.CSSProperties 123 | className?: string 124 | rowClassName?: 125 | | string 126 | | ((opt: { rowData: T; rowIndex: number }) => string | undefined) 127 | cellClassName?: 128 | | string 129 | | ((opt: { 130 | rowData: unknown 131 | rowIndex: number 132 | columnId?: string 133 | }) => string | undefined) 134 | onChange?: (value: T[], operations: Operation[]) => void 135 | columns?: Partial>[] 136 | gutterColumn?: SimpleColumn | false 137 | stickyRightColumn?: SimpleColumn 138 | rowKey?: string | ((opts: { rowData: T; rowIndex: number }) => string) 139 | height?: number 140 | rowHeight?: number | ((opt: { rowData: T; rowIndex: number }) => number) 141 | headerRowHeight?: number 142 | addRowsComponent?: 143 | | ((props: AddRowsComponentProps) => React.ReactElement | null) 144 | | false 145 | createRow?: () => T 146 | duplicateRow?: (opts: { rowData: T; rowIndex: number }) => T 147 | autoAddRow?: boolean 148 | lockRows?: boolean 149 | disableContextMenu?: boolean 150 | disableExpandSelection?: boolean 151 | disableSmartDelete?: boolean 152 | contextMenuComponent?: ( 153 | props: ContextMenuComponentProps 154 | ) => React.ReactElement | null 155 | onFocus?: (opts: { cell: CellWithId }) => void 156 | onBlur?: (opts: { cell: CellWithId }) => void 157 | onActiveCellChange?: (opts: { cell: CellWithId | null }) => void 158 | onSelectionChange?: (opts: { selection: SelectionWithId | null }) => void 159 | onScroll?: React.UIEventHandler | undefined 160 | } 161 | 162 | type CellWithIdInput = { 163 | col: number | string 164 | row: number 165 | } 166 | 167 | type SelectionWithIdInput = { min: CellWithIdInput; max: CellWithIdInput } 168 | 169 | export type CellWithId = { 170 | colId?: string 171 | col: number 172 | row: number 173 | } 174 | 175 | export type SelectionWithId = { min: CellWithId; max: CellWithId } 176 | 177 | export type DataSheetGridRef = { 178 | activeCell: CellWithId | null 179 | selection: SelectionWithId | null 180 | setActiveCell: (activeCell: CellWithIdInput | null) => void 181 | setSelection: (selection: SelectionWithIdInput | null) => void 182 | } 183 | -------------------------------------------------------------------------------- /src/utils/copyPasting.ts: -------------------------------------------------------------------------------- 1 | import { parseDom } from './domParser' 2 | 3 | export const parseTextHtmlData = (data: string): string[][] => { 4 | const doc = parseDom(data.replace(//g, '\n')) 5 | const table = doc.getElementsByTagName('table')[0] 6 | 7 | if (table) { 8 | const rows: string[][] = [] 9 | 10 | for (let i = 0; i < table.rows.length; i++) { 11 | const row: string[] = [] 12 | rows.push(row) 13 | 14 | for (let j = 0; j < table.rows[i].cells.length; j++) { 15 | row.push(table.rows[i].cells[j].textContent ?? '') 16 | } 17 | } 18 | 19 | return rows 20 | } 21 | 22 | const span = doc.getElementsByTagName('span')[0] 23 | 24 | if (span) { 25 | return [[span.textContent ?? '']] 26 | } 27 | 28 | return [['']] 29 | } 30 | 31 | export const parseTextPlainData = (data: string): string[][] => { 32 | const cleanData = data.replace(/\r|\n$/g, '') 33 | const output: string[][] = [[]] 34 | let cursor = 0 35 | let startCell = 0 36 | let quoted = false 37 | let lastRowIndex = 0 38 | 39 | const saveCell = () => { 40 | let str = cleanData.slice(startCell, cursor) 41 | if (str[0] === '"' && str[str.length - 1] === '"') { 42 | quoted = true 43 | } 44 | 45 | if (quoted && str[str.length - 1] === '"' && str.includes('\n')) { 46 | str = str.slice(1, str.length - 1).replace(/""/g, '"') 47 | quoted = false 48 | } 49 | 50 | if (quoted && str[str.length - 1] !== '"') { 51 | str.split('\n').forEach((cell, i, { length }) => { 52 | output[lastRowIndex].push(cell) 53 | 54 | if (i < length - 1) { 55 | output.push([]) 56 | lastRowIndex++ 57 | } 58 | }) 59 | } else { 60 | output[lastRowIndex].push(str) 61 | } 62 | } 63 | 64 | while (cursor < cleanData.length) { 65 | if ( 66 | quoted && 67 | cleanData[cursor] === '"' && 68 | ![undefined, '\t', '"'].includes(cleanData[cursor + 1]) 69 | ) { 70 | quoted = false 71 | } 72 | 73 | if (quoted && cleanData[cursor] === '"' && cleanData[cursor + 1] === '"') { 74 | cursor++ 75 | } 76 | 77 | if (cursor === startCell && cleanData[cursor] === '"') { 78 | quoted = true 79 | } 80 | 81 | if (cleanData[cursor] === '\t') { 82 | saveCell() 83 | startCell = cursor + 1 84 | quoted = false 85 | } 86 | 87 | if (cleanData[cursor] === '\n' && !quoted) { 88 | saveCell() 89 | output.push([]) 90 | startCell = cursor + 1 91 | lastRowIndex++ 92 | } 93 | 94 | cursor++ 95 | } 96 | 97 | saveCell() 98 | 99 | return output 100 | } 101 | 102 | export const encodeHtml = (str: string) => { 103 | return str 104 | .replace(/&/g, '&') 105 | .replace(//g, '>') 107 | .replace(/"/g, '"') 108 | .replace(/'/g, ''') 109 | } 110 | 111 | export const isPrintableUnicode = (str: string): boolean => { 112 | return str.match(/^[^\x00-\x20\x7F-\x9F]$/) !== null 113 | } 114 | -------------------------------------------------------------------------------- /src/utils/domParser.ts: -------------------------------------------------------------------------------- 1 | const parser = 2 | typeof DOMParser !== 'undefined' 3 | ? new DOMParser() 4 | : { parseFromString: () => null as unknown as Document } 5 | 6 | export const parseDom = (html: string): Document => { 7 | return parser.parseFromString(html, 'text/html') 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/tab.ts: -------------------------------------------------------------------------------- 1 | export const getAllTabbableElements = () => 2 | Array.from(document.querySelectorAll('*')).filter((element) => { 3 | return ( 4 | element instanceof HTMLElement && 5 | typeof element.tabIndex === 'number' && 6 | element.tabIndex >= 0 && 7 | !(element as HTMLInputElement).disabled && 8 | (!(element instanceof HTMLAnchorElement) || 9 | !!element.href || 10 | element.getAttribute('tabIndex') !== null) && 11 | getComputedStyle(element).visibility !== 'collapse' 12 | ) 13 | }) as HTMLElement[] 14 | -------------------------------------------------------------------------------- /src/utils/typeCheck.ts: -------------------------------------------------------------------------------- 1 | import { Cell, CellWithId, Column, Selection, SelectionWithId } from '../types' 2 | 3 | export const getCell = ( 4 | value: any, 5 | colMax: number, 6 | rowMax: number, 7 | columns: Column[] 8 | ): Cell | null => { 9 | if (value === null || !colMax || !rowMax) { 10 | return null 11 | } 12 | 13 | if (typeof value !== 'object') { 14 | throw new Error('Value must be an object or null') 15 | } 16 | 17 | const colIndex = columns.findIndex((column) => column.id === value.col) 18 | 19 | const cell: Cell = { 20 | col: Math.max( 21 | 0, 22 | Math.min(colMax - 1, colIndex === -1 ? Number(value.col) : colIndex - 1) 23 | ), 24 | row: Math.max(0, Math.min(rowMax - 1, Number(value.row))), 25 | } 26 | 27 | if (isNaN(cell.col) || isNaN(cell.row)) { 28 | throw new Error('col or cell are not valid positive numbers') 29 | } 30 | 31 | return cell 32 | } 33 | 34 | export const getCellWithId = ( 35 | cell: Cell | null, 36 | columns: Column[] 37 | ): typeof cell extends null ? CellWithId | null : CellWithId => 38 | cell 39 | ? { 40 | col: cell.col, 41 | row: cell.row, 42 | colId: columns[cell.col + 1]?.id, 43 | } 44 | : (null as never) 45 | 46 | export const getSelection = ( 47 | value: any, 48 | colMax: number, 49 | rowMax: number, 50 | columns: Column[] 51 | ): Selection | null => { 52 | if (value === null || !colMax || !rowMax) { 53 | return null 54 | } 55 | 56 | if (typeof value !== 'object') { 57 | throw new Error('Value must be an object or null') 58 | } 59 | 60 | const selection = { 61 | min: getCell(value.min, colMax, rowMax, columns), 62 | max: getCell(value.max, colMax, rowMax, columns), 63 | } 64 | 65 | if (!selection.min || !selection.max) { 66 | throw new Error('min and max must be defined') 67 | } 68 | 69 | return selection as Selection 70 | } 71 | 72 | export const getSelectionWithId = ( 73 | selection: Selection | null, 74 | columns: Column[] 75 | ): SelectionWithId | null => 76 | selection 77 | ? { 78 | min: getCellWithId(selection.min, columns), 79 | max: getCellWithId(selection.max, columns), 80 | } 81 | : null 82 | -------------------------------------------------------------------------------- /tests/addRows.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import '@testing-library/jest-dom' 3 | import userEvent from '@testing-library/user-event' 4 | import { render, screen } from '@testing-library/react' 5 | import { 6 | DataSheetGrid, 7 | Column, 8 | textColumn, 9 | keyColumn, 10 | DataSheetGridRef, 11 | } from '../src' 12 | 13 | jest.mock('react-resize-detector', () => ({ 14 | useResizeDetector: () => ({ width: 100, height: 100 }), 15 | })) 16 | 17 | const columns: Column[] = [ 18 | keyColumn('firstName', textColumn), 19 | keyColumn('lastName', textColumn), 20 | ] 21 | 22 | test('Add single row', () => { 23 | const ref = { current: null as unknown as DataSheetGridRef } 24 | const onChange = jest.fn() 25 | 26 | render( 27 | 37 | ) 38 | 39 | userEvent.click(screen.getByText('Add')) 40 | 41 | expect(onChange).toHaveBeenCalledWith( 42 | [ 43 | { 44 | id: 1, 45 | firstName: 'Elon', 46 | lastName: 'Musk', 47 | }, 48 | { 49 | id: 2, 50 | firstName: 'Jeff', 51 | lastName: 'Bezos', 52 | }, 53 | { 54 | id: 3, 55 | }, 56 | ], 57 | [{ type: 'CREATE', fromRowIndex: 2, toRowIndex: 3 }] 58 | ) 59 | }) 60 | 61 | test('No add button when rows are locked', () => { 62 | render() 63 | 64 | expect(screen.queryByText('Add')).not.toBeInTheDocument() 65 | }) 66 | 67 | test('No add button when addRowsComponent receives false', () => { 68 | render() 69 | 70 | expect(screen.queryByText('Add')).not.toBeInTheDocument() 71 | }) 72 | 73 | test('Add multiple rows', () => { 74 | const ref = { current: null as unknown as DataSheetGridRef } 75 | const onChange = jest.fn() 76 | 77 | render( 78 | 92 | ) 93 | 94 | userEvent.type(screen.getByRole('spinbutton'), '{selectall}3') 95 | userEvent.click(screen.getByText('Add')) 96 | 97 | expect(onChange).toHaveBeenCalledWith( 98 | [ 99 | { 100 | id: 1, 101 | firstName: 'Elon', 102 | lastName: 'Musk', 103 | }, 104 | { 105 | id: 2, 106 | firstName: 'Jeff', 107 | lastName: 'Bezos', 108 | }, 109 | { 110 | id: 3, 111 | }, 112 | { 113 | id: 4, 114 | }, 115 | { 116 | id: 5, 117 | }, 118 | ], 119 | [{ type: 'CREATE', fromRowIndex: 2, toRowIndex: 5 }] 120 | ) 121 | 122 | expect(ref.current.activeCell).toEqual({ 123 | col: 0, 124 | colId: 'firstName', 125 | row: 4, 126 | }) 127 | }) 128 | -------------------------------------------------------------------------------- /tests/arrows.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import '@testing-library/jest-dom' 3 | import userEvent from '@testing-library/user-event' 4 | import { render, act } from '@testing-library/react' 5 | import { 6 | DataSheetGrid, 7 | Column, 8 | textColumn, 9 | keyColumn, 10 | DataSheetGridRef, 11 | checkboxColumn, 12 | } from '../src' 13 | 14 | jest.mock('react-resize-detector', () => ({ 15 | useResizeDetector: () => ({ width: 100, height: 100 }), 16 | })) 17 | 18 | const data = [ 19 | { active: false, firstName: 'Elon', lastName: 'Musk' }, 20 | { active: true, firstName: 'Jeff', lastName: 'Bezos' }, 21 | { active: false, firstName: 'Richard', lastName: 'Branson' }, 22 | ] 23 | const columns: Column[] = [ 24 | keyColumn('active', checkboxColumn), 25 | keyColumn('firstName', textColumn), 26 | keyColumn('lastName', textColumn), 27 | ] 28 | 29 | test('Up from cell', () => { 30 | const ref = { current: null as unknown as DataSheetGridRef } 31 | render() 32 | 33 | act(() => ref.current.setActiveCell({ col: 1, row: 1 })) 34 | 35 | userEvent.keyboard('[ArrowUp]') 36 | expect(ref.current.activeCell).toEqual({ 37 | col: 1, 38 | colId: 'firstName', 39 | row: 0, 40 | }) 41 | }) 42 | 43 | test('Up from top row', () => { 44 | const ref = { current: null as unknown as DataSheetGridRef } 45 | render() 46 | 47 | act(() => ref.current.setActiveCell({ col: 1, row: 0 })) 48 | 49 | userEvent.keyboard('[ArrowUp]') 50 | expect(ref.current.activeCell).toEqual({ 51 | col: 1, 52 | colId: 'firstName', 53 | row: 0, 54 | }) 55 | }) 56 | 57 | test('Up from selection', () => { 58 | const ref = { current: null as unknown as DataSheetGridRef } 59 | render() 60 | 61 | act(() => 62 | ref.current.setSelection({ 63 | min: { 64 | col: 1, 65 | row: 1, 66 | }, 67 | max: { 68 | col: 2, 69 | row: 2, 70 | }, 71 | }) 72 | ) 73 | 74 | userEvent.keyboard('[ArrowUp]') 75 | expect(ref.current.selection).toEqual({ 76 | max: { 77 | col: 1, 78 | colId: 'firstName', 79 | row: 0, 80 | }, 81 | min: { 82 | col: 1, 83 | colId: 'firstName', 84 | row: 0, 85 | }, 86 | }) 87 | }) 88 | 89 | test('Cmd + Up', () => { 90 | const ref = { current: null as unknown as DataSheetGridRef } 91 | render() 92 | 93 | act(() => ref.current.setActiveCell({ col: 1, row: 2 })) 94 | 95 | userEvent.keyboard('[MetaLeft>][ArrowUp][/MetaLeft]') 96 | expect(ref.current.activeCell).toEqual({ 97 | col: 1, 98 | colId: 'firstName', 99 | row: 0, 100 | }) 101 | }) 102 | 103 | test('Ctrl + Up', () => { 104 | const ref = { current: null as unknown as DataSheetGridRef } 105 | render() 106 | 107 | act(() => ref.current.setActiveCell({ col: 1, row: 2 })) 108 | 109 | userEvent.keyboard('[ControlLeft>][ArrowUp][/ControlLeft]') 110 | expect(ref.current.activeCell).toEqual({ 111 | col: 1, 112 | colId: 'firstName', 113 | row: 0, 114 | }) 115 | }) 116 | 117 | test('Shift + Up', () => { 118 | const ref = { current: null as unknown as DataSheetGridRef } 119 | render() 120 | 121 | act(() => ref.current.setActiveCell({ col: 1, row: 2 })) 122 | 123 | userEvent.keyboard('[ShiftLeft>][ArrowUp][/ShiftLeft]') 124 | expect(ref.current.selection).toEqual({ 125 | min: { 126 | col: 1, 127 | colId: 'firstName', 128 | row: 1, 129 | }, 130 | max: { 131 | col: 1, 132 | colId: 'firstName', 133 | row: 2, 134 | }, 135 | }) 136 | }) 137 | 138 | test('Shift + Up from selection', () => { 139 | const ref = { current: null as unknown as DataSheetGridRef } 140 | render() 141 | 142 | act(() => 143 | ref.current.setSelection({ 144 | min: { col: 1, row: 2 }, 145 | max: { col: 1, row: 1 }, 146 | }) 147 | ) 148 | 149 | userEvent.keyboard('[ShiftLeft>][ArrowUp][/ShiftLeft]') 150 | expect(ref.current.selection).toEqual({ 151 | min: { 152 | col: 1, 153 | colId: 'firstName', 154 | row: 0, 155 | }, 156 | max: { 157 | col: 1, 158 | colId: 'firstName', 159 | row: 2, 160 | }, 161 | }) 162 | }) 163 | 164 | test('Shift + Up from selection already at the top', () => { 165 | const ref = { current: null as unknown as DataSheetGridRef } 166 | render() 167 | 168 | act(() => 169 | ref.current.setSelection({ 170 | min: { col: 1, row: 2 }, 171 | max: { col: 1, row: 0 }, 172 | }) 173 | ) 174 | 175 | userEvent.keyboard('[ShiftLeft>][ArrowUp][/ShiftLeft]') 176 | expect(ref.current.selection).toEqual({ 177 | min: { 178 | col: 1, 179 | colId: 'firstName', 180 | row: 0, 181 | }, 182 | max: { 183 | col: 1, 184 | colId: 'firstName', 185 | row: 2, 186 | }, 187 | }) 188 | }) 189 | -------------------------------------------------------------------------------- /tests/copy.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import '@testing-library/jest-dom' 3 | import userEvent from '@testing-library/user-event' 4 | import { render, screen, act, fireEvent, waitFor } from '@testing-library/react' 5 | import { 6 | DataSheetGrid, 7 | Column, 8 | textColumn, 9 | keyColumn, 10 | DataSheetGridRef, 11 | } from '../src' 12 | 13 | jest.mock('react-resize-detector', () => ({ 14 | useResizeDetector: () => ({ width: 100, height: 100 }), 15 | })) 16 | 17 | const columns: Column[] = [ 18 | keyColumn('firstName', textColumn), 19 | keyColumn('lastName', textColumn), 20 | ] 21 | 22 | class MockDataTransfer { 23 | data: Record = {} 24 | 25 | setData(format: string, data: string) { 26 | this.data[format] = data 27 | } 28 | } 29 | 30 | const copy = () => { 31 | const clipboardData = new MockDataTransfer() 32 | fireEvent.copy(document, { clipboardData: clipboardData }) 33 | return clipboardData.data 34 | } 35 | 36 | const cut = () => { 37 | const clipboardData = new MockDataTransfer() 38 | fireEvent.cut(document, { clipboardData: clipboardData }) 39 | return clipboardData.data 40 | } 41 | 42 | const rows = [ 43 | { firstName: 'Elon', lastName: 'Musk' }, 44 | { firstName: 'Jeff', lastName: 'Bezos' }, 45 | ] 46 | 47 | test('Copy single cell', async () => { 48 | const ref = { current: null as unknown as DataSheetGridRef } 49 | const onChange = jest.fn() 50 | 51 | render( 52 | 58 | ) 59 | 60 | act(() => ref.current.setActiveCell({ col: 0, row: 1 })) 61 | 62 | expect(copy()).toEqual({ 63 | 'text/html': '
Jeff
', 64 | 'text/plain': 'Jeff', 65 | }) 66 | expect(onChange).not.toHaveBeenCalled() 67 | }) 68 | 69 | test('Copy multiple cell', async () => { 70 | const ref = { current: null as unknown as DataSheetGridRef } 71 | 72 | render() 73 | 74 | act(() => 75 | ref.current.setSelection({ 76 | min: { col: 0, row: 0 }, 77 | max: { col: 1, row: 1 }, 78 | }) 79 | ) 80 | 81 | expect(copy()).toEqual({ 82 | 'text/html': 83 | '
ElonMusk
JeffBezos
', 84 | 'text/plain': 'Elon\tMusk\nJeff\tBezos', 85 | }) 86 | }) 87 | 88 | test('Cut multiple cells', async () => { 89 | const ref = { current: null as unknown as DataSheetGridRef } 90 | const onChange = jest.fn() 91 | 92 | render( 93 | 99 | ) 100 | 101 | act(() => 102 | ref.current.setSelection({ 103 | min: { col: 0, row: 0 }, 104 | max: { col: 0, row: 1 }, 105 | }) 106 | ) 107 | 108 | expect(cut()).toEqual({ 109 | 'text/html': '
Elon
Jeff
', 110 | 'text/plain': 'Elon\nJeff', 111 | }) 112 | expect(onChange).toHaveBeenCalledWith( 113 | [ 114 | { firstName: null, lastName: 'Musk' }, 115 | { firstName: null, lastName: 'Bezos' }, 116 | ], 117 | [{ type: 'UPDATE', fromRowIndex: 0, toRowIndex: 2 }] 118 | ) 119 | }) 120 | -------------------------------------------------------------------------------- /tests/duplicateRow.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import '@testing-library/jest-dom' 3 | import userEvent from '@testing-library/user-event' 4 | import { render, act } from '@testing-library/react' 5 | import { 6 | DataSheetGrid, 7 | Column, 8 | textColumn, 9 | keyColumn, 10 | DataSheetGridRef, 11 | } from '../src' 12 | 13 | jest.mock('react-resize-detector', () => ({ 14 | useResizeDetector: () => ({ width: 100, height: 100 }), 15 | })) 16 | 17 | const columns: Column[] = [ 18 | keyColumn('firstName', textColumn), 19 | keyColumn('lastName', textColumn), 20 | ] 21 | 22 | test('Duplicate row with Cmd+D', () => { 23 | const ref = { current: null as unknown as DataSheetGridRef } 24 | const onChange = jest.fn() 25 | 26 | render( 27 | ({ firstName: 'Richard', lastName: 'Branson' })} 33 | onChange={onChange} 34 | columns={columns} 35 | ref={ref} 36 | /> 37 | ) 38 | 39 | act(() => ref.current.setActiveCell({ col: 0, row: 0 })) 40 | 41 | userEvent.keyboard('[MetaLeft>]d[/MetaLeft]') 42 | 43 | expect(onChange).toHaveBeenCalledWith( 44 | [ 45 | { firstName: 'Elon', lastName: 'Musk' }, 46 | { firstName: 'Richard', lastName: 'Branson' }, 47 | { firstName: 'Jeff', lastName: 'Bezos' }, 48 | ], 49 | [{ type: 'CREATE', fromRowIndex: 1, toRowIndex: 2 }] 50 | ) 51 | expect(ref.current.selection).toEqual({ 52 | min: { 53 | col: 0, 54 | colId: 'firstName', 55 | row: 1, 56 | }, 57 | max: { 58 | col: 1, 59 | colId: 'lastName', 60 | row: 1, 61 | }, 62 | }) 63 | }) 64 | 65 | test('Duplicate row with Ctrl+D', () => { 66 | const ref = { current: null as unknown as DataSheetGridRef } 67 | const onChange = jest.fn() 68 | 69 | render( 70 | 79 | ) 80 | 81 | act(() => 82 | ref.current.setSelection({ 83 | min: { col: 0, row: 1 }, 84 | max: { col: 1, row: 1 }, 85 | }) 86 | ) 87 | 88 | userEvent.keyboard('[ControlLeft>]d[/ControlLeft]') 89 | 90 | expect(onChange).toHaveBeenCalledWith( 91 | [ 92 | { firstName: 'Elon', lastName: 'Musk' }, 93 | { firstName: 'Jeff', lastName: 'Bezos' }, 94 | { firstName: 'Jeff', lastName: 'Bezos' }, 95 | ], 96 | [{ type: 'CREATE', fromRowIndex: 2, toRowIndex: 3 }] 97 | ) 98 | expect(ref.current.selection).toEqual({ 99 | min: { 100 | col: 0, 101 | colId: 'firstName', 102 | row: 2, 103 | }, 104 | max: { 105 | col: 1, 106 | colId: 'lastName', 107 | row: 2, 108 | }, 109 | }) 110 | }) 111 | 112 | test('Duplicate multiple rows', () => { 113 | const ref = { current: null as unknown as DataSheetGridRef } 114 | const onChange = jest.fn() 115 | const duplicateRow = jest.fn(({ rowData }) => ({ ...rowData })) 116 | 117 | render( 118 | 129 | ) 130 | 131 | act(() => 132 | ref.current.setSelection({ 133 | min: { col: 0, row: 0 }, 134 | max: { col: 0, row: 1 }, 135 | }) 136 | ) 137 | 138 | userEvent.keyboard('[ControlLeft>]d[/ControlLeft]') 139 | 140 | expect(onChange).toHaveBeenCalledWith( 141 | [ 142 | { firstName: 'Elon', lastName: 'Musk' }, 143 | { firstName: 'Jeff', lastName: 'Bezos' }, 144 | { firstName: 'Elon', lastName: 'Musk' }, 145 | { firstName: 'Jeff', lastName: 'Bezos' }, 146 | { firstName: 'Richard', lastName: 'Branson' }, 147 | ], 148 | [{ type: 'CREATE', fromRowIndex: 2, toRowIndex: 4 }] 149 | ) 150 | expect(ref.current.selection).toEqual({ 151 | min: { 152 | col: 0, 153 | colId: 'firstName', 154 | row: 2, 155 | }, 156 | max: { 157 | col: 1, 158 | colId: 'lastName', 159 | row: 3, 160 | }, 161 | }) 162 | expect(duplicateRow).toHaveBeenCalledWith({ 163 | rowData: { 164 | firstName: 'Elon', 165 | lastName: 'Musk', 166 | }, 167 | rowIndex: 0, 168 | }) 169 | expect(duplicateRow).toHaveBeenCalledWith({ 170 | rowData: { 171 | firstName: 'Jeff', 172 | lastName: 'Bezos', 173 | }, 174 | rowIndex: 1, 175 | }) 176 | }) 177 | 178 | test('Try to duplicate locked rows', () => { 179 | const ref = { current: null as unknown as DataSheetGridRef } 180 | const onChange = jest.fn() 181 | 182 | render( 183 | ({ firstName: 'Richard', lastName: 'Branson' })} 189 | onChange={onChange} 190 | columns={columns} 191 | lockRows 192 | ref={ref} 193 | /> 194 | ) 195 | 196 | act(() => ref.current.setActiveCell({ col: 0, row: 0 })) 197 | 198 | userEvent.keyboard('[MetaLeft>]d[/MetaLeft]') 199 | 200 | expect(onChange).not.toHaveBeenCalled() 201 | }) 202 | -------------------------------------------------------------------------------- /tests/escape.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import '@testing-library/jest-dom' 3 | import userEvent from '@testing-library/user-event' 4 | import { render, screen, act } from '@testing-library/react' 5 | import { 6 | DataSheetGrid, 7 | Column, 8 | textColumn, 9 | keyColumn, 10 | DataSheetGridRef, 11 | } from '../src' 12 | 13 | jest.mock('react-resize-detector', () => ({ 14 | useResizeDetector: () => ({ width: 100, height: 100 }), 15 | })) 16 | 17 | const data = [ 18 | { firstName: 'Elon', lastName: 'Musk' }, 19 | { firstName: 'Jeff', lastName: 'Bezos' }, 20 | ] 21 | const columns: Column[] = [ 22 | keyColumn('firstName', textColumn), 23 | keyColumn('lastName', textColumn), 24 | ] 25 | 26 | test('Escape from editing', () => { 27 | const ref = { current: null as unknown as DataSheetGridRef } 28 | render() 29 | 30 | act(() => ref.current.setActiveCell({ col: 0, row: 0 })) 31 | 32 | userEvent.keyboard('[Enter]') 33 | userEvent.keyboard('[Escape]') 34 | expect(ref.current.activeCell).toEqual({ 35 | col: 0, 36 | colId: 'firstName', 37 | row: 0, 38 | }) 39 | }) 40 | 41 | test('Escape from selection', () => { 42 | const ref = { current: null as unknown as DataSheetGridRef } 43 | render() 44 | 45 | act(() => 46 | ref.current.setSelection({ 47 | min: { col: 0, row: 0 }, 48 | max: { col: 1, row: 1 }, 49 | }) 50 | ) 51 | 52 | userEvent.keyboard('[Escape]') 53 | expect(ref.current.selection).toEqual({ 54 | min: { 55 | col: 0, 56 | colId: 'firstName', 57 | row: 0, 58 | }, 59 | max: { 60 | col: 0, 61 | colId: 'firstName', 62 | row: 0, 63 | }, 64 | }) 65 | }) 66 | 67 | test('Escape from active', () => { 68 | const ref = { current: null as unknown as DataSheetGridRef } 69 | render() 70 | 71 | act(() => ref.current.setActiveCell({ col: 0, row: 0 })) 72 | 73 | userEvent.keyboard('[Escape]') 74 | expect(ref.current.activeCell).toEqual(null) 75 | }) 76 | -------------------------------------------------------------------------------- /tests/helpers/DataWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { DataSheetGrid, DataSheetGridProps, DataSheetGridRef } from '../../src' 2 | import React, { useState } from 'react' 3 | 4 | export const DataWrapper = ({ 5 | dataRef, 6 | dsgRef, 7 | ...rest 8 | }: Omit & { 9 | dataRef: { current: any[] } 10 | dsgRef: { current: DataSheetGridRef } 11 | }) => { 12 | const [value, setValue] = useState(dataRef.current) 13 | 14 | return ( 15 | { 18 | setValue(newData) 19 | dataRef.current = newData 20 | }} 21 | ref={dsgRef} 22 | {...rest} 23 | /> 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /tests/helpers/styleMock.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | -------------------------------------------------------------------------------- /tests/insertRow.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import '@testing-library/jest-dom' 3 | import userEvent from '@testing-library/user-event' 4 | import { render, act } from '@testing-library/react' 5 | import { 6 | DataSheetGrid, 7 | Column, 8 | textColumn, 9 | keyColumn, 10 | DataSheetGridRef, 11 | } from '../src' 12 | 13 | jest.mock('react-resize-detector', () => ({ 14 | useResizeDetector: () => ({ width: 100, height: 100 }), 15 | })) 16 | 17 | const columns: Column[] = [ 18 | keyColumn('firstName', textColumn), 19 | keyColumn('lastName', textColumn), 20 | ] 21 | 22 | test('Insert row with Shift+Enter', () => { 23 | const ref = { current: null as unknown as DataSheetGridRef } 24 | const onChange = jest.fn() 25 | 26 | render( 27 | ({ firstName: 'Richard', lastName: 'Branson' })} 33 | onChange={onChange} 34 | columns={columns} 35 | ref={ref} 36 | /> 37 | ) 38 | 39 | act(() => ref.current.setActiveCell({ col: 1, row: 0 })) 40 | 41 | userEvent.keyboard('[ShiftLeft>][Enter][/ShiftLeft]') 42 | 43 | expect(onChange).toHaveBeenCalledWith( 44 | [ 45 | { firstName: 'Elon', lastName: 'Musk' }, 46 | { firstName: 'Richard', lastName: 'Branson' }, 47 | { firstName: 'Jeff', lastName: 'Bezos' }, 48 | ], 49 | [{ type: 'CREATE', fromRowIndex: 1, toRowIndex: 2 }] 50 | ) 51 | expect(ref.current.selection).toEqual({ 52 | min: { 53 | col: 1, 54 | colId: 'lastName', 55 | row: 1, 56 | }, 57 | max: { 58 | col: 1, 59 | colId: 'lastName', 60 | row: 1, 61 | }, 62 | }) 63 | }) 64 | 65 | test('Insert row from selection', () => { 66 | const ref = { current: null as unknown as DataSheetGridRef } 67 | const onChange = jest.fn() 68 | 69 | render( 70 | 79 | ) 80 | 81 | act(() => 82 | ref.current.setSelection({ 83 | min: { col: 1, row: 0 }, 84 | max: { col: 0, row: 1 }, 85 | }) 86 | ) 87 | 88 | userEvent.keyboard('[ShiftLeft>][Enter][/ShiftLeft]') 89 | 90 | expect(onChange).toHaveBeenCalledWith( 91 | [ 92 | { firstName: 'Elon', lastName: 'Musk' }, 93 | { firstName: 'Jeff', lastName: 'Bezos' }, 94 | {}, 95 | ], 96 | [{ type: 'CREATE', fromRowIndex: 2, toRowIndex: 3 }] 97 | ) 98 | expect(ref.current.activeCell).toEqual({ 99 | col: 1, 100 | colId: 'lastName', 101 | row: 2, 102 | }) 103 | }) 104 | 105 | test('Insert row with locked rows', () => { 106 | const ref = { current: null as unknown as DataSheetGridRef } 107 | const onChange = jest.fn() 108 | 109 | render( 110 | 120 | ) 121 | 122 | act(() => ref.current.setActiveCell({ col: 0, row: 0 })) 123 | 124 | userEvent.keyboard('[ShiftLeft>][Enter][/ShiftLeft]') 125 | 126 | expect(onChange).not.toHaveBeenCalled() 127 | }) 128 | -------------------------------------------------------------------------------- /tests/selectAll.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import '@testing-library/jest-dom' 3 | import userEvent from '@testing-library/user-event' 4 | import { render, screen, act } from '@testing-library/react' 5 | import { 6 | DataSheetGrid, 7 | Column, 8 | textColumn, 9 | keyColumn, 10 | DataSheetGridRef, 11 | } from '../src' 12 | 13 | jest.mock('react-resize-detector', () => ({ 14 | useResizeDetector: () => ({ width: 100, height: 100 }), 15 | })) 16 | 17 | const data = [ 18 | { firstName: 'Elon', lastName: 'Musk' }, 19 | { firstName: 'Jeff', lastName: 'Bezos' }, 20 | ] 21 | const columns: Column[] = [ 22 | keyColumn('firstName', textColumn), 23 | keyColumn('lastName', textColumn), 24 | ] 25 | 26 | test('Select all with Cmd+A', () => { 27 | const ref = { current: null as unknown as DataSheetGridRef } 28 | render() 29 | 30 | act(() => ref.current.setActiveCell({ col: 0, row: 0 })) 31 | 32 | userEvent.keyboard('[MetaLeft>]a[/MetaLeft]') 33 | expect(ref.current.selection).toEqual({ 34 | min: { 35 | col: 0, 36 | colId: 'firstName', 37 | row: 0, 38 | }, 39 | max: { 40 | col: 1, 41 | colId: 'lastName', 42 | row: 1, 43 | }, 44 | }) 45 | }) 46 | 47 | test('Select all with Ctrl+A', () => { 48 | const ref = { current: null as unknown as DataSheetGridRef } 49 | render() 50 | 51 | act(() => ref.current.setActiveCell({ col: 0, row: 0 })) 52 | 53 | userEvent.keyboard('[ControlLeft>]a[/ControlLeft]') 54 | expect(ref.current.selection).toEqual({ 55 | min: { 56 | col: 0, 57 | colId: 'firstName', 58 | row: 0, 59 | }, 60 | max: { 61 | col: 1, 62 | colId: 'lastName', 63 | row: 1, 64 | }, 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | global.ResizeObserver = require('resize-observer-polyfill') 2 | 3 | export {} 4 | -------------------------------------------------------------------------------- /tests/tab.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import '@testing-library/jest-dom' 3 | import userEvent from '@testing-library/user-event' 4 | import { render, screen, act } from '@testing-library/react' 5 | import { 6 | DataSheetGrid, 7 | Column, 8 | textColumn, 9 | keyColumn, 10 | DataSheetGridRef, 11 | } from '../src' 12 | 13 | jest.mock('react-resize-detector', () => ({ 14 | useResizeDetector: () => ({ width: 100, height: 100 }), 15 | })) 16 | 17 | const data = [ 18 | { firstName: 'Elon', lastName: 'Musk' }, 19 | { firstName: 'Jeff', lastName: 'Bezos' }, 20 | ] 21 | const columns: Column[] = [ 22 | keyColumn('firstName', textColumn), 23 | keyColumn('lastName', textColumn), 24 | ] 25 | 26 | test('Tab from outside', () => { 27 | const ref = { current: null as unknown as DataSheetGridRef } 28 | render( 29 | <> 30 | 31 | 32 | 33 | 34 | ) 35 | 36 | userEvent.click(screen.getByTestId('input-before')) 37 | 38 | userEvent.tab() 39 | expect(ref.current.activeCell).toEqual({ 40 | col: 0, 41 | colId: 'firstName', 42 | row: 0, 43 | }) 44 | }) 45 | 46 | test('Tab from cell', () => { 47 | const ref = { current: null as unknown as DataSheetGridRef } 48 | render() 49 | 50 | act(() => ref.current.setActiveCell({ col: 0, row: 1 })) 51 | 52 | userEvent.tab() 53 | expect(ref.current.activeCell).toEqual({ 54 | col: 1, 55 | colId: 'lastName', 56 | row: 1, 57 | }) 58 | }) 59 | 60 | test('Tab from last cell of row', () => { 61 | const ref = { current: null as unknown as DataSheetGridRef } 62 | render() 63 | 64 | act(() => ref.current.setActiveCell({ col: 1, row: 0 })) 65 | 66 | userEvent.tab() 67 | expect(ref.current.activeCell).toEqual({ 68 | col: 0, 69 | colId: 'firstName', 70 | row: 1, 71 | }) 72 | }) 73 | 74 | test('Tab from last cell of last row', () => { 75 | const ref = { current: null as unknown as DataSheetGridRef } 76 | render( 77 | <> 78 | 79 | 80 | 81 | 82 | ) 83 | 84 | act(() => ref.current.setActiveCell({ col: 1, row: 1 })) 85 | 86 | userEvent.tab() 87 | expect(ref.current.activeCell).toEqual(null) 88 | expect(screen.getByTestId('input-after')).toHaveFocus() 89 | }) 90 | 91 | test('Shift tab from outside', () => { 92 | const ref = { current: null as unknown as DataSheetGridRef } 93 | render( 94 | <> 95 | 96 | 97 | 98 | 99 | ) 100 | 101 | userEvent.click(screen.getByTestId('input-after')) 102 | 103 | userEvent.tab({ shift: true }) 104 | expect(ref.current.activeCell).toEqual({ 105 | col: 1, 106 | colId: 'lastName', 107 | row: 1, 108 | }) 109 | }) 110 | 111 | test('Shift tab from cell', () => { 112 | const ref = { current: null as unknown as DataSheetGridRef } 113 | render( 114 | <> 115 | 116 | 117 | 118 | 119 | ) 120 | 121 | act(() => ref.current.setActiveCell({ col: 1, row: 1 })) 122 | 123 | userEvent.tab({ shift: true }) 124 | expect(ref.current.activeCell).toEqual({ 125 | col: 0, 126 | colId: 'firstName', 127 | row: 1, 128 | }) 129 | }) 130 | 131 | test('Shift tab from first cell of row', () => { 132 | const ref = { current: null as unknown as DataSheetGridRef } 133 | render( 134 | <> 135 | 136 | 137 | 138 | 139 | ) 140 | 141 | act(() => ref.current.setActiveCell({ col: 0, row: 1 })) 142 | 143 | userEvent.tab({ shift: true }) 144 | expect(ref.current.activeCell).toEqual({ 145 | col: 1, 146 | colId: 'lastName', 147 | row: 0, 148 | }) 149 | }) 150 | 151 | test('Shift tab from first cell of first row', () => { 152 | const ref = { current: null as unknown as DataSheetGridRef } 153 | render( 154 | <> 155 | 156 | 157 | 158 | 159 | ) 160 | 161 | act(() => ref.current.setActiveCell({ col: 0, row: 0 })) 162 | 163 | userEvent.tab({ shift: true }) 164 | expect(ref.current.activeCell).toEqual(null) 165 | expect(screen.getByTestId('input-before')).toHaveFocus() 166 | }) 167 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "target": "ES2015", 5 | "module": "commonjs", 6 | "allowJs": false, 7 | "jsx": "react", 8 | "declaration": true, 9 | "declarationMap": true, 10 | "sourceMap": true, 11 | "rootDir": "src", 12 | "lib": [ 13 | "DOM", 14 | "ESNext" 15 | ], 16 | "outDir": "dist", 17 | "downlevelIteration": true, 18 | "isolatedModules": true, 19 | "strict": true, 20 | "alwaysStrict": true, 21 | "noImplicitReturns": true, 22 | "moduleResolution": "node", 23 | "esModuleInterop": true, 24 | "skipLibCheck": true, 25 | "forceConsistentCasingInFileNames": true 26 | }, 27 | "exclude": [ 28 | "node_modules", 29 | "dist", 30 | "example", 31 | "src/**/*.test.ts", 32 | "src/**/*.test.tsx" 33 | ], 34 | "include": [ 35 | "src/**/*" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /website/.nvmrc: -------------------------------------------------------------------------------- 1 | 16.14 -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | # Online documentation 2 | 3 | This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. 4 | 5 | ## Installation 6 | 7 | ```console 8 | npm install 9 | ``` 10 | 11 | ## Local Development 12 | 13 | ```console 14 | npm start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. 18 | Most changes are reflected live without having to restart the server. 19 | 20 | ## Build / Deployment 21 | 22 | The documentation is automatically built and deployed as commits are pushed to GitHub. 23 | -------------------------------------------------------------------------------- /website/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /website/docs/api-reference/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "API Reference", 3 | "position": 10 4 | } 5 | -------------------------------------------------------------------------------- /website/docs/api-reference/cell-components.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | import ActiveFocus from '../../src/demos/activeFocus' 5 | 6 | # Cell component props 7 | 8 | Cell components are used to render each individual cells of a column: 9 | ```tsx 10 | const MyCellComponent = ({ rowData, setRowData }) => { 11 | // Render a cell reflecting the value of `rowData` and update it using `setRowData` 12 | } 13 | 14 | const columns = [{ title: 'A', component: MyCellComponent }] 15 | ``` 16 | 17 | ## Data 18 | ### rowData 19 | > Type: `any` 20 | 21 | The row object from which tha value of the cell should be extracted. Make sure to 22 | [use `keyColumn`](../performance/optimization-guidelines#use-keycolumn) if you do not need the entire row object. 23 | 24 | ### setRowData 25 | > Type: `(rowData) => void` 26 | 27 | This function should be called to update the row with its new value. 28 | 29 | ## Extra information 30 | ### rowIndex 31 | > Type: `number` 32 | 33 | Index of the row. 34 | 35 | ### columnIndex 36 | > Type: `number` 37 | 38 | Index of the column. 39 | 40 | ### columnData 41 | > Type: `any` 42 | 43 | The column's data. [More details](columns#columndata) 44 | 45 | ## Cell state 46 | Try editing a cell to see the difference between `active` and `focus`: 47 | 48 | 49 | ### active 50 | > Type: `boolean` 51 | 52 | True when the cell is active / highlighted. 53 | 54 | It is recommended to hide any placeholder while a cell is not active to avoid having an entire empty column with the 55 | same text repeating. 56 | 57 | ### focus 58 | > Type: `boolean` 59 | 60 | True when the cell is focused / being edited. 61 | 62 | It is recommended to apply style `pointerEvents: none` to your components while they are not in focus 63 | to prevent any unwanted interactions or cursor. 64 | 65 | ### disabled 66 | > Type: `boolean` 67 | 68 | True when the cell is disabled | 69 | 70 | ## Control functions 71 | ### stopEditing 72 | > Type: `({ nextRow = true }) => void` 73 | 74 | This function can be called to exit edit mode programmatically. This is mainly used when `disableKeys` is true 75 | but it can have other use-cases. 76 | 77 | Optionally you can pass the `nextRow` parameter to `false` so the active / highlighted cell stays on the current 78 | cell instead of going to the next row. 79 | 80 | ### insertRowBelow 81 | > Type: `() => void` 82 | 83 | This function can be called to insert a row below the current one. 84 | 85 | ### duplicateRow 86 | > Type: `() => void` 87 | 88 | This function can be called to duplicate the current row. 89 | 90 | ### deleteRow 91 | > Type: `() => void` 92 | 93 | This function can be called to delete the current row. 94 | 95 | ### getContextMenuItems 96 | > Type: `() => ContextMenuItem[]` 97 | 98 | This function can be called to get the list of available context menu items (insert row, duplicate selected rows...). 99 | -------------------------------------------------------------------------------- /website/docs/api-reference/columns.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | import DisableColumns from '../../src/demos/disableColumns' 5 | 6 | # Columns 7 | 8 | Columns are simple objects and can be declared as such: 9 | ```tsx 10 | const columns = [ 11 | { title: 'A', id: 'a', /*...*/ }, 12 | { title: 'B', id: 'b', /*...*/ }, 13 | ] 14 | ``` 15 | 16 | ## title 17 | > Type: `JSX.Element`
18 | > Default: `null` 19 | 20 | Element to display in the header row. 21 | 22 | ## id 23 | > Type: `string` 24 | 25 | You can specify a unique ID for each column. This allows you to specify the column ID instead of its index when [controlling the grid](../controlling-the-grid). 26 | 27 | ## Sizing 28 | Sizing uses the [flexbox algorithm](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Flexible_Box_Layout/Basic_Concepts_of_Flexbox#properties_applied_to_flex_items), checkout some [examples](../columns#responsive-width). 29 | 30 | ### basis 31 | > Type: `number`
32 | > Default: `0` 33 | 34 | Same as the [flex-basis property](https://developer.mozilla.org/en-US/docs/Web/CSS/flex-basis). 35 | This is the initial width of the column in pixels before growing or shrinking to fit the grid width. 36 | 37 | ### grow 38 | > Type: `number`
39 | > Default: `1` 40 | 41 | Same as the [flex-grow property](https://developer.mozilla.org/en-US/docs/Web/CSS/flex-grow). 42 | This is a unit-less factor that dictates how much this column should grow to occupy the entire available width of the grid. 43 | 44 | ### shrink 45 | > Type: `number`
46 | > Default: `1` 47 | 48 | Same as the [flex-shrink property](https://developer.mozilla.org/en-US/docs/Web/CSS/flex-shrink). 49 | This is a unit-less factor that dictates how much this column should shrink to fit to the available width of the grid. 50 | 51 | ### minWidth 52 | > Type: `number`
53 | > Default: `100` 54 | 55 | Minimum width of the column in pixels. Can be `0` for no minimum value. 56 | 57 | ### maxWidth 58 | > Type: `number | null`
59 | > Default: `null` 60 | 61 | Maximum width of the column in pixels. Can be `null` for no maximum value. 62 | 63 | ## Copy pasting 64 | ### copyValue 65 | > Type: `({ rowData, rowIndex }) => number | string | null` 66 | 67 | A function that returns the value of the copied cell. If the user copies multiple cells at once, this function 68 | will be called multiple times. It can return a string, a number, or null, but it will always be turned into a string 69 | in the end. 70 | 71 | 72 | ### pasteValue 73 | > Type: `({ rowData, value, rowIndex }) => any` 74 | 75 | A function that takes in a row and the `value` to be pasted, and should return the updated row. If the value should 76 | be ignored, it should still return the unchanged row. The `value` is always a string, except if `prePasteValues` returns 77 | something else, and should therefore be casted to whatever type is needed. 78 | 79 | It is recommended to make sure that the `pasteValue` can handle all values returned by `copyValue` otherwise a 80 | user might be able to copy something but not paste it. 81 | 82 | ### prePasteValues 83 | > Type: `(values: string[]) => any[] | Promise` 84 | 85 | This function is called right before `pasteValue` with all the data that the user is trying to paste for that column. 86 | This gives you the opportunity to treat the pasted data as a whole and change it before passing it to `pasteValue`. 87 | 88 | This is where you would do some asynchronous work (eg. mapping names to actual IDs) and return a Promise. 89 | 90 | ### deleteValue 91 | > Type: `({ rowData, rowIndex }) => any` 92 | 93 | A function that deletes the column value of a row. Used when the user cuts, clears a cell, or deletes a row. 94 | 95 | ## Rendering 96 | ### component 97 | > Type: `CellComponent` 98 | 99 | A React component that renders a cell. [See props](cell-components) 100 | 101 | ### columnData 102 | > Type: `any` 103 | 104 | A value to pass to every cell component of the column through the `columnData` prop. Usually used to hold some kind 105 | of options for the column. 106 | 107 | For example, to implement a select column you would use `columnData` to pass the choices: 108 | ```tsx 109 | const SelectComponent = ({ columnData: choices }) => { 110 | // Render cell using `choices` 111 | } 112 | 113 | const selectColumn = (choices) => ({ 114 | columnData: choices, 115 | component: SelectComponent, 116 | }) 117 | 118 | function App() { 119 | return 122 | } 123 | ``` 124 | 125 | ### headerClassName 126 | > Type: `string` 127 | 128 | CSS class of the header cell. 129 | 130 | ### cellClassName 131 | > Type: `string | (({ rowData, rowIndex, columnId }) => string | undefined)` 132 | 133 | CSS class of each cell of the column. Can be a `string` if the class is the same for all cells, or a function 134 | if you need a different class per row. 135 | 136 | ## Options 137 | ### disabled 138 | > Type: `boolean | (({ rowData, rowIndex }) => boolean)`
139 | > Default: `false` 140 | 141 | Disable the entire column by passing `true`, or disable it for specific rows by passing a function. Disabled cells 142 | cannot be edited but their values can still be copied. 143 | 144 | Try toggling the "active" column: 145 | 146 | 147 | 148 | ```tsx 149 | !rowData.active, /*...*/ }, 153 | { title: 'Last name', disabled: true, /*...*/ }, 154 | ]} 155 | /> 156 | ``` 157 | 158 | :::note 159 | Notice that in this example you cannot delete a row using the `Del` key, this is because you can only delete empty rows. 160 | This might be problematic depending on your use-case, to solve this issue you can use [`isCellEmpty`](#iscellempty) 161 | and always return true to ignore this column. 162 | ::: 163 | 164 | ### disableKeys 165 | > Type: `boolean`
166 | > Default: `false` 167 | 168 | Usually when the user is editing a cell, pressing the up, down, or return key will exit editing mode. 169 | Setting `disableKeys` to `true` will prevent this behavior. This is used when the widget needs to handle the up and 170 | down keys itself (eg. to increase the value, or select a choice). Usually the widget also needs to handle the return 171 | key by calling [stopEditing](cell-components#stopediting). 172 | 173 | ### keepFocus 174 | > Type: `boolean`
175 | > Default: `false` 176 | 177 | When you implement a widget using a portal (ie. a div that is not a direct children of the cell) you might 178 | want the cell to keep focus when the user interacts with that element (even though it is actually 179 | outside of the grid itself). This means that you have to handle the click event and call [stopEditing](cell-components#stopediting) 180 | yourself to release focus. 181 | 182 | ## Behavior 183 | 184 | ### isCellEmpty 185 | > Type: `({ rowData, rowIndex }) => boolean`
186 | > Default: `() => false` 187 | 188 | When pressing `Del`, a user can only delete empty rows (a user can still delete non-empty rows using a right-click). 189 | A row is considered empty when all its cells are empty. This function allows to customize what is considered 190 | an empty cell. 191 | 192 | Because the default value is a function that **always** returns `false` (meaning the cell is never considered empty), 193 | you must implement your own logic to let the user delete rows using the `Del` key. 194 | -------------------------------------------------------------------------------- /website/docs/api-reference/ref.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | --- 4 | 5 | # Ref 6 | 7 | See how to use a ref to [control the grid from a parent](../controlling-the-grid). 8 | 9 | ## Reading values 10 | ### activeCell 11 | > Type: `{ colId?: string, col: number, row: number } | null` 12 | 13 | The active / highlighted cell or null if no cell is active. `col` is the index of the column and `colId` is its [`id`](columns#id) 14 | if it is specified. 15 | 16 | ### selection 17 | > Type: `{ min: Cell, max: Cell } | null` 18 | 19 | The current selection rect or null if nothing is selected or active. `min` and `max` are of the same type as `activeCell`. 20 | 21 | ## Writing values 22 | ### setActiveCell 23 | > Type: `(activeCell: { col: number | string, row: number } | null) => void` 24 | 25 | Set the current active cell, pass null to blur the grid. The column can be specified using its index (a number) or its [`id`](columns#id) (a string). 26 | 27 | ### setSelection 28 | > Type: `(selection: { min: Cell, max: Cell } | null) => void` 29 | 30 | Set the current selection, pass null to blur the grid. `min` and `max` are of the same type as `setActiveCell`. 31 | -------------------------------------------------------------------------------- /website/docs/controlling-the-grid.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | --- 4 | import Ref from '../src/demos/ref' 5 | 6 | # Controlling the grid from parent 7 | 8 | You can control the grid from a parent component using a ref: 9 | 10 | ```tsx 11 | import { 12 | DataSheetGrid, 13 | DataSheetGridRef, 14 | } from 'react-datasheet-grid' 15 | 16 | function App() { 17 | const ref = useRef(null) 18 | 19 | return 20 | } 21 | ``` 22 | 23 | You can then use that ref to control the grid from any callback. 24 | 25 | ```tsx 26 | function App() { 27 | const ref = useRef(null) 28 | 29 | useEffect(() => { 30 | ref.current?.setSelection({ 31 | min: { col: 'firstName', row: 0 }, 32 | max: { col: 2, row: 3 }, 33 | }) 34 | }, []) 35 | 36 | return ( 37 | <> 38 | 39 | 73 | ), 74 | }} 75 | /> 76 |
77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /website/src/demos/styleGenerator.css: -------------------------------------------------------------------------------- 1 | .generator-form label { 2 | padding-right: 10px; 3 | color: #7f7f7f; 4 | } 5 | 6 | .generator-form label.active { 7 | color: #000000; 8 | } 9 | 10 | .generator-form input { 11 | border: none; 12 | background: #f8f8f8; 13 | padding: 5px 8px; 14 | min-width: 200px; 15 | font-size: 14px; 16 | } 17 | 18 | .generator-form input:focus { 19 | background: #f1f1f1; 20 | outline: none; 21 | } 22 | -------------------------------------------------------------------------------- /website/src/demos/styleGenerator.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useState } from 'react' 2 | import { 3 | DynamicDataSheetGrid, 4 | checkboxColumn, 5 | textColumn, 6 | dateColumn, 7 | intColumn, 8 | floatColumn, 9 | percentColumn, 10 | keyColumn, 11 | } from 'react-datasheet-grid' 12 | import faker from 'faker' 13 | import CodeBlock from '@theme/CodeBlock' 14 | import './styleGenerator.css' 15 | 16 | const customProps = [ 17 | { name: '--dsg-border-color', defaultValue: '#e8ebed', type: 'color' }, 18 | { 19 | name: '--dsg-selection-border-color', 20 | defaultValue: 'rgb(69, 128, 230)', 21 | type: 'color', 22 | }, 23 | { name: '--dsg-selection-border-radius', defaultValue: '2px', type: 'size' }, 24 | { name: '--dsg-selection-border-width', defaultValue: '2px', type: 'size' }, 25 | { 26 | name: '--dsg-selection-background-color', 27 | defaultValue: 'rgba(69, 128, 230, 0.04)', 28 | type: 'color', 29 | }, 30 | { 31 | name: '--dsg-selection-disabled-border-color', 32 | defaultValue: '#9DA6AB', 33 | type: 'color', 34 | }, 35 | { 36 | name: '--dsg-selection-disabled-background-color', 37 | defaultValue: 'rgba(0, 0, 0, 0.04)', 38 | type: 'color', 39 | }, 40 | { name: '--dsg-corner-indicator-width', defaultValue: '10px', type: 'size' }, 41 | { 42 | name: '--dsg-header-text-color', 43 | defaultValue: 'rgb(157, 166, 171)', 44 | type: 'color', 45 | }, 46 | { 47 | name: '--dsg-header-active-text-color', 48 | defaultValue: 'black', 49 | type: 'color', 50 | }, 51 | { name: '--dsg-cell-background-color', defaultValue: 'white', type: 'color' }, 52 | { 53 | name: '--dsg-cell-disabled-background-color', 54 | defaultValue: 'rgb(250, 250, 250)', 55 | type: 'color', 56 | }, 57 | { name: '--dsg-transition-duration', defaultValue: '.1s', type: 'duration' }, 58 | { 59 | name: '--dsg-expand-rows-indicator-width', 60 | defaultValue: '10px', 61 | type: 'size', 62 | }, 63 | { name: '--dsg-scroll-shadow-width', defaultValue: '7px', type: 'size' }, 64 | { 65 | name: '--dsg-scroll-shadow-color', 66 | defaultValue: 'rgba(0,0,0,.2)', 67 | type: 'color', 68 | }, 69 | ] 70 | 71 | export default () => { 72 | const [cpValues, setCpValues] = useState({}) 73 | const [data, setData] = useState(() => 74 | new Array(50).fill(0).map(() => ({ 75 | active: faker.datatype.boolean(), 76 | firstName: faker.name.firstName(), 77 | lastName: faker.name.lastName(), 78 | job: faker.name.jobTitle(), 79 | area: faker.name.jobArea(), 80 | int: faker.datatype.number(1100), 81 | float: faker.datatype.number(1000) / 100, 82 | percent: faker.datatype.number(100) / 100, 83 | date: new Date(faker.date.past()), 84 | })) 85 | ) 86 | 87 | const columns = useMemo( 88 | () => [ 89 | { ...keyColumn('active', checkboxColumn), title: 'Active' }, 90 | { 91 | ...keyColumn('firstName', textColumn), 92 | title: 'First name', 93 | minWidth: 150, 94 | }, 95 | { 96 | ...keyColumn('lastName', textColumn), 97 | title: 'Last name', 98 | minWidth: 150, 99 | }, 100 | { 101 | ...keyColumn('int', intColumn), 102 | title: 'Integer', 103 | minWidth: 150, 104 | disabled: ({ rowData }) => rowData.active, 105 | }, 106 | { 107 | ...keyColumn('float', floatColumn), 108 | title: 'Float', 109 | minWidth: 150, 110 | disabled: true, 111 | }, 112 | { 113 | ...keyColumn('percent', percentColumn), 114 | title: 'Percentage', 115 | minWidth: 150, 116 | }, 117 | { ...keyColumn('date', dateColumn), title: 'Date' }, 118 | { 119 | ...keyColumn('job', textColumn), 120 | title: 'Job', 121 | minWidth: 250, 122 | }, 123 | { 124 | ...keyColumn('area', textColumn), 125 | title: 'Job area', 126 | minWidth: 150, 127 | }, 128 | ], 129 | [] 130 | ) 131 | 132 | return ( 133 | <> 134 |
135 | {customProps.map(({ name, defaultValue }) => ( 136 |
137 | 140 | 145 | setCpValues({ 146 | ...cpValues, 147 | [name]: e.target.value || undefined, 148 | }) 149 | } 150 | /> 151 |
152 | ))} 153 |
154 | {`:root {${Object.entries(cpValues) 155 | .filter(([_, value]) => value) 156 | .map(([key, value]) => `\n ${key}: ${value};`) 157 | .join('')}\n}`} 158 | 165 | 166 | ) 167 | } 168 | -------------------------------------------------------------------------------- /website/src/demos/trackRows.css: -------------------------------------------------------------------------------- 1 | .row-deleted .dsg-cell { 2 | background: #fff1f0; 3 | } 4 | 5 | .row-created .dsg-cell { 6 | background: #f6ffed; 7 | } 8 | 9 | .row-updated .dsg-cell { 10 | background: #fff7e6; 11 | } 12 | 13 | .btn { 14 | margin-bottom: 10px; 15 | margin-right: 10px; 16 | border: none; 17 | background: var(--ifm-menu-color-background-active); 18 | color: var(--ifm-menu-color); 19 | border-radius: 0.25rem; 20 | line-height: 1.25; 21 | padding: var(--ifm-menu-link-padding-vertical) var(--ifm-menu-link-padding-horizontal); 22 | font-size: 1rem; 23 | } 24 | 25 | .btn:hover { 26 | color: var(--ifm-menu-color-active); 27 | } 28 | -------------------------------------------------------------------------------- /website/src/demos/trackRows.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useRef, useState } from 'react' 2 | import { DataSheetGrid, keyColumn, textColumn } from 'react-datasheet-grid' 3 | import faker from 'faker' 4 | 5 | type Row = { 6 | id: number 7 | firstName?: string | null 8 | lastName?: string | null 9 | job?: string | null 10 | } 11 | import './trackRows.css' 12 | 13 | export const FinalResult = () => { 14 | const counter = useRef(1) 15 | const genId = () => counter.current++ 16 | 17 | const [data, setData] = useState(() => 18 | new Array(5).fill(0).map( 19 | () => 20 | ({ 21 | id: genId(), 22 | firstName: faker.name.firstName(), 23 | lastName: faker.name.lastName(), 24 | job: faker.name.jobTitle(), 25 | } as Row) 26 | ) 27 | ) 28 | const [prevData, setPrevData] = useState(data) 29 | 30 | const createdRowIds = useMemo(() => new Set(), []) 31 | const deletedRowIds = useMemo(() => new Set(), []) 32 | const updatedRowIds = useMemo(() => new Set(), []) 33 | 34 | const commit = () => { 35 | const newData = data.filter(({ id }) => !deletedRowIds.has(id)) 36 | setData(newData) 37 | createdRowIds.clear() 38 | deletedRowIds.clear() 39 | updatedRowIds.clear() 40 | setPrevData(newData) 41 | } 42 | 43 | const cancel = () => { 44 | setData(prevData) 45 | createdRowIds.clear() 46 | deletedRowIds.clear() 47 | updatedRowIds.clear() 48 | } 49 | 50 | return ( 51 | <> 52 | 55 | 58 | 59 | value={data} 60 | rowClassName={({ rowData: { id } }) => { 61 | if (deletedRowIds.has(id)) { 62 | return 'row-deleted' 63 | } 64 | if (createdRowIds.has(id)) { 65 | return 'row-created' 66 | } 67 | if (updatedRowIds.has(id)) { 68 | return 'row-updated' 69 | } 70 | }} 71 | onChange={(newValue, operations) => { 72 | for (const operation of operations) { 73 | if (operation.type === 'CREATE') { 74 | newValue 75 | .slice(operation.fromRowIndex, operation.toRowIndex) 76 | .forEach(({ id }) => createdRowIds.add(id)) 77 | } 78 | 79 | if (operation.type === 'UPDATE') { 80 | newValue 81 | .slice(operation.fromRowIndex, operation.toRowIndex) 82 | .forEach(({ id }) => { 83 | if (!createdRowIds.has(id) && !deletedRowIds.has(id)) { 84 | updatedRowIds.add(id) 85 | } 86 | }) 87 | } 88 | 89 | if (operation.type === 'DELETE') { 90 | let keptRows = 0 91 | 92 | data 93 | .slice(operation.fromRowIndex, operation.toRowIndex) 94 | .forEach(({ id }, i) => { 95 | updatedRowIds.delete(id) 96 | 97 | if (createdRowIds.has(id)) { 98 | createdRowIds.delete(id) 99 | } else { 100 | deletedRowIds.add(id) 101 | newValue.splice( 102 | operation.fromRowIndex + keptRows++, 103 | 0, 104 | data[operation.fromRowIndex + i] 105 | ) 106 | } 107 | }) 108 | } 109 | } 110 | 111 | setData(newValue) 112 | }} 113 | createRow={() => ({ id: genId() })} 114 | duplicateRow={({ rowData }) => ({ ...rowData, id: genId() })} 115 | columns={[ 116 | { ...keyColumn('firstName', textColumn), title: 'First Name' }, 117 | { ...keyColumn('lastName', textColumn), title: 'Last Name' }, 118 | { ...keyColumn('job', textColumn), title: 'Job' }, 119 | ]} 120 | /> 121 | 122 | ) 123 | } 124 | -------------------------------------------------------------------------------- /website/src/demos/underlyingData.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { 3 | DataSheetGrid, 4 | checkboxColumn, 5 | textColumn, 6 | keyColumn, 7 | intColumn, 8 | } from 'react-datasheet-grid' 9 | import faker from 'faker' 10 | import CodeBlock from '@theme/CodeBlock' 11 | 12 | export default () => { 13 | const [data, setData] = useState( 14 | new Array(3).fill(0).map((_, i) => ({ 15 | active: i % 2 === 0, 16 | firstName: faker.name.firstName(), 17 | number: faker.datatype.number(150), 18 | })) 19 | ) 20 | 21 | return ( 22 |
23 | 33 | 34 | {JSON.stringify(data.slice(0, 5), null, 2)} 35 | 36 |
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /website/src/demos/uniqueIds.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from 'react' 2 | import { DataSheetGrid, keyColumn, textColumn } from 'react-datasheet-grid' 3 | import faker from 'faker' 4 | 5 | // eslint-disable-next-line react/display-name 6 | export default () => { 7 | const counter = useRef(1) 8 | const genId = () => counter.current++ 9 | 10 | const [data, setData] = useState(() => 11 | new Array(5).fill(0).map(() => ({ 12 | id: genId(), 13 | firstName: faker.name.firstName(), 14 | lastName: faker.name.lastName(), 15 | })) 16 | ) 17 | 18 | return ( 19 |
20 | ({ id: genId() })} 24 | duplicateRow={({ rowData }) => ({ ...rowData, id: genId() })} 25 | columns={[ 26 | { 27 | ...keyColumn('id', textColumn), 28 | title: 'ID', 29 | disabled: true, 30 | isCellEmpty: () => true, 31 | }, 32 | { ...keyColumn('firstName', textColumn), title: 'First name' }, 33 | { ...keyColumn('lastName', textColumn), title: 'Last name' }, 34 | ]} 35 | /> 36 |
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /website/src/demos/xxl.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react' 2 | import { 3 | DataSheetGrid, 4 | checkboxColumn, 5 | textColumn, 6 | dateColumn, 7 | intColumn, 8 | floatColumn, 9 | percentColumn, 10 | keyColumn, 11 | DataSheetGridRef, 12 | } from 'react-datasheet-grid' 13 | import faker from 'faker' 14 | 15 | export default () => { 16 | const [data, setData] = useState(() => 17 | new Array(100000).fill(0).map(() => ({ 18 | active: faker.datatype.boolean(), 19 | firstName: faker.name.firstName(), 20 | lastName: faker.name.lastName(), 21 | job: faker.name.jobTitle(), 22 | area: faker.name.jobArea(), 23 | int: faker.datatype.number(1100), 24 | float: faker.datatype.number(1000) / 100, 25 | percent: faker.datatype.number(100) / 100, 26 | date: new Date(faker.date.past()), 27 | })) 28 | ) 29 | const ref = useRef() 30 | 31 | useEffect(() => { 32 | setTimeout(() => { 33 | ref.current.setActiveCell({ col: 1, row: 99999 }) 34 | setTimeout(() => { 35 | ref.current.setActiveCell({ col: 1, row: 99998 }) 36 | }, 0) 37 | }, 0) 38 | }, []) 39 | 40 | return ( 41 | 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /website/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | 3 | /** 4 | * CSS files with the .module.css suffix will be treated as CSS modules 5 | * and scoped locally. 6 | */ 7 | 8 | .heroBanner { 9 | padding: 4rem 0; 10 | text-align: center; 11 | position: relative; 12 | overflow: hidden; 13 | background: #303846; 14 | color: white; 15 | } 16 | 17 | @media screen and (max-width: 966px) { 18 | .heroBanner { 19 | padding: 2rem; 20 | } 21 | } 22 | 23 | .buttons { 24 | display: flex; 25 | align-items: center; 26 | justify-content: center; 27 | } 28 | 29 | .feature { 30 | padding: 60px 20px; 31 | font-size: 17px; 32 | } 33 | 34 | .feature figure { 35 | display: flex; 36 | justify-content: center; 37 | } 38 | 39 | .feature h2 { 40 | font-size: 25px; 41 | } 42 | 43 | .feature:nth-child(odd) { 44 | background: #F7F7F7; 45 | } 46 | 47 | html[data-theme='dark'] .feature:nth-child(odd) { 48 | background: #202223; 49 | } 50 | 51 | .feature:nth-child(odd) figure { 52 | order: 1; 53 | } 54 | 55 | .feature > div { 56 | display: flex; 57 | align-items: center; 58 | margin: 0 auto; 59 | max-width: 900px; 60 | } 61 | 62 | .feature > div > * { 63 | flex: 1; 64 | padding: 0 20px; 65 | margin: 0; 66 | } 67 | 68 | .partner { 69 | display: flex; 70 | align-items: center; 71 | max-width: 800px; 72 | margin: 40px auto; 73 | gap: 50px; 74 | position: relative; 75 | background: white; 76 | border-radius: 20px; 77 | padding: 30px; 78 | } 79 | 80 | .partner:before { 81 | content: ""; 82 | z-index: -1; 83 | position: absolute; 84 | top: 0; 85 | right: 0; 86 | bottom: 0; 87 | left: 0; 88 | background: linear-gradient(-45deg, #e81cff 0%, #40c9ff 100% ); 89 | transform: translate3d(0px, 10px, 0) scale(0.95); 90 | filter: blur(20px); 91 | opacity: 0.7; 92 | transition: opacity 0.3s; 93 | border-radius: inherit; 94 | } 95 | 96 | /* 97 | * Prevents issues when the parent creates a 98 | * stacking context. (For example, using the transform 99 | * property ) 100 | */ 101 | .partner::after { 102 | content: ""; 103 | z-index: -1; 104 | position: absolute; 105 | top: 0; 106 | right: 0; 107 | bottom: 0; 108 | left: 0; 109 | background: inherit; 110 | border-radius: inherit; 111 | } 112 | 113 | 114 | .partner figure { 115 | padding: 0; 116 | margin: 0; 117 | flex: 1; 118 | display: flex; 119 | align-items: center; 120 | justify-content: center; 121 | } 122 | 123 | .partner aside { 124 | padding: 0; 125 | margin: 0; 126 | flex: 2; 127 | } 128 | 129 | .partner figure > a { 130 | color: #252525; 131 | } 132 | 133 | .partner figure > a > div { 134 | left: 62%; 135 | background: #252525; 136 | transition: all .3s cubic-bezier(.47,1.64,.41,.8); 137 | } 138 | 139 | .partner figure > a:hover > div { 140 | left: 36%; 141 | } -------------------------------------------------------------------------------- /website/src/pages/markdown-page.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Markdown page example 3 | --- 4 | 5 | # Markdown page example 6 | 7 | You don't need React to write simple standalone pages. 8 | -------------------------------------------------------------------------------- /website/src/pages/style.css: -------------------------------------------------------------------------------- 1 | .react-rotating-text-cursor { 2 | animation: blinking-cursor 0.8s cubic-bezier(0.68, 0.01, 0.01, 0.99) 0s infinite; 3 | } 4 | 5 | @keyframes blinking-cursor { 6 | 0% { 7 | opacity: 0; 8 | } 9 | 50% { 10 | opacity: 1; 11 | } 12 | 100% { 13 | opacity: 0; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /website/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nick-keller/react-datasheet-grid/779bd227711afa89ee4901cee5de3bb0082dac4b/website/static/.nojekyll -------------------------------------------------------------------------------- /website/static/img/copy-paste.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nick-keller/react-datasheet-grid/779bd227711afa89ee4901cee5de3bb0082dac4b/website/static/img/copy-paste.gif -------------------------------------------------------------------------------- /website/static/img/custom-widgets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nick-keller/react-datasheet-grid/779bd227711afa89ee4901cee5de3bb0082dac4b/website/static/img/custom-widgets.png -------------------------------------------------------------------------------- /website/static/img/expand-selection.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nick-keller/react-datasheet-grid/779bd227711afa89ee4901cee5de3bb0082dac4b/website/static/img/expand-selection.gif -------------------------------------------------------------------------------- /website/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nick-keller/react-datasheet-grid/779bd227711afa89ee4901cee5de3bb0082dac4b/website/static/img/favicon.ico -------------------------------------------------------------------------------- /website/static/img/logos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nick-keller/react-datasheet-grid/779bd227711afa89ee4901cee5de3bb0082dac4b/website/static/img/logos.png -------------------------------------------------------------------------------- /website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/docusaurus/tsconfig.json", 3 | "include": ["src/"], 4 | "downlevelIteration": true 5 | } 6 | --------------------------------------------------------------------------------