├── .eslintignore ├── .eslintrc ├── .github └── workflows │ ├── test-and-release.yml │ └── test-pull-request.yml ├── .gitignore ├── LICENSE ├── README.md ├── cypress.json ├── cypress ├── fixtures │ └── example.json ├── integration │ ├── cell.js │ ├── column.js │ ├── inline_filters.js │ ├── new_instance.js │ └── row.js ├── plugins │ └── index.js ├── support │ ├── commands.js │ └── index.js └── tsconfig.json ├── index.html ├── package.json ├── rollup.config.js ├── src ├── body-renderer.js ├── cellmanager.js ├── columnmanager.js ├── dark.css ├── datamanager.js ├── datatable.js ├── defaults.js ├── dom.js ├── filterRows.js ├── icons.js ├── index.js ├── keyboard.js ├── rowmanager.js ├── style.css ├── style.js ├── translationmanager.js ├── translations │ ├── de.json │ ├── en.json │ ├── fr.json │ ├── index.js │ └── it.json └── utils.js ├── test ├── datamanager.spec.js └── utils.spec.js └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | docs -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 6, 4 | "sourceType": "module" 5 | }, 6 | 7 | "env": { 8 | "browser": true, 9 | "es6": true, 10 | "node": true, 11 | "mocha": true 12 | }, 13 | 14 | "globals": { 15 | "document": false, 16 | "escape": false, 17 | "navigator": false, 18 | "unescape": false, 19 | "window": false, 20 | "describe": true, 21 | "before": true, 22 | "it": true, 23 | "expect": true, 24 | "sinon": true, 25 | "Clusterize": true, 26 | "cy": true, 27 | "Cypress": true 28 | }, 29 | 30 | "plugins": [ 31 | 32 | ], 33 | 34 | "rules": { 35 | "block-scoped-var": 2, 36 | "brace-style": [2, "1tbs", { "allowSingleLine": true }], 37 | "camelcase": [2, { "properties": "always" }], 38 | "comma-spacing": [2, { "before": false, "after": true }], 39 | "comma-style": [2, "last"], 40 | "complexity": 0, 41 | "consistent-return": 2, 42 | "consistent-this": 0, 43 | "curly": [2, "multi-line"], 44 | "default-case": 0, 45 | "dot-location": [2, "property"], 46 | "dot-notation": 0, 47 | "eol-last": 2, 48 | "eqeqeq": [2, "allow-null"], 49 | "func-names": 0, 50 | "func-style": 0, 51 | "generator-star-spacing": [2, "both"], 52 | "guard-for-in": 0, 53 | "handle-callback-err": [2, "^(err|error|anySpecificError)$" ], 54 | "indent": [2, 4, { "SwitchCase": 1 }], 55 | "key-spacing": [2, { "beforeColon": false, "afterColon": true }], 56 | "keyword-spacing": [2, {"before": true, "after": true}], 57 | "linebreak-style": 0, 58 | "max-depth": 0, 59 | "max-len": [2, 120, 4], 60 | "max-nested-callbacks": 0, 61 | "max-params": 0, 62 | "max-statements": 0, 63 | "new-cap": [2, { "newIsCap": true, "capIsNew": false }], 64 | "newline-after-var": [0, "always"], 65 | "new-parens": 2, 66 | "no-alert": 0, 67 | "no-array-constructor": 2, 68 | "no-bitwise": 0, 69 | "no-caller": 2, 70 | "no-catch-shadow": 0, 71 | "no-cond-assign": 2, 72 | "no-console": 0, 73 | "no-constant-condition": 0, 74 | "no-continue": 0, 75 | "no-control-regex": 2, 76 | "no-debugger": 2, 77 | "no-delete-var": 2, 78 | "no-div-regex": 0, 79 | "no-dupe-args": 2, 80 | "no-dupe-keys": 2, 81 | "no-duplicate-case": 2, 82 | "no-else-return": 2, 83 | "no-empty": 0, 84 | "no-empty-character-class": 2, 85 | "no-eq-null": 0, 86 | "no-eval": 2, 87 | "no-ex-assign": 2, 88 | "no-extend-native": 2, 89 | "no-extra-bind": 2, 90 | "no-extra-boolean-cast": 2, 91 | "no-extra-parens": 0, 92 | "no-extra-semi": 0, 93 | "no-extra-strict": 0, 94 | "no-fallthrough": 2, 95 | "no-floating-decimal": 2, 96 | "no-func-assign": 2, 97 | "no-implied-eval": 2, 98 | "no-inline-comments": 0, 99 | "no-inner-declarations": [2, "functions"], 100 | "no-invalid-regexp": 2, 101 | "no-irregular-whitespace": 2, 102 | "no-iterator": 2, 103 | "no-label-var": 2, 104 | "no-labels": 2, 105 | "no-lone-blocks": 0, 106 | "no-lonely-if": 0, 107 | "no-loop-func": 0, 108 | "no-mixed-requires": 0, 109 | "no-mixed-spaces-and-tabs": [2, false], 110 | "no-multi-spaces": 2, 111 | "no-multi-str": 2, 112 | "no-multiple-empty-lines": [2, { "max": 1 }], 113 | "no-native-reassign": 2, 114 | "no-negated-in-lhs": 2, 115 | "no-nested-ternary": 0, 116 | "no-new": 2, 117 | "no-new-func": 2, 118 | "no-new-object": 2, 119 | "no-new-require": 2, 120 | "no-new-wrappers": 2, 121 | "no-obj-calls": 2, 122 | "no-octal": 2, 123 | "no-octal-escape": 2, 124 | "no-path-concat": 0, 125 | "no-plusplus": 0, 126 | "no-process-env": 0, 127 | "no-process-exit": 0, 128 | "no-proto": 2, 129 | "no-redeclare": 2, 130 | "no-regex-spaces": 2, 131 | "no-reserved-keys": 0, 132 | "no-restricted-modules": 0, 133 | "no-return-assign": 2, 134 | "no-script-url": 0, 135 | "no-self-compare": 2, 136 | "no-sequences": 2, 137 | "no-shadow": 0, 138 | "no-shadow-restricted-names": 2, 139 | "no-spaced-func": 2, 140 | "no-sparse-arrays": 2, 141 | "no-sync": 0, 142 | "no-ternary": 0, 143 | "no-throw-literal": 2, 144 | "no-trailing-spaces": 2, 145 | "no-undef": 2, 146 | "no-undef-init": 2, 147 | "no-undefined": 0, 148 | "no-underscore-dangle": 0, 149 | "no-unneeded-ternary": 2, 150 | "no-unreachable": 2, 151 | "no-unused-expressions": 0, 152 | "no-unused-vars": [2, { "vars": "all", "args": "none" }], 153 | "no-use-before-define": 0, 154 | "no-var": 0, 155 | "no-void": 0, 156 | "no-warning-comments": 0, 157 | "no-with": 2, 158 | "one-var": 0, 159 | "operator-assignment": 0, 160 | "operator-linebreak": [2, "after"], 161 | "padded-blocks": 0, 162 | "quote-props": 0, 163 | "quotes": [2, "single", "avoid-escape"], 164 | "radix": 2, 165 | "semi": [2, "always"], 166 | "semi-spacing": 0, 167 | "sort-vars": 0, 168 | "space-before-blocks": [2, "always"], 169 | "space-before-function-paren": [2, {"anonymous": "always", "named": "never"}], 170 | "space-in-brackets": 0, 171 | "space-in-parens": [2, "never"], 172 | "space-infix-ops": 2, 173 | "space-unary-ops": [2, { "words": true, "nonwords": false }], 174 | "spaced-comment": [2, "always"], 175 | "strict": 0, 176 | "use-isnan": 2, 177 | "valid-jsdoc": 0, 178 | "valid-typeof": 2, 179 | "vars-on-top": 2, 180 | "wrap-iife": [2, "any"], 181 | "wrap-regex": 0, 182 | "yoda": [2, "never"] 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /.github/workflows/test-and-release.yml: -------------------------------------------------------------------------------- 1 | name: Test and Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | test-and-release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Lint, build and test 12 | uses: cypress-io/github-action@v2 13 | with: 14 | build: yarn lint-and-build 15 | start: yarn cy:server 16 | record: true 17 | env: 18 | CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | - name: Release 21 | env: 22 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | run: yarn semantic-release 25 | -------------------------------------------------------------------------------- /.github/workflows/test-pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Test Pull Request 2 | on: pull_request 3 | jobs: 4 | test-pull-request: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - name: Lint, build and test 9 | uses: cypress-io/github-action@v2 10 | with: 11 | build: yarn lint-and-build 12 | start: yarn cy:server 13 | record: true 14 | env: 15 | CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | # Remove some common IDE working directories 30 | .idea 31 | .vscode 32 | 33 | # npm debug logs 34 | npm-debug.log.* 35 | 36 | .DS_Store 37 | 38 | # cypress 39 | cypress/screenshots 40 | cypress/videos 41 | 42 | # dist 43 | dist 44 | 45 | .env -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2018 Frappe Technologies Pvt. Ltd. 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | datatable-logo 4 | 5 |

Frappe DataTable

6 | 7 | **A modern datatable library for the web** 8 | 9 | [![Test and Release](https://github.com/frappe/datatable/workflows/Test%20and%20Release/badge.svg)](https://github.com/frappe/datatable/actions?query=workflow%3A%22Test+and+Release%22) 10 | [![npm version](https://badge.fury.io/js/frappe-datatable.svg)](https://badge.fury.io/js/frappe-datatable) 11 | ![npm bundle size (minified + gzip)](https://img.shields.io/bundlephobia/minzip/frappe-datatable.svg) 12 | 13 | 14 | ![datatable-demo-2](https://user-images.githubusercontent.com/9355208/40740030-5412aa40-6465-11e8-8542-b0247ab1daac.gif) 15 | 16 |
17 | 18 | ## Frappe Datatable 19 | 20 | Frappe DataTable is a simple, modern and interactive datatable library for displaying tabular data. Originally built for [ERPNext](https://github.com/frappe/erpnext), it can be used to render large amount of rows without sacrificing performance and has the basic data grid features like inline editing and keyboard navigation. It does not require jQuery, unlike most data grids out there. 21 | 22 | ### Motivation 23 | 24 | I was trying to remove all legacy UI components from the [frappe](https://github.com/frappe/frappe) codebase. We were using [SlickGrid](https://github.com/mleibman/SlickGrid) for rendering tables. It was unmaintained and UI was dated. Other datatable solutions either didn't have the features we needed or were closed source. So we built our own. 25 | 26 | 27 | ### Key Features 28 | 29 | - **Cell**: Enable editing within individual cells and features like custom formatters, inline editing, and mouse selection. Users can easily copy cell content, navigate through cells using the keyboard, and take advantage of a custom cell editor for advanced functionality. 30 | - **Column**: Columns are highly flexible, allowing users to reorder, resize, and sort them with ease. Additional features include hiding/removing columns and adding custom actions. 31 | - **Row**: Rows support advanced interactions, including row selection, tree-structured organization, and inline filters for precise control. They handle large datasets efficiently with dynamic row heights. 32 | 33 | 34 | ## Usage 35 | 36 | ```bash 37 | yarn add frappe-datatable 38 | # or 39 | npm install frappe-datatable 40 | ``` 41 | 42 | > Note: [`sortablejs`](https://github.com/RubaXa/Sortable) is required to be installed as well. 43 | 44 | 45 | ```js 46 | const datatable = new DataTable('#datatable', { 47 | columns: [ 'First Name', 'Last Name', 'Position' ], 48 | data: [ 49 | [ 'Don', 'Joe', 'Designer' ], 50 | [ 'Mary', 'Jane', 'Software Developer' ] 51 | ] 52 | }); 53 | ``` 54 | 55 | ## Development Setup 56 | 57 | * `yarn start` - Start dev server 58 | * Open `index.html` located in the root folder, and start development. 59 | * Run `yarn lint` before committing changes 60 | * This project uses [commitizen](https://github.com/commitizen/cz-cli) for conventional commit messages, use `yarn commit` command instead of `git commit` 61 | 62 | ## Links 63 | 64 | - [Making a new datatable for the web](https://medium.com/frapp%C3%A9-thoughts/things-i-learned-building-a-library-for-the-web-6846a588bf53) 65 | 66 |
67 |
68 |
69 | 70 | 71 | 72 | Frappe Technologies 73 | 74 | 75 |
76 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:8989", 3 | "projectId": "2nsyux" 4 | } 5 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /cypress/integration/cell.js: -------------------------------------------------------------------------------- 1 | describe('Cell', function () { 2 | before(function () { 3 | cy.visit('/'); 4 | }); 5 | 6 | it('focuses cell on click', function () { 7 | cy.clickCell(2, 0) 8 | .should('have.class', 'dt-cell--focus'); 9 | }); 10 | 11 | it('not focuses cell which are not focusable', function () { 12 | cy.clickCell(1, 0) 13 | .should('not.have.class', 'dt-cell--focus'); 14 | }); 15 | 16 | it('edit cell on enter press', function () { 17 | cy.getCell(4, 0).type('{enter}') 18 | .should('have.class', 'dt-cell--editing') 19 | .type('{enter}') 20 | .should('not.have.class', 'dt-cell--editing'); 21 | }); 22 | 23 | it('edit cell on double click', function () { 24 | cy.getCell(4, 0) 25 | .as('target') 26 | .dblclick({ force: true }) 27 | .should('have.class', 'dt-cell--editing'); 28 | 29 | cy.clickCell(3, 0); 30 | 31 | cy.get('@target').should('not.have.class', 'dt-cell--editing'); 32 | }); 33 | 34 | it('edit cell', function () { 35 | cy.getCell(4, 1).dblclick({ force: true }); 36 | cy.getCell(4, 1).find('input').click(); 37 | cy.focused().type('{selectall}{del}Test{enter}'); 38 | cy.getCell(4, 1).contains('Test'); 39 | }); 40 | 41 | it('if editing is false: editing should not activate', function () { 42 | cy.getCell(3, 0).dblclick({ force: true }) 43 | .should('not.have.class', 'dt-cell--editing'); 44 | }); 45 | 46 | it('navigation using arrow keys', function () { 47 | cy.clickCell(2, 0) 48 | .type('{rightarrow}'); 49 | 50 | cy.get('.dt-cell--focus') 51 | .should('have.class', 'dt-cell--3-0') 52 | .click({ force: true }) 53 | .type('{downarrow}'); 54 | 55 | cy.get('.dt-cell--focus') 56 | .should('have.class', 'dt-cell--3-1'); 57 | // TODO: test navigation over hidden rows 58 | }); 59 | 60 | it('navigation using ctrl + arrow keys', function () { 61 | cy.clickCell(2, 0) 62 | .type('{ctrl}{rightarrow}'); 63 | cy.get('.dt-cell--focus') 64 | .should('have.class', 'dt-cell--11-0'); 65 | }); 66 | 67 | it('cell selection using shift + arrow keys', function () { 68 | cy.getCell(2, 1) 69 | .type('{shift}{rightarrow}{rightarrow}{downarrow}'); 70 | 71 | // 6 cells and 2 headers 72 | cy.get('.dt-cell--highlight').should('have.length', 6 + 2); 73 | 74 | cy.clickCell(2, 0); 75 | }); 76 | 77 | it('mouse selection', function () { 78 | // TODO: 79 | // cy.getCell(2, 1) 80 | // .trigger('mousedown', { which: 1, pageX: 331, pageY: 207, force: true }) 81 | // .trigger('mousemove', { which: 1, pageX: 489, pageY: 312 }) 82 | // .trigger('mouseup'); 83 | }); 84 | }); -------------------------------------------------------------------------------- /cypress/integration/column.js: -------------------------------------------------------------------------------- 1 | describe('Column', function () { 2 | before(function () { 3 | cy.visit('/'); 4 | }); 5 | 6 | it('header dropdown toggles on click', function () { 7 | cy.getColumnCell(2) 8 | .find('.dt-dropdown__toggle') 9 | .as('toggle') 10 | .click(); 11 | cy.get('.dt-dropdown__list') 12 | .as('dropdown-list') 13 | .should('be.visible'); 14 | 15 | cy.getColumnCell(2).click(); 16 | 17 | cy.get('@dropdown-list').should('not.be.visible'); 18 | }); 19 | 20 | it('sort ascending button should work', function () { 21 | cy.clickDropdown(2); 22 | cy.clickDropdownItem(2, 'Sort Ascending'); 23 | 24 | cy.window().then(win => win.datatable.getColumn(2)) 25 | .its('sortOrder') 26 | .should('eq', 'asc'); 27 | 28 | cy.window().then(win => win.datatable.datamanager) 29 | .its('currentSort.colIndex') 30 | .should('eq', 2); 31 | 32 | cy.get('.dt-scrollable .dt-row:first div:nth-of-type(3)') 33 | .contains('Airi Satou'); 34 | 35 | cy.clickDropdownItem(2, 'Reset sorting'); 36 | }); 37 | 38 | it('removes column using dropdown action', function () { 39 | cy.get('.dt-cell--header').should('have.length', 12); 40 | 41 | cy.clickDropdown(5); 42 | cy.clickDropdownItem(5, 'Remove column'); 43 | 44 | cy.get('.dt-cell--header').should('have.length', 11); 45 | }); 46 | 47 | it('resize column with mouse drag', function () { 48 | cy.get('.dt-cell--header-4 .dt-cell__resize-handle').as('resize-handle'); 49 | cy 50 | .get('@resize-handle') 51 | .trigger('mousedown') 52 | .trigger('mousemove', { pageX: 700, pageY: 20, which: 1 }) 53 | .trigger('mouseup'); 54 | 55 | cy.getColumnCell(4).invoke('css', 'width').then((width) => { 56 | cy.getColumnCell(4) 57 | .should('have.css', 'width', width); 58 | cy.getCell(4, 1) 59 | .should('have.css', 'width', width); 60 | }); 61 | }); 62 | 63 | it('resize column using double click', function () { 64 | cy.get('.dt-cell--header-4 .dt-cell__resize-handle').trigger('dblclick'); 65 | cy.getColumnCell(4).should('have.css', 'width') 66 | .and('match', /9\dpx/); 67 | cy.getCell(4, 1).should('have.css', 'width') 68 | .and('match', /9\dpx/); 69 | }); 70 | }); -------------------------------------------------------------------------------- /cypress/integration/inline_filters.js: -------------------------------------------------------------------------------- 1 | describe('Inline Filters', function () { 2 | before(function () { 3 | cy.visit('/'); 4 | }); 5 | 6 | beforeEach(function () { 7 | cy.get('.dt-filter[data-col-index=4]').as('filterInput4'); 8 | cy.get('.dt-filter[data-col-index=5]').as('filterInput5'); 9 | cy.get('.dt-filter[data-col-index=6]').as('filterInput6'); 10 | cy.get('.dt-row[data-row-index=0]').should('be.visible'); 11 | }); 12 | 13 | it('simple text filter', function () { 14 | cy.getCell(4, 0).click().type('{ctrl}f'); 15 | 16 | cy.get('@filterInput4').type('edin'); 17 | cy.get('.dt-row-0').should('be.visible'); 18 | cy.get('.dt-row-1').should('not.exist'); 19 | cy.get('@filterInput4').clear(); 20 | }); 21 | 22 | it('simple number filter', function () { 23 | cy.get('@filterInput5').type('2360'); 24 | cy.get('.dt-row[data-row-index=8]').should('be.visible'); 25 | cy.get('.dt-row[data-row-index=15]').should('not.exist'); 26 | cy.get('.dt-row[data-row-index=22]').should('not.exist'); 27 | cy.get('@filterInput5').clear(); 28 | }); 29 | 30 | it('greater than', function () { 31 | cy.get('@filterInput5').type('> 6000'); 32 | cy.get('.dt-row[data-row-index=0]').should('not.exist'); 33 | cy.get('.dt-row[data-row-index=3]').should('be.visible'); 34 | cy.get('@filterInput5').clear(); 35 | }); 36 | 37 | it('less than', function () { 38 | cy.get('@filterInput5').type('< 2000'); 39 | cy.get('.dt-row[data-row-index=0]').should('not.exist'); 40 | cy.get('.dt-row[data-row-index=51]').should('be.visible'); 41 | cy.get('@filterInput5').clear(); 42 | }); 43 | 44 | it('range', function () { 45 | cy.get('@filterInput5').type(' 2000: 5000'); 46 | cy.get('.dt-row[data-row-index=4]').should('not.exist'); 47 | cy.get('.dt-row[data-row-index=5]').should('be.visible'); 48 | cy.get('@filterInput5').clear(); 49 | }); 50 | 51 | it('equals', function () { 52 | cy.get('@filterInput5').type('=9608'); 53 | cy.get('.dt-row-6').should('be.visible'); 54 | cy.get('@filterInput5').clear(); 55 | }); 56 | 57 | it('multiple filters', function () { 58 | cy.get('@filterInput4').type('to'); 59 | cy.get('@filterInput5').type('54'); 60 | 61 | cy.get('.dt-row[data-row-index=4]').should('be.visible'); 62 | cy.get('.dt-row[data-row-index=1]').should('not.exist'); 63 | cy.get('@filterInput4').clear(); 64 | cy.get('@filterInput5').clear(); 65 | }); 66 | 67 | it('greater than for string type filters', function () { 68 | cy.get('@filterInput6').type('> 01/07/2011'); 69 | cy.get('.dt-row[data-row-index=0]').should('not.exist'); 70 | cy.get('.dt-row[data-row-index=1]').should('be.visible'); 71 | cy.get('.dt-row[data-row-index=3]').should('be.visible'); 72 | cy.get('.dt-row[data-row-index=5]').should('be.visible'); 73 | cy.get('@filterInput6').clear(); 74 | }); 75 | 76 | it('filters with sorting', function () { 77 | cy.visit('/'); 78 | cy.clickDropdown(7); 79 | cy.clickDropdownItem(7, 'Sort Descending'); 80 | cy.get('.dt-filter[data-col-index=5]').as('filterInput5'); 81 | cy.getCell(5, 24).click().type('{ctrl}f'); 82 | cy.get('@filterInput5').type('>3000', {delay: 100}); 83 | 84 | cy.get('.dt-scrollable .dt-row:first') 85 | .should('contain', 'Angelica') 86 | .should('have.class', 'dt-row-24'); 87 | cy.get('@filterInput5').clear(); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /cypress/integration/new_instance.js: -------------------------------------------------------------------------------- 1 | describe('DataTable init', function () { 2 | it('instance is created without any errors', () => { 3 | cy.visit('/'); 4 | 5 | cy.window() 6 | .its('DataTable') 7 | .then(DataTable => { 8 | // eslint-disable-next-line 9 | new DataTable('#datatable2', { 10 | columns: ['Name', 'Position'], 11 | data: [ 12 | ['Faris', 'Developer'] 13 | ] 14 | }); 15 | }); 16 | 17 | cy.get('#datatable2 .datatable') 18 | .contains('Faris'); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /cypress/integration/row.js: -------------------------------------------------------------------------------- 1 | describe('Row', function () { 2 | before(function () { 3 | cy.visit('/'); 4 | }); 5 | 6 | it('check / uncheck row', function () { 7 | cy.get('.dt-scrollable .dt-row:first') 8 | .find('input[type="checkbox"]') 9 | .click(); 10 | 11 | cy.get('[data-row-index="0"]').should('have.class', 'dt-row--highlight'); 12 | 13 | cy.get('.dt-toast').contains('1 row selected'); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | module.exports = (on, config) => { 15 | // `on` is used to hook into various events Cypress emits 16 | // `config` is the resolved Cypress config 17 | }; 18 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | 27 | Cypress.Commands.add('getCell', (col, row) => { 28 | return cy.get(`.dt-cell--${col}-${row}`); 29 | }); 30 | 31 | Cypress.Commands.add('clickCell', (col, row) => { 32 | return cy.getCell(col, row).click({ force: true }); 33 | }); 34 | 35 | Cypress.Commands.add('getColumnCell', (col) => { 36 | return cy.get(`.dt-cell--header-${col}`); 37 | }); 38 | 39 | Cypress.Commands.add('clickDropdown', (col) => { 40 | return cy.getColumnCell(col) 41 | .find('.dt-dropdown__toggle') 42 | .click(); 43 | }); 44 | 45 | Cypress.Commands.add('clickDropdownItem', (col, item) => { 46 | return cy.get(`.dt-dropdown__list-item:contains("${item}")`) 47 | .click({ force: true }); 48 | }); 49 | 50 | Cypress.Commands.add('typeTab', (shiftKey, ctrlKey) => { 51 | cy.focused().trigger('keydown', { 52 | keyCode: 9, 53 | which: 9, 54 | shiftKey: shiftKey, 55 | ctrlKey: ctrlKey 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "baseUrl": "../node_modules", 5 | "types": [ 6 | "cypress" 7 | ] 8 | }, 9 | "include": [ 10 | "**/*.*" 11 | ] 12 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Frappe DataTable 9 | 16 | 17 | 18 | 19 | 20 |

Frappe DataTable

21 | 22 | 23 | 24 | 25 | 29 | 33 |
34 | 35 |
36 | 37 |
38 | 39 |
40 | 41 | 42 | 43 | 356 | 357 | 358 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frappe-datatable", 3 | "version": "0.0.0-development", 4 | "description": "A modern datatable library for the web", 5 | "main": "dist/frappe-datatable.cjs.js", 6 | "unpkg": "dist/frappe-datatable.min.js", 7 | "jsdelivr": "dist/frappe-datatable.min.js", 8 | "scripts": { 9 | "start": "yarn run dev", 10 | "build": "rollup -c && NODE_ENV=production rollup -c", 11 | "dev": "rollup -c -w", 12 | "cy:server": "http-server -p 8989", 13 | "cy:open": "cypress open", 14 | "cy:run": "cypress run", 15 | "test": "start-server-and-test cy:server http://localhost:8989 cy:run", 16 | "test-local": "start-server-and-test cy:server http://localhost:8989 cy:open", 17 | "travis-deploy-once": "travis-deploy-once", 18 | "semantic-release": "semantic-release", 19 | "lint": "eslint src", 20 | "lint-and-build": "yarn lint && yarn build", 21 | "commit": "npx git-cz" 22 | }, 23 | "files": [ 24 | "dist", 25 | "src" 26 | ], 27 | "devDependencies": { 28 | "autoprefixer": "^9.0.0", 29 | "chai": "3.5.0", 30 | "cypress": "^9.2.0", 31 | "cz-conventional-changelog": "^2.1.0", 32 | "deepmerge": "^2.0.1", 33 | "eslint": "^5.0.1", 34 | "eslint-config-airbnb": "^16.1.0", 35 | "eslint-config-airbnb-base": "^12.1.0", 36 | "eslint-plugin-import": "^2.11.0", 37 | "http-server": "^0.11.1", 38 | "mocha": "3.3.0", 39 | "postcss-custom-properties": "^7.0.0", 40 | "postcss-nested": "^3.0.0", 41 | "rollup": "^0.59.4", 42 | "rollup-plugin-commonjs": "^8.3.0", 43 | "rollup-plugin-eslint": "^4.0.0", 44 | "rollup-plugin-json": "^2.3.0", 45 | "rollup-plugin-node-resolve": "^3.0.3", 46 | "rollup-plugin-postcss": "^1.2.8", 47 | "rollup-plugin-uglify-es": "^0.0.1", 48 | "semantic-release": "^17.1.1", 49 | "start-server-and-test": "^1.4.1", 50 | "travis-deploy-once": "^5.0.1" 51 | }, 52 | "repository": { 53 | "type": "git", 54 | "url": "https://github.com/frappe/datatable.git" 55 | }, 56 | "keywords": [ 57 | "datatable", 58 | "data", 59 | "grid", 60 | "table" 61 | ], 62 | "author": "Faris Ansari", 63 | "license": "MIT", 64 | "bugs": { 65 | "url": "https://github.com/frappe/datatable/issues" 66 | }, 67 | "homepage": "https://frappe.io/datatable", 68 | "dependencies": { 69 | "hyperlist": "^1.0.0-beta", 70 | "lodash": "^4.17.5", 71 | "sortablejs": "^1.7.0" 72 | }, 73 | "config": { 74 | "commitizen": { 75 | "path": "cz-conventional-changelog" 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import json from 'rollup-plugin-json'; 2 | import uglify from 'rollup-plugin-uglify-es'; 3 | import nodeResolve from 'rollup-plugin-node-resolve'; 4 | import commonjs from 'rollup-plugin-commonjs'; 5 | import postcss from 'rollup-plugin-postcss'; 6 | import nested from 'postcss-nested'; 7 | import customProperties from 'postcss-custom-properties'; 8 | import autoprefixer from 'autoprefixer'; 9 | import eslint from 'rollup-plugin-eslint'; 10 | import merge from 'deepmerge'; 11 | 12 | const production = process.env.NODE_ENV === 'production'; 13 | 14 | const baseJS = { 15 | input: 'src/index.js', 16 | output: { 17 | file: '', 18 | globals: { 19 | sortablejs: 'Sortable', 20 | 'clusterize.js': 'Clusterize' 21 | } 22 | }, 23 | plugins: [ 24 | json(), 25 | eslint({ 26 | exclude: '**/*.json' 27 | }), 28 | nodeResolve(), 29 | commonjs() 30 | ], 31 | external: ['sortablejs', 'clusterize.js'] 32 | }; 33 | 34 | const baseCSS = { 35 | input: 'src/style.css', 36 | output: { 37 | file: '' 38 | }, 39 | plugins: [ 40 | postcss({ 41 | extract: true, 42 | minimize: production, 43 | plugins: [ 44 | customProperties(), 45 | nested(), 46 | autoprefixer() 47 | ] 48 | }) 49 | ] 50 | }; 51 | 52 | const devIIFE = merge(baseJS, { 53 | output: { 54 | file: 'dist/frappe-datatable.js', 55 | format: 'iife', 56 | name: 'DataTable' 57 | } 58 | }); 59 | 60 | const devCjs = merge(baseJS, { 61 | output: { 62 | file: 'dist/frappe-datatable.cjs.js', 63 | format: 'cjs' 64 | } 65 | }); 66 | 67 | const devCSS = merge(baseCSS, { 68 | output: { 69 | file: 'dist/frappe-datatable.css', 70 | format: 'cjs' 71 | } 72 | }); 73 | 74 | // production 75 | const prodIIFE = merge(devIIFE, { 76 | output: { 77 | file: 'dist/frappe-datatable.min.js' 78 | }, 79 | plugins: [ 80 | uglify() 81 | ] 82 | }); 83 | 84 | const prodCSS = merge(devCSS, { 85 | output: { 86 | file: 'dist/frappe-datatable.min.css' 87 | } 88 | }); 89 | 90 | const developmentAssets = [devIIFE, devCjs, devCSS]; 91 | const productionAssets = [prodIIFE, prodCSS]; 92 | const assets = production ? productionAssets : developmentAssets; 93 | 94 | export default assets; 95 | -------------------------------------------------------------------------------- /src/body-renderer.js: -------------------------------------------------------------------------------- 1 | import HyperList from 'hyperlist'; 2 | 3 | export default class BodyRenderer { 4 | constructor(instance) { 5 | this.instance = instance; 6 | this.options = instance.options; 7 | this.datamanager = instance.datamanager; 8 | this.rowmanager = instance.rowmanager; 9 | this.cellmanager = instance.cellmanager; 10 | this.bodyScrollable = instance.bodyScrollable; 11 | this.footer = this.instance.footer; 12 | this.log = instance.log; 13 | } 14 | 15 | renderRows(rows) { 16 | this.visibleRows = rows; 17 | this.visibleRowIndices = rows.map(row => row.meta.rowIndex); 18 | this.instance.noData = false; 19 | if (rows.length === 0) { 20 | this.bodyScrollable.innerHTML = this.getNoDataHTML(); 21 | this.instance.noData = true; 22 | this.footer.innerHTML = ''; 23 | return; 24 | } 25 | 26 | // Create a temporary set for faster lookups. 27 | // We can't change this.visibleRowIndices as it would be breaking for users. 28 | let visibleRowIndicesSet = new Set(this.visibleRowIndices); 29 | const rowViewOrder = this.datamanager.rowViewOrder.map(index => { 30 | if (visibleRowIndicesSet.has(index)) { 31 | return index; 32 | } 33 | return null; 34 | }).filter(index => index !== null); 35 | 36 | const computedStyle = getComputedStyle(this.bodyScrollable); 37 | 38 | let config = { 39 | width: computedStyle.width, 40 | height: computedStyle.height, 41 | itemHeight: this.options.cellHeight, 42 | total: rows.length, 43 | generate: (index) => { 44 | const el = document.createElement('div'); 45 | const rowIndex = rowViewOrder[index]; 46 | const row = this.datamanager.getRow(rowIndex); 47 | const rowHTML = this.rowmanager.getRowHTML(row, row.meta); 48 | el.innerHTML = rowHTML; 49 | return el.children[0]; 50 | }, 51 | afterRender: () => { 52 | this.restoreState(); 53 | } 54 | }; 55 | 56 | if (!this.hyperlist) { 57 | this.hyperlist = new HyperList(this.bodyScrollable, config); 58 | } else { 59 | this.hyperlist.refresh(this.bodyScrollable, config); 60 | } 61 | 62 | this.renderFooter(); 63 | } 64 | 65 | render() { 66 | const rows = this.datamanager.getRowsForView(); 67 | this.renderRows(rows); 68 | // setDimensions requires atleast 1 row to exist in dom 69 | this.instance.setDimensions(); 70 | } 71 | 72 | renderFooter() { 73 | if (!this.options.showTotalRow) return; 74 | 75 | const totalRow = this.getTotalRow(); 76 | let html = this.rowmanager.getRowHTML(totalRow, { isTotalRow: 1, rowIndex: 'totalRow' }); 77 | 78 | this.footer.innerHTML = html; 79 | } 80 | 81 | getTotalRow() { 82 | const columns = this.datamanager.getColumns(); 83 | const totalRowTemplate = columns.map(col => { 84 | let content = null; 85 | if (['_rowIndex', '_checkbox'].includes(col.id)) { 86 | content = ''; 87 | } 88 | return { 89 | content, 90 | isTotalRow: 1, 91 | colIndex: col.colIndex, 92 | column: col 93 | }; 94 | }); 95 | 96 | const totalRow = totalRowTemplate.map((cell, i) => { 97 | if (cell.content === '') return cell; 98 | 99 | if (this.options.hooks.columnTotal) { 100 | const columnValues = this.visibleRows.map(row => row[i].content); 101 | const result = this.options.hooks.columnTotal.call(this.instance, columnValues, cell); 102 | if (result != null) { 103 | cell.content = result; 104 | return cell; 105 | } 106 | } 107 | 108 | cell.content = this.visibleRows.reduce((acc, prevRow) => { 109 | const prevCell = prevRow[i]; 110 | if (typeof prevCell.content === 'number') { 111 | if (acc == null) acc = 0; 112 | return acc + prevCell.content; 113 | } 114 | return acc; 115 | }, cell.content); 116 | 117 | return cell; 118 | }); 119 | 120 | return totalRow; 121 | } 122 | 123 | restoreState() { 124 | this.rowmanager.highlightCheckedRows(); 125 | this.cellmanager.selectAreaOnClusterChanged(); 126 | this.cellmanager.focusCellOnClusterChanged(); 127 | } 128 | 129 | showToastMessage(message, hideAfter) { 130 | this.instance.toastMessage.innerHTML = this.getToastMessageHTML(message); 131 | 132 | if (hideAfter) { 133 | setTimeout(() => { 134 | this.clearToastMessage(); 135 | }, hideAfter * 1000); 136 | } 137 | } 138 | 139 | clearToastMessage() { 140 | this.instance.toastMessage.innerHTML = ''; 141 | } 142 | 143 | getNoDataHTML() { 144 | const style = window.getComputedStyle(this.instance.header); 145 | const matrix = new DOMMatrixReadOnly(style.transform); 146 | const width = (-matrix.m41) + this.instance.header.clientWidth; 147 | const height = this.bodyScrollable.clientHeight; 148 | return ` 149 |
153 |
154 | ${this.options.noDataMessage} 155 |
156 |
157 | `; 158 | } 159 | 160 | getToastMessageHTML(message) { 161 | return `${message}`; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/cellmanager.js: -------------------------------------------------------------------------------- 1 | import { 2 | copyTextToClipboard, 3 | makeDataAttributeString, 4 | throttle, 5 | linkProperties, 6 | escapeHTML, 7 | } from './utils'; 8 | import $ from './dom'; 9 | import icons from './icons'; 10 | 11 | export default class CellManager { 12 | constructor(instance) { 13 | this.instance = instance; 14 | linkProperties(this, this.instance, [ 15 | 'wrapper', 16 | 'options', 17 | 'style', 18 | 'header', 19 | 'bodyScrollable', 20 | 'columnmanager', 21 | 'rowmanager', 22 | 'datamanager', 23 | 'keyboard', 24 | 'footer' 25 | ]); 26 | 27 | this.bindEvents(); 28 | } 29 | 30 | bindEvents() { 31 | this.bindFocusCell(); 32 | this.bindEditCell(); 33 | this.bindKeyboardSelection(); 34 | this.bindCopyCellContents(); 35 | this.bindMouseEvents(); 36 | this.bindTreeEvents(); 37 | } 38 | 39 | bindFocusCell() { 40 | this.bindKeyboardNav(); 41 | } 42 | 43 | bindEditCell() { 44 | this.$editingCell = null; 45 | 46 | $.on(this.bodyScrollable, 'dblclick', '.dt-cell', (e, cell) => { 47 | this.activateEditing(cell); 48 | }); 49 | 50 | this.keyboard.on('enter', () => { 51 | if (this.$focusedCell && !this.$editingCell) { 52 | // enter keypress on focused cell 53 | this.activateEditing(this.$focusedCell); 54 | } else if (this.$editingCell) { 55 | // enter keypress on editing cell 56 | this.deactivateEditing(); 57 | } 58 | }); 59 | } 60 | 61 | bindKeyboardNav() { 62 | const focusLastCell = (direction) => { 63 | if (!this.$focusedCell || this.$editingCell) { 64 | return false; 65 | } 66 | 67 | let $cell = this.$focusedCell; 68 | const { 69 | rowIndex, 70 | colIndex 71 | } = $.data($cell); 72 | 73 | if (direction === 'left') { 74 | $cell = this.getLeftMostCell$(rowIndex); 75 | } else if (direction === 'right') { 76 | $cell = this.getRightMostCell$(rowIndex); 77 | } else if (direction === 'up') { 78 | $cell = this.getTopMostCell$(colIndex); 79 | } else if (direction === 'down') { 80 | $cell = this.getBottomMostCell$(colIndex); 81 | } 82 | 83 | this.focusCell($cell); 84 | return true; 85 | }; 86 | 87 | ['left', 'right', 'up', 'down', 'tab', 'shift+tab'] 88 | .map(direction => this.keyboard.on(direction, () => this.focusCellInDirection(direction))); 89 | 90 | ['left', 'right', 'up', 'down'] 91 | .map(direction => this.keyboard.on(`ctrl+${direction}`, () => focusLastCell(direction))); 92 | 93 | this.keyboard.on('esc', () => { 94 | this.deactivateEditing(false); 95 | this.columnmanager.toggleFilter(false); 96 | }); 97 | 98 | if (this.options.inlineFilters) { 99 | this.keyboard.on('ctrl+f', (e) => { 100 | const $cell = $.closest('.dt-cell', e.target); 101 | const { colIndex } = $.data($cell); 102 | 103 | this.activateFilter(colIndex); 104 | return true; 105 | }); 106 | 107 | $.on(this.header, 'focusin', '.dt-filter', () => { 108 | this.unfocusCell(this.$focusedCell); 109 | }); 110 | } 111 | } 112 | 113 | bindKeyboardSelection() { 114 | const getNextSelectionCursor = (direction) => { 115 | let $selectionCursor = this.getSelectionCursor(); 116 | 117 | if (direction === 'left') { 118 | $selectionCursor = this.getLeftCell$($selectionCursor); 119 | } else if (direction === 'right') { 120 | $selectionCursor = this.getRightCell$($selectionCursor); 121 | } else if (direction === 'up') { 122 | $selectionCursor = this.getAboveCell$($selectionCursor); 123 | } else if (direction === 'down') { 124 | $selectionCursor = this.getBelowCell$($selectionCursor); 125 | } 126 | 127 | return $selectionCursor; 128 | }; 129 | 130 | ['left', 'right', 'up', 'down'] 131 | .map(direction => 132 | this.keyboard.on(`shift+${direction}`, () => this.selectArea(getNextSelectionCursor(direction)))); 133 | } 134 | 135 | bindCopyCellContents() { 136 | this.keyboard.on('ctrl+c', () => { 137 | const noOfCellsCopied = this.copyCellContents(this.$focusedCell, this.$selectionCursor); 138 | const message = this.instance.translate('{count} cells copied', { 139 | count: noOfCellsCopied 140 | }); 141 | 142 | if (noOfCellsCopied) { 143 | this.instance.showToastMessage(message, 2); 144 | } 145 | }); 146 | 147 | if (this.options.pasteFromClipboard) { 148 | this.keyboard.on('ctrl+v', (e) => { 149 | // hack 150 | // https://stackoverflow.com/a/2177059/5353542 151 | this.instance.pasteTarget.focus(); 152 | 153 | setTimeout(() => { 154 | const data = this.instance.pasteTarget.value; 155 | this.instance.pasteTarget.value = ''; 156 | this.pasteContentInCell(data); 157 | }, 10); 158 | 159 | return false; 160 | }); 161 | } 162 | } 163 | 164 | bindMouseEvents() { 165 | let mouseDown = null; 166 | 167 | $.on(this.bodyScrollable, 'mousedown', '.dt-cell', (e) => { 168 | mouseDown = true; 169 | this.focusCell($(e.delegatedTarget)); 170 | }); 171 | 172 | $.on(this.bodyScrollable, 'mouseup', () => { 173 | mouseDown = false; 174 | }); 175 | 176 | if (this.options.showTotalRow) { 177 | $.on(this.footer, 'click', '.dt-cell', (e) => { 178 | 179 | this.focusCell($(e.delegatedTarget)); 180 | }); 181 | 182 | } 183 | 184 | const selectArea = (e) => { 185 | if (!mouseDown) return; 186 | this.selectArea($(e.delegatedTarget)); 187 | }; 188 | 189 | $.on(this.bodyScrollable, 'mousemove', '.dt-cell', throttle(selectArea, 50)); 190 | } 191 | 192 | bindTreeEvents() { 193 | $.on(this.bodyScrollable, 'click', '.dt-tree-node__toggle', (e, $toggle) => { 194 | const $cell = $.closest('.dt-cell', $toggle); 195 | const { rowIndex } = $.data($cell); 196 | 197 | if ($cell.classList.contains('dt-cell--tree-close')) { 198 | this.rowmanager.openSingleNode(rowIndex); 199 | } else { 200 | this.rowmanager.closeSingleNode(rowIndex); 201 | } 202 | }); 203 | } 204 | 205 | focusCell($cell, { 206 | skipClearSelection = 0, 207 | skipDOMFocus = 0, 208 | skipScrollToCell = 0 209 | } = {}) { 210 | if (!$cell) return; 211 | 212 | // don't focus if already editing cell 213 | if ($cell === this.$editingCell) return; 214 | 215 | const { 216 | colIndex, 217 | isHeader 218 | } = $.data($cell); 219 | if (isHeader) { 220 | return; 221 | } 222 | 223 | const column = this.columnmanager.getColumn(colIndex); 224 | if (column.focusable === false) { 225 | return; 226 | } 227 | 228 | if (!skipScrollToCell) { 229 | this.scrollToCell($cell); 230 | } 231 | 232 | this.deactivateEditing(); 233 | if (!skipClearSelection) { 234 | this.clearSelection(); 235 | } 236 | 237 | if (this.$focusedCell) { 238 | this.$focusedCell.classList.remove('dt-cell--focus'); 239 | } 240 | 241 | this.$focusedCell = $cell; 242 | $cell.classList.add('dt-cell--focus'); 243 | 244 | if (!skipDOMFocus) { 245 | // so that keyboard nav works 246 | $cell.focus(); 247 | } 248 | 249 | this.highlightRowColumnHeader($cell); 250 | } 251 | 252 | unfocusCell($cell) { 253 | if (!$cell) return; 254 | 255 | // remove cell border 256 | $cell.classList.remove('dt-cell--focus'); 257 | this.$focusedCell = null; 258 | 259 | // reset header background 260 | if (this.lastHeaders) { 261 | this.lastHeaders.forEach(header => header && header.classList.remove('dt-cell--highlight')); 262 | } 263 | } 264 | 265 | highlightRowColumnHeader($cell) { 266 | const { 267 | colIndex, 268 | rowIndex 269 | } = $.data($cell); 270 | 271 | const srNoColIndex = this.datamanager.getColumnIndexById('_rowIndex'); 272 | const colHeaderSelector = `.dt-cell--header-${colIndex}`; 273 | const rowHeaderSelector = `.dt-cell--${srNoColIndex}-${rowIndex}`; 274 | 275 | if (this.lastHeaders) { 276 | this.lastHeaders.forEach(header => header && header.classList.remove('dt-cell--highlight')); 277 | } 278 | 279 | const colHeader = $(colHeaderSelector, this.wrapper); 280 | const rowHeader = $(rowHeaderSelector, this.wrapper); 281 | 282 | this.lastHeaders = [colHeader, rowHeader]; 283 | this.lastHeaders.forEach(header => header && header.classList.add('dt-cell--highlight')); 284 | } 285 | 286 | selectAreaOnClusterChanged() { 287 | if (!(this.$focusedCell && this.$selectionCursor)) return; 288 | const { 289 | colIndex, 290 | rowIndex 291 | } = $.data(this.$selectionCursor); 292 | const $cell = this.getCell$(colIndex, rowIndex); 293 | 294 | if (!$cell || $cell === this.$selectionCursor) return; 295 | 296 | // selectArea needs $focusedCell 297 | const fCell = $.data(this.$focusedCell); 298 | this.$focusedCell = this.getCell$(fCell.colIndex, fCell.rowIndex); 299 | 300 | this.selectArea($cell); 301 | } 302 | 303 | focusCellOnClusterChanged() { 304 | if (!this.$focusedCell) return; 305 | 306 | const { 307 | colIndex, 308 | rowIndex 309 | } = $.data(this.$focusedCell); 310 | const $cell = this.getCell$(colIndex, rowIndex); 311 | 312 | if (!$cell) return; 313 | // this function is called after hyperlist renders the rows after scroll, 314 | // focusCell calls clearSelection which resets the area selection 315 | // so a flag to skip it 316 | // we also skip DOM focus and scroll to cell 317 | // because it fights with the user scroll 318 | this.focusCell($cell, { 319 | skipClearSelection: 1, 320 | skipDOMFocus: 1, 321 | skipScrollToCell: 1 322 | }); 323 | } 324 | 325 | selectArea($selectionCursor) { 326 | if (!this.$focusedCell) return; 327 | 328 | if (this._selectArea(this.$focusedCell, $selectionCursor)) { 329 | // valid selection 330 | this.$selectionCursor = $selectionCursor; 331 | } 332 | } 333 | 334 | _selectArea($cell1, $cell2) { 335 | if ($cell1 === $cell2) return false; 336 | const cells = this.getCellsInRange($cell1, $cell2); 337 | if (!cells) return false; 338 | 339 | this.clearSelection(); 340 | this._selectedCells = cells.map(index => this.getCell$(...index)); 341 | requestAnimationFrame(() => { 342 | this._selectedCells.map($cell => $cell.classList.add('dt-cell--highlight')); 343 | }); 344 | return true; 345 | } 346 | 347 | getCellsInRange($cell1, $cell2) { 348 | let colIndex1, rowIndex1, colIndex2, rowIndex2; 349 | 350 | if (typeof $cell1 === 'number') { 351 | [colIndex1, rowIndex1, colIndex2, rowIndex2] = arguments; 352 | } else 353 | if (typeof $cell1 === 'object') { 354 | if (!($cell1 && $cell2)) { 355 | return false; 356 | } 357 | 358 | const cell1 = $.data($cell1); 359 | const cell2 = $.data($cell2); 360 | 361 | colIndex1 = +cell1.colIndex; 362 | colIndex2 = +cell2.colIndex; 363 | 364 | if (this.columnmanager.sortState) { 365 | this.sortedColumn = true; 366 | rowIndex1 = this.datamanager.rowViewOrder.indexOf(parseInt(cell1.rowIndex, 10)); 367 | rowIndex2 = this.datamanager.rowViewOrder.indexOf(parseInt(cell2.rowIndex, 10)); 368 | } else { 369 | rowIndex1 = +cell1.rowIndex; 370 | rowIndex2 = +cell2.rowIndex; 371 | } 372 | 373 | } 374 | 375 | if (rowIndex1 > rowIndex2) { 376 | [rowIndex1, rowIndex2] = [rowIndex2, rowIndex1]; 377 | } 378 | 379 | if (colIndex1 > colIndex2) { 380 | [colIndex1, colIndex2] = [colIndex2, colIndex1]; 381 | } 382 | 383 | if (this.isStandardCell(colIndex1) || this.isStandardCell(colIndex2)) { 384 | return false; 385 | } 386 | 387 | const cells = []; 388 | let colIndex = colIndex1; 389 | let rowIndex = rowIndex1; 390 | const rowIndices = []; 391 | 392 | while (rowIndex <= rowIndex2) { 393 | rowIndices.push(rowIndex); 394 | rowIndex += 1; 395 | } 396 | 397 | rowIndices.map((rowIndex) => { 398 | while (colIndex <= colIndex2) { 399 | cells.push([colIndex, rowIndex]); 400 | colIndex++; 401 | } 402 | colIndex = colIndex1; 403 | }); 404 | if (this.columnmanager.sortState) { 405 | cells.forEach(selectedCells => { 406 | selectedCells[1] = this.datamanager.rowViewOrder[selectedCells[1]]; 407 | }); 408 | } 409 | return cells; 410 | } 411 | 412 | clearSelection() { 413 | (this._selectedCells || []) 414 | .forEach($cell => $cell.classList.remove('dt-cell--highlight')); 415 | 416 | this._selectedCells = []; 417 | this.$selectionCursor = null; 418 | } 419 | 420 | getSelectionCursor() { 421 | return this.$selectionCursor || this.$focusedCell; 422 | } 423 | 424 | activateEditing($cell) { 425 | this.focusCell($cell); 426 | const { 427 | rowIndex, 428 | colIndex 429 | } = $.data($cell); 430 | 431 | const col = this.columnmanager.getColumn(colIndex); 432 | if (col && (col.editable === false || col.focusable === false)) { 433 | return; 434 | } 435 | 436 | const cell = this.getCell(colIndex, rowIndex); 437 | if (cell && cell.editable === false) { 438 | return; 439 | } 440 | 441 | if (this.$editingCell) { 442 | const { 443 | _rowIndex, 444 | _colIndex 445 | } = $.data(this.$editingCell); 446 | 447 | if (rowIndex === _rowIndex && colIndex === _colIndex) { 448 | // editing the same cell 449 | return; 450 | } 451 | } 452 | 453 | this.$editingCell = $cell; 454 | $cell.classList.add('dt-cell--editing'); 455 | 456 | const $editCell = $('.dt-cell__edit', $cell); 457 | $editCell.innerHTML = ''; 458 | 459 | const editor = this.getEditor(colIndex, rowIndex, cell.content, $editCell); 460 | 461 | if (editor) { 462 | this.currentCellEditor = editor; 463 | // initialize editing input with cell value 464 | editor.initValue(cell.content, rowIndex, col); 465 | } 466 | } 467 | 468 | deactivateEditing(submitValue = true) { 469 | if (submitValue) { 470 | this.submitEditing(); 471 | } 472 | // keep focus on the cell so that keyboard navigation works 473 | if (this.$focusedCell) this.$focusedCell.focus(); 474 | 475 | if (!this.$editingCell) return; 476 | this.$editingCell.classList.remove('dt-cell--editing'); 477 | this.$editingCell = null; 478 | } 479 | 480 | getEditor(colIndex, rowIndex, value, parent) { 481 | const column = this.datamanager.getColumn(colIndex); 482 | const row = this.datamanager.getRow(rowIndex); 483 | const data = this.datamanager.getData(rowIndex); 484 | let editor = this.options.getEditor ? 485 | this.options.getEditor(colIndex, rowIndex, value, parent, column, row, data) : 486 | this.getDefaultEditor(parent); 487 | 488 | if (editor === false) { 489 | // explicitly returned false 490 | return false; 491 | } 492 | if (editor === undefined) { 493 | // didn't return editor, fallback to default 494 | editor = this.getDefaultEditor(parent); 495 | } 496 | 497 | return editor; 498 | } 499 | 500 | getDefaultEditor(parent) { 501 | const $input = $.create('input', { 502 | class: 'dt-input', 503 | type: 'text', 504 | inside: parent 505 | }); 506 | 507 | return { 508 | initValue(value) { 509 | $input.focus(); 510 | $input.value = value; 511 | }, 512 | getValue() { 513 | return $input.value; 514 | }, 515 | setValue(value) { 516 | $input.value = value; 517 | } 518 | }; 519 | } 520 | 521 | submitEditing() { 522 | let promise = Promise.resolve(); 523 | if (!this.$editingCell) return promise; 524 | 525 | const $cell = this.$editingCell; 526 | const { 527 | rowIndex, 528 | colIndex 529 | } = $.data($cell); 530 | const col = this.datamanager.getColumn(colIndex); 531 | 532 | if ($cell) { 533 | const editor = this.currentCellEditor; 534 | 535 | if (editor) { 536 | let valuePromise = editor.getValue(); 537 | 538 | // convert to stubbed Promise 539 | if (!valuePromise.then) { 540 | valuePromise = Promise.resolve(valuePromise); 541 | } 542 | 543 | promise = valuePromise.then((value) => { 544 | const oldValue = this.getCell(colIndex, rowIndex).content; 545 | 546 | if (oldValue === value) return false; 547 | 548 | const done = editor.setValue(value, rowIndex, col); 549 | 550 | // update cell immediately 551 | this.updateCell(colIndex, rowIndex, value, true); 552 | $cell.focus(); 553 | 554 | if (done && done.then) { 555 | // revert to oldValue if promise fails 556 | done.catch((e) => { 557 | console.log(e); 558 | this.updateCell(colIndex, rowIndex, oldValue); 559 | }); 560 | } 561 | return done; 562 | }); 563 | } 564 | } 565 | 566 | this.currentCellEditor = null; 567 | return promise; 568 | } 569 | 570 | copyCellContents($cell1, $cell2) { 571 | if (!$cell2 && $cell1) { 572 | // copy only focusedCell 573 | const { 574 | colIndex, 575 | rowIndex, 576 | isTotalRow 577 | } = $.data($cell1); 578 | let copiedContent = ''; 579 | if (isTotalRow) { 580 | let choosenFooterCell = this.$focusedCell; 581 | copiedContent = choosenFooterCell.children[0].title; 582 | } else { 583 | const cell = this.getCell(colIndex, rowIndex); 584 | copiedContent = cell.content; 585 | } 586 | copyTextToClipboard(copiedContent); 587 | return 1; 588 | } 589 | const cells = this.getCellsInRange($cell1, $cell2); 590 | 591 | if (!cells) return 0; 592 | 593 | const rows = cells 594 | // get cell objects 595 | .map(index => this.getCell(...index)) 596 | // convert to array of rows 597 | .reduce((acc, curr) => { 598 | const rowIndex = curr.rowIndex; 599 | 600 | acc[rowIndex] = acc[rowIndex] || []; 601 | acc[rowIndex].push(curr.content); 602 | 603 | return acc; 604 | }, []); 605 | 606 | const values = rows 607 | // join values by tab 608 | .map(row => row.join('\t')) 609 | // join rows by newline 610 | .join('\n'); 611 | 612 | copyTextToClipboard(values); 613 | 614 | // return no of cells copied 615 | return rows.reduce((total, row) => total + row.length, 0); 616 | } 617 | 618 | pasteContentInCell(data) { 619 | if (!this.$focusedCell) return; 620 | 621 | const matrix = data 622 | .split('\n') 623 | .map(row => row.split('\t')) 624 | .filter(row => row.length && row.every(it => it)); 625 | 626 | let { colIndex, rowIndex } = $.data(this.$focusedCell); 627 | 628 | let focusedCell = { 629 | colIndex: +colIndex, 630 | rowIndex: +rowIndex 631 | }; 632 | 633 | matrix.forEach((row, i) => { 634 | let rowIndex = i + focusedCell.rowIndex; 635 | row.forEach((cell, j) => { 636 | let colIndex = j + focusedCell.colIndex; 637 | this.updateCell(colIndex, rowIndex, cell, true); 638 | }); 639 | }); 640 | } 641 | 642 | activateFilter(colIndex) { 643 | this.columnmanager.toggleFilter(); 644 | this.columnmanager.focusFilter(colIndex); 645 | 646 | if (!this.columnmanager.isFilterShown) { 647 | // put focus back on cell 648 | this.$focusedCell && this.$focusedCell.focus(); 649 | } 650 | } 651 | 652 | updateCell(colIndex, rowIndex, value, refreshHtml = false) { 653 | const cell = this.datamanager.updateCell(colIndex, rowIndex, { 654 | content: value 655 | }); 656 | this.refreshCell(cell, refreshHtml); 657 | } 658 | 659 | refreshCell(cell, refreshHtml = false) { 660 | const $cell = $(this.selector(cell.colIndex, cell.rowIndex), this.bodyScrollable); 661 | $cell.innerHTML = this.getCellContent(cell, refreshHtml); 662 | } 663 | 664 | toggleTreeButton(rowIndex, flag) { 665 | const colIndex = this.columnmanager.getFirstColumnIndex(); 666 | const $cell = this.getCell$(colIndex, rowIndex); 667 | if ($cell) { 668 | $cell.classList[flag ? 'remove' : 'add']('dt-cell--tree-close'); 669 | } 670 | } 671 | 672 | isStandardCell(colIndex) { 673 | // Standard cells are in Sr. No and Checkbox column 674 | return colIndex < this.columnmanager.getFirstColumnIndex(); 675 | } 676 | 677 | focusCellInDirection(direction) { 678 | if (!this.$focusedCell || (this.$editingCell && ['left', 'right', 'up', 'down'].includes(direction))) { 679 | return false; 680 | } else if (this.$editingCell && ['tab', 'shift+tab'].includes(direction)) { 681 | this.deactivateEditing(); 682 | } 683 | 684 | let $cell = this.$focusedCell; 685 | 686 | if (direction === 'left' || direction === 'shift+tab') { 687 | $cell = this.getLeftCell$($cell); 688 | } else if (direction === 'right' || direction === 'tab') { 689 | $cell = this.getRightCell$($cell); 690 | } else if (direction === 'up') { 691 | $cell = this.getAboveCell$($cell); 692 | } else if (direction === 'down') { 693 | $cell = this.getBelowCell$($cell); 694 | } 695 | 696 | if (!$cell) { 697 | return false; 698 | } 699 | 700 | const { 701 | colIndex 702 | } = $.data($cell); 703 | const column = this.columnmanager.getColumn(colIndex); 704 | 705 | if (!column.focusable) { 706 | let $prevFocusedCell = this.$focusedCell; 707 | this.unfocusCell($prevFocusedCell); 708 | this.$focusedCell = $cell; 709 | let ret = this.focusCellInDirection(direction); 710 | if (!ret) { 711 | this.focusCell($prevFocusedCell); 712 | } 713 | return ret; 714 | } 715 | 716 | this.focusCell($cell); 717 | return true; 718 | } 719 | 720 | getCell$(colIndex, rowIndex) { 721 | return $(this.selector(colIndex, rowIndex), this.bodyScrollable); 722 | } 723 | 724 | getAboveCell$($cell) { 725 | const { 726 | colIndex 727 | } = $.data($cell); 728 | 729 | let $aboveRow = $cell.parentElement.previousElementSibling; 730 | while ($aboveRow && $aboveRow.classList.contains('dt-row--hide')) { 731 | $aboveRow = $aboveRow.previousElementSibling; 732 | } 733 | 734 | if (!$aboveRow) return $cell; 735 | return $(`.dt-cell--col-${colIndex}`, $aboveRow); 736 | } 737 | 738 | getBelowCell$($cell) { 739 | const { 740 | colIndex 741 | } = $.data($cell); 742 | 743 | let $belowRow = $cell.parentElement.nextElementSibling; 744 | while ($belowRow && $belowRow.classList.contains('dt-row--hide')) { 745 | $belowRow = $belowRow.nextElementSibling; 746 | } 747 | 748 | if (!$belowRow) return $cell; 749 | return $(`.dt-cell--col-${colIndex}`, $belowRow); 750 | } 751 | 752 | getLeftCell$($cell) { 753 | return $cell.previousElementSibling; 754 | } 755 | 756 | getRightCell$($cell) { 757 | return $cell.nextElementSibling; 758 | } 759 | 760 | getLeftMostCell$(rowIndex) { 761 | return this.getCell$(this.columnmanager.getFirstColumnIndex(), rowIndex); 762 | } 763 | 764 | getRightMostCell$(rowIndex) { 765 | return this.getCell$(this.columnmanager.getLastColumnIndex(), rowIndex); 766 | } 767 | 768 | getTopMostCell$(colIndex) { 769 | return this.getCell$(colIndex, this.rowmanager.getFirstRowIndex()); 770 | } 771 | 772 | getBottomMostCell$(colIndex) { 773 | return this.getCell$(colIndex, this.rowmanager.getLastRowIndex()); 774 | } 775 | 776 | getCell(colIndex, rowIndex) { 777 | return this.instance.datamanager.getCell(colIndex, rowIndex); 778 | } 779 | 780 | getRowHeight() { 781 | return $.style($('.dt-row', this.bodyScrollable), 'height'); 782 | } 783 | 784 | scrollToCell($cell) { 785 | if ($.inViewport($cell, this.bodyScrollable) || $.inViewport($cell, this.footer)) return false; 786 | 787 | const { 788 | rowIndex 789 | } = $.data($cell); 790 | this.rowmanager.scrollToRow(rowIndex); 791 | return false; 792 | } 793 | 794 | getRowCountPerPage() { 795 | return Math.ceil(this.instance.getViewportHeight() / this.getRowHeight()); 796 | } 797 | 798 | getCellHTML(cell) { 799 | const { 800 | rowIndex, 801 | colIndex, 802 | isHeader, 803 | isFilter, 804 | isTotalRow 805 | } = cell; 806 | const dataAttr = makeDataAttributeString({ 807 | rowIndex, 808 | colIndex, 809 | isHeader, 810 | isFilter, 811 | isTotalRow 812 | }); 813 | 814 | const row = this.datamanager.getRow(rowIndex); 815 | 816 | const isBodyCell = !(isHeader || isFilter || isTotalRow); 817 | 818 | const className = [ 819 | 'dt-cell', 820 | 'dt-cell--col-' + colIndex, 821 | isBodyCell ? `dt-cell--${colIndex}-${rowIndex}` : '', 822 | isBodyCell ? 'dt-cell--row-' + rowIndex : '', 823 | isHeader ? 'dt-cell--header' : '', 824 | isHeader ? `dt-cell--header-${colIndex}` : '', 825 | isFilter ? 'dt-cell--filter' : '', 826 | isBodyCell && (row && row.meta.isTreeNodeClose) ? 'dt-cell--tree-close' : '' 827 | ].join(' '); 828 | 829 | return ` 830 |
831 | ${this.getCellContent(cell)} 832 |
833 | `; 834 | } 835 | 836 | getCellContent(cell, refreshHtml = false) { 837 | const { 838 | isHeader, 839 | isFilter, 840 | colIndex 841 | } = cell; 842 | 843 | const editable = !isHeader && cell.editable !== false; 844 | const editCellHTML = editable ? this.getEditCellHTML(colIndex) : ''; 845 | 846 | const sortable = isHeader && cell.sortable !== false; 847 | const sortIndicator = sortable ? 848 | ` 849 | ${this.options.sortIndicator[cell.sortOrder]} 850 | ` : 851 | ''; 852 | 853 | const resizable = isHeader && cell.resizable !== false; 854 | const resizeColumn = resizable ? '' : ''; 855 | 856 | const hasDropdown = isHeader && cell.dropdown !== false; 857 | const dropdown = hasDropdown ? this.columnmanager.getDropdownHTML() : ''; 858 | 859 | let customFormatter = CellManager.getCustomCellFormatter(cell); 860 | let contentHTML; 861 | if (isHeader || isFilter || !customFormatter) { 862 | contentHTML = cell.content; 863 | } else { 864 | if (!cell.html || refreshHtml) { 865 | const row = this.datamanager.getRow(cell.rowIndex); 866 | const data = this.datamanager.getData(cell.rowIndex); 867 | contentHTML = customFormatter(cell.content, row, cell.column, data); 868 | } else { 869 | contentHTML = cell.html; 870 | } 871 | } 872 | 873 | cell.html = contentHTML; 874 | 875 | if (this.options.treeView && !(isHeader || isFilter) && cell.indent !== undefined) { 876 | const nextRow = this.datamanager.getRow(cell.rowIndex + 1); 877 | const addToggle = nextRow && nextRow.meta.indent > cell.indent; 878 | const leftPadding = 20; 879 | const unit = 'px'; 880 | 881 | // Add toggle and indent in the first column 882 | const firstColumnIndex = this.datamanager.getColumnIndexById('_rowIndex') + 1; 883 | if (firstColumnIndex === cell.colIndex) { 884 | const padding = ((cell.indent || 0)) * leftPadding; 885 | const toggleHTML = addToggle ? 886 | ` 887 | ${icons.chevronDown} 888 | ${icons.chevronRight} 889 | ` : ''; 890 | contentHTML = ` 891 | ${toggleHTML} 892 | ${contentHTML} 893 | `; 894 | } 895 | } 896 | 897 | const className = [ 898 | 'dt-cell__content', 899 | isHeader ? `dt-cell__content--header-${colIndex}` : `dt-cell__content--col-${colIndex}` 900 | ].join(' '); 901 | 902 | let cellContentHTML = ` 903 |
904 | ${contentHTML} 905 | ${sortIndicator} 906 | ${resizeColumn} 907 | ${dropdown} 908 |
909 | ${editCellHTML} 910 | `; 911 | 912 | let div = document.createElement('div'); 913 | div.innerHTML = contentHTML; 914 | 915 | let textContent = div.textContent; 916 | textContent = textContent.replace(/\s+/g, ' ').trim(); 917 | 918 | cellContentHTML = cellContentHTML.replace('>', ` title="${escapeHTML(textContent)}">`); 919 | 920 | return cellContentHTML; 921 | } 922 | 923 | getEditCellHTML(colIndex) { 924 | return `
`; 925 | } 926 | 927 | selector(colIndex, rowIndex) { 928 | return `.dt-cell--${colIndex}-${rowIndex}`; 929 | } 930 | 931 | static getCustomCellFormatter(cell) { 932 | return cell.format || (cell.column && cell.column.format) || null; 933 | } 934 | } 935 | -------------------------------------------------------------------------------- /src/columnmanager.js: -------------------------------------------------------------------------------- 1 | import $ from './dom'; 2 | import Sortable from 'sortablejs'; 3 | import { 4 | linkProperties, 5 | debounce 6 | } from './utils'; 7 | 8 | export default class ColumnManager { 9 | constructor(instance) { 10 | this.instance = instance; 11 | 12 | linkProperties(this, this.instance, [ 13 | 'options', 14 | 'fireEvent', 15 | 'header', 16 | 'datamanager', 17 | 'cellmanager', 18 | 'style', 19 | 'wrapper', 20 | 'rowmanager', 21 | 'bodyScrollable', 22 | 'bodyRenderer' 23 | ]); 24 | 25 | this.bindEvents(); 26 | } 27 | 28 | renderHeader() { 29 | this.header.innerHTML = '
'; 30 | this.refreshHeader(); 31 | } 32 | 33 | refreshHeader() { 34 | const columns = this.datamanager.getColumns(); 35 | 36 | // refresh html 37 | $('div', this.header).innerHTML = this.getHeaderHTML(columns); 38 | 39 | this.$filterRow = $('.dt-row-filter', this.header); 40 | if (this.$filterRow) { 41 | $.style(this.$filterRow, { display: 'none' }); 42 | } 43 | // reset columnMap 44 | this.$columnMap = []; 45 | this.bindMoveColumn(); 46 | } 47 | 48 | getHeaderHTML(columns) { 49 | let html = this.rowmanager.getRowHTML(columns, { 50 | isHeader: 1 51 | }); 52 | if (this.options.inlineFilters) { 53 | html += this.rowmanager.getRowHTML(columns, { 54 | isFilter: 1 55 | }); 56 | } 57 | return html; 58 | } 59 | 60 | bindEvents() { 61 | this.bindDropdown(); 62 | this.bindResizeColumn(); 63 | this.bindPerfectColumnWidth(); 64 | this.bindFilter(); 65 | } 66 | 67 | bindDropdown() { 68 | let toggleClass = '.dt-dropdown__toggle'; 69 | let dropdownClass = '.dt-dropdown__list'; 70 | 71 | // attach the dropdown list to container 72 | this.instance.dropdownContainer.innerHTML = this.getDropdownListHTML(); 73 | this.$dropdownList = this.instance.dropdownContainer.firstElementChild; 74 | 75 | $.on(this.header, 'click', toggleClass, e => { 76 | this.openDropdown(e); 77 | }); 78 | 79 | const deactivateDropdownOnBodyClick = (e) => { 80 | const selector = [ 81 | toggleClass, toggleClass + ' *', 82 | dropdownClass, dropdownClass + ' *' 83 | ].join(','); 84 | if (e.target.matches(selector)) return; 85 | deactivateDropdown(); 86 | }; 87 | $.on(document.body, 'click', deactivateDropdownOnBodyClick); 88 | document.addEventListener('scroll', deactivateDropdown, true); 89 | 90 | this.instance.on('onDestroy', () => { 91 | $.off(document.body, 'click', deactivateDropdownOnBodyClick); 92 | $.off(document, 'scroll', deactivateDropdown); 93 | }); 94 | 95 | $.on(this.$dropdownList, 'click', '.dt-dropdown__list-item', (e, $item) => { 96 | if (!this._dropdownActiveColIndex) return; 97 | const dropdownItems = this.options.headerDropdown; 98 | const { index } = $.data($item); 99 | const colIndex = this._dropdownActiveColIndex; 100 | let callback = dropdownItems[index].action; 101 | 102 | callback && callback.call(this.instance, this.getColumn(colIndex)); 103 | this.hideDropdown(); 104 | }); 105 | 106 | const _this = this; 107 | function deactivateDropdown(e) { 108 | _this.hideDropdown(); 109 | } 110 | 111 | this.hideDropdown(); 112 | } 113 | 114 | openDropdown(e) { 115 | if (!this._dropdownWidth) { 116 | $.style(this.$dropdownList, { display: '' }); 117 | this._dropdownWidth = $.style(this.$dropdownList, 'width'); 118 | } 119 | $.style(this.$dropdownList, { 120 | display: '', 121 | left: (e.clientX - this._dropdownWidth + 4) + 'px', 122 | top: (e.clientY + 4) + 'px' 123 | }); 124 | const $cell = $.closest('.dt-cell', e.target); 125 | const { colIndex } = $.data($cell); 126 | this._dropdownActiveColIndex = colIndex; 127 | } 128 | 129 | hideDropdown() { 130 | $.style(this.$dropdownList, { 131 | display: 'none' 132 | }); 133 | this._dropdownActiveColIndex = null; 134 | } 135 | 136 | bindResizeColumn() { 137 | let isDragging = false; 138 | let $resizingCell, startWidth, startX; 139 | 140 | $.on(this.header, 'mousedown', '.dt-cell .dt-cell__resize-handle', (e, $handle) => { 141 | document.body.classList.add('dt-resize'); 142 | const $cell = $handle.parentNode.parentNode; 143 | $resizingCell = $cell; 144 | const { 145 | colIndex 146 | } = $.data($resizingCell); 147 | const col = this.getColumn(colIndex); 148 | 149 | if (col && col.resizable === false) { 150 | return; 151 | } 152 | 153 | isDragging = true; 154 | startWidth = $.style($('.dt-cell__content', $resizingCell), 'width'); 155 | startX = e.pageX; 156 | }); 157 | 158 | const onMouseup = (e) => { 159 | document.body.classList.remove('dt-resize'); 160 | if (!$resizingCell) return; 161 | isDragging = false; 162 | 163 | const { 164 | colIndex 165 | } = $.data($resizingCell); 166 | this.setColumnWidth(colIndex); 167 | this.style.setBodyStyle(); 168 | $resizingCell = null; 169 | }; 170 | $.on(document.body, 'mouseup', onMouseup); 171 | this.instance.on('onDestroy', () => { 172 | $.off(document.body, 'mouseup', onMouseup); 173 | }); 174 | 175 | const onMouseMove = (e) => { 176 | if (!isDragging) return; 177 | let delta = e.pageX - startX; 178 | if (this.options.direction === 'rtl') { 179 | delta = -1 * delta; 180 | } 181 | const finalWidth = startWidth + delta; 182 | const { 183 | colIndex 184 | } = $.data($resizingCell); 185 | 186 | let columnMinWidth = this.options.minimumColumnWidth; 187 | if (columnMinWidth > finalWidth) { 188 | // don't resize past 30 pixels 189 | return; 190 | } 191 | this.datamanager.updateColumn(colIndex, { 192 | width: finalWidth 193 | }); 194 | this.setColumnHeaderWidth(colIndex); 195 | }; 196 | $.on(document.body, 'mousemove', onMouseMove); 197 | this.instance.on('onDestroy', () => { 198 | $.off(document.body, 'mousemove', onMouseMove); 199 | }); 200 | } 201 | 202 | bindPerfectColumnWidth() { 203 | $.on(this.header, 'dblclick', '.dt-cell .dt-cell__resize-handle', (e, $handle) => { 204 | const $cell = $handle.parentNode.parentNode; 205 | const { colIndex } = $.data($cell); 206 | 207 | let longestCell = this.bodyRenderer.visibleRows 208 | .map(d => d[colIndex]) 209 | .reduce((acc, curr) => acc.content.length > curr.content.length ? acc : curr); 210 | 211 | let $longestCellHTML = this.cellmanager.getCellHTML(longestCell); 212 | let $div = document.createElement('div'); 213 | $div.innerHTML = $longestCellHTML; 214 | let cellText = $div.querySelector('.dt-cell__content').textContent; 215 | 216 | let { 217 | borderLeftWidth, 218 | borderRightWidth, 219 | paddingLeft, 220 | paddingRight 221 | } = $.getStyle(this.bodyScrollable.querySelector('.dt-cell__content')); 222 | 223 | let padding = [borderLeftWidth, borderRightWidth, paddingLeft, paddingRight] 224 | .map(parseFloat) 225 | .reduce((sum, val) => sum + val); 226 | 227 | let width = $.measureTextWidth(cellText) + padding; 228 | this.datamanager.updateColumn(colIndex, { width }); 229 | this.setColumnHeaderWidth(colIndex); 230 | this.setColumnWidth(colIndex); 231 | }); 232 | } 233 | 234 | bindMoveColumn() { 235 | if (this.options.disableReorderColumn) return; 236 | 237 | const $parent = $('.dt-row', this.header); 238 | 239 | this.sortable = Sortable.create($parent, { 240 | onEnd: (e) => { 241 | const { 242 | oldIndex, 243 | newIndex 244 | } = e; 245 | const $draggedCell = e.item; 246 | const { 247 | colIndex 248 | } = $.data($draggedCell); 249 | if (+colIndex === newIndex) return; 250 | 251 | this.switchColumn(oldIndex, newIndex); 252 | }, 253 | preventOnFilter: false, 254 | filter: '.dt-cell__resize-handle, .dt-dropdown', 255 | chosenClass: 'dt-cell--dragging', 256 | animation: 150 257 | }); 258 | } 259 | 260 | sortColumn(colIndex, nextSortOrder) { 261 | this.instance.freeze(); 262 | this.sortRows(colIndex, nextSortOrder) 263 | .then(() => { 264 | this.refreshHeader(); 265 | return this.rowmanager.refreshRows(); 266 | }) 267 | .then(() => this.instance.unfreeze()) 268 | .then(() => { 269 | this.fireEvent('onSortColumn', this.getColumn(colIndex)); 270 | this.setSortState(); 271 | }); 272 | } 273 | 274 | saveSorting(colIndex) { 275 | let currentColumn = this.getColumn(colIndex); 276 | let saveSorting = { 277 | [currentColumn.name]: { 278 | colIndex: colIndex, 279 | sortOrder: currentColumn.sortOrder 280 | } 281 | }; 282 | this.sortingKey = this.options.sortingKey ? `${this.options.sortingKey}::sortedColumns` : 'sortedColumns' ; 283 | localStorage.setItem(this.sortingKey, JSON.stringify(saveSorting)); 284 | } 285 | setSortState(sortOrder) { 286 | if (sortOrder === 'none') { 287 | this.sortState = false; 288 | } else { 289 | this.sortState = true; 290 | } 291 | } 292 | 293 | removeColumn(colIndex) { 294 | const removedCol = this.getColumn(colIndex); 295 | this.instance.freeze(); 296 | this.datamanager.removeColumn(colIndex) 297 | .then(() => { 298 | this.refreshHeader(); 299 | return this.rowmanager.refreshRows(); 300 | }) 301 | .then(() => this.instance.unfreeze()) 302 | .then(() => { 303 | this.fireEvent('onRemoveColumn', removedCol); 304 | }); 305 | } 306 | 307 | switchColumn(oldIndex, newIndex) { 308 | this.instance.freeze(); 309 | this.datamanager.switchColumn(oldIndex, newIndex) 310 | .then(() => { 311 | this.refreshHeader(); 312 | return this.rowmanager.refreshRows(); 313 | }) 314 | .then(() => { 315 | this.setColumnWidth(oldIndex); 316 | this.setColumnWidth(newIndex); 317 | this.instance.unfreeze(); 318 | }) 319 | .then(() => { 320 | this.fireEvent('onSwitchColumn', 321 | this.getColumn(oldIndex), this.getColumn(newIndex) 322 | ); 323 | }); 324 | } 325 | 326 | toggleFilter(flag) { 327 | if (!this.options.inlineFilters) return; 328 | 329 | let showFilter; 330 | if (flag === undefined) { 331 | showFilter = !this.isFilterShown; 332 | } else { 333 | showFilter = flag; 334 | } 335 | 336 | if (showFilter) { 337 | $.style(this.$filterRow, { display: '' }); 338 | } else { 339 | $.style(this.$filterRow, { display: 'none' }); 340 | } 341 | 342 | this.isFilterShown = showFilter; 343 | this.style.setBodyStyle(); 344 | } 345 | 346 | focusFilter(colIndex) { 347 | if (!this.isFilterShown) return; 348 | 349 | const $filterInput = $(`.dt-cell--col-${colIndex} .dt-filter`, this.$filterRow); 350 | $filterInput.focus(); 351 | } 352 | 353 | bindFilter() { 354 | if (!this.options.inlineFilters) return; 355 | const handler = e => { 356 | this.applyFilter(this.getAppliedFilters()); 357 | }; 358 | $.on(this.header, 'keydown', '.dt-filter', debounce(handler, 300)); 359 | } 360 | 361 | applyFilter(filters) { 362 | this.datamanager.filterRows(filters) 363 | .then(({ 364 | rowsToShow 365 | }) => { 366 | this.rowmanager.showRows(rowsToShow); 367 | }); 368 | } 369 | 370 | getAppliedFilters() { 371 | const filters = {}; 372 | $.each('.dt-filter', this.header).map((input) => { 373 | const value = input.value; 374 | if (value) { 375 | filters[input.dataset.colIndex] = value; 376 | } 377 | }); 378 | return filters; 379 | } 380 | 381 | applyDefaultSortOrder() { 382 | // sort rows if any 1 column has a default sortOrder set 383 | const columnsToSort = this.getColumns().filter(col => col.sortOrder !== 'none'); 384 | 385 | if (columnsToSort.length === 1) { 386 | const column = columnsToSort[0]; 387 | this.sortColumn(column.colIndex, column.sortOrder); 388 | } 389 | } 390 | 391 | applySavedSortOrder() { 392 | 393 | let key = this.options.sortingKey ? `${this.options.sortingKey}::sortedColumns` : 'sortedColumns' ; 394 | let sortingConfig = JSON.parse(localStorage.getItem(key)); 395 | if (sortingConfig) { 396 | const columnsToSort = Object.values(sortingConfig); 397 | for (let column of columnsToSort) { 398 | this.sortColumn(column.colIndex, column.sortOrder); 399 | this.sortState = true; 400 | } 401 | } 402 | } 403 | 404 | sortRows(colIndex, sortOrder) { 405 | return this.datamanager.sortRows(colIndex, sortOrder); 406 | } 407 | 408 | getColumn(colIndex) { 409 | return this.datamanager.getColumn(colIndex); 410 | } 411 | 412 | getColumns() { 413 | return this.datamanager.getColumns(); 414 | } 415 | 416 | setColumnWidth(colIndex, width) { 417 | colIndex = +colIndex; 418 | 419 | let columnWidth = width || this.getColumn(colIndex).width; 420 | 421 | const selector = [ 422 | `.dt-cell__content--col-${colIndex}`, 423 | `.dt-cell__edit--col-${colIndex}` 424 | ].join(', '); 425 | 426 | const styles = { 427 | width: columnWidth + 'px' 428 | }; 429 | 430 | this.style.setStyle(selector, styles); 431 | } 432 | 433 | setColumnHeaderWidth(colIndex) { 434 | colIndex = +colIndex; 435 | this.$columnMap = this.$columnMap || []; 436 | const selector = `.dt-cell__content--header-${colIndex}`; 437 | const { 438 | width 439 | } = this.getColumn(colIndex); 440 | 441 | let $column = this.$columnMap[colIndex]; 442 | if (!$column) { 443 | $column = this.header.querySelector(selector); 444 | this.$columnMap[colIndex] = $column; 445 | } 446 | 447 | $column.style.width = width + 'px'; 448 | } 449 | 450 | getColumnMinWidth(colIndex) { 451 | colIndex = +colIndex; 452 | return this.getColumn(colIndex).minWidth || 24; 453 | } 454 | 455 | getFirstColumnIndex() { 456 | return this.datamanager.getColumnIndexById('_rowIndex') + 1; 457 | } 458 | 459 | getHeaderCell$(colIndex) { 460 | return $(`.dt-cell--header-${colIndex}`, this.header); 461 | } 462 | 463 | getLastColumnIndex() { 464 | return this.datamanager.getColumnCount() - 1; 465 | } 466 | 467 | getDropdownHTML() { 468 | const { dropdownButton } = this.options; 469 | 470 | return ` 471 |
472 |
${dropdownButton}
473 |
474 | `; 475 | } 476 | 477 | getDropdownListHTML() { 478 | const { headerDropdown: dropdownItems } = this.options; 479 | return ` 480 |
481 | ${dropdownItems.map((d, i) => ` 482 |
486 | ${d.label} 487 |
488 | `).join('')} 489 |
490 | `; 491 | } 492 | 493 | toggleDropdownItem(index) { 494 | $('.dt-dropdown__list', this.instance.dropdownContainer).children[index].classList.toggle('dt-hidden'); 495 | } 496 | } 497 | -------------------------------------------------------------------------------- /src/dark.css: -------------------------------------------------------------------------------- 1 | .datatable { 2 | --dt-border-color: #424242; 3 | --dt-light-bg: #2e3538; 4 | --dt-text-color: #dfe2e5; 5 | --dt-text-light: #dfe2e5; 6 | --dt-cell-bg: #1c1f20; 7 | --dt-focus-border-width: 1px; 8 | --dt-selection-highlight-color: var(--dt-light-bg); 9 | --dt-toast-message-border: 1px solid var(--dt-border-color); 10 | --dt-header-cell-bg: #262c2e; 11 | } 12 | -------------------------------------------------------------------------------- /src/datamanager.js: -------------------------------------------------------------------------------- 1 | import { 2 | isNumeric, 3 | nextTick, 4 | isNumber, 5 | notSet 6 | } from './utils'; 7 | 8 | export default class DataManager { 9 | constructor(options) { 10 | this.options = options; 11 | this.sortRows = nextTick(this.sortRows, this); 12 | this.switchColumn = nextTick(this.switchColumn, this); 13 | this.removeColumn = nextTick(this.removeColumn, this); 14 | this.options.filterRows = nextTick(this.options.filterRows, this); 15 | } 16 | 17 | init(data, columns) { 18 | if (!data) { 19 | data = this.options.data; 20 | } 21 | if (columns) { 22 | this.options.columns = columns; 23 | } 24 | 25 | this.data = data; 26 | 27 | this.rowCount = 0; 28 | this.columns = []; 29 | this.rows = []; 30 | 31 | this.prepareColumns(); 32 | this.validateData(this.data); 33 | this.rows = this.prepareRows(this.data); 34 | this.prepareTreeRows(); 35 | this.prepareRowView(); 36 | this.prepareNumericColumns(); 37 | } 38 | 39 | // computed property 40 | get currentSort() { 41 | const col = this.columns.find(col => col.sortOrder !== 'none'); 42 | return col || { 43 | colIndex: -1, 44 | sortOrder: 'none' 45 | }; 46 | } 47 | 48 | prepareColumns() { 49 | this.columns = []; 50 | this.validateColumns(); 51 | this.prepareDefaultColumns(); 52 | this.prepareHeader(); 53 | } 54 | 55 | prepareDefaultColumns() { 56 | if (this.options.checkboxColumn && !this.hasColumnById('_checkbox')) { 57 | const cell = { 58 | id: '_checkbox', 59 | content: this.getCheckboxHTML(), 60 | editable: false, 61 | resizable: false, 62 | sortable: false, 63 | focusable: false, 64 | dropdown: false, 65 | width: 32 66 | }; 67 | this.columns.push(cell); 68 | } 69 | 70 | if (this.options.serialNoColumn && !this.hasColumnById('_rowIndex')) { 71 | let cell = { 72 | id: '_rowIndex', 73 | content: '', 74 | align: 'center', 75 | editable: false, 76 | resizable: false, 77 | focusable: false, 78 | dropdown: false 79 | }; 80 | if (this.options.data.length > 1000) { 81 | cell.resizable = true; 82 | } 83 | this.columns.push(cell); 84 | } 85 | } 86 | 87 | prepareHeader() { 88 | let columns = this.columns.concat(this.options.columns); 89 | const baseCell = { 90 | isHeader: 1, 91 | editable: true, 92 | sortable: true, 93 | resizable: true, 94 | focusable: true, 95 | dropdown: true, 96 | width: null, 97 | format: (value) => { 98 | if (value === null || value === undefined) { 99 | return ''; 100 | } 101 | return value + ''; 102 | } 103 | }; 104 | 105 | this.columns = columns 106 | .map((cell, i) => this.prepareCell(cell, i)) 107 | .map(col => Object.assign({}, baseCell, col)) 108 | .map(col => { 109 | col.content = col.content || col.name || ''; 110 | col.id = col.id || col.content; 111 | return col; 112 | }); 113 | } 114 | 115 | prepareCell(content, i) { 116 | const cell = { 117 | content: '', 118 | sortOrder: 'none', 119 | colIndex: i, 120 | column: this.columns[i] 121 | }; 122 | 123 | if (content !== null && typeof content === 'object') { 124 | // passed as column/header 125 | Object.assign(cell, content); 126 | } else { 127 | cell.content = content; 128 | } 129 | 130 | return cell; 131 | } 132 | 133 | prepareNumericColumns() { 134 | const row0 = this.getRow(0); 135 | if (!row0) return; 136 | this.columns = this.columns.map((column, i) => { 137 | 138 | const cellValue = row0[i].content; 139 | if (!column.align && isNumeric(cellValue)) { 140 | column.align = 'right'; 141 | } 142 | 143 | return column; 144 | }); 145 | } 146 | 147 | prepareRows(data) { 148 | return data.map((d, i) => { 149 | const index = this._getNextRowCount(); 150 | 151 | let row = []; 152 | let meta = { 153 | rowIndex: index 154 | }; 155 | 156 | if (Array.isArray(d)) { 157 | // row is an array 158 | if (this.options.checkboxColumn) { 159 | row.push(this.getCheckboxHTML()); 160 | } 161 | if (this.options.serialNoColumn) { 162 | row.push((index + 1) + ''); 163 | } 164 | row = row.concat(d); 165 | 166 | while (row.length < this.columns.length) { 167 | row.push(''); 168 | } 169 | 170 | } else { 171 | // row is an object 172 | for (let col of this.columns) { 173 | if (col.id === '_checkbox') { 174 | row.push(this.getCheckboxHTML()); 175 | } else if (col.id === '_rowIndex') { 176 | row.push((index + 1) + ''); 177 | } else { 178 | row.push(d[col.id]); 179 | } 180 | } 181 | 182 | meta.indent = d.indent || 0; 183 | } 184 | 185 | return this.prepareRow(row, meta); 186 | }); 187 | } 188 | 189 | prepareTreeRows() { 190 | this.rows.forEach((row, i) => { 191 | if (isNumber(row.meta.indent)) { 192 | // if (i === 36) debugger; 193 | const nextRow = this.getRow(i + 1); 194 | row.meta.isLeaf = !nextRow || 195 | notSet(nextRow.meta.indent) || 196 | nextRow.meta.indent <= row.meta.indent; 197 | row.meta.isTreeNodeClose = false; 198 | } 199 | }); 200 | } 201 | 202 | prepareRowView() { 203 | // This is order in which rows will be rendered in the table. 204 | // When sorting happens, only this.rowViewOrder will change 205 | // and not the original this.rows 206 | this.rowViewOrder = this.rows.map(row => row.meta.rowIndex); 207 | } 208 | 209 | prepareRow(row, meta) { 210 | row = row 211 | .map((cell, i) => this.prepareCell(cell, i)) 212 | .map(cell => { 213 | // Following code is equivalent but avoids memory allocation and copying. 214 | // return Object.assign({rowIndex: meta.rowIndex, indent: meta.indent}, cell) 215 | if (cell.rowIndex == null) { 216 | cell.rowIndex = meta.rowIndex; 217 | } 218 | if (cell.indent == null) { 219 | cell.indent = meta.indent; 220 | } 221 | return cell; 222 | }); 223 | 224 | // monkey patched in array object 225 | row.meta = meta; 226 | return row; 227 | } 228 | 229 | validateColumns() { 230 | const columns = this.options.columns; 231 | if (!Array.isArray(columns)) { 232 | throw new DataError('`columns` must be an array'); 233 | } 234 | 235 | columns.forEach((column, i) => { 236 | if (typeof column !== 'string' && typeof column !== 'object') { 237 | throw new DataError(`column "${i}" must be a string or an object`); 238 | } 239 | }); 240 | } 241 | 242 | validateData(data) { 243 | if (Array.isArray(data) && 244 | (data.length === 0 || Array.isArray(data[0]) || typeof data[0] === 'object')) { 245 | return true; 246 | } 247 | throw new DataError('`data` must be an array of arrays or objects'); 248 | } 249 | 250 | appendRows(rows) { 251 | this.validateData(rows); 252 | this.rows = this.rows.concat(this.prepareRows(rows)); 253 | this.prepareTreeRows(); 254 | this.prepareRowView(); 255 | } 256 | 257 | sortRows(colIndex, sortOrder = 'none') { 258 | colIndex = +colIndex; 259 | 260 | // reset sortOrder and update for colIndex 261 | this.getColumns() 262 | .map(col => { 263 | if (col.colIndex === colIndex) { 264 | col.sortOrder = sortOrder; 265 | } else { 266 | col.sortOrder = 'none'; 267 | } 268 | }); 269 | 270 | this._sortRows(colIndex, sortOrder); 271 | } 272 | 273 | _sortRows(colIndex, sortOrder) { 274 | 275 | if (this.currentSort.colIndex === colIndex) { 276 | // reverse the array if only sortOrder changed 277 | if ( 278 | (this.currentSort.sortOrder === 'asc' && sortOrder === 'desc') || 279 | (this.currentSort.sortOrder === 'desc' && sortOrder === 'asc') 280 | ) { 281 | this.reverseArray(this.rowViewOrder); 282 | this.currentSort.sortOrder = sortOrder; 283 | return; 284 | } 285 | } 286 | 287 | this.rowViewOrder.sort((a, b) => { 288 | const aIndex = a; 289 | const bIndex = b; 290 | 291 | let aContent = this.getCell(colIndex, a).content; 292 | let bContent = this.getCell(colIndex, b).content; 293 | aContent = aContent == null ? '' : aContent; 294 | bContent = bContent == null ? '' : bContent; 295 | 296 | if (sortOrder === 'none') { 297 | return aIndex - bIndex; 298 | } else if (sortOrder === 'asc') { 299 | if (aContent < bContent) return -1; 300 | if (aContent > bContent) return 1; 301 | if (aContent === bContent) return 0; 302 | } else if (sortOrder === 'desc') { 303 | if (aContent < bContent) return 1; 304 | if (aContent > bContent) return -1; 305 | if (aContent === bContent) return 0; 306 | } 307 | return 0; 308 | }); 309 | 310 | if (this.hasColumnById('_rowIndex')) { 311 | // update row index 312 | const srNoColIndex = this.getColumnIndexById('_rowIndex'); 313 | this.rows.forEach((row, index) => { 314 | const viewIndex = this.rowViewOrder.indexOf(index); 315 | const cell = row[srNoColIndex]; 316 | cell.content = (viewIndex + 1) + ''; 317 | }); 318 | } 319 | } 320 | 321 | reverseArray(array) { 322 | let left = null; 323 | let right = null; 324 | let length = array.length; 325 | 326 | for (left = 0, right = length - 1; left < right; left += 1, right -= 1) { 327 | const temporary = array[left]; 328 | 329 | array[left] = array[right]; 330 | array[right] = temporary; 331 | } 332 | } 333 | 334 | switchColumn(index1, index2) { 335 | // update columns 336 | const temp = this.columns[index1]; 337 | this.columns[index1] = this.columns[index2]; 338 | this.columns[index2] = temp; 339 | 340 | this.columns[index1].colIndex = index1; 341 | this.columns[index2].colIndex = index2; 342 | 343 | // update rows 344 | this.rows.forEach(row => { 345 | const newCell1 = Object.assign({}, row[index1], { 346 | colIndex: index2 347 | }); 348 | const newCell2 = Object.assign({}, row[index2], { 349 | colIndex: index1 350 | }); 351 | 352 | row[index2] = newCell1; 353 | row[index1] = newCell2; 354 | }); 355 | } 356 | 357 | removeColumn(index) { 358 | index = +index; 359 | const filter = cell => cell.colIndex !== index; 360 | const map = (cell, i) => Object.assign({}, cell, { 361 | colIndex: i 362 | }); 363 | // update columns 364 | this.columns = this.columns 365 | .filter(filter) 366 | .map(map); 367 | 368 | // update rows 369 | this.rows.forEach(row => { 370 | // remove cell 371 | row.splice(index, 1); 372 | // update colIndex 373 | row.forEach((cell, i) => { 374 | cell.colIndex = i; 375 | }); 376 | }); 377 | } 378 | 379 | updateRow(row, rowIndex) { 380 | if (row.length < this.columns.length) { 381 | if (this.hasColumnById('_rowIndex')) { 382 | const val = (rowIndex + 1) + ''; 383 | 384 | row = [val].concat(row); 385 | } 386 | 387 | if (this.hasColumnById('_checkbox')) { 388 | const val = ''; 389 | 390 | row = [val].concat(row); 391 | } 392 | } 393 | 394 | const _row = this.prepareRow(row, {rowIndex}); 395 | const index = this.rows.findIndex(row => row[0].rowIndex === rowIndex); 396 | this.rows[index] = _row; 397 | 398 | return _row; 399 | } 400 | 401 | updateCell(colIndex, rowIndex, options) { 402 | let cell; 403 | if (typeof colIndex === 'object') { 404 | // cell object was passed, 405 | // must have colIndex, rowIndex 406 | cell = colIndex; 407 | colIndex = cell.colIndex; 408 | rowIndex = cell.rowIndex; 409 | // the object passed must be merged with original cell 410 | options = cell; 411 | } 412 | cell = this.getCell(colIndex, rowIndex); 413 | 414 | // mutate object directly 415 | for (let key in options) { 416 | const newVal = options[key]; 417 | if (newVal !== undefined) { 418 | cell[key] = newVal; 419 | } 420 | } 421 | 422 | return cell; 423 | } 424 | 425 | updateColumn(colIndex, keyValPairs) { 426 | const column = this.getColumn(colIndex); 427 | for (let key in keyValPairs) { 428 | const newVal = keyValPairs[key]; 429 | if (newVal !== undefined) { 430 | column[key] = newVal; 431 | } 432 | } 433 | return column; 434 | } 435 | 436 | filterRows(filters) { 437 | return this.options.filterRows(this.rows, filters, this) 438 | .then(result => { 439 | if (!result) { 440 | result = this.getAllRowIndices(); 441 | } 442 | 443 | if (!result.then) { 444 | result = Promise.resolve(result); 445 | } 446 | 447 | return result.then(rowsToShow => { 448 | this._filteredRows = rowsToShow; 449 | 450 | const rowsToHide = this.getAllRowIndices() 451 | .filter(index => !rowsToShow.includes(index)); 452 | 453 | return { 454 | rowsToHide, 455 | rowsToShow 456 | }; 457 | }); 458 | }); 459 | } 460 | 461 | getFilteredRowIndices() { 462 | return this._filteredRows || this.getAllRowIndices(); 463 | } 464 | 465 | getAllRowIndices() { 466 | return this.rows.map(row => row.meta.rowIndex); 467 | } 468 | 469 | getRowCount() { 470 | return this.rowCount; 471 | } 472 | 473 | _getNextRowCount() { 474 | const val = this.rowCount; 475 | 476 | this.rowCount++; 477 | return val; 478 | } 479 | 480 | getRows(start, end) { 481 | return this.rows.slice(start, end); 482 | } 483 | 484 | getRowsForView(start, end) { 485 | const rows = this.rowViewOrder.map(i => this.rows[i]); 486 | return rows.slice(start, end); 487 | } 488 | 489 | getColumns(skipStandardColumns) { 490 | let columns = this.columns; 491 | 492 | if (skipStandardColumns) { 493 | columns = columns.slice(this.getStandardColumnCount()); 494 | } 495 | 496 | return columns; 497 | } 498 | 499 | getStandardColumnCount() { 500 | if (this.options.checkboxColumn && this.options.serialNoColumn) { 501 | return 2; 502 | } 503 | 504 | if (this.options.checkboxColumn || this.options.serialNoColumn) { 505 | return 1; 506 | } 507 | 508 | return 0; 509 | } 510 | 511 | getColumnCount(skipStandardColumns) { 512 | let val = this.columns.length; 513 | 514 | if (skipStandardColumns) { 515 | val = val - this.getStandardColumnCount(); 516 | } 517 | 518 | return val; 519 | } 520 | 521 | getColumn(colIndex) { 522 | colIndex = +colIndex; 523 | 524 | if (colIndex < 0) { 525 | // negative indexes 526 | colIndex = this.columns.length + colIndex; 527 | } 528 | 529 | return this.columns.find(col => col.colIndex === colIndex); 530 | } 531 | 532 | getColumnById(id) { 533 | return this.columns.find(col => col.id === id); 534 | } 535 | 536 | getRow(rowIndex) { 537 | rowIndex = +rowIndex; 538 | return this.rows[rowIndex]; 539 | } 540 | 541 | getCell(colIndex, rowIndex) { 542 | rowIndex = +rowIndex; 543 | colIndex = +colIndex; 544 | return this.getRow(rowIndex)[colIndex]; 545 | } 546 | 547 | getChildren(parentRowIndex) { 548 | parentRowIndex = +parentRowIndex; 549 | const parentIndent = this.getRow(parentRowIndex).meta.indent; 550 | const out = []; 551 | 552 | for (let i = parentRowIndex + 1; i < this.rowCount; i++) { 553 | const row = this.getRow(i); 554 | if (isNaN(row.meta.indent)) continue; 555 | 556 | if (row.meta.indent > parentIndent) { 557 | out.push(i); 558 | } 559 | 560 | if (row.meta.indent === parentIndent) { 561 | break; 562 | } 563 | } 564 | 565 | return out; 566 | } 567 | 568 | getImmediateChildren(parentRowIndex) { 569 | parentRowIndex = +parentRowIndex; 570 | const parentIndent = this.getRow(parentRowIndex).meta.indent; 571 | const out = []; 572 | const childIndent = parentIndent + 1; 573 | 574 | for (let i = parentRowIndex + 1; i < this.rowCount; i++) { 575 | const row = this.getRow(i); 576 | if (isNaN(row.meta.indent) || row.meta.indent > childIndent) continue; 577 | 578 | if (row.meta.indent === childIndent) { 579 | out.push(i); 580 | } 581 | 582 | if (row.meta.indent === parentIndent) { 583 | break; 584 | } 585 | } 586 | 587 | return out; 588 | } 589 | 590 | get() { 591 | return { 592 | columns: this.columns, 593 | rows: this.rows 594 | }; 595 | } 596 | 597 | /** 598 | * Returns the original data which was passed 599 | * based on rowIndex 600 | * @param {Number} rowIndex 601 | * @returns Array|Object 602 | * @memberof DataManager 603 | */ 604 | getData(rowIndex) { 605 | return this.data[rowIndex]; 606 | } 607 | 608 | hasColumn(name) { 609 | return Boolean(this.columns.find(col => col.content === name)); 610 | } 611 | 612 | hasColumnById(id) { 613 | return Boolean(this.columns.find(col => col.id === id)); 614 | } 615 | 616 | getColumnIndex(name) { 617 | return this.columns.findIndex(col => col.content === name); 618 | } 619 | 620 | getColumnIndexById(id) { 621 | return this.columns.findIndex(col => col.id === id); 622 | } 623 | 624 | getCheckboxHTML() { 625 | return ''; 626 | } 627 | } 628 | 629 | // Custom Errors 630 | export class DataError extends TypeError {}; 631 | -------------------------------------------------------------------------------- /src/datatable.js: -------------------------------------------------------------------------------- 1 | import $ from './dom'; 2 | import DataManager from './datamanager'; 3 | import CellManager from './cellmanager'; 4 | import ColumnManager from './columnmanager'; 5 | import RowManager from './rowmanager'; 6 | import BodyRenderer from './body-renderer'; 7 | import Style from './style'; 8 | import Keyboard from './keyboard'; 9 | import TranslationManager from './translationmanager'; 10 | import getDefaultOptions from './defaults'; 11 | 12 | let defaultComponents = { 13 | DataManager, 14 | CellManager, 15 | ColumnManager, 16 | RowManager, 17 | BodyRenderer, 18 | Style, 19 | Keyboard 20 | }; 21 | 22 | class DataTable { 23 | constructor(wrapper, options) { 24 | DataTable.instances++; 25 | 26 | if (typeof wrapper === 'string') { 27 | // css selector 28 | wrapper = document.querySelector(wrapper); 29 | } 30 | this.wrapper = wrapper; 31 | if (!(this.wrapper instanceof HTMLElement)) { 32 | throw new Error('Invalid argument given for `wrapper`'); 33 | } 34 | 35 | this.initializeTranslations(options); 36 | this.setDefaultOptions(); 37 | this.buildOptions(options); 38 | this.prepare(); 39 | this.initializeComponents(); 40 | 41 | if (this.options.data) { 42 | this.refresh(); 43 | this.columnmanager.applyDefaultSortOrder(); 44 | if (this.options.saveSorting) { 45 | this.setupSaveSorting(); 46 | this.columnmanager.applySavedSortOrder(); 47 | } 48 | } 49 | } 50 | 51 | initializeTranslations(options) { 52 | this.language = options.language || 'en'; 53 | this.translationManager = new TranslationManager(this.language); 54 | 55 | if (options.translations) { 56 | this.translationManager.addTranslations(options.translations); 57 | } 58 | } 59 | 60 | setDefaultOptions() { 61 | this.DEFAULT_OPTIONS = getDefaultOptions(this); 62 | } 63 | 64 | buildOptions(options) { 65 | this.options = this.options || {}; 66 | 67 | this.options = Object.assign( 68 | {}, this.DEFAULT_OPTIONS, 69 | this.options || {}, options 70 | ); 71 | 72 | options.headerDropdown = options.headerDropdown || []; 73 | this.options.headerDropdown = [ 74 | ...this.DEFAULT_OPTIONS.headerDropdown, 75 | ...options.headerDropdown 76 | ]; 77 | 78 | // custom user events 79 | this.events = Object.assign( 80 | {}, this.DEFAULT_OPTIONS.events, 81 | this.options.events || {}, 82 | options.events || {} 83 | ); 84 | this.fireEvent = this.fireEvent.bind(this); 85 | } 86 | 87 | prepare() { 88 | this.prepareDom(); 89 | this.unfreeze(); 90 | } 91 | 92 | initializeComponents() { 93 | let components = Object.assign({}, defaultComponents, this.options.overrideComponents); 94 | let { 95 | Style, 96 | Keyboard, 97 | DataManager, 98 | RowManager, 99 | ColumnManager, 100 | CellManager, 101 | BodyRenderer 102 | } = components; 103 | 104 | this.style = new Style(this); 105 | this.keyboard = new Keyboard(this.wrapper); 106 | this.datamanager = new DataManager(this.options); 107 | this.rowmanager = new RowManager(this); 108 | this.columnmanager = new ColumnManager(this); 109 | this.cellmanager = new CellManager(this); 110 | this.bodyRenderer = new BodyRenderer(this); 111 | } 112 | 113 | prepareDom() { 114 | this.wrapper.innerHTML = ` 115 |
116 |
117 |
118 | 119 |
120 | 121 | ${this.options.freezeMessage} 122 | 123 |
124 |
125 |
126 | 127 |
128 | `; 129 | 130 | this.datatableWrapper = $('.datatable', this.wrapper); 131 | this.header = $('.dt-header', this.wrapper); 132 | this.footer = $('.dt-footer', this.wrapper); 133 | this.bodyScrollable = $('.dt-scrollable', this.wrapper); 134 | this.freezeContainer = $('.dt-freeze', this.wrapper); 135 | this.toastMessage = $('.dt-toast', this.wrapper); 136 | this.pasteTarget = $('.dt-paste-target', this.wrapper); 137 | this.dropdownContainer = $('.dt-dropdown-container', this.wrapper); 138 | } 139 | 140 | refresh(data, columns) { 141 | this.datamanager.init(data, columns); 142 | this.render(); 143 | this.setDimensions(); 144 | } 145 | 146 | destroy() { 147 | this.wrapper.innerHTML = ''; 148 | this.style.destroy(); 149 | this.fireEvent('onDestroy'); 150 | } 151 | 152 | appendRows(rows) { 153 | this.datamanager.appendRows(rows); 154 | this.rowmanager.refreshRows(); 155 | } 156 | 157 | refreshRow(row, rowIndex) { 158 | this.rowmanager.refreshRow(row, rowIndex); 159 | } 160 | 161 | render() { 162 | this.renderHeader(); 163 | this.renderBody(); 164 | } 165 | 166 | renderHeader() { 167 | this.columnmanager.renderHeader(); 168 | } 169 | 170 | renderBody() { 171 | this.bodyRenderer.render(); 172 | } 173 | 174 | setDimensions() { 175 | this.style.setDimensions(); 176 | } 177 | 178 | showToastMessage(message, hideAfter) { 179 | this.bodyRenderer.showToastMessage(message, hideAfter); 180 | } 181 | 182 | clearToastMessage() { 183 | this.bodyRenderer.clearToastMessage(); 184 | } 185 | 186 | getColumn(colIndex) { 187 | return this.datamanager.getColumn(colIndex); 188 | } 189 | 190 | getColumns() { 191 | return this.datamanager.getColumns(); 192 | } 193 | 194 | getRows() { 195 | return this.datamanager.getRows(); 196 | } 197 | 198 | getCell(colIndex, rowIndex) { 199 | return this.datamanager.getCell(colIndex, rowIndex); 200 | } 201 | 202 | getColumnHeaderElement(colIndex) { 203 | return this.columnmanager.getColumnHeaderElement(colIndex); 204 | } 205 | 206 | getViewportHeight() { 207 | if (!this.viewportHeight) { 208 | this.viewportHeight = $.style(this.bodyScrollable, 'height'); 209 | } 210 | 211 | return this.viewportHeight; 212 | } 213 | 214 | sortColumn(colIndex, sortOrder) { 215 | this.columnmanager.sortColumn(colIndex, sortOrder); 216 | } 217 | saveSorting(colIndex, nextSortOrder) { 218 | this.columnmanager.saveSorting(colIndex, nextSortOrder); 219 | } 220 | 221 | removeColumn(colIndex) { 222 | this.columnmanager.removeColumn(colIndex); 223 | } 224 | 225 | scrollToLastColumn() { 226 | this.datatableWrapper.scrollLeft = 9999; 227 | } 228 | 229 | freeze() { 230 | $.style(this.freezeContainer, { 231 | display: '' 232 | }); 233 | } 234 | 235 | unfreeze() { 236 | $.style(this.freezeContainer, { 237 | display: 'none' 238 | }); 239 | } 240 | 241 | updateOptions(options) { 242 | this.buildOptions(options); 243 | } 244 | 245 | fireEvent(eventName, ...args) { 246 | // fire internalEventHandlers if any 247 | // and then user events 248 | const handlers = [ 249 | ...(this._internalEventHandlers[eventName] || []), 250 | this.events[eventName] 251 | ].filter(Boolean); 252 | 253 | for (let handler of handlers) { 254 | handler.apply(this, args); 255 | } 256 | } 257 | 258 | on(event, handler) { 259 | this._internalEventHandlers = this._internalEventHandlers || {}; 260 | this._internalEventHandlers[event] = this._internalEventHandlers[event] || []; 261 | this._internalEventHandlers[event].push(handler); 262 | } 263 | 264 | log() { 265 | if (this.options.logs) { 266 | console.log.apply(console, arguments); 267 | } 268 | } 269 | 270 | translate(str, args) { 271 | return this.translationManager.translate(str, args); 272 | } 273 | setupSaveSorting() { 274 | // add options in default headerdropdown 275 | let action = { 276 | label: this.translate('Save Sorting'), 277 | action: function (column) { 278 | this.saveSorting(column.colIndex, column.sotOrder); 279 | }, 280 | display: 'hidden' 281 | }; 282 | this.options.headerDropdown.push(action); 283 | this.columnmanager.bindDropdown(); 284 | // add events for onSortColumn 285 | this.on('onSortColumn', function (column) { 286 | this.columnmanager.toggleDropdownItem(4); 287 | if (column.sortOrder === 'none') { 288 | localStorage.removeItem(this.columnmanager.sortingKey); 289 | } 290 | }); 291 | } 292 | } 293 | 294 | DataTable.instances = 0; 295 | 296 | export default DataTable; 297 | -------------------------------------------------------------------------------- /src/defaults.js: -------------------------------------------------------------------------------- 1 | import filterRows from './filterRows'; 2 | import icons from './icons'; 3 | 4 | export default function getDefaultOptions(instance) { 5 | return { 6 | columns: [], 7 | data: [], 8 | dropdownButton: icons.chevronDown, 9 | headerDropdown: [ 10 | { 11 | label: instance.translate('Sort Ascending'), 12 | action: function (column) { 13 | this.sortColumn(column.colIndex, 'asc'); 14 | } 15 | }, 16 | { 17 | label: instance.translate('Sort Descending'), 18 | action: function (column) { 19 | this.sortColumn(column.colIndex, 'desc'); 20 | } 21 | }, 22 | { 23 | label: instance.translate('Reset sorting'), 24 | action: function (column) { 25 | this.sortColumn(column.colIndex, 'none'); 26 | } 27 | }, 28 | { 29 | label: instance.translate('Remove column'), 30 | action: function (column) { 31 | this.removeColumn(column.colIndex); 32 | } 33 | } 34 | ], 35 | events: { 36 | onRemoveColumn(column) {}, 37 | onSwitchColumn(column1, column2) {}, 38 | onSortColumn(column) {}, 39 | onCheckRow(row) {}, 40 | onDestroy() {} 41 | }, 42 | hooks: { 43 | columnTotal: null 44 | }, 45 | sortIndicator: { 46 | asc: '↑', 47 | desc: '↓', 48 | none: '' 49 | }, 50 | overrideComponents: { 51 | // ColumnManager: CustomColumnManager 52 | }, 53 | filterRows: filterRows, 54 | freezeMessage: '', 55 | getEditor: null, 56 | serialNoColumn: true, 57 | checkboxColumn: false, 58 | clusterize: true, 59 | logs: false, 60 | layout: 'fixed', // fixed, fluid, ratio 61 | noDataMessage: instance.translate('No Data'), 62 | cellHeight: 40, 63 | minimumColumnWidth: 30, 64 | inlineFilters: false, 65 | treeView: false, 66 | checkedRowStatus: true, 67 | dynamicRowHeight: false, 68 | pasteFromClipboard: false, 69 | showTotalRow: false, 70 | direction: 'ltr', 71 | disableReorderColumn: false 72 | }; 73 | }; 74 | -------------------------------------------------------------------------------- /src/dom.js: -------------------------------------------------------------------------------- 1 | export default function $(expr, con) { 2 | return typeof expr === 'string' ? 3 | (con || document).querySelector(expr) : 4 | expr || null; 5 | } 6 | 7 | $.each = (expr, con) => { 8 | return typeof expr === 'string' ? 9 | Array.from((con || document).querySelectorAll(expr)) : 10 | expr || null; 11 | }; 12 | 13 | $.create = (tag, o) => { 14 | let element = document.createElement(tag); 15 | 16 | for (let i in o) { 17 | let val = o[i]; 18 | 19 | if (i === 'inside') { 20 | $(val).appendChild(element); 21 | } else 22 | if (i === 'around') { 23 | let ref = $(val); 24 | ref.parentNode.insertBefore(element, ref); 25 | element.appendChild(ref); 26 | } else 27 | if (i === 'styles') { 28 | if (typeof val === 'object') { 29 | Object.keys(val).map(prop => { 30 | element.style[prop] = val[prop]; 31 | }); 32 | } 33 | } else 34 | if (i in element) { 35 | element[i] = val; 36 | } else { 37 | element.setAttribute(i, val); 38 | } 39 | } 40 | 41 | return element; 42 | }; 43 | 44 | $.on = (element, event, selector, callback) => { 45 | if (!callback) { 46 | callback = selector; 47 | $.bind(element, event, callback); 48 | } else { 49 | $.delegate(element, event, selector, callback); 50 | } 51 | }; 52 | 53 | $.off = (element, event, handler) => { 54 | element.removeEventListener(event, handler); 55 | }; 56 | 57 | $.bind = (element, event, callback) => { 58 | event.split(/\s+/).forEach(function (event) { 59 | element.addEventListener(event, callback); 60 | }); 61 | }; 62 | 63 | $.delegate = (element, event, selector, callback) => { 64 | element.addEventListener(event, function (e) { 65 | const delegatedTarget = e.target.closest(selector); 66 | if (delegatedTarget) { 67 | e.delegatedTarget = delegatedTarget; 68 | callback.call(this, e, delegatedTarget); 69 | } 70 | }); 71 | }; 72 | 73 | $.unbind = (element, o) => { 74 | if (element) { 75 | for (let event in o) { 76 | let callback = o[event]; 77 | 78 | event.split(/\s+/).forEach(function (event) { 79 | element.removeEventListener(event, callback); 80 | }); 81 | } 82 | } 83 | }; 84 | 85 | $.fire = (target, type, properties) => { 86 | let evt = document.createEvent('HTMLEvents'); 87 | 88 | evt.initEvent(type, true, true); 89 | 90 | for (let j in properties) { 91 | evt[j] = properties[j]; 92 | } 93 | 94 | return target.dispatchEvent(evt); 95 | }; 96 | 97 | $.data = (element, attrs) => { // eslint-disable-line 98 | if (!attrs) { 99 | return element.dataset; 100 | } 101 | 102 | for (const attr in attrs) { 103 | element.dataset[attr] = attrs[attr]; 104 | } 105 | }; 106 | 107 | $.style = (elements, styleMap) => { // eslint-disable-line 108 | 109 | if (typeof styleMap === 'string') { 110 | return $.getStyle(elements, styleMap); 111 | } 112 | 113 | if (!Array.isArray(elements)) { 114 | elements = [elements]; 115 | } 116 | 117 | elements.map(element => { 118 | for (const prop in styleMap) { 119 | element.style[prop] = styleMap[prop]; 120 | } 121 | }); 122 | }; 123 | 124 | $.removeStyle = (elements, styleProps) => { 125 | if (!Array.isArray(elements)) { 126 | elements = [elements]; 127 | } 128 | 129 | if (!Array.isArray(styleProps)) { 130 | styleProps = [styleProps]; 131 | } 132 | 133 | elements.map(element => { 134 | for (const prop of styleProps) { 135 | element.style[prop] = ''; 136 | } 137 | }); 138 | }; 139 | 140 | $.getStyle = (element, prop) => { 141 | if (!prop) { 142 | return getComputedStyle(element); 143 | } 144 | 145 | let val = getComputedStyle(element)[prop]; 146 | 147 | if (['width', 'height'].includes(prop)) { 148 | val = parseFloat(val); 149 | } 150 | 151 | return val; 152 | }; 153 | 154 | $.closest = (selector, element) => { 155 | if (!element) return null; 156 | 157 | if (element.matches(selector)) { 158 | return element; 159 | } 160 | 161 | return $.closest(selector, element.parentNode); 162 | }; 163 | 164 | $.inViewport = (el, parentEl) => { 165 | const { 166 | top, 167 | left, 168 | bottom, 169 | right 170 | } = el.getBoundingClientRect(); 171 | const { 172 | top: pTop, 173 | left: pLeft, 174 | bottom: pBottom, 175 | right: pRight 176 | } = parentEl.getBoundingClientRect(); 177 | 178 | return top >= pTop && left >= pLeft && bottom <= pBottom && right <= pRight; 179 | }; 180 | 181 | $.scrollTop = function scrollTop(element, pixels) { 182 | requestAnimationFrame(() => { 183 | element.scrollTop = pixels; 184 | }); 185 | }; 186 | 187 | $.scrollbarSize = function scrollbarSize() { 188 | if (!$.scrollBarSizeValue) { 189 | $.scrollBarSizeValue = getScrollBarSize(); 190 | } 191 | return $.scrollBarSizeValue; 192 | }; 193 | 194 | function getScrollBarSize() { 195 | // assume scrollbar width and height would be the same 196 | 197 | // Create the measurement node 198 | const scrollDiv = document.createElement('div'); 199 | $.style(scrollDiv, { 200 | width: '100px', 201 | height: '100px', 202 | overflow: 'scroll', 203 | position: 'absolute', 204 | top: '-9999px' 205 | }); 206 | document.body.appendChild(scrollDiv); 207 | 208 | // Get the scrollbar width 209 | const scrollbarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth; 210 | 211 | // Delete the DIV 212 | document.body.removeChild(scrollDiv); 213 | 214 | return scrollbarWidth; 215 | } 216 | 217 | $.hasVerticalOverflow = function (element) { 218 | return element.scrollHeight > element.offsetHeight + 10; 219 | }; 220 | 221 | $.hasHorizontalOverflow = function (element) { 222 | return element.scrollWidth > element.offsetWidth + 10; 223 | }; 224 | 225 | $.measureTextWidth = function (text) { 226 | const div = document.createElement('div'); 227 | div.style.position = 'absolute'; 228 | div.style.visibility = 'hidden'; 229 | div.style.height = 'auto'; 230 | div.style.width = 'auto'; 231 | div.style.whiteSpace = 'nowrap'; 232 | div.innerText = text; 233 | document.body.appendChild(div); 234 | return div.clientWidth + 1; 235 | }; 236 | -------------------------------------------------------------------------------- /src/filterRows.js: -------------------------------------------------------------------------------- 1 | import { isNumber, stripHTML } from './utils'; 2 | import CellManager from './cellmanager'; 3 | 4 | export default function filterRows(rows, filters, data) { 5 | let filteredRowIndices = []; 6 | 7 | if (Object.keys(filters).length === 0) { 8 | return rows.map(row => row.meta.rowIndex); 9 | } 10 | 11 | for (let colIndex in filters) { 12 | const keyword = filters[colIndex]; 13 | 14 | const filteredRows = filteredRowIndices.length ? 15 | filteredRowIndices.map(i => rows[i]) : 16 | rows; 17 | 18 | const cells = filteredRows.map(row => row[colIndex]); 19 | 20 | let filter = guessFilter(keyword); 21 | let filterMethod = getFilterMethod(rows, data, filter); 22 | 23 | if (filterMethod) { 24 | filteredRowIndices = filterMethod(filter.text, cells); 25 | } else { 26 | filteredRowIndices = cells.map(cell => cell.rowIndex); 27 | } 28 | } 29 | 30 | return filteredRowIndices; 31 | }; 32 | 33 | function getFilterMethod(rows, allData, filter) { 34 | const getFormattedValue = cell => { 35 | let formatter = CellManager.getCustomCellFormatter(cell); 36 | let rowData = rows[cell.rowIndex]; 37 | if (allData && allData.data && allData.data.length) { 38 | rowData = allData.data[cell.rowIndex]; 39 | } 40 | if (formatter && cell.content) { 41 | cell.html = formatter(cell.content, rows[cell.rowIndex], cell.column, rowData, filter); 42 | return stripHTML(cell.html); 43 | } 44 | return cell.content || ''; 45 | }; 46 | 47 | const stringCompareValue = cell => 48 | String(stripHTML(cell.html || '') || getFormattedValue(cell)).toLowerCase(); 49 | 50 | const numberCompareValue = cell => parseFloat(cell.content); 51 | 52 | const getCompareValues = (cell, keyword) => { 53 | if (cell.column.compareValue) { 54 | const compareValues = cell.column.compareValue(cell, keyword); 55 | if (compareValues && Array.isArray(compareValues)) return compareValues; 56 | } 57 | 58 | // check if it can be converted to number 59 | const float = numberCompareValue(cell); 60 | if (!isNaN(float)) { 61 | return [float, keyword]; 62 | } 63 | 64 | return [stringCompareValue(cell), keyword]; 65 | }; 66 | 67 | let filterMethodMap = { 68 | contains(keyword, cells) { 69 | return cells 70 | .filter(cell => { 71 | const needle = (keyword || '').toLowerCase(); 72 | return !needle || 73 | (cell.content || '').toLowerCase().includes(needle) || 74 | stringCompareValue(cell).includes(needle); 75 | }) 76 | .map(cell => cell.rowIndex); 77 | }, 78 | 79 | greaterThan(keyword, cells) { 80 | return cells 81 | .filter(cell => { 82 | const [compareValue, keywordValue] = getCompareValues(cell, keyword); 83 | return compareValue > keywordValue; 84 | }) 85 | .map(cell => cell.rowIndex); 86 | }, 87 | 88 | lessThan(keyword, cells) { 89 | return cells 90 | .filter(cell => { 91 | const [compareValue, keywordValue] = getCompareValues(cell, keyword); 92 | return compareValue < keywordValue; 93 | }) 94 | .map(cell => cell.rowIndex); 95 | }, 96 | 97 | equals(keyword, cells) { 98 | return cells 99 | .filter(cell => { 100 | const value = parseFloat(cell.content); 101 | return value === keyword; 102 | }) 103 | .map(cell => cell.rowIndex); 104 | }, 105 | 106 | notEquals(keyword, cells) { 107 | return cells 108 | .filter(cell => { 109 | const value = parseFloat(cell.content); 110 | return value !== keyword; 111 | }) 112 | .map(cell => cell.rowIndex); 113 | }, 114 | 115 | range(rangeValues, cells) { 116 | return cells 117 | .filter(cell => { 118 | const values1 = getCompareValues(cell, rangeValues[0]); 119 | const values2 = getCompareValues(cell, rangeValues[1]); 120 | const value = values1[0]; 121 | return value >= values1[1] && value <= values2[1]; 122 | }) 123 | .map(cell => cell.rowIndex); 124 | }, 125 | 126 | containsNumber(keyword, cells) { 127 | return cells 128 | .filter(cell => { 129 | let number = parseFloat(keyword, 10); 130 | let string = keyword; 131 | let hayNumber = numberCompareValue(cell); 132 | let hayString = stringCompareValue(cell); 133 | 134 | return number === hayNumber || hayString.includes(string); 135 | }) 136 | .map(cell => cell.rowIndex); 137 | } 138 | }; 139 | 140 | return filterMethodMap[filter.type]; 141 | } 142 | 143 | function guessFilter(keyword = '') { 144 | if (keyword.length === 0) return {}; 145 | 146 | let compareString = keyword; 147 | 148 | if (['>', '<', '='].includes(compareString[0])) { 149 | compareString = keyword.slice(1); 150 | } else if (compareString.startsWith('!=')) { 151 | compareString = keyword.slice(2); 152 | } 153 | 154 | if (keyword.startsWith('>')) { 155 | if (compareString) { 156 | return { 157 | type: 'greaterThan', 158 | text: compareString.trim() 159 | }; 160 | } 161 | } 162 | 163 | if (keyword.startsWith('<')) { 164 | if (compareString) { 165 | return { 166 | type: 'lessThan', 167 | text: compareString.trim() 168 | }; 169 | } 170 | } 171 | 172 | if (keyword.startsWith('=')) { 173 | if (isNumber(compareString)) { 174 | return { 175 | type: 'equals', 176 | text: Number(keyword.slice(1).trim()) 177 | }; 178 | } 179 | } 180 | 181 | if (isNumber(compareString)) { 182 | return { 183 | type: 'containsNumber', 184 | text: compareString 185 | }; 186 | } 187 | 188 | if (keyword.startsWith('!=')) { 189 | if (isNumber(compareString)) { 190 | return { 191 | type: 'notEquals', 192 | text: Number(keyword.slice(2).trim()) 193 | }; 194 | } 195 | } 196 | 197 | if (keyword.split(':').length === 2 && keyword.split(':').every(v => isNumber(v.trim()))) { 198 | compareString = keyword.split(':'); 199 | return { 200 | type: 'range', 201 | text: compareString.map(v => v.trim()) 202 | }; 203 | } 204 | 205 | return { 206 | type: 'contains', 207 | text: compareString.toLowerCase() 208 | }; 209 | } 210 | -------------------------------------------------------------------------------- /src/icons.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | 3 | // Icons from https://feathericons.com/ 4 | 5 | let icons = { 6 | chevronDown: '', 7 | chevronRight: '' 8 | }; 9 | 10 | export default icons; 11 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import DataTable from './datatable.js'; 2 | import packageJson from '../package.json'; 3 | DataTable.__version__ = packageJson.version; 4 | 5 | export default DataTable; 6 | -------------------------------------------------------------------------------- /src/keyboard.js: -------------------------------------------------------------------------------- 1 | import $ from './dom'; 2 | 3 | const KEYCODES = { 4 | 13: 'enter', 5 | 91: 'meta', 6 | 16: 'shift', 7 | 17: 'ctrl', 8 | 18: 'alt', 9 | 37: 'left', 10 | 38: 'up', 11 | 39: 'right', 12 | 40: 'down', 13 | 9: 'tab', 14 | 27: 'esc', 15 | 67: 'c', 16 | 70: 'f', 17 | 86: 'v' 18 | }; 19 | 20 | export default class Keyboard { 21 | constructor(element) { 22 | this.listeners = {}; 23 | $.on(element, 'keydown', this.handler.bind(this)); 24 | } 25 | 26 | handler(e) { 27 | let key = KEYCODES[e.keyCode]; 28 | 29 | if (e.shiftKey && key !== 'shift') { 30 | key = 'shift+' + key; 31 | } 32 | 33 | if ((e.ctrlKey && key !== 'ctrl') || (e.metaKey && key !== 'meta')) { 34 | key = 'ctrl+' + key; 35 | } 36 | 37 | const listeners = this.listeners[key]; 38 | 39 | if (listeners && listeners.length > 0) { 40 | for (let listener of listeners) { 41 | const preventBubbling = listener(e); 42 | if (preventBubbling === undefined || preventBubbling === true) { 43 | e.preventDefault(); 44 | } 45 | } 46 | } 47 | } 48 | 49 | on(key, listener) { 50 | const keys = key.split(',').map(k => k.trim()); 51 | 52 | keys.map(key => { 53 | this.listeners[key] = this.listeners[key] || []; 54 | this.listeners[key].push(listener); 55 | }); 56 | } 57 | } 58 | 59 | export let keyCode = KEYCODES; 60 | -------------------------------------------------------------------------------- /src/rowmanager.js: -------------------------------------------------------------------------------- 1 | import $ from './dom'; 2 | import { 3 | makeDataAttributeString, 4 | nextTick, 5 | ensureArray, 6 | linkProperties, 7 | uniq, 8 | numberSortAsc 9 | } from './utils'; 10 | 11 | export default class RowManager { 12 | constructor(instance) { 13 | this.instance = instance; 14 | linkProperties(this, this.instance, [ 15 | 'options', 16 | 'fireEvent', 17 | 'wrapper', 18 | 'bodyScrollable', 19 | 'bodyRenderer', 20 | 'style' 21 | ]); 22 | 23 | this.bindEvents(); 24 | this.refreshRows = nextTick(this.refreshRows, this); 25 | } 26 | 27 | get datamanager() { 28 | return this.instance.datamanager; 29 | } 30 | 31 | get cellmanager() { 32 | return this.instance.cellmanager; 33 | } 34 | 35 | bindEvents() { 36 | this.bindCheckbox(); 37 | } 38 | 39 | bindCheckbox() { 40 | if (!this.options.checkboxColumn) return; 41 | 42 | // map of checked rows 43 | this.checkMap = []; 44 | 45 | $.on(this.wrapper, 'click', '.dt-cell--col-0 [type="checkbox"]', (e, $checkbox) => { 46 | const $cell = $checkbox.closest('.dt-cell'); 47 | const { 48 | rowIndex, 49 | isHeader 50 | } = $.data($cell); 51 | const checked = $checkbox.checked; 52 | 53 | if (isHeader) { 54 | this.checkAll(checked); 55 | } else { 56 | this.checkRow(rowIndex, checked); 57 | } 58 | }); 59 | } 60 | 61 | refreshRows() { 62 | this.instance.renderBody(); 63 | this.instance.setDimensions(); 64 | } 65 | 66 | refreshRow(row, rowIndex) { 67 | const _row = this.datamanager.updateRow(row, rowIndex); 68 | 69 | _row.forEach(cell => { 70 | this.cellmanager.refreshCell(cell, true); 71 | }); 72 | } 73 | 74 | getCheckedRows() { 75 | if (!this.checkMap) { 76 | return []; 77 | } 78 | 79 | let out = []; 80 | for (let rowIndex in this.checkMap) { 81 | const checked = this.checkMap[rowIndex]; 82 | if (checked === 1) { 83 | out.push(rowIndex); 84 | } 85 | } 86 | 87 | return out; 88 | } 89 | 90 | highlightCheckedRows() { 91 | this.getCheckedRows() 92 | .map(rowIndex => this.checkRow(rowIndex, true)); 93 | } 94 | 95 | checkRow(rowIndex, toggle) { 96 | const value = toggle ? 1 : 0; 97 | const selector = rowIndex => `.dt-cell--0-${rowIndex} [type="checkbox"]`; 98 | // update internal map 99 | this.checkMap[rowIndex] = value; 100 | // set checkbox value explicitly 101 | $.each(selector(rowIndex), this.bodyScrollable) 102 | .map(input => { 103 | input.checked = toggle; 104 | }); 105 | // highlight row 106 | this.highlightRow(rowIndex, toggle); 107 | this.showCheckStatus(); 108 | this.fireEvent('onCheckRow', this.datamanager.getRow(rowIndex)); 109 | } 110 | 111 | checkAll(toggle) { 112 | const value = toggle ? 1 : 0; 113 | 114 | // update internal map 115 | if (toggle) { 116 | if (this.datamanager._filteredRows) { 117 | this.datamanager._filteredRows.forEach(f => { 118 | this.checkRow(f, toggle); 119 | }); 120 | } else { 121 | this.checkMap = Array.from(Array(this.getTotalRows())).map(c => value); 122 | } 123 | } else { 124 | this.checkMap = []; 125 | } 126 | // set checkbox value 127 | $.each('.dt-cell--col-0 [type="checkbox"]', this.bodyScrollable) 128 | .map(input => { 129 | input.checked = toggle; 130 | }); 131 | // highlight all 132 | this.highlightAll(toggle); 133 | this.showCheckStatus(); 134 | this.fireEvent('onCheckRow'); 135 | } 136 | 137 | showCheckStatus() { 138 | if (!this.options.checkedRowStatus) return; 139 | const checkedRows = this.getCheckedRows(); 140 | const count = checkedRows.length; 141 | if (count > 0) { 142 | let message = this.instance.translate('{count} rows selected', { 143 | count: count 144 | }); 145 | this.bodyRenderer.showToastMessage(message); 146 | } else { 147 | this.bodyRenderer.clearToastMessage(); 148 | } 149 | } 150 | 151 | highlightRow(rowIndex, toggle = true) { 152 | const $row = this.getRow$(rowIndex); 153 | if (!$row) return; 154 | 155 | if (!toggle && this.bodyScrollable.classList.contains('dt-scrollable--highlight-all')) { 156 | $row.classList.add('dt-row--unhighlight'); 157 | return; 158 | } 159 | 160 | if (toggle && $row.classList.contains('dt-row--unhighlight')) { 161 | $row.classList.remove('dt-row--unhighlight'); 162 | } 163 | 164 | this._highlightedRows = this._highlightedRows || {}; 165 | 166 | if (toggle) { 167 | $row.classList.add('dt-row--highlight'); 168 | this._highlightedRows[rowIndex] = $row; 169 | } else { 170 | $row.classList.remove('dt-row--highlight'); 171 | delete this._highlightedRows[rowIndex]; 172 | } 173 | } 174 | 175 | highlightAll(toggle = true) { 176 | if (toggle) { 177 | this.bodyScrollable.classList.add('dt-scrollable--highlight-all'); 178 | } else { 179 | this.bodyScrollable.classList.remove('dt-scrollable--highlight-all'); 180 | for (const rowIndex in this._highlightedRows) { 181 | const $row = this._highlightedRows[rowIndex]; 182 | $row.classList.remove('dt-row--highlight'); 183 | } 184 | this._highlightedRows = {}; 185 | } 186 | } 187 | 188 | showRows(rowIndices) { 189 | rowIndices = ensureArray(rowIndices); 190 | const rows = rowIndices.map(rowIndex => this.datamanager.getRow(rowIndex)); 191 | this.bodyRenderer.renderRows(rows); 192 | } 193 | 194 | showAllRows() { 195 | const rowIndices = this.datamanager.getAllRowIndices(); 196 | this.showRows(rowIndices); 197 | } 198 | 199 | getChildrenToShowForNode(rowIndex) { 200 | const row = this.datamanager.getRow(rowIndex); 201 | row.meta.isTreeNodeClose = false; 202 | 203 | return this.datamanager.getImmediateChildren(rowIndex); 204 | } 205 | 206 | openSingleNode(rowIndex) { 207 | const childrenToShow = this.getChildrenToShowForNode(rowIndex); 208 | const visibleRowIndices = this.bodyRenderer.visibleRowIndices; 209 | const rowsToShow = uniq([...childrenToShow, ...visibleRowIndices]).sort(numberSortAsc); 210 | 211 | this.showRows(rowsToShow); 212 | } 213 | 214 | getChildrenToHideForNode(rowIndex) { 215 | const row = this.datamanager.getRow(rowIndex); 216 | row.meta.isTreeNodeClose = true; 217 | 218 | const rowsToHide = this.datamanager.getChildren(rowIndex); 219 | rowsToHide.forEach(rowIndex => { 220 | const row = this.datamanager.getRow(rowIndex); 221 | if (!row.meta.isLeaf) { 222 | row.meta.isTreeNodeClose = true; 223 | } 224 | }); 225 | 226 | return rowsToHide; 227 | } 228 | 229 | closeSingleNode(rowIndex) { 230 | const rowsToHide = this.getChildrenToHideForNode(rowIndex); 231 | const visibleRows = this.bodyRenderer.visibleRowIndices; 232 | const rowsToShow = visibleRows 233 | .filter(rowIndex => !rowsToHide.includes(rowIndex)) 234 | .sort(numberSortAsc); 235 | 236 | this.showRows(rowsToShow); 237 | } 238 | 239 | expandAllNodes() { 240 | let rows = this.datamanager.getRows(); 241 | let rootNodes = rows.filter(row => !row.meta.isLeaf); 242 | 243 | const childrenToShow = rootNodes.map(row => this.getChildrenToShowForNode(row.meta.rowIndex)).flat(); 244 | const visibleRowIndices = this.bodyRenderer.visibleRowIndices; 245 | const rowsToShow = uniq([...childrenToShow, ...visibleRowIndices]).sort(numberSortAsc); 246 | 247 | this.showRows(rowsToShow); 248 | } 249 | 250 | collapseAllNodes() { 251 | let rows = this.datamanager.getRows(); 252 | let rootNodes = rows.filter(row => row.meta.indent === 0); 253 | 254 | const rowsToHide = rootNodes.map(row => this.getChildrenToHideForNode(row.meta.rowIndex)).flat(); 255 | const visibleRows = this.bodyRenderer.visibleRowIndices; 256 | const rowsToShow = visibleRows 257 | .filter(rowIndex => !rowsToHide.includes(rowIndex)) 258 | .sort(numberSortAsc); 259 | 260 | this.showRows(rowsToShow); 261 | } 262 | 263 | setTreeDepth(depth) { 264 | let rows = this.datamanager.getRows(); 265 | 266 | const rowsToOpen = rows.filter(row => row.meta.indent < depth); 267 | const rowsToClose = rows.filter(row => row.meta.indent >= depth); 268 | const rowsToHide = rowsToClose.filter(row => row.meta.indent > depth); 269 | 270 | rowsToClose.forEach(row => { 271 | if (!row.meta.isLeaf) { 272 | row.meta.isTreeNodeClose = true; 273 | } 274 | }); 275 | rowsToOpen.forEach(row => { 276 | if (!row.meta.isLeaf) { 277 | row.meta.isTreeNodeClose = false; 278 | } 279 | }); 280 | 281 | const rowsToShow = rows 282 | .filter(row => !rowsToHide.includes(row)) 283 | .map(row => row.meta.rowIndex) 284 | .sort(numberSortAsc); 285 | this.showRows(rowsToShow); 286 | } 287 | 288 | getRow$(rowIndex) { 289 | return $(this.selector(rowIndex), this.bodyScrollable); 290 | } 291 | 292 | getTotalRows() { 293 | return this.datamanager.getRowCount(); 294 | } 295 | 296 | getFirstRowIndex() { 297 | return 0; 298 | } 299 | 300 | getLastRowIndex() { 301 | return this.datamanager.getRowCount() - 1; 302 | } 303 | 304 | scrollToRow(rowIndex) { 305 | rowIndex = +rowIndex; 306 | this._lastScrollTo = this._lastScrollTo || 0; 307 | const $row = this.getRow$(rowIndex); 308 | if ($.inViewport($row, this.bodyScrollable)) return; 309 | 310 | const { 311 | height 312 | } = $row.getBoundingClientRect(); 313 | const { 314 | top, 315 | bottom 316 | } = this.bodyScrollable.getBoundingClientRect(); 317 | const rowsInView = Math.floor((bottom - top) / height); 318 | 319 | let offset = 0; 320 | if (rowIndex > this._lastScrollTo) { 321 | offset = height * ((rowIndex + 1) - rowsInView); 322 | } else { 323 | offset = height * ((rowIndex + 1) - 1); 324 | } 325 | 326 | this._lastScrollTo = rowIndex; 327 | $.scrollTop(this.bodyScrollable, offset); 328 | } 329 | 330 | getRowHTML(row, props) { 331 | const dataAttr = makeDataAttributeString(props); 332 | let rowIdentifier = props.rowIndex; 333 | 334 | if (props.isFilter) { 335 | row = row.map(cell => (Object.assign({}, cell, { 336 | content: this.getFilterInput({ 337 | colIndex: cell.colIndex, 338 | name: cell.name 339 | }), 340 | isFilter: 1, 341 | isHeader: undefined, 342 | editable: false 343 | }))); 344 | 345 | rowIdentifier = 'filter'; 346 | } 347 | 348 | if (props.isHeader) { 349 | rowIdentifier = 'header'; 350 | } 351 | 352 | return ` 353 |
354 | ${row.map(cell => this.cellmanager.getCellHTML(cell)).join('')} 355 |
356 | `; 357 | } 358 | 359 | getFilterInput(props) { 360 | let title = `title="Filter based on ${props.name || 'Index'}"`; 361 | const dataAttr = makeDataAttributeString(props); 362 | return ``; 364 | } 365 | 366 | selector(rowIndex) { 367 | return `.dt-row-${rowIndex}`; 368 | } 369 | } 370 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --dt-border-color: #d1d8dd; 3 | --dt-primary-color: rgb(82, 146, 247); 4 | --dt-light-bg: #f5f7fa; 5 | --dt-light-red: #FD8B8B; 6 | --dt-light-yellow: #fffce7; 7 | --dt-orange: rgb(255, 160, 10); 8 | --dt-text-color: #000000; 9 | --dt-text-light: #dfe2e5; 10 | --dt-spacer-1: 0.25rem; 11 | --dt-spacer-2: 0.5rem; 12 | --dt-spacer-3: 1rem; 13 | --dt-border-radius: 3px; 14 | --dt-cell-bg: #fff; 15 | --dt-focus-border-width: 2px; 16 | --dt-selection-highlight-color: var(--dt-light-yellow); 17 | --dt-toast-message-border: none; 18 | --dt-header-cell-bg: var(--dt-cell-bg); 19 | --dt-no-data-message-width: 90px; 20 | } 21 | 22 | .datatable { 23 | *, *::after, *::before { 24 | box-sizing: border-box; 25 | } 26 | } 27 | 28 | .datatable { 29 | position: relative; 30 | overflow: hidden; 31 | } 32 | 33 | .dt-scrollable { 34 | height: 40vw; 35 | overflow: auto; 36 | border-top: 2px solid var(--dt-border-color); 37 | 38 | &--highlight-all { 39 | background-color: var(--dt-selection-highlight-color); 40 | } 41 | 42 | &__no-data { 43 | text-align: center; 44 | padding: var(--dt-spacer-3); 45 | border-left: 1px solid var(--dt-border-color); 46 | border-right: 1px solid var(--dt-border-color); 47 | .no-data-message{ 48 | position: absolute; 49 | top: 100px; 50 | left: 50px; 51 | border: none; 52 | width: var(--dt-no-data-message-width); 53 | } 54 | } 55 | } 56 | 57 | .dt-row { 58 | display: flex; 59 | 60 | &--highlight .dt-cell { 61 | background-color: var(--dt-selection-highlight-color); 62 | } 63 | 64 | &--unhighlight .dt-cell { 65 | background-color: var(--dt-cell-bg); 66 | } 67 | 68 | &--hide { 69 | display: none; 70 | } 71 | 72 | &:last-child:not(.dt-row-filter) { 73 | border-bottom: 1px solid var(--dt-border-color); 74 | } 75 | } 76 | 77 | .dt-cell { 78 | border: 1px solid var(--dt-border-color); 79 | border-bottom: none; 80 | border-right: none; 81 | position: relative; 82 | outline: none; 83 | padding: 0; 84 | background-color: var(--dt-cell-bg); 85 | color: var(--dt-text-color); 86 | /* 87 | Fix for firefox and Edge 88 | https://stackoverflow.com/a/16337203 89 | firefox paints td background over border 90 | */ 91 | background-clip: padding-box; 92 | user-select: none; 93 | 94 | &__content { 95 | padding: var(--dt-spacer-2); 96 | border: var(--dt-focus-border-width) solid transparent; 97 | height: 100%; 98 | text-overflow: ellipsis; 99 | white-space: nowrap; 100 | overflow: hidden; 101 | } 102 | 103 | &__edit { 104 | display: none; 105 | padding: var(--dt-spacer-2); 106 | background-color: var(--dt-cell-bg); 107 | border: var(--dt-focus-border-width) solid var(--dt-orange); 108 | z-index: 1; 109 | height: 100%; 110 | } 111 | 112 | &__resize-handle { 113 | opacity: 0; 114 | position: absolute; 115 | right: -3px; 116 | top: 0; 117 | width: 5px; 118 | height: 100%; 119 | cursor: col-resize; 120 | z-index: 1; 121 | } 122 | 123 | &--editing &__content { 124 | display: none; 125 | } 126 | 127 | &--editing &__edit { 128 | display: block; 129 | } 130 | 131 | &--focus &__content { 132 | border-color: var(--dt-primary-color); 133 | } 134 | 135 | &--highlight { 136 | background-color: var(--dt-light-bg); 137 | } 138 | 139 | &--dragging { 140 | background-color: var(--dt-light-bg); 141 | } 142 | 143 | &--header { 144 | background-color: var(--dt-header-cell-bg); 145 | } 146 | 147 | &--header:last-child { 148 | border-right: 1px solid var(--dt-border-color); 149 | } 150 | 151 | &--header &__content { 152 | padding-right: var(--dt-spacer-3); 153 | font-weight: bold; 154 | } 155 | 156 | &--header:hover .dt-dropdown__toggle { 157 | opacity: 1; 158 | } 159 | 160 | &--tree-close { 161 | .icon-open { 162 | display: none; 163 | } 164 | 165 | .icon-close { 166 | display: flex; 167 | } 168 | } 169 | 170 | &:last-child { 171 | border-right: 1px solid var(--dt-border-color); 172 | } 173 | } 174 | 175 | .datatable[dir=rtl] .dt-cell__resize-handle { 176 | right: unset; 177 | left: -3px; 178 | } 179 | 180 | .icon-open, .icon-close { 181 | width: 16px; 182 | height: 16px; 183 | } 184 | 185 | .icon-open { 186 | display: flex; 187 | } 188 | 189 | .icon-close { 190 | display: none; 191 | } 192 | 193 | .dt-dropdown { 194 | position: absolute; 195 | right: 10px; 196 | display: inline-flex; 197 | vertical-align: top; 198 | text-align: left; 199 | font-weight: normal; 200 | cursor: pointer; 201 | 202 | &__toggle { 203 | opacity: 0; 204 | background-color: var(--dt-header-cell-bg); 205 | } 206 | 207 | &__list { 208 | position: fixed; 209 | min-width: 8rem; 210 | z-index: 1; 211 | cursor: pointer; 212 | background-color: var(--dt-cell-bg); 213 | border-radius: var(--dt-border-radius); 214 | padding: var(--dt-spacer-2) 0; 215 | box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1); 216 | } 217 | 218 | &__list-item { 219 | padding: var(--dt-spacer-2) var(--dt-spacer-3); 220 | 221 | &:hover { 222 | background-color: var(--dt-light-bg); 223 | } 224 | } 225 | 226 | &--active &__list { 227 | display: block; 228 | } 229 | } 230 | 231 | .dt-tree-node { 232 | display: flex; 233 | align-items: center; 234 | position: relative; 235 | 236 | &__toggle { 237 | display: inline-block; 238 | cursor: pointer; 239 | margin-right: 0.2rem; 240 | } 241 | } 242 | 243 | .dt-toast { 244 | position: absolute; 245 | bottom: var(--dt-spacer-3); 246 | left: 50%; 247 | transform: translateX(-50%); 248 | 249 | &__message { 250 | display: inline-block; 251 | background-color: rgba(0, 0, 0, 0.8); 252 | color: var(--dt-text-light); 253 | border-radius: var(--dt-border-radius); 254 | padding: var(--dt-spacer-2) var(--dt-spacer-3); 255 | border: var(--dt-toast-message-border); 256 | } 257 | } 258 | 259 | .dt-input { 260 | outline: none; 261 | width: 100%; 262 | border: none; 263 | overflow: visible; 264 | font-family: inherit; 265 | font-size: inherit; 266 | line-height: inherit; 267 | background-color: inherit; 268 | color: inherit; 269 | margin: 0; 270 | padding: 0; 271 | } 272 | 273 | .dt-freeze { 274 | display: flex; 275 | justify-content: center; 276 | align-content: center; 277 | position: absolute; 278 | left: 0; 279 | right: 0; 280 | top: 0; 281 | bottom: 0; 282 | background-color: var(--dt-light-bg); 283 | opacity: 0.5; 284 | font-size: 2em; 285 | 286 | &__message { 287 | position: absolute; 288 | top: 50%; 289 | transform: translateY(-50%); 290 | } 291 | } 292 | 293 | .dt-paste-target { 294 | position: fixed; 295 | left: -999em; 296 | } 297 | 298 | .dt-hidden{ 299 | display: none; 300 | } 301 | 302 | body.dt-resize { 303 | cursor: col-resize; 304 | } 305 | -------------------------------------------------------------------------------- /src/style.js: -------------------------------------------------------------------------------- 1 | import $ from './dom'; 2 | import { 3 | camelCaseToDash, 4 | linkProperties, 5 | throttle 6 | } from './utils'; 7 | 8 | export default class Style { 9 | constructor(instance) { 10 | this.instance = instance; 11 | 12 | linkProperties(this, this.instance, [ 13 | 'options', 'datamanager', 'columnmanager', 14 | 'header', 'footer', 'bodyScrollable', 'datatableWrapper', 15 | 'getColumn', 'bodyRenderer' 16 | ]); 17 | 18 | this.scopeClass = 'dt-instance-' + instance.constructor.instances; 19 | instance.datatableWrapper.classList.add(this.scopeClass); 20 | 21 | const styleEl = document.createElement('style'); 22 | instance.wrapper.insertBefore(styleEl, instance.datatableWrapper); 23 | this.styleEl = styleEl; 24 | 25 | this.bindResizeWindow(); 26 | this.bindScrollHeader(); 27 | } 28 | 29 | get stylesheet() { 30 | return this.styleEl.sheet; 31 | } 32 | 33 | bindResizeWindow() { 34 | this.onWindowResize = this.onWindowResize.bind(this); 35 | this.onWindowResize = throttle(this.onWindowResize, 300); 36 | 37 | if (this.options.layout === 'fluid') { 38 | $.on(window, 'resize', this.onWindowResize); 39 | } 40 | } 41 | 42 | bindScrollHeader() { 43 | this._settingHeaderPosition = false; 44 | 45 | $.on(this.bodyScrollable, 'scroll', (e) => { 46 | if (this._settingHeaderPosition) return; 47 | 48 | this._settingHeaderPosition = true; 49 | 50 | requestAnimationFrame(() => { 51 | const left = -e.target.scrollLeft; 52 | 53 | $.style(this.header, { 54 | transform: `translateX(${left}px)` 55 | }); 56 | $.style(this.footer, { 57 | transform: `translateX(${left}px)` 58 | }); 59 | this._settingHeaderPosition = false; 60 | if (this.instance.noData) { 61 | $.style($('.no-data-message'), { 62 | left: `${this.instance.wrapper.clientWidth / 2 - (left)}px` 63 | }); 64 | } 65 | this._settingHeaderPosition = false; 66 | }); 67 | }); 68 | } 69 | 70 | onWindowResize() { 71 | this.distributeRemainingWidth(); 72 | this.refreshColumnWidth(); 73 | this.setBodyStyle(); 74 | } 75 | 76 | destroy() { 77 | this.styleEl.remove(); 78 | $.off(window, 'resize', this.onWindowResize); 79 | } 80 | 81 | setStyle(selector, styleObject) { 82 | if (selector.includes(',')) { 83 | selector.split(',') 84 | .map(s => s.trim()) 85 | .forEach(selector => { 86 | this.setStyle(selector, styleObject); 87 | }); 88 | return; 89 | } 90 | 91 | selector = selector.trim(); 92 | if (!selector) return; 93 | 94 | this._styleRulesMap = this._styleRulesMap || {}; 95 | const prefixedSelector = this._getPrefixedSelector(selector); 96 | 97 | if (this._styleRulesMap[prefixedSelector]) { 98 | this.removeStyle(selector); 99 | 100 | // merge with old styleobject 101 | styleObject = Object.assign({}, this._styleRulesMap[prefixedSelector], styleObject); 102 | } 103 | 104 | const styleString = this._getRuleString(styleObject); 105 | const ruleString = `${prefixedSelector} { ${styleString} }`; 106 | 107 | this._styleRulesMap[prefixedSelector] = styleObject; 108 | this.stylesheet.insertRule(ruleString); 109 | } 110 | 111 | removeStyle(selector) { 112 | if (selector.includes(',')) { 113 | selector.split(',') 114 | .map(s => s.trim()) 115 | .forEach(selector => { 116 | this.removeStyle(selector); 117 | }); 118 | return; 119 | } 120 | 121 | selector = selector.trim(); 122 | if (!selector) return; 123 | 124 | // find and remove 125 | const prefixedSelector = this._getPrefixedSelector(selector); 126 | const index = Array.from(this.stylesheet.cssRules) 127 | .findIndex(rule => rule.selectorText === prefixedSelector); 128 | 129 | if (index === -1) return; 130 | this.stylesheet.deleteRule(index); 131 | } 132 | 133 | _getPrefixedSelector(selector) { 134 | return `.${this.scopeClass} ${selector}`; 135 | } 136 | 137 | _getRuleString(styleObject) { 138 | return Object.keys(styleObject) 139 | .map(prop => { 140 | let dashed = prop; 141 | if (!prop.includes('-')) { 142 | dashed = camelCaseToDash(prop); 143 | } 144 | return `${dashed}:${styleObject[prop]};`; 145 | }) 146 | .join(''); 147 | } 148 | 149 | setDimensions() { 150 | this.setCellHeight(); 151 | this.setupMinWidth(); 152 | this.setupNaturalColumnWidth(); 153 | this.setupColumnWidth(); 154 | this.distributeRemainingWidth(); 155 | this.setColumnStyle(); 156 | this.setBodyStyle(); 157 | } 158 | 159 | setCellHeight() { 160 | this.setStyle('.dt-cell', { 161 | height: this.options.cellHeight + 'px' 162 | }); 163 | } 164 | 165 | setupMinWidth() { 166 | $.each('.dt-cell--header', this.header).map(col => { 167 | const { colIndex } = $.data(col); 168 | const column = this.getColumn(colIndex); 169 | 170 | if (!column.minWidth) { 171 | const width = $.style($('.dt-cell__content', col), 'width'); 172 | // only set this once 173 | column.minWidth = width; 174 | } 175 | }); 176 | } 177 | 178 | setupNaturalColumnWidth() { 179 | if (!$('.dt-row')) return; 180 | 181 | $.each('.dt-row-header .dt-cell', this.header).map($headerCell => { 182 | const { colIndex } = $.data($headerCell); 183 | const column = this.datamanager.getColumn(colIndex); 184 | let width = $.style($('.dt-cell__content', $headerCell), 'width'); 185 | if (typeof width === 'number' && width >= this.options.minimumColumnWidth) { 186 | column.naturalWidth = width; 187 | } else { 188 | column.naturalWidth = this.options.minimumColumnWidth; 189 | } 190 | }); 191 | 192 | // set initial width as naturally calculated by table's first row 193 | $.each('.dt-row-0 .dt-cell', this.bodyScrollable).map($cell => { 194 | const { 195 | colIndex 196 | } = $.data($cell); 197 | const column = this.datamanager.getColumn(colIndex); 198 | 199 | let naturalWidth = $.style($('.dt-cell__content', $cell), 'width'); 200 | 201 | if (typeof naturalWidth === 'number' && naturalWidth >= column.naturalWidth) { 202 | column.naturalWidth = naturalWidth; 203 | } else { 204 | column.naturalWidth = column.naturalWidth; 205 | } 206 | }); 207 | } 208 | 209 | setupColumnWidth() { 210 | if (this.options.layout === 'ratio') { 211 | let totalWidth = $.style(this.datatableWrapper, 'width'); 212 | 213 | if (this.options.serialNoColumn) { 214 | const rowIndexColumn = this.datamanager.getColumnById('_rowIndex'); 215 | totalWidth = totalWidth - rowIndexColumn.width - 1; 216 | } 217 | 218 | if (this.options.checkboxColumn) { 219 | const rowIndexColumn = this.datamanager.getColumnById('_checkbox'); 220 | totalWidth = totalWidth - rowIndexColumn.width - 1; 221 | } 222 | 223 | const totalParts = this.datamanager.getColumns() 224 | .map(column => { 225 | if (column.id === '_rowIndex' || column.id === '_checkbox') { 226 | return 0; 227 | } 228 | if (!column.width) { 229 | column.width = 1; 230 | } 231 | column.ratioWidth = parseInt(column.width, 10); 232 | return column.ratioWidth; 233 | }) 234 | .reduce((a, c) => a + c); 235 | 236 | const onePart = totalWidth / totalParts; 237 | 238 | this.datamanager.getColumns() 239 | .map(column => { 240 | if (column.id === '_rowIndex' || column.id === '_checkbox') return; 241 | column.width = Math.floor(onePart * column.ratioWidth) - 1; 242 | }); 243 | } else { 244 | this.datamanager.getColumns() 245 | .map(column => { 246 | if (!column.width) { 247 | column.width = column.naturalWidth; 248 | } 249 | if (column.id === '_rowIndex') { 250 | column.width = this.getRowIndexColumnWidth(); 251 | } 252 | if (column.width < this.options.minimumColumnWidth) { 253 | column.width = this.options.minimumColumnWidth; 254 | } 255 | }); 256 | } 257 | } 258 | 259 | distributeRemainingWidth() { 260 | if (this.options.layout !== 'fluid') return; 261 | 262 | const wrapperWidth = $.style(this.instance.datatableWrapper, 'width'); 263 | let firstRow = $('.dt-row', this.bodyScrollable); 264 | let firstRowWidth = wrapperWidth; 265 | if (!firstRow) { 266 | let headerRow = $('.dt-row', this.instance.header); 267 | let cellWidths = Array.from(headerRow.children) 268 | .map(cell => cell.offsetWidth); 269 | firstRowWidth = cellWidths.reduce((sum, a) => sum + a, 0); 270 | } else { 271 | firstRowWidth = $.style(firstRow, 'width'); 272 | } 273 | const resizableColumns = this.datamanager.getColumns().filter(col => col.resizable); 274 | const deltaWidth = (wrapperWidth - firstRowWidth) / resizableColumns.length; 275 | 276 | resizableColumns.map(col => { 277 | const width = $.style(this.getColumnHeaderElement(col.colIndex), 'width'); 278 | let finalWidth = Math.floor(width + deltaWidth) - 2; 279 | 280 | this.datamanager.updateColumn(col.colIndex, { 281 | width: finalWidth 282 | }); 283 | }); 284 | } 285 | 286 | setColumnStyle() { 287 | // align columns 288 | this.datamanager.getColumns() 289 | .map(column => { 290 | // alignment 291 | if (!column.align) { 292 | column.align = 'left'; 293 | } 294 | if (!['left', 'center', 'right'].includes(column.align)) { 295 | column.align = 'left'; 296 | } 297 | this.setStyle(`.dt-cell--col-${column.colIndex}`, { 298 | 'text-align': column.align 299 | }); 300 | 301 | // width 302 | this.columnmanager.setColumnHeaderWidth(column.colIndex); 303 | this.columnmanager.setColumnWidth(column.colIndex); 304 | }); 305 | } 306 | 307 | refreshColumnWidth() { 308 | this.datamanager.getColumns() 309 | .map(column => { 310 | this.columnmanager.setColumnHeaderWidth(column.colIndex); 311 | this.columnmanager.setColumnWidth(column.colIndex); 312 | }); 313 | } 314 | 315 | setBodyStyle() { 316 | const bodyWidth = $.style(this.datatableWrapper, 'width'); 317 | const firstRow = $('.dt-row', this.bodyScrollable); 318 | if (!firstRow) return; 319 | const rowWidth = $.style(firstRow, 'width'); 320 | 321 | let width = bodyWidth > rowWidth ? rowWidth + 10 : bodyWidth; 322 | $.style(this.bodyScrollable, { 323 | width: width + 'px' 324 | }); 325 | 326 | // remove the body height, so that it resets to it's original 327 | $.removeStyle(this.bodyScrollable, 'height'); 328 | 329 | // when there are less rows than the container 330 | // adapt the container height 331 | let bodyHeight = $.getStyle(this.bodyScrollable, 'height'); 332 | const scrollHeight = (this.bodyRenderer.hyperlist || {})._scrollHeight || Infinity; 333 | const hasHorizontalOverflow = $.hasHorizontalOverflow(this.bodyScrollable); 334 | 335 | let height; 336 | 337 | if (scrollHeight < bodyHeight) { 338 | height = scrollHeight; 339 | 340 | // account for scrollbar size when 341 | // there is horizontal overflow 342 | if (hasHorizontalOverflow) { 343 | height += $.scrollbarSize(); 344 | } 345 | 346 | $.style(this.bodyScrollable, { 347 | height: height + 'px' 348 | }); 349 | } 350 | 351 | const verticalOverflow = this.bodyScrollable.scrollHeight - this.bodyScrollable.offsetHeight; 352 | if (verticalOverflow < $.scrollbarSize()) { 353 | // if verticalOverflow is less than scrollbar size 354 | // then most likely scrollbar is causing the scroll 355 | // which is not needed 356 | $.style(this.bodyScrollable, { 357 | overflowY: 'hidden' 358 | }); 359 | } 360 | 361 | if (this.options.layout === 'fluid') { 362 | $.style(this.bodyScrollable, { 363 | overflowX: 'hidden' 364 | }); 365 | } 366 | } 367 | 368 | getColumnHeaderElement(colIndex) { 369 | colIndex = +colIndex; 370 | if (colIndex < 0) return null; 371 | return $(`.dt-cell--col-${colIndex}`, this.header); 372 | } 373 | 374 | getRowIndexColumnWidth() { 375 | const rowCount = this.datamanager.getRowCount(); 376 | const padding = 22; 377 | return $.measureTextWidth(rowCount + '') + padding; 378 | } 379 | } 380 | -------------------------------------------------------------------------------- /src/translationmanager.js: -------------------------------------------------------------------------------- 1 | import { format } from './utils'; 2 | import getTranslations from './translations'; 3 | 4 | export default class TranslationManager { 5 | constructor(language) { 6 | this.language = language; 7 | this.translations = getTranslations(); 8 | } 9 | 10 | addTranslations(translations) { 11 | this.translations = Object.assign(this.translations, translations); 12 | } 13 | 14 | translate(sourceText, args) { 15 | let translation = (this.translations[this.language] && 16 | this.translations[this.language][sourceText]) || sourceText; 17 | 18 | if (typeof translation === 'object') { 19 | translation = args && args.count ? 20 | this.getPluralizedTranslation(translation, args.count) : 21 | sourceText; 22 | } 23 | 24 | return format(translation, args || {}); 25 | } 26 | 27 | getPluralizedTranslation(translations, count) { 28 | return translations[count] || translations['default']; 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "Sort Ascending": "Aufsteigend sortieren", 3 | "Sort Descending": "Absteigend sortieren", 4 | "Reset sorting": "Sortierung zurücksetzen", 5 | "Remove column": "Spalte entfernen", 6 | "No Data": "Keine Daten", 7 | "{count} cells copied": { 8 | "1": "{count} Zelle kopiert", 9 | "default": "{count} Zellen kopiert" 10 | }, 11 | "{count} rows selected": { 12 | "1": "{count} Zeile ausgewählt", 13 | "default": "{count} Zeilen ausgewählt" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "Sort Ascending": "Sort Ascending", 3 | "Sort Descending": "Sort Descending", 4 | "Reset sorting": "Reset sorting", 5 | "Remove column": "Remove column", 6 | "No Data": "No Data", 7 | "{count} cells copied": { 8 | "1": "{count} cell copied", 9 | "default": "{count} cells copied" 10 | }, 11 | "{count} rows selected": { 12 | "1": "{count} row selected", 13 | "default": "{count} rows selected" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/translations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "Sort Ascending": "Trier par ordre croissant", 3 | "Sort Descending": "Trier par ordre décroissant", 4 | "Reset sorting": "Réinitialiser le tri", 5 | "Remove column": "Supprimer colonne", 6 | "No Data": "Pas de données", 7 | "{count} cells copied": { 8 | "1": "{count} cellule copiée", 9 | "default": "{count} cellules copiées" 10 | }, 11 | "{count} rows selected": { 12 | "1": "{count} ligne sélectionnée", 13 | "default": "{count} lignes sélectionnées" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/translations/index.js: -------------------------------------------------------------------------------- 1 | import en from './en.json'; 2 | import de from './de.json'; 3 | import fr from './fr.json'; 4 | import it from './it.json'; 5 | 6 | export default function getTranslations() { 7 | return { 8 | en, 9 | de, 10 | fr, 11 | it, 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /src/translations/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "Sort Ascending": "Ordinamento ascendente", 3 | "Sort Descending": "Ordinamento decrescente", 4 | "Reset sorting": "Azzeramento ordinamento", 5 | "Remove column": "Rimuovi colonna", 6 | "No Data": "Nessun dato", 7 | "{count} cells copied": { 8 | "1": "Copiato {count} cella", 9 | "default": "{count} celle copiate" 10 | }, 11 | "{count} rows selected": { 12 | "1": "{count} linea selezionata", 13 | "default": "{count} linee selezionate" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import _throttle from 'lodash/throttle'; 2 | import _debounce from 'lodash/debounce'; 3 | import _uniq from 'lodash/uniq'; 4 | 5 | export function camelCaseToDash(str) { 6 | return str.replace(/([A-Z])/g, (g) => `-${g[0].toLowerCase()}`); 7 | } 8 | 9 | export function makeDataAttributeString(props) { 10 | const keys = Object.keys(props); 11 | 12 | return keys 13 | .map((key) => { 14 | const _key = camelCaseToDash(key); 15 | const val = props[key]; 16 | 17 | if (val === undefined) return ''; 18 | return `data-${_key}="${val}" `; 19 | }) 20 | .join('') 21 | .trim(); 22 | } 23 | 24 | export function copyTextToClipboard(text) { 25 | // https://stackoverflow.com/a/30810322/5353542 26 | var textArea = document.createElement('textarea'); 27 | 28 | // 29 | // *** This styling is an extra step which is likely not required. *** 30 | // 31 | // Why is it here? To ensure: 32 | // 1. the element is able to have focus and selection. 33 | // 2. if element was to flash render it has minimal visual impact. 34 | // 3. less flakyness with selection and copying which **might** occur if 35 | // the textarea element is not visible. 36 | // 37 | // The likelihood is the element won't even render, not even a flash, 38 | // so some of these are just precautions. However in IE the element 39 | // is visible whilst the popup box asking the user for permission for 40 | // the web page to copy to the clipboard. 41 | // 42 | 43 | // Place in top-left corner of screen regardless of scroll position. 44 | textArea.style.position = 'fixed'; 45 | textArea.style.top = 0; 46 | textArea.style.left = 0; 47 | 48 | // Ensure it has a small width and height. Setting to 1px / 1em 49 | // doesn't work as this gives a negative w/h on some browsers. 50 | textArea.style.width = '2em'; 51 | textArea.style.height = '2em'; 52 | 53 | // We don't need padding, reducing the size if it does flash render. 54 | textArea.style.padding = 0; 55 | 56 | // Clean up any borders. 57 | textArea.style.border = 'none'; 58 | textArea.style.outline = 'none'; 59 | textArea.style.boxShadow = 'none'; 60 | 61 | // Avoid flash of white box if rendered for any reason. 62 | textArea.style.background = 'transparent'; 63 | 64 | textArea.value = text; 65 | 66 | document.body.appendChild(textArea); 67 | 68 | textArea.select(); 69 | 70 | try { 71 | document.execCommand('copy'); 72 | } catch (err) { 73 | console.log('Oops, unable to copy'); 74 | } 75 | 76 | document.body.removeChild(textArea); 77 | } 78 | 79 | export function isNumeric(val) { 80 | return !isNaN(val); 81 | } 82 | 83 | export let throttle = _throttle; 84 | 85 | export let debounce = _debounce; 86 | 87 | export function nextTick(fn, context = null) { 88 | return (...args) => { 89 | return new Promise(resolve => { 90 | const execute = () => { 91 | const out = fn.apply(context, args); 92 | resolve(out); 93 | }; 94 | setTimeout(execute); 95 | }); 96 | }; 97 | }; 98 | 99 | export function linkProperties(target, source, properties) { 100 | const props = properties.reduce((acc, prop) => { 101 | acc[prop] = { 102 | get() { 103 | return source[prop]; 104 | } 105 | }; 106 | return acc; 107 | }, {}); 108 | Object.defineProperties(target, props); 109 | }; 110 | 111 | export function isSet(val) { 112 | return val !== undefined || val !== null; 113 | } 114 | 115 | export function notSet(val) { 116 | return !isSet(val); 117 | } 118 | 119 | export function isNumber(val) { 120 | return !isNaN(val); 121 | } 122 | 123 | export function ensureArray(val) { 124 | if (!Array.isArray(val)) { 125 | return [val]; 126 | } 127 | return val; 128 | } 129 | 130 | export function uniq(arr) { 131 | return _uniq(arr); 132 | } 133 | 134 | export function numberSortAsc(a, b) { 135 | return a - b; 136 | }; 137 | 138 | export function stripHTML(html) { 139 | return html.replace(/<[^>]*>/g, ''); 140 | }; 141 | 142 | export function format(str, args) { 143 | if (!str) return str; 144 | 145 | Object.keys(args).forEach(arg => { 146 | let regex = new RegExp(`{(${arg})}`, 'g'); 147 | str = str.replace(regex, args[arg]); 148 | }); 149 | 150 | return str; 151 | }; 152 | 153 | export function escapeHTML(txt) { 154 | if (!txt) return ''; 155 | let escapeHtmlMapping = { 156 | '&': '&', 157 | '<': '<', 158 | '>': '>', 159 | '"': '"', 160 | "'": ''', 161 | '/': '/', 162 | '`': '`', 163 | '=': '=', 164 | }; 165 | 166 | return String(txt).replace(/[&<>"'`=/]/g, (char) => escapeHtmlMapping[char] || char); 167 | }; 168 | -------------------------------------------------------------------------------- /test/datamanager.spec.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, before */ 2 | 3 | import chai from 'chai'; 4 | import DataManager, { 5 | DataError 6 | } from '../src/datamanager'; 7 | 8 | chai.expect(); 9 | const expect = chai.expect; 10 | 11 | describe.only('DataManager instance', () => { 12 | 13 | it('should initialize rows and columns given options', () => { 14 | const datamanager = getDataManagerInstance(); 15 | expect(datamanager).has.property('rows'); 16 | expect(datamanager).has.property('columns'); 17 | expect(datamanager.rowCount).to.equal(3); 18 | expect(datamanager._serialNoColumnAdded).to.equal(false); 19 | expect(datamanager._checkboxColumnAdded).to.equal(false); 20 | }); 21 | 22 | describe('prepareRows', () => { 23 | const datamanager = getDataManagerInstance(); 24 | 25 | it('should properly build row object when bare minimum options are given', () => { 26 | const firstRow = datamanager.getRow(0); 27 | expect(firstRow).to.deep.equal([ 28 | { 29 | colIndex: 0, 30 | content: 'Faris', 31 | rowIndex: 0 32 | }, 33 | { 34 | colIndex: 1, 35 | content: 'faris@test.com', 36 | rowIndex: 0 37 | }, 38 | { 39 | colIndex: 2, 40 | content: 'Software Developer', 41 | rowIndex: 0 42 | } 43 | ]); 44 | }); 45 | 46 | it('should throw when rows parameter is not an Array', () => { 47 | expect(() => datamanager.init({ 48 | columns: ['Name'], 49 | rows: 2 50 | })).to.throw(DataError, '`rows` must be an array'); 51 | }); 52 | 53 | it('should throw when any of the row\'s length doesn\'t match column length', () => { 54 | expect(() => datamanager.init({ 55 | columns: ['Name'], 56 | rows: [[]] 57 | })).to.throw(DataError, 'column length'); 58 | }); 59 | 60 | it('should not throw given valid data', () => { 61 | expect(() => datamanager.init({ 62 | columns: ['Name'], 63 | rows: [['Faris']] 64 | })).to.not.throw(); 65 | }); 66 | 67 | }); 68 | 69 | describe('prepareColumns', () => { 70 | const datamanager = getDataManagerInstance(); 71 | 72 | it('should properly build column object with bare minimum options', () => { 73 | const firstColumn = datamanager.getColumn(0); 74 | expect(firstColumn.colIndex).eq(0); 75 | expect(firstColumn.content).eq('Name'); 76 | expect(firstColumn.isHeader).eq(1); 77 | }); 78 | 79 | it('should throw when columns parameter is not an Array', () => { 80 | expect(() => datamanager.init({ 81 | columns: 2 82 | })).to.throw(DataError, 'must be an array'); 83 | }); 84 | 85 | it('should throw when any of the column is not a string or object', () => { 86 | expect(() => datamanager.init({ 87 | columns: [2] 88 | })).to.throw(DataError, 'must be a string or an object'); 89 | }); 90 | 91 | it('should not throw given valid params', () => { 92 | expect(() => datamanager.init({ 93 | columns: ['Name'], 94 | rows: [['Test']] 95 | })).to.not.throw(); 96 | }); 97 | 98 | it('should properly build column object when editable is false', () => { 99 | const data = { 100 | columns: [ 101 | { content: 'Name', editable: false } 102 | ], 103 | rows: [ 104 | ['Faris'] 105 | ] 106 | }; 107 | datamanager.init(data); 108 | const firstColumn = datamanager.getColumn(0); 109 | expect(firstColumn.colIndex).eq(0); 110 | expect(firstColumn.content).eq('Name'); 111 | expect(firstColumn.isHeader).eq(1); 112 | }); 113 | }); 114 | 115 | describe('prepareNumericColumns', () => { 116 | const datamanager = getDataManagerInstance(); 117 | it('should assign `align: right` to columns with numeric data', () => { 118 | datamanager.init({ 119 | columns: ['Name', 'Number'], 120 | rows: [ 121 | ['Faris', '123'] 122 | ] 123 | }); 124 | 125 | const column0 = datamanager.getColumn(0); 126 | const column1 = datamanager.getColumn(1); 127 | expect(column0.align).to.not.equal('right'); 128 | expect(column1.align).to.equal('right'); 129 | }); 130 | }); 131 | }); 132 | 133 | function getDataManagerInstance(opts = {}) { 134 | const options = Object.assign({}, { 135 | data: { 136 | columns: ['Name', 'Email', 'Occupation'], 137 | rows: [ 138 | ['Faris', 'faris@test.com', 'Software Developer'], 139 | ['Manas', 'manas@test.com', 'Software Engineer'], 140 | ['Ameya', 'ameya@test.com', 'Hacker'] 141 | ] 142 | } 143 | }, opts); 144 | 145 | const datamanager = new DataManager(options); 146 | datamanager.init(); 147 | return datamanager; 148 | } 149 | -------------------------------------------------------------------------------- /test/utils.spec.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, before */ 2 | 3 | import chai from 'chai'; 4 | import { 5 | makeDataAttributeString, 6 | getCSSString, 7 | buildCSSRule, 8 | removeCSSRule 9 | } from '../src/utils.js'; 10 | 11 | chai.expect(); 12 | const expect = chai.expect; 13 | 14 | describe('#utils', () => { 15 | describe('makeDataAttributeString', () => { 16 | it('should return the correct data-attr string', () => { 17 | const props = { 18 | isHeader: 1, 19 | colIndex: 0, 20 | rowIndex: 4 21 | }; 22 | 23 | expect(makeDataAttributeString(props)) 24 | .to.be.equal('data-is-header="1" data-col-index="0" data-row-index="4"'); 25 | }); 26 | }); 27 | 28 | describe('getCSSString', () => { 29 | it('should return CSS key value pairs', () => { 30 | const style = { 31 | width: '2px', 32 | height: '4px', 33 | 'margin-top': '3px' 34 | }; 35 | 36 | expect(getCSSString(style)) 37 | .to.be.equal('width: 2px; height: 4px; margin-top: 3px;'); 38 | }); 39 | }); 40 | 41 | describe('buildCSSRule', () => { 42 | it('should return CSS rule string with updated properties', () => { 43 | const rule = '.test'; 44 | const style = { 45 | width: '2px', 46 | height: '4px', 47 | 'margin-top': '3px' 48 | }; 49 | 50 | const ruleString = buildCSSRule(rule, style); 51 | 52 | expect(ruleString) 53 | .to.be.equal('.test { width: 2px; height: 4px; margin-top: 3px; }'); 54 | 55 | const updatedRuleString = buildCSSRule(rule, { width: '5px' }, ruleString); 56 | 57 | expect(updatedRuleString) 58 | .to.be.equal('.test { width: 5px; height: 4px; margin-top: 3px; }'); 59 | 60 | const updatedRuleString2 = buildCSSRule(rule, { height: '19px' }, updatedRuleString); 61 | 62 | expect(updatedRuleString2) 63 | .to.be.equal('.test { width: 5px; height: 19px; margin-top: 3px; }'); 64 | 65 | const updatedRuleString3 = buildCSSRule('.test2', { height: '45px' }, updatedRuleString2); 66 | 67 | expect(updatedRuleString3) 68 | .to.be.equal('.test { width: 5px; height: 19px; margin-top: 3px; }.test2 { height: 45px; }'); 69 | }); 70 | }); 71 | 72 | describe('removeCSSRule', () => { 73 | it('should remove the css rule based on the selector', () => { 74 | const rule = '.test'; 75 | const cssRuleString = `.test {margin-top: 2px;} .test2 {color: blue;}`; 76 | 77 | expect(removeCSSRule(rule, cssRuleString)) 78 | .to.be.equal('.test2 {color: blue;}'); 79 | }) 80 | }) 81 | }); 82 | --------------------------------------------------------------------------------