├── .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 | 
4 | [](https://coveralls.io/github/nick-keller/react-datasheet-grid)
5 | [](https://www.npmjs.com/package/react-datasheet-grid)
6 | [](https://github.com/nick-keller/react-datasheet-grid)
7 | 
8 | [](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 | 
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': '',
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 | '',
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': '',
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 |