├── docs └── Demo.gif ├── .idea └── .gitignore ├── jest.config.js ├── .github └── workflows │ ├── npm-publish.yml │ └── minify.yml ├── tests ├── setup.js ├── default-context-menu.test.js ├── sort-click-debug.test.js ├── visual-sort-test.test.js ├── filter-sort.test.js ├── virtual-scrolling.test.js ├── filter-sort-bug.test.js ├── header-cell-alignment.test.js ├── multiple-grid-and-right-click.test.js ├── csv-export.test.js ├── search-filter-reset.test.js ├── min-width-constraints.test.js ├── opengrid.test.js ├── column-width-alignment.test.js ├── constructor.test.js ├── data-processing.test.js ├── column-reorder.test.js └── column-resize.test.js ├── LICENSE ├── package.json ├── examples ├── example.html ├── filter-demo.html └── theme-demo.html ├── README.md ├── opengrid.min.css └── dist └── opengrid.min.css /docs/Demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amurgola/OpenGridJs/HEAD/docs/Demo.gif -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jsdom', 3 | setupFilesAfterEnv: ['/tests/setup.js'], 4 | testMatch: [ 5 | '/tests/**/*.test.js' 6 | ], 7 | collectCoverageFrom: [ 8 | 'src/opengrid.js', 9 | '!dist/opengrid.min.js' 10 | ], 11 | coverageDirectory: 'coverage', 12 | coverageReporters: ['text', 'lcov', 'html'] 13 | }; -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 16 18 | - run: npm ci 19 | 20 | publish-npm: 21 | needs: build 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v3 25 | - uses: actions/setup-node@v3 26 | with: 27 | node-version: 16 28 | registry-url: https://registry.npmjs.org/ 29 | - run: npm ci 30 | - run: npm publish 31 | env: 32 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 33 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | // Test setup file 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | // Mock fetch for tests 6 | global.fetch = jest.fn(); 7 | 8 | // Add CSS for proper testing 9 | document.head.innerHTML = ` 10 | 29 | `; 30 | 31 | // Helper to create test data 32 | global.createTestData = (count = 10) => { 33 | return Array.from({ length: count }, (_, i) => ({ 34 | id: i + 1, 35 | name: `Test User ${i + 1}`, 36 | email: `user${i + 1}@example.com`, 37 | age: 20 + i, 38 | status: i % 2 === 0 ? 'active' : 'inactive' 39 | })); 40 | }; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Andrew Murgola 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/minify.yml: -------------------------------------------------------------------------------- 1 | name: Minify Assets 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - 'src/opengrid.js' 8 | - 'src/opengrid.css' 9 | pull_request: 10 | branches: [ main ] 11 | paths: 12 | - 'src/opengrid.js' 13 | - 'src/opengrid.css' 14 | 15 | jobs: 16 | minify: 17 | runs-on: ubuntu-latest 18 | permissions: 19 | contents: write 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | with: 24 | token: ${{ secrets.GITHUB_TOKEN }} 25 | 26 | - name: Setup Node.js 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: '18' 30 | 31 | - name: Install dependencies 32 | run: | 33 | npm install -g terser 34 | npm install -g clean-css-cli 35 | 36 | - name: Build all files 37 | run: | 38 | npm run build 39 | cp opengrid.min.js dist/ 40 | cp opengrid.min.css dist/ 41 | 42 | - name: Push changes 43 | uses: stefanzweifel/git-auto-commit-action@v5 44 | with: 45 | commit_message: "Auto-minify assets [skip ci]" 46 | file_pattern: "opengrid.min.js opengrid.min.css opengrid.js opengrid.css dist/opengrid.min.js dist/opengrid.min.css" 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opengridjs", 3 | "version": "1.2.1", 4 | "description": "A lightweight JavaScript grid framework that allows you to create fast and easy-to-use data grids in your web application. It supports virtual scrolling, custom column headers, and context menus.", 5 | "main": "opengrid.min.js", 6 | "style": "opengrid.min.css", 7 | "unpkg": "opengrid.min.js", 8 | "jsdelivr": "opengrid.min.js", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/amurgola/OpenGridJs.git" 12 | }, 13 | "author": "Andrew Paul Murgola (andy@murgo.la)", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/amurgola/OpenGridJs/issues" 17 | }, 18 | "homepage": "https://github.com/amurgola/OpenGridJs#readme", 19 | "scripts": { 20 | "test": "jest", 21 | "test:watch": "jest --watch", 22 | "test:coverage": "jest --coverage", 23 | "build": "npm run build:js && npm run build:css && npm run copy:src", 24 | "build:js": "terser src/opengrid.js --compress --mangle --output opengrid.min.js", 25 | "build:css": "cleancss -o opengrid.min.css src/opengrid.css", 26 | "copy:src": "cp src/opengrid.js opengrid.js && cp src/opengrid.css opengrid.css", 27 | "dev": "echo 'Development mode - use src/ files directly'" 28 | }, 29 | "files": [ 30 | "opengrid.min.js", 31 | "opengrid.min.css", 32 | "opengrid.js", 33 | "opengrid.css", 34 | "src/", 35 | "dist/", 36 | "README.md", 37 | "LICENSE" 38 | ], 39 | "devDependencies": { 40 | "jest": "^30.0.3", 41 | "jest-environment-jsdom": "^30.0.2", 42 | "jsdom": "^26.1.0", 43 | "terser": "^5.19.2", 44 | "clean-css-cli": "^5.6.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /examples/example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | OpenGrid Example 7 | 8 | 9 | 28 | 29 | 30 |
31 |

OpenGrid Examples

32 | 33 |

Simple Example (No Setup Required)

34 |
35 | 36 |

Advanced Example (With Custom Setup)

37 |
38 |
39 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /tests/default-context-menu.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for default context menu functionality including Copy Row and Export to CSV 3 | */ 4 | 5 | const OpenGrid = require('../src/opengrid.js'); 6 | 7 | describe('Default Context Menu', () => { 8 | let container; 9 | let testData; 10 | let grid; 11 | 12 | beforeEach(() => { 13 | container = document.createElement('div'); 14 | container.className = 'test-grid'; 15 | document.body.appendChild(container); 16 | 17 | testData = [ 18 | { id: 1, name: 'Alice', role: 'Developer', email: 'alice@example.com' }, 19 | { id: 2, name: 'Bob', role: 'Designer', email: 'bob@example.com' }, 20 | { id: 3, name: 'Charlie', role: 'Manager', email: 'charlie@example.com' } 21 | ]; 22 | 23 | // Create grid without context menu options (should create default) 24 | grid = new OpenGrid('test-grid', testData, 400); 25 | }); 26 | 27 | afterEach(() => { 28 | document.body.removeChild(container); 29 | }); 30 | 31 | test('should create default context menu when none provided', () => { 32 | const gridRows = container.querySelectorAll('.opengridjs-grid-row'); 33 | expect(gridRows.length).toBeGreaterThan(0); 34 | 35 | // Simulate right-click on first row 36 | const firstRow = gridRows[0]; 37 | const contextMenuEvent = new MouseEvent('contextmenu', { 38 | bubbles: true, 39 | cancelable: true, 40 | clientX: 100, 41 | clientY: 100 42 | }); 43 | 44 | firstRow.dispatchEvent(contextMenuEvent); 45 | 46 | // Check if context menu appears 47 | const contextMenu = container.querySelector('.opengridjs-contextMenu'); 48 | expect(contextMenu).toBeTruthy(); 49 | 50 | // Check if default options are present 51 | const menuButtons = contextMenu.querySelectorAll('.opengridjs-context-menu-button'); 52 | expect(menuButtons.length).toBe(2); 53 | 54 | const buttonTexts = Array.from(menuButtons).map(btn => btn.textContent); 55 | expect(buttonTexts).toContain('Copy Row'); 56 | expect(buttonTexts).toContain('Export to CSV'); 57 | }); 58 | 59 | test('should mark built-in functions correctly', () => { 60 | const gridRows = container.querySelectorAll('.opengridjs-grid-row'); 61 | const firstRow = gridRows[0]; 62 | 63 | const contextMenuEvent = new MouseEvent('contextmenu', { 64 | bubbles: true, 65 | cancelable: true, 66 | clientX: 100, 67 | clientY: 100 68 | }); 69 | 70 | firstRow.dispatchEvent(contextMenuEvent); 71 | 72 | const menuButtons = container.querySelectorAll('.opengridjs-context-menu-button'); 73 | 74 | menuButtons.forEach(button => { 75 | const isBuiltIn = button.getAttribute('data-built-in'); 76 | expect(isBuiltIn).toBe('true'); 77 | }); 78 | }); 79 | 80 | test('should have proper CSS classes for default menu items', () => { 81 | const gridRows = container.querySelectorAll('.opengridjs-grid-row'); 82 | const firstRow = gridRows[0]; 83 | 84 | const contextMenuEvent = new MouseEvent('contextmenu', { 85 | bubbles: true, 86 | cancelable: true, 87 | clientX: 100, 88 | clientY: 100 89 | }); 90 | 91 | firstRow.dispatchEvent(contextMenuEvent); 92 | 93 | const copyButton = container.querySelector('.opengridjs-copy-row'); 94 | const exportButton = container.querySelector('.opengridjs-export-csv'); 95 | 96 | expect(copyButton).toBeTruthy(); 97 | expect(exportButton).toBeTruthy(); 98 | expect(copyButton.getAttribute('data-action')).toBe('copyRow'); 99 | expect(exportButton.getAttribute('data-action')).toBe('exportToCSV'); 100 | }); 101 | 102 | test('should use "Actions" as default title when none provided', () => { 103 | const gridRows = container.querySelectorAll('.opengridjs-grid-row'); 104 | const firstRow = gridRows[0]; 105 | 106 | const contextMenuEvent = new MouseEvent('contextmenu', { 107 | bubbles: true, 108 | cancelable: true, 109 | clientX: 100, 110 | clientY: 100 111 | }); 112 | 113 | firstRow.dispatchEvent(contextMenuEvent); 114 | 115 | const title = container.querySelector('.opengridjs-title'); 116 | expect(title).toBeTruthy(); 117 | expect(title.textContent).toBe('Actions'); 118 | }); 119 | 120 | test('should handle copy row functionality', async () => { 121 | // Mock clipboard API and secure context 122 | const mockWriteText = jest.fn().mockResolvedValue(); 123 | Object.defineProperty(navigator, 'clipboard', { 124 | value: { 125 | writeText: mockWriteText 126 | }, 127 | writable: true 128 | }); 129 | Object.defineProperty(window, 'isSecureContext', { 130 | value: true, 131 | writable: true 132 | }); 133 | 134 | const testRowData = { id: 1, name: 'Alice', role: 'Developer' }; 135 | 136 | // Test copyRow method directly 137 | await grid.copyRow(testRowData); 138 | 139 | expect(mockWriteText).toHaveBeenCalledWith('id: 1\nname: Alice\nrole: Developer'); 140 | }); 141 | 142 | test('should handle export functionality', () => { 143 | // Mock URL.createObjectURL and document methods 144 | const mockCreateObjectURL = jest.fn().mockReturnValue('blob:mock-url'); 145 | const mockClick = jest.fn(); 146 | const mockAppendChild = jest.fn(); 147 | const mockRemoveChild = jest.fn(); 148 | 149 | global.URL.createObjectURL = mockCreateObjectURL; 150 | 151 | const mockLink = { 152 | href: '', 153 | setAttribute: jest.fn(), 154 | click: mockClick 155 | }; 156 | 157 | jest.spyOn(document, 'createElement').mockReturnValue(mockLink); 158 | jest.spyOn(document.body, 'appendChild').mockImplementation(mockAppendChild); 159 | jest.spyOn(document.body, 'removeChild').mockImplementation(mockRemoveChild); 160 | 161 | // Test exportToCSV method directly 162 | grid.exportToCSV(); 163 | 164 | expect(mockCreateObjectURL).toHaveBeenCalled(); 165 | expect(mockAppendChild).toHaveBeenCalledWith(mockLink); 166 | expect(mockClick).toHaveBeenCalled(); 167 | expect(mockRemoveChild).toHaveBeenCalledWith(mockLink); 168 | expect(mockLink.setAttribute).toHaveBeenCalledWith('download', 'export.csv'); 169 | 170 | // Restore mocks 171 | document.createElement.mockRestore(); 172 | document.body.appendChild.mockRestore(); 173 | document.body.removeChild.mockRestore(); 174 | }); 175 | }); -------------------------------------------------------------------------------- /tests/sort-click-debug.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Debug test for sorting click events with filters 3 | */ 4 | 5 | const OpenGrid = require('../src/opengrid.js'); 6 | 7 | describe('Sort Click Event Debugging', () => { 8 | let container; 9 | let testData; 10 | let grid; 11 | 12 | beforeEach(() => { 13 | container = document.createElement('div'); 14 | container.className = 'test-grid'; 15 | document.body.appendChild(container); 16 | 17 | testData = [ 18 | { id: 1, name: 'Charlie', department: 'Engineering' }, 19 | { id: 2, name: 'Alice', department: 'Engineering' }, 20 | { id: 3, name: 'Bob', department: 'Engineering' }, 21 | ]; 22 | 23 | grid = new OpenGrid('test-grid', testData, 400); 24 | }); 25 | 26 | afterEach(() => { 27 | document.body.removeChild(container); 28 | }); 29 | 30 | test('Debug: Check header structure after filter application', () => { 31 | console.log('=== HEADER STRUCTURE DEBUG ==='); 32 | 33 | // Apply filter first 34 | grid.columnFilters = { department: new Set(['Engineering']) }; 35 | grid.applyAllFilters(); 36 | 37 | // Check header structure 38 | const headerItem = container.querySelector('[data-header="name"]'); 39 | console.log('Header item HTML:', headerItem.outerHTML); 40 | 41 | const headerText = headerItem.querySelector('.opengridjs-header-text'); 42 | const headerActions = headerItem.querySelector('.opengridjs-header-actions'); 43 | const filterButton = headerItem.querySelector('.opengridjs-filter-button'); 44 | const sortIndicator = headerItem.querySelector('.opengridjs-sort-indicator'); 45 | 46 | console.log('Has header text element:', !!headerText); 47 | console.log('Has header actions element:', !!headerActions); 48 | console.log('Has filter button:', !!filterButton); 49 | console.log('Has sort indicator:', !!sortIndicator); 50 | 51 | // Test different click targets 52 | console.log('\n=== TESTING CLICK TARGETS ==='); 53 | 54 | // Test clicking different parts of the header 55 | const targets = [ 56 | { name: 'headerItem', element: headerItem }, 57 | { name: 'headerText', element: headerText }, 58 | { name: 'headerActions', element: headerActions }, 59 | { name: 'sortIndicator', element: sortIndicator } 60 | ].filter(t => t.element); 61 | 62 | targets.forEach(target => { 63 | console.log(`\nTesting click on: ${target.name}`); 64 | console.log(`Element data-header:`, target.element.getAttribute('data-header')); 65 | console.log(`Closest header data-header:`, target.element.closest('.opengridjs-grid-header-item')?.getAttribute('data-header')); 66 | 67 | // Simulate what the click handler does 68 | const header = target.element.getAttribute("data-header") || target.element.closest('.opengridjs-grid-header-item')?.getAttribute("data-header"); 69 | console.log(`Resolved header:`, header); 70 | 71 | const headerData = grid.headerData.find(x => x.data == header); 72 | console.log(`Found headerData:`, !!headerData); 73 | if (headerData) { 74 | console.log(`Current sortDirection:`, headerData.sortDirection); 75 | } 76 | }); 77 | }); 78 | 79 | test('Debug: Manual click event simulation', () => { 80 | console.log('=== MANUAL CLICK SIMULATION ==='); 81 | 82 | // Apply filter 83 | grid.columnFilters = { department: new Set(['Engineering']) }; 84 | grid.applyAllFilters(); 85 | 86 | console.log('Names before sort:', grid.gridData.map(item => item.data.name)); 87 | 88 | // Get the header and simulate click manually 89 | const headerItem = container.querySelector('[data-header="name"]'); 90 | const headerText = headerItem.querySelector('.opengridjs-header-text'); 91 | 92 | // Create mock event for different targets 93 | const mockEventOnHeader = { 94 | target: headerItem, 95 | stopPropagation: () => {}, 96 | preventDefault: () => {} 97 | }; 98 | 99 | const mockEventOnText = { 100 | target: headerText, 101 | stopPropagation: () => {}, 102 | preventDefault: () => {} 103 | }; 104 | 105 | console.log('\nSimulating click on header item...'); 106 | 107 | // Manually call the sort logic 108 | const header = mockEventOnHeader.target.getAttribute("data-header") || mockEventOnHeader.target.closest('.opengridjs-grid-header-item')?.getAttribute("data-header"); 109 | console.log('Resolved header from header click:', header); 110 | 111 | if (header) { 112 | const headerData = grid.headerData.find(x => x.data == header); 113 | if (headerData) { 114 | console.log('Found headerData, applying sort...'); 115 | headerData.sortDirection = headerData.sortDirection == null || headerData.sortDirection == 'desc' ? 'asc' : 'desc'; 116 | grid.sortState = { header: header, sortDirection: headerData.sortDirection }; 117 | 118 | console.log('New sort state:', grid.sortState); 119 | 120 | // Apply the sort logic manually 121 | if (Object.keys(grid.columnFilters).length > 0) { 122 | console.log('Filters active, calling applyAllFilters...'); 123 | grid.applyAllFilters(); 124 | } else { 125 | console.log('No filters, calling sortData directly...'); 126 | grid.sortData(); 127 | grid.rerender(); 128 | } 129 | 130 | console.log('Names after sort:', grid.gridData.map(item => item.data.name)); 131 | } 132 | } 133 | }); 134 | 135 | test('Debug: Actual DOM click event', () => { 136 | console.log('=== ACTUAL DOM CLICK EVENT ==='); 137 | 138 | // Apply filter 139 | grid.columnFilters = { department: new Set(['Engineering']) }; 140 | grid.applyAllFilters(); 141 | 142 | console.log('Names before DOM click:', grid.gridData.map(item => item.data.name)); 143 | 144 | // Try actual DOM click 145 | const headerItem = container.querySelector('[data-header="name"]'); 146 | console.log('Clicking on header item...'); 147 | headerItem.click(); 148 | 149 | console.log('Names after DOM click:', grid.gridData.map(item => item.data.name)); 150 | console.log('Sort state after DOM click:', grid.sortState); 151 | console.log('Grid data length:', grid.gridData.length); 152 | }); 153 | }); -------------------------------------------------------------------------------- /tests/visual-sort-test.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test to check if DOM elements actually update when sorting with filters 3 | */ 4 | 5 | const OpenGrid = require('../src/opengrid.js'); 6 | 7 | describe('Visual DOM Update Test for Sorting with Filters', () => { 8 | let container; 9 | let testData; 10 | let grid; 11 | 12 | beforeEach(() => { 13 | container = document.createElement('div'); 14 | container.className = 'test-grid'; 15 | document.body.appendChild(container); 16 | 17 | testData = [ 18 | { id: 1, name: 'Zebra', department: 'Engineering', salary: 80000 }, 19 | { id: 2, name: 'Alpha', department: 'Engineering', salary: 60000 }, 20 | { id: 3, name: 'Beta', department: 'Engineering', salary: 90000 }, 21 | { id: 4, name: 'Delta', department: 'HR', salary: 70000 }, 22 | ]; 23 | 24 | grid = new OpenGrid('test-grid', testData, 400); 25 | 26 | // Give some time for rendering 27 | jest.advanceTimersByTime(100); 28 | }); 29 | 30 | afterEach(() => { 31 | document.body.removeChild(container); 32 | }); 33 | 34 | test('DOM rows should visually update when sorting with active filter', () => { 35 | console.log('=== VISUAL DOM UPDATE TEST ==='); 36 | 37 | // Apply filter for Engineering only 38 | const departmentButton = container.querySelector('[data-column="department"]'); 39 | departmentButton.click(); 40 | 41 | const checkboxes = document.querySelectorAll('.opengridjs-filter-option input[type="checkbox"]'); 42 | checkboxes.forEach(checkbox => { 43 | const label = checkbox.parentElement.querySelector('span').textContent; 44 | checkbox.checked = label === 'Engineering'; 45 | }); 46 | 47 | const applyBtn = document.querySelector('.opengridjs-filter-apply'); 48 | applyBtn.click(); 49 | 50 | console.log('Filter applied...'); 51 | 52 | // Get current visible DOM rows 53 | let domRows = container.querySelectorAll('.opengridjs-grid-row'); 54 | let visibleNames = Array.from(domRows).map(row => { 55 | const columnItems = row.querySelectorAll('.opengridjs-grid-column-item'); 56 | const nameCell = columnItems[1]; // Index 1 should be the name column (0=id, 1=name, 2=department, 3=salary) 57 | return nameCell ? nameCell.textContent.trim() : 'NO_TEXT'; 58 | }); 59 | 60 | console.log('DOM names before sort:', visibleNames); 61 | console.log('Grid data names before sort:', grid.gridData.map(item => item.data.name)); 62 | 63 | // Now click to sort by name 64 | const nameHeader = container.querySelector('[data-header="name"]'); 65 | console.log('Clicking name header to sort...'); 66 | nameHeader.click(); 67 | 68 | // Allow time for DOM updates 69 | jest.advanceTimersByTime(100); 70 | 71 | // Check DOM after sort 72 | domRows = container.querySelectorAll('.opengridjs-grid-row'); 73 | visibleNames = Array.from(domRows).map(row => { 74 | const columnItems = row.querySelectorAll('.opengridjs-grid-column-item'); 75 | const nameCell = columnItems[1]; // Index 1 should be the name column 76 | return nameCell ? nameCell.textContent.trim() : 'NO_TEXT'; 77 | }); 78 | 79 | console.log('DOM names after sort:', visibleNames); 80 | console.log('Grid data names after sort:', grid.gridData.map(item => item.data.name)); 81 | console.log('Sort state:', grid.sortState); 82 | 83 | // Verify both internal data and DOM are sorted 84 | const expectedOrder = ['Alpha', 'Beta', 'Zebra']; 85 | expect(grid.gridData.map(item => item.data.name)).toEqual(expectedOrder); 86 | expect(visibleNames).toEqual(expectedOrder); 87 | 88 | // Test descending sort 89 | console.log('\nTesting descending sort...'); 90 | nameHeader.click(); 91 | 92 | jest.advanceTimersByTime(100); 93 | 94 | domRows = container.querySelectorAll('.opengridjs-grid-row'); 95 | visibleNames = Array.from(domRows).map(row => { 96 | const columnItems = row.querySelectorAll('.opengridjs-grid-column-item'); 97 | const nameCell = columnItems[1]; // Index 1 should be the name column 98 | return nameCell ? nameCell.textContent.trim() : 'NO_TEXT'; 99 | }); 100 | 101 | console.log('DOM names after descending sort:', visibleNames); 102 | console.log('Grid data names after descending sort:', grid.gridData.map(item => item.data.name)); 103 | 104 | const expectedDescOrder = ['Zebra', 'Beta', 'Alpha']; 105 | expect(grid.gridData.map(item => item.data.name)).toEqual(expectedDescOrder); 106 | expect(visibleNames).toEqual(expectedDescOrder); 107 | }); 108 | 109 | test('Check if rerender is actually updating DOM positions', () => { 110 | console.log('=== DOM POSITION UPDATE TEST ==='); 111 | 112 | // Apply filter 113 | grid.columnFilters = { department: new Set(['Engineering']) }; 114 | grid.applyAllFilters(); 115 | 116 | // Check initial row positions 117 | let domRows = container.querySelectorAll('.opengridjs-grid-row'); 118 | console.log('Initial DOM rows count:', domRows.length); 119 | 120 | domRows.forEach((row, index) => { 121 | const columnItems = row.querySelectorAll('.opengridjs-grid-column-item'); 122 | const nameCell = columnItems[1]; // Index 1 for name column 123 | const topStyle = row.style.top; 124 | console.log(`Row ${index}: name="${nameCell?.textContent}", top="${topStyle}"`); 125 | }); 126 | 127 | // Apply sort 128 | grid.sortState = { header: 'name', sortDirection: 'asc' }; 129 | grid.applyAllFilters(); 130 | 131 | // Check updated positions 132 | domRows = container.querySelectorAll('.opengridjs-grid-row'); 133 | console.log('\nAfter sort DOM rows count:', domRows.length); 134 | 135 | domRows.forEach((row, index) => { 136 | const columnItems = row.querySelectorAll('.opengridjs-grid-column-item'); 137 | const nameCell = columnItems[1]; // Index 1 for name column 138 | const topStyle = row.style.top; 139 | console.log(`Row ${index}: name="${nameCell?.textContent}", top="${topStyle}"`); 140 | }); 141 | 142 | // Verify we have the expected names in order 143 | const namesInDOM = Array.from(domRows).map(row => { 144 | const columnItems = row.querySelectorAll('.opengridjs-grid-column-item'); 145 | const nameCell = columnItems[1]; // Index 1 for name column 146 | return nameCell?.textContent || ''; 147 | }); 148 | 149 | expect(namesInDOM).toEqual(['Alpha', 'Beta', 'Zebra']); 150 | }); 151 | }); -------------------------------------------------------------------------------- /tests/filter-sort.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for sorting with filters active 3 | */ 4 | 5 | const OpenGrid = require('../src/opengrid.js'); 6 | 7 | describe('OpenGrid Filter and Sort Integration', () => { 8 | let container; 9 | let testData; 10 | 11 | beforeEach(() => { 12 | // Set up DOM container 13 | container = document.createElement('div'); 14 | container.className = 'test-grid'; 15 | document.body.appendChild(container); 16 | 17 | // Create test data 18 | testData = [ 19 | { id: 1, name: 'Alice', department: 'Engineering', salary: 80000 }, 20 | { id: 2, name: 'Bob', department: 'Sales', salary: 60000 }, 21 | { id: 3, name: 'Charlie', department: 'Engineering', salary: 90000 }, 22 | { id: 4, name: 'Diana', department: 'HR', salary: 70000 }, 23 | { id: 5, name: 'Eve', department: 'Sales', salary: 65000 }, 24 | { id: 6, name: 'Frank', department: 'Engineering', salary: 85000 }, 25 | ]; 26 | }); 27 | 28 | afterEach(() => { 29 | // Clean up 30 | document.body.removeChild(container); 31 | }); 32 | 33 | test('should maintain filter when sorting is applied', () => { 34 | const grid = new OpenGrid('test-grid', testData, 400); 35 | 36 | // Apply department filter for Engineering only 37 | grid.columnFilters = { 38 | department: new Set(['Engineering']) 39 | }; 40 | grid.applyAllFilters(); 41 | 42 | // Should have 3 Engineering entries 43 | expect(grid.gridData.length).toBe(3); 44 | 45 | // Apply sort by name ascending 46 | const nameHeader = container.querySelector('[data-header="name"]'); 47 | nameHeader.click(); 48 | 49 | // Should still have 3 entries (filter maintained) 50 | expect(grid.gridData.length).toBe(3); 51 | 52 | // Check that data is both filtered and sorted 53 | const names = grid.gridData.map(item => item.data.name); 54 | expect(names).toEqual(['Alice', 'Charlie', 'Frank']); 55 | 56 | // All should be Engineering 57 | grid.gridData.forEach(item => { 58 | expect(item.data.department).toBe('Engineering'); 59 | }); 60 | }); 61 | 62 | test('should maintain sort when filter is applied', () => { 63 | const grid = new OpenGrid('test-grid', testData, 400); 64 | 65 | // First apply sort by salary descending 66 | const salaryHeader = container.querySelector('[data-header="salary"]'); 67 | salaryHeader.click(); // ascending 68 | salaryHeader.click(); // descending 69 | 70 | // Check initial sort 71 | let salaries = grid.gridData.map(item => item.data.salary); 72 | expect(salaries[0]).toBe(90000); // Charlie has highest salary 73 | 74 | // Now apply filter for Sales and Engineering 75 | grid.columnFilters = { 76 | department: new Set(['Sales', 'Engineering']) 77 | }; 78 | grid.applyAllFilters(); 79 | 80 | // Should exclude HR (Diana) 81 | expect(grid.gridData.length).toBe(5); 82 | 83 | // Check that sort is maintained 84 | salaries = grid.gridData.map(item => item.data.salary); 85 | expect(salaries).toEqual([90000, 85000, 80000, 65000, 60000]); 86 | }); 87 | 88 | test('should handle multiple filters with sorting', () => { 89 | const grid = new OpenGrid('test-grid', testData, 400); 90 | 91 | // Apply multiple filters 92 | grid.columnFilters = { 93 | department: new Set(['Engineering', 'Sales']) 94 | }; 95 | grid.applyAllFilters(); 96 | 97 | // Apply sort by name 98 | const nameHeader = container.querySelector('[data-header="name"]'); 99 | nameHeader.click(); 100 | 101 | // Should have 5 entries (3 Engineering + 2 Sales) 102 | expect(grid.gridData.length).toBe(5); 103 | 104 | // Check sort order 105 | const names = grid.gridData.map(item => item.data.name); 106 | expect(names).toEqual(['Alice', 'Bob', 'Charlie', 'Eve', 'Frank']); 107 | }); 108 | 109 | test('should maintain sort when filters are cleared', () => { 110 | const grid = new OpenGrid('test-grid', testData, 400); 111 | 112 | // Apply sort 113 | const nameHeader = container.querySelector('[data-header="name"]'); 114 | nameHeader.click(); 115 | 116 | // Apply filter 117 | grid.columnFilters = { 118 | department: new Set(['Engineering']) 119 | }; 120 | grid.applyAllFilters(); 121 | 122 | expect(grid.gridData.length).toBe(3); 123 | 124 | // Clear filters 125 | grid.clearAllFilters(); 126 | 127 | // Should have all data back 128 | expect(grid.gridData.length).toBe(6); 129 | 130 | // Sort should still be applied 131 | const names = grid.gridData.map(item => item.data.name); 132 | expect(names).toEqual(['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank']); 133 | }); 134 | 135 | test('should handle changing sort direction with filters active', () => { 136 | const grid = new OpenGrid('test-grid', testData, 400); 137 | 138 | // Apply filter 139 | grid.columnFilters = { 140 | department: new Set(['Engineering']) 141 | }; 142 | grid.applyAllFilters(); 143 | 144 | // Apply ascending sort 145 | const salaryHeader = container.querySelector('[data-header="salary"]'); 146 | salaryHeader.click(); 147 | 148 | let salaries = grid.gridData.map(item => item.data.salary); 149 | expect(salaries).toEqual([80000, 85000, 90000]); 150 | 151 | // Change to descending 152 | salaryHeader.click(); 153 | 154 | salaries = grid.gridData.map(item => item.data.salary); 155 | expect(salaries).toEqual([90000, 85000, 80000]); 156 | 157 | // Filter should still be active 158 | expect(grid.gridData.length).toBe(3); 159 | grid.gridData.forEach(item => { 160 | expect(item.data.department).toBe('Engineering'); 161 | }); 162 | }); 163 | 164 | test('should handle sorting different columns with filters', () => { 165 | const grid = new OpenGrid('test-grid', testData, 400); 166 | 167 | // Apply filter 168 | grid.columnFilters = { 169 | department: new Set(['Engineering', 'Sales']) 170 | }; 171 | grid.applyAllFilters(); 172 | 173 | // Sort by name 174 | let nameHeader = container.querySelector('[data-header="name"]'); 175 | nameHeader.click(); 176 | 177 | let names = grid.gridData.map(item => item.data.name); 178 | expect(names[0]).toBe('Alice'); 179 | 180 | // Sort by salary instead 181 | const salaryHeader = container.querySelector('[data-header="salary"]'); 182 | salaryHeader.click(); 183 | 184 | let salaries = grid.gridData.map(item => item.data.salary); 185 | expect(salaries).toEqual([60000, 65000, 80000, 85000, 90000]); 186 | 187 | // Filter should still be active 188 | expect(grid.gridData.length).toBe(5); 189 | }); 190 | }); -------------------------------------------------------------------------------- /tests/virtual-scrolling.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test Suite 2.3: Virtual Scrolling 3 | * Phase 2 - Core Functionality Tests 4 | * 5 | * Coverage targets: 6 | * - Lines 504-523 (renderVisible) 7 | * - Lines 525-562 (addRow, removeRow) 8 | * - Lines 847-854 (isNearBottom) 9 | * - Scroll position calculations 10 | */ 11 | 12 | const OpenGrid = require('../src/opengrid.js'); 13 | 14 | describe('OpenGrid Virtual Scrolling', () => { 15 | let container; 16 | 17 | beforeEach(() => { 18 | container = document.createElement('div'); 19 | container.className = 'test-grid'; 20 | document.body.appendChild(container); 21 | }); 22 | 23 | afterEach(() => { 24 | if (document.body.contains(container)) { 25 | document.body.removeChild(container); 26 | } 27 | }); 28 | 29 | describe('renderVisible Method', () => { 30 | test('should render only visible rows initially', () => { 31 | // Create 100 rows, but only ~11 can fit in 400px viewport (35px per row) 32 | const data = Array.from({ length: 100 }, (_, i) => ({ 33 | id: i, 34 | name: `Row ${i}` 35 | })); 36 | 37 | const grid = new OpenGrid('test-grid', data, 400); 38 | 39 | const renderedRows = container.querySelectorAll('.opengridjs-grid-row'); 40 | // Should render visible items + buffer 41 | expect(renderedRows.length).toBeLessThan(100); 42 | expect(renderedRows.length).toBeGreaterThan(0); 43 | }); 44 | 45 | test('should set correct row positions using top style', () => { 46 | const data = Array.from({ length: 5 }, (_, i) => ({ id: i })); 47 | 48 | const grid = new OpenGrid('test-grid', data, 400); 49 | 50 | const rows = container.querySelectorAll('.opengridjs-grid-row'); 51 | expect(rows[0].style.top).toBe('0px'); 52 | expect(rows[1].style.top).toBe('35px'); 53 | expect(rows[2].style.top).toBe('70px'); 54 | }); 55 | 56 | test('should update isRendered flag when row is added', () => { 57 | const data = [{ id: 1, name: 'Test' }]; 58 | 59 | const grid = new OpenGrid('test-grid', data, 400); 60 | 61 | // Find the item that's been rendered 62 | const renderedItems = grid.gridData.filter(item => item.isRendered); 63 | expect(renderedItems.length).toBeGreaterThan(0); 64 | }); 65 | }); 66 | 67 | describe('addRow Method', () => { 68 | test('should add row to DOM with correct data-id', () => { 69 | const data = [{ id: 'test-123', name: 'Test Row' }]; 70 | 71 | const grid = new OpenGrid('test-grid', data, 400); 72 | 73 | const row = container.querySelector('.opengridjs-grid-row'); 74 | expect(row.getAttribute('data-id')).toBe('test-123'); 75 | }); 76 | 77 | test('should create correct number of column items', () => { 78 | const data = [{ 79 | id: 1, 80 | col1: 'A', 81 | col2: 'B', 82 | col3: 'C' 83 | }]; 84 | 85 | const grid = new OpenGrid('test-grid', data, 400); 86 | 87 | const columns = container.querySelectorAll('.opengridjs-grid-column-item'); 88 | expect(columns.length).toBe(4); // id + 3 columns 89 | }); 90 | 91 | test('should apply row class name based on position', () => { 92 | const data = [ 93 | { id: 1, name: 'Row 1' }, 94 | { id: 2, name: 'Row 2' } 95 | ]; 96 | 97 | const grid = new OpenGrid('test-grid', data, 400); 98 | 99 | const row1 = container.querySelector('.opengridjs-grid-row-0'); 100 | const row2 = container.querySelector('.opengridjs-grid-row-35'); 101 | 102 | expect(row1).toBeTruthy(); 103 | expect(row2).toBeTruthy(); 104 | }); 105 | }); 106 | 107 | describe('removeRow Method', () => { 108 | test('should remove row from DOM', () => { 109 | const data = Array.from({ length: 50 }, (_, i) => ({ id: i })); 110 | 111 | const grid = new OpenGrid('test-grid', data, 400); 112 | 113 | const gridRowsContainer = container.querySelector('.opengridjs-grid-rows-container'); 114 | const initialRows = container.querySelectorAll('.opengridjs-grid-row').length; 115 | 116 | // Scroll down to trigger removal of top rows 117 | gridRowsContainer.scrollTop = 500; 118 | grid.rerender(); 119 | 120 | const rowsAfterScroll = container.querySelectorAll('.opengridjs-grid-row').length; 121 | 122 | // Virtual scrolling should maintain similar number of rows 123 | expect(rowsAfterScroll).toBeGreaterThan(0); 124 | expect(rowsAfterScroll).toBeLessThan(50); 125 | }); 126 | 127 | test('should set isRendered to false when row is removed', () => { 128 | const data = Array.from({ length: 50 }, (_, i) => ({ id: i })); 129 | 130 | const grid = new OpenGrid('test-grid', data, 400); 131 | 132 | // All items outside viewport should have isRendered = false 133 | const unrenderedItems = grid.gridData.filter(item => !item.isRendered); 134 | expect(unrenderedItems.length).toBeGreaterThan(0); 135 | }); 136 | }); 137 | 138 | describe('isNearBottom Method', () => { 139 | test('should return false when not at bottom', () => { 140 | const data = Array.from({ length: 100 }, (_, i) => ({ id: i })); 141 | 142 | const grid = new OpenGrid('test-grid', data, 400); 143 | 144 | const gridRowsContainer = container.querySelector('.opengridjs-grid-rows-container'); 145 | gridRowsContainer.scrollTop = 0; 146 | 147 | const result = grid.isNearBottom(gridRowsContainer); 148 | 149 | expect(result).toBe(false); 150 | }); 151 | 152 | test('should track loaded positions in loadedAtGridHeight', () => { 153 | const data = Array.from({ length: 10 }, (_, i) => ({ id: i })); 154 | let loadMoreCalled = false; 155 | const loadMoreFunc = () => { 156 | loadMoreCalled = true; 157 | }; 158 | 159 | const grid = new OpenGrid('test-grid', data, 400, {}, loadMoreFunc); 160 | 161 | expect(grid.loadedAtGridHeight).toBeDefined(); 162 | expect(Array.isArray(grid.loadedAtGridHeight)).toBe(true); 163 | }); 164 | }); 165 | 166 | describe('Grid Rows Container', () => { 167 | test('should set correct height on rows container', () => { 168 | const data = Array.from({ length: 10 }, (_, i) => ({ id: i })); 169 | 170 | const grid = new OpenGrid('test-grid', data, 400); 171 | 172 | const gridRowsContainer = container.querySelector('.opengridjs-grid-rows-container'); 173 | expect(gridRowsContainer.style.height).toBe('400px'); 174 | }); 175 | 176 | test('should calculate total content height correctly', () => { 177 | const data = Array.from({ length: 20 }, (_, i) => ({ id: i })); 178 | 179 | const grid = new OpenGrid('test-grid', data, 400); 180 | 181 | const gridRows = container.querySelector('.opengridjs-grid-rows'); 182 | // 20 rows * 35px = 700px 183 | expect(gridRows.style.height).toBe('700px'); 184 | }); 185 | }); 186 | }); 187 | -------------------------------------------------------------------------------- /tests/filter-sort-bug.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test to reproduce sorting bug while filters are active 3 | * This test simulates the actual UI workflow a user would follow 4 | */ 5 | 6 | const OpenGrid = require('../src/opengrid.js'); 7 | 8 | describe('Sorting Bug with Active Filters - UI Workflow', () => { 9 | let container; 10 | let testData; 11 | let grid; 12 | 13 | beforeEach(() => { 14 | // Set up DOM container 15 | container = document.createElement('div'); 16 | container.className = 'test-grid'; 17 | document.body.appendChild(container); 18 | 19 | // Create test data with clear sorting patterns 20 | testData = [ 21 | { id: 1, name: 'Alice', department: 'Engineering', salary: 80000 }, 22 | { id: 2, name: 'Bob', department: 'Sales', salary: 60000 }, 23 | { id: 3, name: 'Charlie', department: 'Engineering', salary: 90000 }, 24 | { id: 4, name: 'Diana', department: 'HR', salary: 70000 }, 25 | { id: 5, name: 'Eve', department: 'Sales', salary: 65000 }, 26 | { id: 6, name: 'Frank', department: 'Engineering', salary: 85000 }, 27 | ]; 28 | 29 | grid = new OpenGrid('test-grid', testData, 400); 30 | }); 31 | 32 | afterEach(() => { 33 | document.body.removeChild(container); 34 | }); 35 | 36 | test('BUG REPRODUCTION: Apply filter via UI, then try to sort via UI', () => { 37 | console.log('=== TESTING UI WORKFLOW ==='); 38 | 39 | // Step 1: Apply filter via UI (like a user would) 40 | console.log('Step 1: Applying filter via UI...'); 41 | const departmentButton = container.querySelector('[data-column="department"]'); 42 | departmentButton.click(); 43 | 44 | // Simulate unchecking HR and Sales, leaving only Engineering 45 | const checkboxes = document.querySelectorAll('.opengridjs-filter-option input[type="checkbox"]'); 46 | checkboxes.forEach(checkbox => { 47 | const label = checkbox.parentElement.querySelector('span').textContent; 48 | checkbox.checked = label === 'Engineering'; 49 | }); 50 | 51 | const applyBtn = document.querySelector('.opengridjs-filter-apply'); 52 | applyBtn.click(); 53 | 54 | console.log('Filter applied. Visible rows:', grid.gridData.length); 55 | console.log('Names after filter:', grid.gridData.map(item => item.data.name)); 56 | 57 | // Verify filter worked 58 | expect(grid.gridData.length).toBe(3); 59 | expect(grid.gridData.map(item => item.data.name)).toEqual(['Alice', 'Charlie', 'Frank']); 60 | 61 | // Step 2: Try to sort by name via UI (like a user would) 62 | console.log('Step 2: Attempting to sort by name via UI...'); 63 | const nameHeader = container.querySelector('[data-header="name"]'); 64 | 65 | console.log('Before sort click - Names:', grid.gridData.map(item => item.data.name)); 66 | nameHeader.click(); 67 | console.log('After sort click - Names:', grid.gridData.map(item => item.data.name)); 68 | 69 | // Check if sorting worked while filter is active 70 | const namesAfterSort = grid.gridData.map(item => item.data.name); 71 | console.log('Expected sorted order:', ['Alice', 'Charlie', 'Frank']); 72 | console.log('Actual order after sort:', namesAfterSort); 73 | 74 | // This should pass if sorting works with filters 75 | expect(namesAfterSort).toEqual(['Alice', 'Charlie', 'Frank']); // Already in order, but testing mechanism 76 | expect(grid.gridData.length).toBe(3); // Filter should still be active 77 | 78 | // Try sorting in descending order 79 | console.log('Step 3: Attempting descending sort...'); 80 | nameHeader.click(); 81 | const namesDescending = grid.gridData.map(item => item.data.name); 82 | console.log('Expected descending order:', ['Frank', 'Charlie', 'Alice']); 83 | console.log('Actual descending order:', namesDescending); 84 | 85 | expect(namesDescending).toEqual(['Frank', 'Charlie', 'Alice']); 86 | expect(grid.gridData.length).toBe(3); // Filter should still be active 87 | }); 88 | 89 | test('BUG REPRODUCTION: Sort by salary while department filter is active', () => { 90 | console.log('=== TESTING SALARY SORT WITH FILTER ==='); 91 | 92 | // Apply Engineering filter 93 | const departmentButton = container.querySelector('[data-column="department"]'); 94 | departmentButton.click(); 95 | 96 | const checkboxes = document.querySelectorAll('.opengridjs-filter-option input[type="checkbox"]'); 97 | checkboxes.forEach(checkbox => { 98 | const label = checkbox.parentElement.querySelector('span').textContent; 99 | checkbox.checked = label === 'Engineering'; 100 | }); 101 | 102 | const applyBtn = document.querySelector('.opengridjs-filter-apply'); 103 | applyBtn.click(); 104 | 105 | console.log('Engineering salaries before sort:', grid.gridData.map(item => item.data.salary)); 106 | 107 | // Try to sort by salary 108 | const salaryHeader = container.querySelector('[data-header="salary"]'); 109 | salaryHeader.click(); // Ascending 110 | 111 | const salariesAsc = grid.gridData.map(item => item.data.salary); 112 | console.log('Expected ascending salaries:', [80000, 85000, 90000]); 113 | console.log('Actual ascending salaries:', salariesAsc); 114 | 115 | expect(salariesAsc).toEqual([80000, 85000, 90000]); 116 | expect(grid.gridData.length).toBe(3); 117 | 118 | // Try descending 119 | salaryHeader.click(); 120 | const salariesDesc = grid.gridData.map(item => item.data.salary); 121 | console.log('Expected descending salaries:', [90000, 85000, 80000]); 122 | console.log('Actual descending salaries:', salariesDesc); 123 | 124 | expect(salariesDesc).toEqual([90000, 85000, 80000]); 125 | expect(grid.gridData.length).toBe(3); 126 | }); 127 | 128 | test('DIAGNOSTIC: Check state after each operation', () => { 129 | console.log('=== DIAGNOSTIC TEST ==='); 130 | 131 | // Initial state 132 | console.log('Initial gridData count:', grid.gridData.length); 133 | console.log('Initial columnFilters:', Object.keys(grid.columnFilters)); 134 | console.log('Initial sortState:', grid.sortState); 135 | 136 | // Apply filter 137 | grid.columnFilters = { department: new Set(['Engineering']) }; 138 | grid.applyAllFilters(); 139 | 140 | console.log('After filter - gridData count:', grid.gridData.length); 141 | console.log('After filter - columnFilters:', Object.keys(grid.columnFilters)); 142 | console.log('After filter - sortState:', grid.sortState); 143 | console.log('After filter - filteredData length:', grid.filteredData ? grid.filteredData.length : 'null'); 144 | 145 | // Try to sort 146 | grid.sortState = { header: 'name', sortDirection: 'asc' }; 147 | grid.sortData(); 148 | 149 | console.log('After sortData() - gridData count:', grid.gridData.length); 150 | console.log('After sortData() - names:', grid.gridData.map(item => item.data.name)); 151 | 152 | // This will help us see what's happening 153 | expect(grid.gridData.length).toBe(3); 154 | }); 155 | }); -------------------------------------------------------------------------------- /tests/header-cell-alignment.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests to verify header and cell minimum width alignment 3 | */ 4 | 5 | const OpenGrid = require('../src/opengrid.js'); 6 | 7 | describe('Header and Cell Minimum Width Alignment', () => { 8 | let container; 9 | let testData; 10 | 11 | beforeEach(() => { 12 | container = document.createElement('div'); 13 | container.className = 'test-grid'; 14 | document.body.appendChild(container); 15 | 16 | testData = [ 17 | { id: 1, short: 'A', long: 'Very long description that should determine column width' }, 18 | { id: 2, short: 'BB', long: 'Short' }, 19 | { id: 3, short: 'CCC', long: 'Medium length text here' }, 20 | ]; 21 | }); 22 | 23 | afterEach(() => { 24 | document.body.removeChild(container); 25 | }); 26 | 27 | test('should calculate minimum width considering header actions', async () => { 28 | const grid = new OpenGrid('test-grid', testData, 400); 29 | 30 | // Wait for minimum width calculation 31 | await new Promise(resolve => setTimeout(resolve, 10)); 32 | 33 | grid.headerData.forEach((header, index) => { 34 | console.log(`Column ${index} (${header.data}): contentMinWidth = ${header.contentMinWidth}px`); 35 | 36 | // Check that contentMinWidth accounts for header text + actions + padding 37 | expect(header.contentMinWidth).toBeGreaterThanOrEqual(80); // Baseline minimum 38 | 39 | // For columns with header actions, minimum width should be larger 40 | // to account for filter button and sort indicator 41 | if (header.data === 'long') { 42 | // This column has long text, so should have a substantial minimum width 43 | expect(header.contentMinWidth).toBeGreaterThan(120); 44 | } 45 | }); 46 | }); 47 | 48 | test('should apply same minimum width to both headers and cells', async () => { 49 | const grid = new OpenGrid('test-grid', testData, 400); 50 | 51 | // Wait for minimum width calculation 52 | await new Promise(resolve => setTimeout(resolve, 10)); 53 | 54 | const headerItems = container.querySelectorAll('.opengridjs-grid-header-item'); 55 | const firstRow = container.querySelector('.opengridjs-grid-row'); 56 | 57 | if (firstRow) { 58 | const cells = firstRow.querySelectorAll('.opengridjs-grid-column-item'); 59 | 60 | headerItems.forEach((headerItem, index) => { 61 | const cell = cells[index]; 62 | if (cell && grid.headerData[index]) { 63 | const headerStyle = grid.getColumnStyle(grid.headerData[index]); 64 | const cellStyle = grid.getColumnStyle(grid.headerData[index]); 65 | 66 | console.log(`Column ${index} header style: ${headerStyle}`); 67 | console.log(`Column ${index} cell style: ${cellStyle}`); 68 | 69 | // Both should have the same minimum width constraint 70 | expect(headerStyle).toEqual(cellStyle); 71 | 72 | // Both should contain the same min-width value 73 | const minWidth = grid.headerData[index].contentMinWidth; 74 | if (minWidth) { 75 | expect(headerStyle).toContain(`min-width: ${minWidth}px`); 76 | expect(cellStyle).toContain(`min-width: ${minWidth}px`); 77 | } 78 | } 79 | }); 80 | } 81 | }); 82 | 83 | test('should handle header actions width in calculation', async () => { 84 | const grid = new OpenGrid('test-grid', testData, 400); 85 | 86 | // Wait for minimum width calculation 87 | await new Promise(resolve => setTimeout(resolve, 10)); 88 | 89 | // Manually calculate what the minimum width should be for a column 90 | const headerItems = container.querySelectorAll('.opengridjs-grid-header-item'); 91 | const firstHeader = headerItems[0]; 92 | 93 | if (firstHeader) { 94 | const headerText = firstHeader.querySelector('.opengridjs-header-text'); 95 | const headerActions = firstHeader.querySelector('.opengridjs-header-actions'); 96 | 97 | expect(headerText).toBeTruthy(); 98 | expect(headerActions).toBeTruthy(); 99 | 100 | // The minimum width should account for: 101 | // - Header text width 102 | // - Actions width (filter button + sort indicator ≈ 50px) 103 | // - Padding (32px total) 104 | const calculatedMinWidth = grid.headerData[0].contentMinWidth; 105 | expect(calculatedMinWidth).toBeGreaterThan(80); // Should be more than just baseline 106 | } 107 | }); 108 | 109 | test('should prevent header from being smaller than cell content', async () => { 110 | // Create data where cell content is longer than header text 111 | const wideData = [ 112 | { name: 'A', description: 'This is an extremely long description that should determine the minimum width for this column and prevent weird alignment issues' }, 113 | { name: 'B', description: 'Another very long piece of text that extends beyond typical column widths' }, 114 | ]; 115 | 116 | const grid = new OpenGrid('test-grid', wideData, 400); 117 | 118 | // Wait for minimum width calculation 119 | await new Promise(resolve => setTimeout(resolve, 10)); 120 | 121 | const descriptionHeader = grid.headerData.find(h => h.data === 'description'); 122 | expect(descriptionHeader).toBeTruthy(); 123 | 124 | // The minimum width should be large enough to accommodate the long cell content 125 | expect(descriptionHeader.contentMinWidth).toBeGreaterThan(200); 126 | 127 | // Verify that both header and cell styles use this minimum width 128 | const style = grid.getColumnStyle(descriptionHeader); 129 | expect(style).toContain(`min-width: ${descriptionHeader.contentMinWidth}px`); 130 | }); 131 | 132 | test('should maintain alignment when column is resized to minimum', async () => { 133 | const grid = new OpenGrid('test-grid', testData, 400); 134 | 135 | // Wait for minimum width calculation 136 | await new Promise(resolve => setTimeout(resolve, 10)); 137 | 138 | const originalMinWidth = grid.headerData[0].contentMinWidth; 139 | 140 | // Simulate resizing to below minimum (should be clamped to minimum) 141 | const startWidth = 200; 142 | const deltaX = -(originalMinWidth + 50); // Try to resize below minimum 143 | const minAllowedWidth = grid.headerData[0].contentMinWidth || 80; 144 | const newWidth = Math.max(minAllowedWidth, startWidth + deltaX); 145 | 146 | // The new width should be clamped to the minimum 147 | expect(newWidth).toBe(minAllowedWidth); 148 | expect(newWidth).toBeGreaterThanOrEqual(originalMinWidth); 149 | 150 | // Update the column width 151 | grid.headerData[0].width = `min-width:${newWidth}px`; 152 | 153 | // Both header and cell should use the same style 154 | const style = grid.getColumnStyle(grid.headerData[0]); 155 | expect(style).toContain(`min-width: ${grid.headerData[0].contentMinWidth}px`); 156 | expect(style).toContain(`min-width:${newWidth}px`); 157 | }); 158 | }); -------------------------------------------------------------------------------- /tests/multiple-grid-and-right-click.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for multiple grid instances and right-click filter menu functionality 3 | */ 4 | 5 | const OpenGrid = require('../src/opengrid.js'); 6 | 7 | describe('Multiple Grid Instances and Right-click Features', () => { 8 | let container1, container2; 9 | let testData1, testData2; 10 | let grid1, grid2; 11 | 12 | beforeEach(() => { 13 | // Create two grid containers 14 | container1 = document.createElement('div'); 15 | container1.className = 'grid-1'; 16 | document.body.appendChild(container1); 17 | 18 | container2 = document.createElement('div'); 19 | container2.className = 'grid-2'; 20 | document.body.appendChild(container2); 21 | 22 | testData1 = [ 23 | { id: 1, name: 'Alice', role: 'Developer' }, 24 | { id: 2, name: 'Bob', role: 'Designer' }, 25 | ]; 26 | 27 | testData2 = [ 28 | { id: 3, name: 'Charlie', department: 'Sales' }, 29 | { id: 4, name: 'David', department: 'Marketing' }, 30 | ]; 31 | 32 | grid1 = new OpenGrid('grid-1', testData1, 400); 33 | grid2 = new OpenGrid('grid-2', testData2, 400); 34 | }); 35 | 36 | afterEach(() => { 37 | document.body.removeChild(container1); 38 | document.body.removeChild(container2); 39 | }); 40 | 41 | test('should create independent grid instances', () => { 42 | expect(grid1).toBeDefined(); 43 | expect(grid2).toBeDefined(); 44 | expect(grid1.rootElement).toBe(container1); 45 | expect(grid2.rootElement).toBe(container2); 46 | expect(grid1.gridData.length).toBe(2); 47 | expect(grid2.gridData.length).toBe(2); 48 | }); 49 | 50 | test('should scope context menus to correct grid instance', () => { 51 | const contextMenuOptions = [ 52 | { actionName: 'Edit', actionFunctionName: 'editRow', className: 'edit-btn' } 53 | ]; 54 | 55 | // Create grids with context menus 56 | grid1 = new OpenGrid('grid-1', testData1, 400, { contextMenuOptions }); 57 | grid2 = new OpenGrid('grid-2', testData2, 400, { contextMenuOptions }); 58 | 59 | // Get rows from each grid 60 | const grid1Rows = container1.querySelectorAll('.opengridjs-grid-row'); 61 | const grid2Rows = container2.querySelectorAll('.opengridjs-grid-row'); 62 | 63 | expect(grid1Rows.length).toBeGreaterThan(0); 64 | expect(grid2Rows.length).toBeGreaterThan(0); 65 | 66 | // Simulate right-click on grid2 row 67 | const grid2Row = grid2Rows[0]; 68 | const contextMenuEvent = new MouseEvent('contextmenu', { 69 | bubbles: true, 70 | cancelable: true, 71 | clientX: 100, 72 | clientY: 100 73 | }); 74 | 75 | grid2Row.dispatchEvent(contextMenuEvent); 76 | 77 | // Context menu should appear only in grid2 78 | const grid1ContextMenus = container1.querySelectorAll('.opengridjs-contextMenu'); 79 | const grid2ContextMenus = container2.querySelectorAll('.opengridjs-contextMenu'); 80 | 81 | expect(grid1ContextMenus.length).toBe(0); 82 | expect(grid2ContextMenus.length).toBe(1); 83 | }); 84 | 85 | test('should scope filter menus to correct grid instance', () => { 86 | // Get filter buttons from each grid 87 | const grid1FilterButtons = container1.querySelectorAll('.opengridjs-filter-button'); 88 | const grid2FilterButtons = container2.querySelectorAll('.opengridjs-filter-button'); 89 | 90 | expect(grid1FilterButtons.length).toBeGreaterThan(0); 91 | expect(grid2FilterButtons.length).toBeGreaterThan(0); 92 | 93 | // Click filter button in grid2 94 | const grid2FilterButton = grid2FilterButtons[0]; 95 | grid2FilterButton.click(); 96 | 97 | // Filter menu should appear only in grid2 98 | const grid1FilterMenus = container1.querySelectorAll('.opengridjs-filter-menu'); 99 | const grid2FilterMenus = container2.querySelectorAll('.opengridjs-filter-menu'); 100 | 101 | expect(grid1FilterMenus.length).toBe(0); 102 | expect(grid2FilterMenus.length).toBe(1); 103 | }); 104 | 105 | test('should open filter menu on header right-click', () => { 106 | // Get header items from grid1 107 | const headerItems = container1.querySelectorAll('.opengridjs-grid-header-item'); 108 | expect(headerItems.length).toBeGreaterThan(0); 109 | 110 | const headerItem = headerItems[0]; // First column header 111 | 112 | // Simulate right-click on header 113 | const contextMenuEvent = new MouseEvent('contextmenu', { 114 | bubbles: true, 115 | cancelable: true, 116 | clientX: 100, 117 | clientY: 100 118 | }); 119 | 120 | headerItem.dispatchEvent(contextMenuEvent); 121 | 122 | // Filter menu should appear 123 | const filterMenus = container1.querySelectorAll('.opengridjs-filter-menu'); 124 | expect(filterMenus.length).toBe(1); 125 | 126 | // Menu should be for the correct column 127 | const filterMenu = filterMenus[0]; 128 | const column = headerItem.getAttribute('data-header'); 129 | expect(filterMenu.getAttribute('data-column')).toBe(column); 130 | }); 131 | 132 | test('should close filter menu when clicking outside (scoped to grid)', (done) => { 133 | // Open filter menu in grid1 134 | const grid1FilterButton = container1.querySelector('.opengridjs-filter-button'); 135 | grid1FilterButton.click(); 136 | 137 | // Wait for event listeners to be attached 138 | setTimeout(() => { 139 | // Verify menu is open 140 | let filterMenus = container1.querySelectorAll('.opengridjs-filter-menu'); 141 | expect(filterMenus.length).toBe(1); 142 | 143 | // Click outside (on document) 144 | const outsideClickEvent = new MouseEvent('click', { 145 | bubbles: true, 146 | cancelable: true 147 | }); 148 | 149 | document.dispatchEvent(outsideClickEvent); 150 | 151 | // Filter menu should now be closed 152 | filterMenus = container1.querySelectorAll('.opengridjs-filter-menu'); 153 | expect(filterMenus.length).toBe(0); 154 | done(); 155 | }, 10); 156 | }); 157 | 158 | test('should not interfere between multiple grid operations', () => { 159 | // Open filter menu in grid1 160 | const grid1FilterButton = container1.querySelector('.opengridjs-filter-button'); 161 | grid1FilterButton.click(); 162 | 163 | // Open filter menu in grid2 164 | const grid2FilterButton = container2.querySelector('.opengridjs-filter-button'); 165 | grid2FilterButton.click(); 166 | 167 | // Both grids should have their own filter menus 168 | const grid1FilterMenus = container1.querySelectorAll('.opengridjs-filter-menu'); 169 | const grid2FilterMenus = container2.querySelectorAll('.opengridjs-filter-menu'); 170 | 171 | expect(grid1FilterMenus.length).toBe(1); 172 | expect(grid2FilterMenus.length).toBe(1); 173 | 174 | // Close grid1 filter menu by clicking its cancel button 175 | const grid1CancelButton = grid1FilterMenus[0].querySelector('.opengridjs-filter-cancel'); 176 | grid1CancelButton.click(); 177 | 178 | // Only grid1 menu should be closed 179 | const grid1FilterMenusAfter = container1.querySelectorAll('.opengridjs-filter-menu'); 180 | const grid2FilterMenusAfter = container2.querySelectorAll('.opengridjs-filter-menu'); 181 | 182 | expect(grid1FilterMenusAfter.length).toBe(0); 183 | expect(grid2FilterMenusAfter.length).toBe(1); 184 | }); 185 | }); -------------------------------------------------------------------------------- /tests/csv-export.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test Suite 4.2: CSV Export 3 | * Phase 4 - Export Features 4 | * 5 | * Coverage targets: 6 | * - Lines 820-836 (exportToCSV method) 7 | */ 8 | 9 | const OpenGrid = require('../src/opengrid.js'); 10 | 11 | describe('OpenGrid CSV Export', () => { 12 | let container; 13 | 14 | beforeEach(() => { 15 | container = document.createElement('div'); 16 | container.className = 'test-grid'; 17 | document.body.appendChild(container); 18 | 19 | // Mock URL.createObjectURL for CSV export 20 | global.URL.createObjectURL = jest.fn(() => 'blob:mock-url'); 21 | global.URL.revokeObjectURL = jest.fn(); 22 | }); 23 | 24 | afterEach(() => { 25 | if (document.body.contains(container)) { 26 | document.body.removeChild(container); 27 | } 28 | jest.clearAllMocks(); 29 | }); 30 | 31 | describe('exportToCSV Method', () => { 32 | test('should create CSV with headers', () => { 33 | const data = [ 34 | { id: 1, name: 'Alice', age: 30 }, 35 | { id: 2, name: 'Bob', age: 25 } 36 | ]; 37 | const grid = new OpenGrid('test-grid', data, 400); 38 | 39 | // Spy on createElement to capture the link 40 | const createElementSpy = jest.spyOn(document, 'createElement'); 41 | 42 | grid.exportToCSV(); 43 | 44 | // Find the link element creation 45 | const linkCall = createElementSpy.mock.results.find( 46 | result => result.value && result.value.tagName === 'A' 47 | ); 48 | 49 | expect(linkCall).toBeTruthy(); 50 | expect(URL.createObjectURL).toHaveBeenCalled(); 51 | 52 | createElementSpy.mockRestore(); 53 | }); 54 | 55 | test('should include all data rows in CSV', () => { 56 | const data = [ 57 | { id: 1, name: 'Alice' }, 58 | { id: 2, name: 'Bob' }, 59 | { id: 3, name: 'Charlie' } 60 | ]; 61 | const grid = new OpenGrid('test-grid', data, 400); 62 | 63 | // Mock Blob to capture CSV content 64 | const originalBlob = global.Blob; 65 | let csvContent = ''; 66 | 67 | global.Blob = jest.fn((content, options) => { 68 | csvContent = content[0]; 69 | return new originalBlob(content, options); 70 | }); 71 | 72 | grid.exportToCSV(); 73 | 74 | expect(csvContent).toContain('Alice'); 75 | expect(csvContent).toContain('Bob'); 76 | expect(csvContent).toContain('Charlie'); 77 | 78 | global.Blob = originalBlob; 79 | }); 80 | 81 | test('should create blob with correct MIME type', () => { 82 | const data = [{ id: 1, name: 'Test' }]; 83 | const grid = new OpenGrid('test-grid', data, 400); 84 | 85 | const originalBlob = global.Blob; 86 | let blobOptions = null; 87 | 88 | global.Blob = jest.fn((content, options) => { 89 | blobOptions = options; 90 | return new originalBlob(content, options); 91 | }); 92 | 93 | grid.exportToCSV(); 94 | 95 | expect(blobOptions.type).toBe('text/csv;charset=utf-8;'); 96 | 97 | global.Blob = originalBlob; 98 | }); 99 | 100 | test('should set download filename to export.csv', () => { 101 | const data = [{ id: 1, name: 'Test' }]; 102 | const grid = new OpenGrid('test-grid', data, 400); 103 | 104 | const createElementSpy = jest.spyOn(document, 'createElement'); 105 | const appendChildSpy = jest.spyOn(document.body, 'appendChild'); 106 | const setAttributeSpy = jest.fn(); 107 | 108 | // Override setAttribute on all created elements 109 | const originalCreateElement = document.createElement.bind(document); 110 | document.createElement = jest.fn((tagName) => { 111 | const element = originalCreateElement(tagName); 112 | if (tagName === 'a') { 113 | element.setAttribute = setAttributeSpy; 114 | element.click = jest.fn(); 115 | } 116 | return element; 117 | }); 118 | 119 | grid.exportToCSV(); 120 | 121 | expect(setAttributeSpy).toHaveBeenCalledWith('download', 'export.csv'); 122 | 123 | document.createElement = originalCreateElement; 124 | createElementSpy.mockRestore(); 125 | appendChildSpy.mockRestore(); 126 | }); 127 | 128 | test('should programmatically click download link', () => { 129 | const data = [{ id: 1, name: 'Test' }]; 130 | const grid = new OpenGrid('test-grid', data, 400); 131 | 132 | const clickSpy = jest.fn(); 133 | 134 | const originalCreateElement = document.createElement.bind(document); 135 | document.createElement = jest.fn((tagName) => { 136 | const element = originalCreateElement(tagName); 137 | if (tagName === 'a') { 138 | element.click = clickSpy; 139 | element.setAttribute = jest.fn(); 140 | } 141 | return element; 142 | }); 143 | 144 | grid.exportToCSV(); 145 | 146 | expect(clickSpy).toHaveBeenCalled(); 147 | 148 | document.createElement = originalCreateElement; 149 | }); 150 | 151 | test('should remove download link after click', () => { 152 | const data = [{ id: 1, name: 'Test' }]; 153 | const grid = new OpenGrid('test-grid', data, 400); 154 | 155 | const removeChildSpy = jest.spyOn(document.body, 'removeChild'); 156 | 157 | grid.exportToCSV(); 158 | 159 | expect(removeChildSpy).toHaveBeenCalled(); 160 | 161 | removeChildSpy.mockRestore(); 162 | }); 163 | 164 | test('should export filtered data when filter is active', () => { 165 | const data = [ 166 | { id: 1, name: 'Alice', type: 'user' }, 167 | { id: 2, name: 'Bob', type: 'admin' }, 168 | { id: 3, name: 'Charlie', type: 'user' } 169 | ]; 170 | const grid = new OpenGrid('test-grid', data, 400); 171 | 172 | // Apply filter 173 | grid.searchFilter('admin'); 174 | 175 | const originalBlob = global.Blob; 176 | let csvContent = ''; 177 | 178 | global.Blob = jest.fn((content, options) => { 179 | csvContent = content[0]; 180 | return new originalBlob(content, options); 181 | }); 182 | 183 | grid.exportToCSV(); 184 | 185 | // Should only include Bob (admin) 186 | expect(csvContent).toContain('Bob'); 187 | expect(csvContent).not.toContain('Alice'); 188 | expect(csvContent).not.toContain('Charlie'); 189 | 190 | global.Blob = originalBlob; 191 | }); 192 | 193 | test('should handle special characters in CSV data', () => { 194 | const data = [ 195 | { id: 1, name: 'Test, User', description: 'Has "quotes"' } 196 | ]; 197 | const grid = new OpenGrid('test-grid', data, 400); 198 | 199 | const originalBlob = global.Blob; 200 | let csvContent = ''; 201 | 202 | global.Blob = jest.fn((content, options) => { 203 | csvContent = content[0]; 204 | return new originalBlob(content, options); 205 | }); 206 | 207 | grid.exportToCSV(); 208 | 209 | // CSV should contain the data (though formatting may vary) 210 | expect(csvContent).toContain('Test, User'); 211 | 212 | global.Blob = originalBlob; 213 | }); 214 | }); 215 | }); 216 | -------------------------------------------------------------------------------- /tests/search-filter-reset.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test Suite 3.3: Search Filter & Reset 3 | * Phase 3 - Advanced Features 4 | * 5 | * Coverage targets: 6 | * - Lines 673-681 (searchFilter) 7 | * - Lines 683-686 (reset) 8 | */ 9 | 10 | const OpenGrid = require('../src/opengrid.js'); 11 | 12 | describe('OpenGrid Search Filter & Reset', () => { 13 | let container; 14 | 15 | beforeEach(() => { 16 | container = document.createElement('div'); 17 | container.className = 'test-grid'; 18 | document.body.appendChild(container); 19 | }); 20 | 21 | afterEach(() => { 22 | if (document.body.contains(container)) { 23 | document.body.removeChild(container); 24 | } 25 | }); 26 | 27 | describe('searchFilter Method', () => { 28 | test('should filter data by search term (case insensitive)', () => { 29 | const data = [ 30 | { id: 1, name: 'Alice', email: 'alice@example.com' }, 31 | { id: 2, name: 'Bob', email: 'bob@example.com' }, 32 | { id: 3, name: 'Charlie', email: 'charlie@example.com' } 33 | ]; 34 | const grid = new OpenGrid('test-grid', data, 400); 35 | 36 | grid.searchFilter('alice'); 37 | 38 | expect(grid.gridData.length).toBe(1); 39 | expect(grid.gridData[0].data.name).toBe('Alice'); 40 | }); 41 | 42 | test('should filter across all columns', () => { 43 | const data = [ 44 | { id: 1, name: 'Alice', email: 'alice@test.com', role: 'admin' }, 45 | { id: 2, name: 'Bob', email: 'bob@example.com', role: 'user' }, 46 | { id: 3, name: 'Charlie', email: 'charlie@test.com', role: 'user' } 47 | ]; 48 | const grid = new OpenGrid('test-grid', data, 400); 49 | 50 | // Search for 'test' - should match email column for Alice and Charlie 51 | grid.searchFilter('test'); 52 | 53 | expect(grid.gridData.length).toBe(2); 54 | expect(grid.gridData.some(item => item.data.name === 'Alice')).toBe(true); 55 | expect(grid.gridData.some(item => item.data.name === 'Charlie')).toBe(true); 56 | }); 57 | 58 | test('should handle partial matches', () => { 59 | const data = [ 60 | { id: 1, name: 'Alexander' }, 61 | { id: 2, name: 'Alexandra' }, 62 | { id: 3, name: 'Bob' } 63 | ]; 64 | const grid = new OpenGrid('test-grid', data, 400); 65 | 66 | grid.searchFilter('alex'); 67 | 68 | expect(grid.gridData.length).toBe(2); 69 | }); 70 | 71 | test('should return empty results for no matches', () => { 72 | const data = [ 73 | { id: 1, name: 'Alice' }, 74 | { id: 2, name: 'Bob' } 75 | ]; 76 | const grid = new OpenGrid('test-grid', data, 400); 77 | 78 | grid.searchFilter('xyz'); 79 | 80 | expect(grid.gridData.length).toBe(0); 81 | }); 82 | 83 | test('should handle numeric search terms', () => { 84 | const data = [ 85 | { id: 123, name: 'Alice' }, 86 | { id: 456, name: 'Bob' }, 87 | { id: 789, name: 'Charlie' } 88 | ]; 89 | const grid = new OpenGrid('test-grid', data, 400); 90 | 91 | grid.searchFilter(456); 92 | 93 | expect(grid.gridData.length).toBe(1); 94 | expect(grid.gridData[0].data.name).toBe('Bob'); 95 | }); 96 | 97 | test('should be case insensitive', () => { 98 | const data = [ 99 | { id: 1, name: 'ALICE' }, 100 | { id: 2, name: 'bob' }, 101 | { id: 3, name: 'ChArLiE' } 102 | ]; 103 | const grid = new OpenGrid('test-grid', data, 400); 104 | 105 | grid.searchFilter('CHARLIE'); 106 | 107 | expect(grid.gridData.length).toBe(1); 108 | expect(grid.gridData[0].data.name).toBe('ChArLiE'); 109 | }); 110 | }); 111 | 112 | describe('reset Method', () => { 113 | test('should reset filtered data back to original', () => { 114 | const data = [ 115 | { id: 1, name: 'Alice' }, 116 | { id: 2, name: 'Bob' }, 117 | { id: 3, name: 'Charlie' } 118 | ]; 119 | const grid = new OpenGrid('test-grid', data, 400); 120 | 121 | // Apply filter 122 | grid.searchFilter('alice'); 123 | expect(grid.gridData.length).toBe(1); 124 | 125 | // Reset 126 | grid.reset(); 127 | 128 | expect(grid.gridData.length).toBe(3); 129 | }); 130 | 131 | test('should restore all original data after reset', () => { 132 | const data = [ 133 | { id: 1, name: 'Alice', age: 30 }, 134 | { id: 2, name: 'Bob', age: 25 }, 135 | { id: 3, name: 'Charlie', age: 35 } 136 | ]; 137 | const grid = new OpenGrid('test-grid', data, 400); 138 | 139 | grid.searchFilter('bob'); 140 | grid.reset(); 141 | 142 | // All records should be present 143 | expect(grid.gridData).toHaveLength(3); 144 | expect(grid.gridData.some(item => item.data.name === 'Alice')).toBe(true); 145 | expect(grid.gridData.some(item => item.data.name === 'Bob')).toBe(true); 146 | expect(grid.gridData.some(item => item.data.name === 'Charlie')).toBe(true); 147 | }); 148 | 149 | test('should work after multiple filters and resets', () => { 150 | const data = [ 151 | { id: 1, name: 'Alice' }, 152 | { id: 2, name: 'Bob' }, 153 | { id: 3, name: 'Charlie' }, 154 | { id: 4, name: 'David' } 155 | ]; 156 | const grid = new OpenGrid('test-grid', data, 400); 157 | 158 | // First filter 159 | grid.searchFilter('a'); // Alice, Charlie, David 160 | expect(grid.gridData.length).toBe(3); 161 | 162 | // Reset 163 | grid.reset(); 164 | expect(grid.gridData.length).toBe(4); 165 | 166 | // Second filter 167 | grid.searchFilter('b'); // Bob 168 | expect(grid.gridData.length).toBe(1); 169 | 170 | // Reset again 171 | grid.reset(); 172 | expect(grid.gridData.length).toBe(4); 173 | }); 174 | 175 | test('should reset maintains data integrity', () => { 176 | const data = [ 177 | { id: 1, name: 'Alice', email: 'alice@test.com', active: true } 178 | ]; 179 | const grid = new OpenGrid('test-grid', data, 400); 180 | 181 | grid.searchFilter('xyz'); // No matches 182 | grid.reset(); 183 | 184 | // Data should be fully restored 185 | expect(grid.gridData[0].data).toEqual(data[0]); 186 | }); 187 | }); 188 | 189 | describe('searchFilter and reset Integration', () => { 190 | test('should filter, reset, and filter again correctly', () => { 191 | const data = [ 192 | { id: 1, category: 'fruit', name: 'Apple' }, 193 | { id: 2, category: 'vegetable', name: 'Broccoli' }, 194 | { id: 3, category: 'fruit', name: 'Banana' } 195 | ]; 196 | const grid = new OpenGrid('test-grid', data, 400); 197 | 198 | // Filter for 'fruit' 199 | grid.searchFilter('fruit'); 200 | expect(grid.gridData.length).toBe(2); 201 | 202 | // Reset 203 | grid.reset(); 204 | expect(grid.gridData.length).toBe(3); 205 | 206 | // Filter for 'vegetable' 207 | grid.searchFilter('vegetable'); 208 | expect(grid.gridData.length).toBe(1); 209 | expect(grid.gridData[0].data.name).toBe('Broccoli'); 210 | }); 211 | }); 212 | }); 213 | -------------------------------------------------------------------------------- /tests/min-width-constraints.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for content-based minimum width constraints 3 | */ 4 | 5 | const OpenGrid = require('../src/opengrid.js'); 6 | 7 | describe('Content-Based Minimum Width Constraints', () => { 8 | let container; 9 | let testData; 10 | 11 | beforeEach(() => { 12 | container = document.createElement('div'); 13 | container.className = 'test-grid'; 14 | document.body.appendChild(container); 15 | 16 | testData = [ 17 | { id: 1, name: 'A', department: 'Engineering', description: 'Very long description that should determine the minimum width of this column' }, 18 | { id: 2, name: 'Really Long Name That Extends', department: 'Sales', description: 'Short' }, 19 | { id: 3, name: 'Bob', department: 'Human Resources Department', description: 'Medium length description here' }, 20 | ]; 21 | }); 22 | 23 | afterEach(() => { 24 | document.body.removeChild(container); 25 | }); 26 | 27 | test('should calculate content-based minimum widths', async () => { 28 | const grid = new OpenGrid('test-grid', testData, 400); 29 | 30 | // Wait for minimum width calculation 31 | await new Promise(resolve => setTimeout(resolve, 10)); 32 | 33 | // Check that contentMinWidth was calculated for each column 34 | grid.headerData.forEach((header, index) => { 35 | console.log(`Column ${index} (${header.data}): contentMinWidth = ${header.contentMinWidth}px`); 36 | expect(header.contentMinWidth).toBeDefined(); 37 | expect(header.contentMinWidth).toBeGreaterThanOrEqual(80); // Should be at least the baseline minimum 38 | }); 39 | 40 | // The description column should have the largest minimum width due to long content 41 | const descriptionHeader = grid.headerData.find(h => h.data === 'description'); 42 | const nameHeader = grid.headerData.find(h => h.data === 'name'); 43 | 44 | expect(descriptionHeader.contentMinWidth).toBeGreaterThan(nameHeader.contentMinWidth); 45 | }); 46 | 47 | test('should enforce minimum width in column styles', async () => { 48 | const grid = new OpenGrid('test-grid', testData, 400); 49 | 50 | // Wait for minimum width calculation 51 | await new Promise(resolve => setTimeout(resolve, 10)); 52 | 53 | grid.headerData.forEach((header, index) => { 54 | const style = grid.getColumnStyle(header); 55 | console.log(`Column ${index} style: ${style}`); 56 | 57 | // Should contain min-width based on content 58 | expect(style).toContain('min-width:'); 59 | expect(style).toContain(`${header.contentMinWidth}px`); 60 | }); 61 | }); 62 | 63 | test('should prevent resizing below content minimum width', async () => { 64 | const grid = new OpenGrid('test-grid', testData, 400); 65 | 66 | // Wait for minimum width calculation 67 | await new Promise(resolve => setTimeout(resolve, 10)); 68 | 69 | const originalMinWidth = grid.headerData[0].contentMinWidth; 70 | 71 | // Simulate trying to resize below minimum width 72 | grid.headerData[0].width = `min-width:${originalMinWidth - 50}px`; 73 | 74 | // The resizing logic should prevent going below contentMinWidth 75 | const deltaX = -(originalMinWidth); 76 | const minAllowedWidth = grid.headerData[0].contentMinWidth || 80; 77 | const newWidth = Math.max(minAllowedWidth, 200 + deltaX); // Simulating 200px start width 78 | 79 | expect(newWidth).toBeGreaterThanOrEqual(minAllowedWidth); 80 | expect(newWidth).toBeGreaterThanOrEqual(originalMinWidth); 81 | }); 82 | 83 | test('should handle empty or short content gracefully', async () => { 84 | const shortData = [ 85 | { a: 'X', b: '', c: null }, 86 | { a: 'Y', b: '1', c: undefined }, 87 | ]; 88 | 89 | const grid = new OpenGrid('test-grid', shortData, 400); 90 | 91 | // Wait for minimum width calculation 92 | await new Promise(resolve => setTimeout(resolve, 10)); 93 | 94 | grid.headerData.forEach(header => { 95 | // Even with short content, should have reasonable minimum width 96 | expect(header.contentMinWidth).toBeGreaterThanOrEqual(80); 97 | }); 98 | }); 99 | 100 | test('should measure text width accurately', () => { 101 | const grid = new OpenGrid('test-grid', testData, 400); 102 | 103 | // Create a test element to measure 104 | const testElement = document.createElement('div'); 105 | testElement.style.font = '14px Arial'; 106 | testElement.style.position = 'absolute'; 107 | testElement.style.visibility = 'hidden'; 108 | document.body.appendChild(testElement); 109 | 110 | const shortText = 'A'; 111 | const longText = 'This is a much longer piece of text'; 112 | 113 | const shortWidth = grid.measureTextWidth(shortText, testElement); 114 | const longWidth = grid.measureTextWidth(longText, testElement); 115 | 116 | console.log(`Short text width: ${shortWidth}px`); 117 | console.log(`Long text width: ${longWidth}px`); 118 | 119 | expect(longWidth).toBeGreaterThanOrEqual(shortWidth); 120 | expect(shortWidth).toBeGreaterThanOrEqual(0); 121 | expect(longWidth).toBeGreaterThan(0); 122 | 123 | document.body.removeChild(testElement); 124 | }); 125 | 126 | test('should update minimum widths when data changes', async () => { 127 | const grid = new OpenGrid('test-grid', testData, 400); 128 | 129 | // Wait for initial calculation 130 | await new Promise(resolve => setTimeout(resolve, 10)); 131 | 132 | const originalMinWidth = grid.headerData.find(h => h.data === 'name').contentMinWidth; 133 | 134 | // Add data with longer names 135 | const newData = [ 136 | ...testData, 137 | { id: 4, name: 'Extremely Long Name That Should Increase Minimum Width Significantly', department: 'Marketing', description: 'Test' } 138 | ]; 139 | 140 | // Simulate data change and recalculation 141 | grid.processData(newData); 142 | grid.generateGridRows(); 143 | grid.updateColumnWidths(); 144 | 145 | // Wait for recalculation 146 | await new Promise(resolve => setTimeout(resolve, 10)); 147 | 148 | const newMinWidth = grid.headerData.find(h => h.data === 'name').contentMinWidth; 149 | 150 | expect(newMinWidth).toBeGreaterThanOrEqual(originalMinWidth); 151 | }); 152 | 153 | test('should handle custom column widths with content constraints', async () => { 154 | const setup = { 155 | columnHeaderNames: [ 156 | { columnName: 'id', columnNameDisplay: 'ID', columnWidth: '50px' }, // Smaller than content needs 157 | { columnName: 'name', columnNameDisplay: 'Full Name', columnWidth: '100px' }, 158 | { columnName: 'department', columnNameDisplay: 'Department' }, 159 | { columnName: 'description', columnNameDisplay: 'Description' } 160 | ] 161 | }; 162 | 163 | const grid = new OpenGrid('test-grid', testData, 400, setup); 164 | 165 | // Wait for minimum width calculation 166 | await new Promise(resolve => setTimeout(resolve, 10)); 167 | 168 | const idHeader = grid.headerData.find(h => h.data === 'id'); 169 | const style = grid.getColumnStyle(idHeader); 170 | 171 | console.log(`ID column style: ${style}`); 172 | 173 | // Should have both the specified min-width and the content-based min-width 174 | expect(style).toContain('min-width:'); 175 | // The content-based min-width should take precedence if larger 176 | if (idHeader.contentMinWidth > 50) { 177 | expect(style).toContain(`min-width: ${idHeader.contentMinWidth}px`); 178 | } 179 | }); 180 | }); -------------------------------------------------------------------------------- /examples/filter-demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | OpenGridJs - Column Filtering Demo 7 | 8 | 9 | 112 | 113 | 114 |
115 |

🔍 Column Filtering Demo

116 |

Experience Excel-like filtering with OpenGridJs

117 | 118 |
119 |
120 |

How to Use Column Filters:

121 |
    122 |
  • Click the button in any column header to open the filter menu
  • 123 |
  • Use checkboxes to select/deselect values you want to show
  • 124 |
  • Use the search box to quickly find specific values
  • 125 |
  • Click "Select All" or "Clear All" for bulk selection
  • 126 |
  • Apply filters from multiple columns simultaneously
  • 127 |
  • Active filters are highlighted in blue
  • 128 |
129 |
130 | 131 |
132 | 133 |
134 |
135 |
0
136 |
Total Rows
137 |
138 |
139 |
0
140 |
Visible Rows
141 |
142 |
143 |
0
144 |
Active Filters
145 |
146 |
147 | 148 | 149 |
150 |
151 | 152 | 221 | 222 | -------------------------------------------------------------------------------- /tests/opengrid.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * OpenGrid Test Suite 3 | * 4 | * Basic tests to verify OpenGrid functionality. 5 | * Note: Due to DOM dependencies, these tests focus on the core logic 6 | * that can be tested without full DOM manipulation. 7 | */ 8 | 9 | const fs = require('fs'); 10 | const path = require('path'); 11 | 12 | describe('OpenGrid Baseline Tests', () => { 13 | 14 | test('OpenGrid source file should exist and contain class definition', () => { 15 | const openGridPath = path.join(__dirname, '../src/opengrid.js'); 16 | 17 | // Verify file exists 18 | expect(fs.existsSync(openGridPath)).toBe(true); 19 | 20 | // Read and verify content 21 | const content = fs.readFileSync(openGridPath, 'utf8'); 22 | expect(content).toContain('class OpenGrid'); 23 | expect(content).toContain('constructor(className, data, gridHeight, setup'); 24 | expect(content).toContain('generateGUID()'); 25 | expect(content).toContain('sortData()'); 26 | expect(content).toContain('searchFilter('); 27 | expect(content).toContain('appendData('); 28 | expect(content).toContain('reset()'); 29 | expect(content).toContain('exportToCSV()'); 30 | }); 31 | 32 | test('OpenGrid class methods should be present in source', () => { 33 | const openGridPath = path.join(__dirname, '../src/opengrid.js'); 34 | const content = fs.readFileSync(openGridPath, 'utf8'); 35 | 36 | // Verify key methods exist 37 | const expectedMethods = [ 38 | 'initGrid()', 39 | 'processData(', 40 | 'generateGridHeader(', 41 | 'generateGridRows()', 42 | 'addEventListeners(', 43 | 'debounce(', 44 | 'createContextMenu(', 45 | 'closeContextMenu(', 46 | 'renderVisible(', 47 | 'addRow(', 48 | 'removeRow(', 49 | 'rerender()', 50 | 'updateData(', 51 | 'stopLoadingMoreData()', 52 | 'isNearBottom(' 53 | ]; 54 | 55 | expectedMethods.forEach(method => { 56 | expect(content).toContain(method); 57 | }); 58 | }); 59 | 60 | test('OpenGrid should have virtual scrolling constants', () => { 61 | const openGridPath = path.join(__dirname, '../src/opengrid.js'); 62 | const content = fs.readFileSync(openGridPath, 'utf8'); 63 | 64 | // Verify virtual scrolling configuration 65 | expect(content).toContain('gridRowPxSize = 35'); 66 | expect(content).toContain('position: currentPosition * this.gridRowPxSize'); 67 | expect(content).toContain('isRendered: false'); 68 | }); 69 | 70 | test('OpenGrid should have sorting functionality', () => { 71 | const openGridPath = path.join(__dirname, '../src/opengrid.js'); 72 | const content = fs.readFileSync(openGridPath, 'utf8'); 73 | 74 | // Verify sorting logic 75 | expect(content).toContain('sortState'); 76 | expect(content).toContain('direction: null'); 77 | expect(content).toContain('.sort((a, b) =>'); 78 | expect(content).toContain('asc'); 79 | expect(content).toContain('desc'); 80 | }); 81 | 82 | test('OpenGrid should handle data with and without IDs', () => { 83 | const openGridPath = path.join(__dirname, '../src/opengrid.js'); 84 | const content = fs.readFileSync(openGridPath, 'utf8'); 85 | 86 | // Verify GUID generation for missing IDs 87 | expect(content).toContain('dataItem.id === undefined'); 88 | expect(content).toContain('dataItem.id === null'); 89 | expect(content).toContain('dataItem.id === \'\''); 90 | expect(content).toContain('this.generateGUID()'); 91 | expect(content).toContain('xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'); 92 | }); 93 | 94 | test('OpenGrid should have context menu functionality', () => { 95 | const openGridPath = path.join(__dirname, '../src/opengrid.js'); 96 | const content = fs.readFileSync(openGridPath, 'utf8'); 97 | 98 | // Verify context menu features 99 | expect(content).toContain('contextMenuItems'); 100 | expect(content).toContain('contextMenuTitle'); 101 | expect(content).toContain('opengridjs-contextMenu'); 102 | expect(content).toContain('contextmenu'); 103 | expect(content).toContain('preventDefault()'); 104 | }); 105 | 106 | test('OpenGrid should have search and filter functionality', () => { 107 | const openGridPath = path.join(__dirname, '../src/opengrid.js'); 108 | const content = fs.readFileSync(openGridPath, 'utf8'); 109 | 110 | // Verify search functionality 111 | expect(content).toContain('searchFilter(term)'); 112 | expect(content).toContain('toLowerCase()'); 113 | expect(content).toContain('includes('); 114 | expect(content).toContain('Object.values('); 115 | }); 116 | 117 | test('OpenGrid should have CSV export functionality', () => { 118 | const openGridPath = path.join(__dirname, '../src/opengrid.js'); 119 | const content = fs.readFileSync(openGridPath, 'utf8'); 120 | 121 | // Verify CSV export 122 | expect(content).toContain('exportToCSV()'); 123 | expect(content).toContain('export.csv'); 124 | expect(content).toContain('createElement(\'a\')'); 125 | expect(content).toContain('createObjectURL'); 126 | expect(content).toContain('Blob'); 127 | }); 128 | 129 | test('OpenGrid should have infinite scroll functionality', () => { 130 | const openGridPath = path.join(__dirname, '../src/opengrid.js'); 131 | const content = fs.readFileSync(openGridPath, 'utf8'); 132 | 133 | // Verify infinite scroll features 134 | expect(content).toContain('loadMoreDataFunction'); 135 | expect(content).toContain('canLoadMoreData'); 136 | expect(content).toContain('isLoadingMoreData'); 137 | expect(content).toContain('isNearBottom('); 138 | expect(content).toContain('stopLoadingMoreData()'); 139 | }); 140 | 141 | test('OpenGrid should have debouncing for performance', () => { 142 | const openGridPath = path.join(__dirname, '../src/opengrid.js'); 143 | const content = fs.readFileSync(openGridPath, 'utf8'); 144 | 145 | // Verify debounce implementation 146 | expect(content).toContain('debounce(func, delay)'); 147 | expect(content).toContain('clearTimeout(inDebounce)'); 148 | expect(content).toContain('setTimeout('); 149 | expect(content).toContain('func.apply(context, args)'); 150 | }); 151 | 152 | test('package.json should have correct test configuration', () => { 153 | const packageJsonPath = path.join(__dirname, '../package.json'); 154 | const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); 155 | 156 | expect(packageJson.scripts.test).toBe('jest'); 157 | expect(packageJson.scripts['test:watch']).toBe('jest --watch'); 158 | expect(packageJson.scripts['test:coverage']).toBe('jest --coverage'); 159 | expect(packageJson.devDependencies.jest).toBeDefined(); 160 | expect(packageJson.devDependencies['jest-environment-jsdom']).toBeDefined(); 161 | }); 162 | 163 | test('jest configuration should be properly set up', () => { 164 | const jestConfigPath = path.join(__dirname, '../jest.config.js'); 165 | 166 | expect(fs.existsSync(jestConfigPath)).toBe(true); 167 | 168 | const jestConfig = fs.readFileSync(jestConfigPath, 'utf8'); 169 | expect(jestConfig).toContain('testEnvironment: \'jsdom\''); 170 | expect(jestConfig).toContain('opengrid.js'); 171 | expect(jestConfig).toContain('coverage'); 172 | }); 173 | }); 174 | 175 | describe('Test Infrastructure', () => { 176 | test('createTestData helper should be available', () => { 177 | // This verifies our test setup is working 178 | expect(global.createTestData).toBeDefined(); 179 | expect(typeof global.createTestData).toBe('function'); 180 | 181 | const testData = global.createTestData(3); 182 | expect(testData).toHaveLength(3); 183 | expect(testData[0]).toHaveProperty('id'); 184 | expect(testData[0]).toHaveProperty('name'); 185 | expect(testData[0]).toHaveProperty('email'); 186 | expect(testData[0]).toHaveProperty('age'); 187 | expect(testData[0]).toHaveProperty('status'); 188 | }); 189 | 190 | test('global mocks should be available', () => { 191 | expect(global.fetch).toBeDefined(); 192 | expect(jest.isMockFunction(global.fetch)).toBe(true); 193 | }); 194 | 195 | test('DOM environment should be available', () => { 196 | expect(document).toBeDefined(); 197 | expect(document.createElement).toBeDefined(); 198 | expect(document.querySelector).toBeDefined(); 199 | 200 | // Test basic DOM functionality 201 | const div = document.createElement('div'); 202 | expect(div.tagName).toBe('DIV'); 203 | 204 | div.className = 'test-class'; 205 | expect(div.className).toBe('test-class'); 206 | }); 207 | }); -------------------------------------------------------------------------------- /tests/column-width-alignment.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for column width alignment between headers and cells 3 | */ 4 | 5 | const OpenGrid = require('../src/opengrid.js'); 6 | 7 | describe('Column Width Alignment', () => { 8 | let container; 9 | let testData; 10 | 11 | beforeEach(() => { 12 | container = document.createElement('div'); 13 | container.className = 'test-grid'; 14 | document.body.appendChild(container); 15 | 16 | testData = [ 17 | { id: 1, name: 'Alice Smith', department: 'Engineering', salary: 80000 }, 18 | { id: 2, name: 'Bob Johnson', department: 'Sales', salary: 60000 }, 19 | { id: 3, name: 'Charlie Brown', department: 'Engineering', salary: 90000 }, 20 | ]; 21 | }); 22 | 23 | afterEach(() => { 24 | document.body.removeChild(container); 25 | }); 26 | 27 | test('header and cell widths should match with percentage widths', () => { 28 | const grid = new OpenGrid('test-grid', testData, 400); 29 | 30 | // Get header items and first row's column items 31 | const headerItems = container.querySelectorAll('.opengridjs-grid-header-item'); 32 | const firstRow = container.querySelector('.opengridjs-grid-row'); 33 | const columnItems = firstRow.querySelectorAll('.opengridjs-grid-column-item'); 34 | 35 | expect(headerItems.length).toBe(columnItems.length); 36 | 37 | // Check that each header and corresponding column have the same width style 38 | headerItems.forEach((header, index) => { 39 | const column = columnItems[index]; 40 | 41 | console.log(`Column ${index}:`); 42 | console.log(` Header style: ${header.style.cssText}`); 43 | console.log(` Column style: ${column.style.cssText}`); 44 | 45 | // Both should have the same width value 46 | expect(header.style.width).toBe(column.style.width); 47 | expect(header.style.flexGrow).toBe(column.style.flexGrow); 48 | expect(header.style.flexShrink).toBe(column.style.flexShrink); 49 | }); 50 | }); 51 | 52 | test('header and cell widths should match with custom pixel widths', () => { 53 | const setup = { 54 | columnHeaderNames: [ 55 | { columnName: 'id', columnNameDisplay: 'ID', columnWidth: '60px' }, 56 | { columnName: 'name', columnNameDisplay: 'Name', columnWidth: '200px' }, 57 | { columnName: 'department', columnNameDisplay: 'Department', columnWidth: '150px' }, 58 | { columnName: 'salary', columnNameDisplay: 'Salary', columnWidth: '120px' } 59 | ] 60 | }; 61 | 62 | const grid = new OpenGrid('test-grid', testData, 400, setup); 63 | 64 | const headerItems = container.querySelectorAll('.opengridjs-grid-header-item'); 65 | const firstRow = container.querySelector('.opengridjs-grid-row'); 66 | const columnItems = firstRow.querySelectorAll('.opengridjs-grid-column-item'); 67 | 68 | expect(headerItems.length).toBe(4); 69 | expect(columnItems.length).toBe(4); 70 | 71 | const expectedWidths = ['60px', '200px', '150px', '120px']; 72 | 73 | headerItems.forEach((header, index) => { 74 | const column = columnItems[index]; 75 | 76 | console.log(`Column ${index} (${expectedWidths[index]}):`); 77 | console.log(` Header style: ${header.style.cssText}`); 78 | console.log(` Column style: ${column.style.cssText}`); 79 | 80 | // Both should contain the expected width 81 | expect(header.style.cssText).toContain(`min-width: ${expectedWidths[index]}`); 82 | expect(column.style.cssText).toContain(`min-width: ${expectedWidths[index]}`); 83 | 84 | // Both should have flex-grow and flex-shrink set to 0 85 | expect(header.style.flexGrow).toBe('0'); 86 | expect(header.style.flexShrink).toBe('0'); 87 | expect(column.style.flexGrow).toBe('0'); 88 | expect(column.style.flexShrink).toBe('0'); 89 | }); 90 | }); 91 | 92 | test('column widths should remain aligned after resizing', () => { 93 | const setup = { 94 | columnHeaderNames: [ 95 | { columnName: 'id', columnNameDisplay: 'ID', columnWidth: '60px' }, 96 | { columnName: 'name', columnNameDisplay: 'Name', columnWidth: '200px' }, 97 | { columnName: 'department', columnNameDisplay: 'Department' }, 98 | { columnName: 'salary', columnNameDisplay: 'Salary' } 99 | ] 100 | }; 101 | 102 | const grid = new OpenGrid('test-grid', testData, 400, setup); 103 | 104 | // Simulate column resize by updating header data 105 | grid.headerData[1].width = 'min-width: 300px'; 106 | grid.updateColumnWidths(); 107 | 108 | const headerItems = container.querySelectorAll('.opengridjs-grid-header-item'); 109 | const firstRow = container.querySelector('.opengridjs-grid-row'); 110 | const columnItems = firstRow.querySelectorAll('.opengridjs-grid-column-item'); 111 | 112 | // Check that the second column (name) has been updated 113 | const nameHeader = headerItems[1]; 114 | const nameColumn = columnItems[1]; 115 | 116 | console.log('After resize:'); 117 | console.log(` Header style: ${nameHeader.style.cssText}`); 118 | console.log(` Column style: ${nameColumn.style.cssText}`); 119 | 120 | // Both should have the new width 121 | expect(nameColumn.style.cssText).toContain('min-width: 300px'); 122 | }); 123 | 124 | test('getColumnStyle should return consistent styles', () => { 125 | const grid = new OpenGrid('test-grid', testData, 400); 126 | 127 | // Test percentage width 128 | const percentHeader = { width: 'width:25%' }; 129 | const percentStyle = grid.getColumnStyle(percentHeader); 130 | expect(percentStyle).toBe('width:25%; flex-grow: 0; flex-shrink: 0; box-sizing: border-box;'); 131 | 132 | // Test pixel width 133 | const pixelHeader = { width: 'min-width: 200px' }; 134 | const pixelStyle = grid.getColumnStyle(pixelHeader); 135 | expect(pixelStyle).toBe('min-width: 200px; flex-grow: 0; flex-shrink: 0; box-sizing: border-box;'); 136 | 137 | // Test width property 138 | const widthHeader = { width: 'width: 150px' }; 139 | const widthStyle = grid.getColumnStyle(widthHeader); 140 | expect(widthStyle).toBe('width: 150px; flex-grow: 0; flex-shrink: 0; box-sizing: border-box;'); 141 | }); 142 | 143 | test('column alignment should work with filters applied', () => { 144 | const setup = { 145 | columnHeaderNames: [ 146 | { columnName: 'id', columnNameDisplay: 'ID', columnWidth: '60px' }, 147 | { columnName: 'name', columnNameDisplay: 'Name', columnWidth: '200px' }, 148 | { columnName: 'department', columnNameDisplay: 'Department', columnWidth: '150px' }, 149 | { columnName: 'salary', columnNameDisplay: 'Salary', columnWidth: '120px' } 150 | ] 151 | }; 152 | 153 | const grid = new OpenGrid('test-grid', testData, 400, setup); 154 | 155 | // Apply filter 156 | grid.columnFilters = { department: new Set(['Engineering']) }; 157 | grid.applyAllFilters(); 158 | 159 | // Check that filtered rows still have proper column alignment 160 | const headerItems = container.querySelectorAll('.opengridjs-grid-header-item'); 161 | const visibleRows = container.querySelectorAll('.opengridjs-grid-row'); 162 | 163 | visibleRows.forEach(row => { 164 | const columnItems = row.querySelectorAll('.opengridjs-grid-column-item'); 165 | 166 | headerItems.forEach((header, index) => { 167 | const column = columnItems[index]; 168 | if (column) { 169 | // Check that styles are still consistent 170 | expect(header.style.cssText).toContain('min-width:'); 171 | expect(column.style.cssText).toContain('min-width:'); 172 | expect(header.style.flexGrow).toBe(column.style.flexGrow); 173 | expect(header.style.flexShrink).toBe(column.style.flexShrink); 174 | } 175 | }); 176 | }); 177 | }); 178 | }); -------------------------------------------------------------------------------- /tests/constructor.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test Suite 2.1: Constructor & Initialization 3 | * Phase 2 - Core Functionality Tests 4 | * 5 | * Coverage targets: 6 | * - Lines 7-60 (constructor) 7 | * - Lines 72-78 (GUID generation) 8 | * - Lines 80-101 (initGrid, processData) 9 | */ 10 | 11 | const OpenGrid = require('../src/opengrid.js'); 12 | 13 | describe('OpenGrid Constructor & Initialization', () => { 14 | let container; 15 | 16 | beforeEach(() => { 17 | container = document.createElement('div'); 18 | container.className = 'test-grid'; 19 | document.body.appendChild(container); 20 | }); 21 | 22 | afterEach(() => { 23 | if (document.body.contains(container)) { 24 | document.body.removeChild(container); 25 | } 26 | }); 27 | 28 | describe('Constructor with Sync Data', () => { 29 | test('should initialize with sync array data', () => { 30 | const data = [ 31 | { id: 1, name: 'Alice', age: 30 }, 32 | { id: 2, name: 'Bob', age: 25 } 33 | ]; 34 | 35 | const grid = new OpenGrid('test-grid', data, 400); 36 | 37 | expect(grid.className).toBe('test-grid'); 38 | expect(grid.gridRowPxSize).toBe(35); 39 | expect(grid.gridRowPxVisibleArea).toBe(400); 40 | expect(grid.originalData).toEqual(data); 41 | expect(grid.gridData.length).toBe(2); 42 | }); 43 | 44 | test('should initialize with empty array', () => { 45 | const grid = new OpenGrid('test-grid', [], 400); 46 | 47 | expect(grid.gridData).toEqual([]); 48 | expect(grid.originalData).toEqual([]); 49 | expect(grid.gridColumnNames).toBeUndefined(); 50 | }); 51 | 52 | test('should initialize with single row', () => { 53 | const data = [{ id: 1, name: 'Single' }]; 54 | 55 | const grid = new OpenGrid('test-grid', data, 400); 56 | 57 | expect(grid.gridData.length).toBe(1); 58 | expect(grid.gridData[0].data).toEqual(data[0]); 59 | }); 60 | 61 | test('should auto-detect column names from data', () => { 62 | const data = [ 63 | { id: 1, name: 'Alice', email: 'alice@example.com', active: true } 64 | ]; 65 | 66 | const grid = new OpenGrid('test-grid', data, 400); 67 | 68 | expect(grid.gridColumnNames).toHaveLength(4); 69 | expect(grid.gridColumnNames[0]).toEqual({ headerName: 'id', field: 'id' }); 70 | expect(grid.gridColumnNames[1]).toEqual({ headerName: 'name', field: 'name' }); 71 | expect(grid.gridColumnNames[2]).toEqual({ headerName: 'email', field: 'email' }); 72 | expect(grid.gridColumnNames[3]).toEqual({ headerName: 'active', field: 'active' }); 73 | }); 74 | }); 75 | 76 | describe('Constructor with Async Data', () => { 77 | test('should initialize with async Promise data', async () => { 78 | const asyncData = () => Promise.resolve([ 79 | { id: 1, name: 'Async User 1' }, 80 | { id: 2, name: 'Async User 2' } 81 | ]); 82 | 83 | const grid = new OpenGrid('test-grid', asyncData, 400); 84 | 85 | // Wait for async initialization 86 | await new Promise(resolve => setTimeout(resolve, 50)); 87 | 88 | expect(grid.originalData).toHaveLength(2); 89 | expect(grid.gridData).toHaveLength(2); 90 | expect(grid.gridData[0].data.name).toBe('Async User 1'); 91 | }); 92 | 93 | test('should handle async data with auto-resize', async () => { 94 | const asyncData = () => Promise.resolve([ 95 | { id: 1, col1: 'A', col2: 'B' } 96 | ]); 97 | 98 | const grid = new OpenGrid('test-grid', asyncData, 400); 99 | 100 | // Wait for async init + setTimeout for auto-resize 101 | await new Promise(resolve => setTimeout(resolve, 50)); 102 | 103 | expect(grid.gridData).toHaveLength(1); 104 | }); 105 | }); 106 | 107 | describe('Constructor with Custom Setup', () => { 108 | test('should initialize with custom context menu options', () => { 109 | const data = [{ id: 1, name: 'Test' }]; 110 | const setup = { 111 | contextMenuOptions: [ 112 | { actionName: 'Custom Action', actionFunctionName: 'customFunc', className: 'custom' } 113 | ] 114 | }; 115 | 116 | const grid = new OpenGrid('test-grid', data, 400, setup); 117 | 118 | expect(grid.contextMenuItems).toEqual(setup.contextMenuOptions); 119 | }); 120 | 121 | test('should initialize with custom context menu title', () => { 122 | const data = [{ id: 1, name: 'Test' }]; 123 | const setup = { 124 | contextMenuTitle: 'Custom Menu Title' 125 | }; 126 | 127 | const grid = new OpenGrid('test-grid', data, 400, setup); 128 | 129 | expect(grid.contextMenuTitle).toBe('Custom Menu Title'); 130 | }); 131 | 132 | test('should initialize with custom column header names', () => { 133 | const data = [{ id: 1, firstName: 'John', lastName: 'Doe' }]; 134 | const setup = { 135 | columnHeaderNames: [ 136 | { columnName: 'id', columnNameDisplay: 'ID', columnWidth: '50px' }, 137 | { columnName: 'firstName', columnNameDisplay: 'First Name', columnWidth: '150px' }, 138 | { columnName: 'lastName', columnNameDisplay: 'Last Name', columnWidth: '150px' } 139 | ] 140 | }; 141 | 142 | const grid = new OpenGrid('test-grid', data, 400, setup); 143 | 144 | expect(grid.headerData).toHaveLength(3); 145 | expect(grid.headerData[0].headerName).toBe('ID'); 146 | expect(grid.headerData[1].headerName).toBe('First Name'); 147 | }); 148 | 149 | test('should initialize with loadMoreDataFunction', () => { 150 | const data = [{ id: 1, name: 'Test' }]; 151 | const loadMoreFunc = jest.fn(); 152 | 153 | const grid = new OpenGrid('test-grid', data, 400, {}, loadMoreFunc); 154 | 155 | expect(grid.loadMoreDataFunction).toBe(loadMoreFunc); 156 | expect(grid.canLoadMoreData).toBe(true); 157 | expect(grid.isLoadingMoreData).toBe(false); 158 | }); 159 | }); 160 | 161 | describe('GUID Generation', () => { 162 | test('should generate GUID for rows with missing id', () => { 163 | const data = [ 164 | { name: 'No ID 1' }, 165 | { name: 'No ID 2' } 166 | ]; 167 | 168 | const grid = new OpenGrid('test-grid', data, 400); 169 | 170 | expect(grid.gridData[0].data.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); 171 | expect(grid.gridData[1].data.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); 172 | expect(grid.gridData[0].data.id).not.toBe(grid.gridData[1].data.id); 173 | }); 174 | 175 | test('should generate GUID for rows with null id', () => { 176 | const data = [{ id: null, name: 'Null ID' }]; 177 | 178 | const grid = new OpenGrid('test-grid', data, 400); 179 | 180 | expect(grid.gridData[0].data.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); 181 | }); 182 | 183 | test('should generate GUID for rows with empty string id', () => { 184 | const data = [{ id: '', name: 'Empty ID' }]; 185 | 186 | const grid = new OpenGrid('test-grid', data, 400); 187 | 188 | expect(grid.gridData[0].data.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); 189 | }); 190 | 191 | test('should preserve existing valid ids', () => { 192 | const data = [ 193 | { id: 'custom-id-1', name: 'Custom 1' }, 194 | { id: 999, name: 'Custom 2' } 195 | ]; 196 | 197 | const grid = new OpenGrid('test-grid', data, 400); 198 | 199 | expect(grid.gridData[0].data.id).toBe('custom-id-1'); 200 | expect(grid.gridData[1].data.id).toBe(999); 201 | }); 202 | }); 203 | 204 | describe('Grid Instance Binding', () => { 205 | test('should bind grid instance to root element', () => { 206 | const data = [{ id: 1, name: 'Test' }]; 207 | 208 | const grid = new OpenGrid('test-grid', data, 400); 209 | 210 | expect(container.gridInstance).toBe(grid); 211 | expect(container.classList.contains('opengridjs-grid')).toBe(true); 212 | }); 213 | 214 | test('should create grid structure with proper class names', () => { 215 | const data = [{ id: 1, name: 'Test' }]; 216 | 217 | const grid = new OpenGrid('test-grid', data, 400); 218 | 219 | expect(container.classList.contains('opengridjs-grid-container')).toBe(true); 220 | expect(container.querySelector('.opengridjs-grid-additional')).toBeTruthy(); 221 | expect(container.querySelector('.opengridjs-grid-header')).toBeTruthy(); 222 | expect(container.querySelector('.opengridjs-grid-rows-container')).toBeTruthy(); 223 | }); 224 | }); 225 | }); 226 | -------------------------------------------------------------------------------- /tests/data-processing.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test Suite 2.2: Data Processing & Formatters 3 | * Phase 2 - Core Functionality Tests 4 | * 5 | * Coverage targets: 6 | * - Lines 88-101 (processData) 7 | * - Lines 525-553 (addRow with formatters) 8 | * - Nested property access (dot notation) 9 | * - Null/undefined/empty value handling 10 | */ 11 | 12 | const OpenGrid = require('../src/opengrid.js'); 13 | 14 | describe('OpenGrid Data Processing & Formatters', () => { 15 | let container; 16 | 17 | beforeEach(() => { 18 | container = document.createElement('div'); 19 | container.className = 'test-grid'; 20 | document.body.appendChild(container); 21 | }); 22 | 23 | afterEach(() => { 24 | if (document.body.contains(container)) { 25 | document.body.removeChild(container); 26 | } 27 | }); 28 | 29 | describe('processData Method', () => { 30 | test('should process data array and add metadata', () => { 31 | const data = [ 32 | { id: 1, name: 'Alice' }, 33 | { id: 2, name: 'Bob' } 34 | ]; 35 | 36 | const grid = new OpenGrid('test-grid', data, 400); 37 | 38 | expect(grid.gridData).toHaveLength(2); 39 | expect(grid.gridData[0].data).toEqual({ id: 1, name: 'Alice' }); 40 | expect(grid.gridData[0].position).toBe(0); // 0 * 35 41 | expect(grid.gridData[0]).toHaveProperty('isRendered'); 42 | expect(grid.gridData[1].position).toBe(35); // 1 * 35 43 | }); 44 | 45 | test('should calculate correct positions based on gridRowPxSize', () => { 46 | const data = Array.from({ length: 5 }, (_, i) => ({ id: i })); 47 | 48 | const grid = new OpenGrid('test-grid', data, 400); 49 | 50 | // Positions are recalculated after sorting, so check that they follow the pattern 51 | const positions = grid.gridData.map(item => item.position); 52 | expect(positions[0]).toBe(0); // 0 * 35 53 | expect(positions[1]).toBe(35); // 1 * 35 54 | expect(positions[2]).toBe(70); // 2 * 35 55 | expect(positions[3]).toBe(105); // 3 * 35 56 | expect(positions[4]).toBe(140); // 4 * 35 57 | }); 58 | 59 | test('should initialize items with metadata structure', () => { 60 | const data = Array.from({ length: 3 }, (_, i) => ({ id: i, name: `Item ${i}` })); 61 | 62 | const grid = new OpenGrid('test-grid', data, 400); 63 | 64 | grid.gridData.forEach((item, index) => { 65 | expect(item).toHaveProperty('data'); 66 | expect(item).toHaveProperty('position'); 67 | expect(item).toHaveProperty('isRendered'); 68 | expect(item.data.id).toBe(index); 69 | }); 70 | }); 71 | }); 72 | 73 | describe('Formatter Functions', () => { 74 | test('should apply formatter to cell values', () => { 75 | const data = [ 76 | { id: 1, price: 1234.56 } 77 | ]; 78 | const setup = { 79 | columnHeaderNames: [ 80 | { columnName: 'id' }, 81 | { 82 | columnName: 'price', 83 | format: (value) => `$${value.toFixed(2)}` 84 | } 85 | ] 86 | }; 87 | 88 | const grid = new OpenGrid('test-grid', data, 400, setup); 89 | 90 | const priceCell = container.querySelector('.opengridjs-grid-row .opengridjs-grid-column-item:nth-child(2)'); 91 | expect(priceCell.textContent).toBe('$1234.56'); 92 | }); 93 | 94 | test('should handle formatters that return HTML strings', () => { 95 | const data = [ 96 | { id: 1, status: 'active' } 97 | ]; 98 | const setup = { 99 | columnHeaderNames: [ 100 | { columnName: 'id' }, 101 | { 102 | columnName: 'status', 103 | format: (value) => value === 'active' ? 'Active' : 'Inactive' 104 | } 105 | ] 106 | }; 107 | 108 | const grid = new OpenGrid('test-grid', data, 400, setup); 109 | 110 | const statusCell = container.querySelector('.opengridjs-grid-row .opengridjs-grid-column-item:nth-child(2)'); 111 | expect(statusCell.innerHTML).toBe('Active'); 112 | }); 113 | 114 | test('should work without formatters (default behavior)', () => { 115 | const data = [ 116 | { id: 1, name: 'Test' } 117 | ]; 118 | 119 | const grid = new OpenGrid('test-grid', data, 400); 120 | 121 | const nameCell = container.querySelector('.opengridjs-grid-row .opengridjs-grid-column-item:nth-child(2)'); 122 | expect(nameCell.textContent).toBe('Test'); 123 | }); 124 | }); 125 | 126 | describe('Nested Property Access (Dot Notation)', () => { 127 | test('should access nested properties with dot notation', () => { 128 | const data = [ 129 | { 130 | id: 1, 131 | user: { 132 | name: 'John Doe', 133 | address: { 134 | city: 'New York' 135 | } 136 | } 137 | } 138 | ]; 139 | const setup = { 140 | columnHeaderNames: [ 141 | { columnName: 'id' }, 142 | { columnName: 'user.name', columnNameDisplay: 'User Name' }, 143 | { columnName: 'user.address.city', columnNameDisplay: 'City' } 144 | ] 145 | }; 146 | 147 | const grid = new OpenGrid('test-grid', data, 400, setup); 148 | 149 | const cells = container.querySelectorAll('.opengridjs-grid-row .opengridjs-grid-column-item'); 150 | expect(cells[1].textContent).toBe('John Doe'); 151 | expect(cells[2].textContent).toBe('New York'); 152 | }); 153 | 154 | test('should handle deeply nested properties', () => { 155 | const data = [ 156 | { 157 | id: 1, 158 | level1: { 159 | level2: { 160 | level3: { 161 | value: 'Deep Value' 162 | } 163 | } 164 | } 165 | } 166 | ]; 167 | const setup = { 168 | columnHeaderNames: [ 169 | { columnName: 'id' }, 170 | { columnName: 'level1.level2.level3.value', columnNameDisplay: 'Deep' } 171 | ] 172 | }; 173 | 174 | const grid = new OpenGrid('test-grid', data, 400, setup); 175 | 176 | const cells = container.querySelectorAll('.opengridjs-grid-row .opengridjs-grid-column-item'); 177 | expect(cells[1].textContent).toBe('Deep Value'); 178 | }); 179 | }); 180 | 181 | describe('Null and Undefined Value Handling', () => { 182 | test('should render null values as  ', () => { 183 | const data = [ 184 | { id: 1, name: null } 185 | ]; 186 | 187 | const grid = new OpenGrid('test-grid', data, 400); 188 | 189 | const nameCell = container.querySelector('.opengridjs-grid-row .opengridjs-grid-column-item:nth-child(2)'); 190 | expect(nameCell.innerHTML).toBe(' '); 191 | }); 192 | 193 | test('should render undefined values as  ', () => { 194 | const data = [ 195 | { id: 1, name: undefined } 196 | ]; 197 | 198 | const grid = new OpenGrid('test-grid', data, 400); 199 | 200 | const nameCell = container.querySelector('.opengridjs-grid-row .opengridjs-grid-column-item:nth-child(2)'); 201 | expect(nameCell.innerHTML).toBe(' '); 202 | }); 203 | 204 | test('should render empty string values as  ', () => { 205 | const data = [ 206 | { id: 1, name: '' } 207 | ]; 208 | const setup = { 209 | columnHeaderNames: [ 210 | { columnName: 'id' }, 211 | { columnName: 'name' } 212 | ] 213 | }; 214 | 215 | const grid = new OpenGrid('test-grid', data, 400, setup); 216 | 217 | const nameCell = container.querySelector('.opengridjs-grid-row .opengridjs-grid-column-item:nth-child(2)'); 218 | // Empty string won't match the nullish coalescing, so it will be the value itself 219 | // But based on the code line 541: found = rowItem.data[header.data] ?? ' '; 220 | // Empty string is truthy for ??, so it passes through 221 | expect(nameCell.textContent).toBe(''); 222 | }); 223 | }); 224 | 225 | describe('Different Data Types', () => { 226 | test('should handle boolean values', () => { 227 | const data = [ 228 | { id: 1, active: true, verified: false } 229 | ]; 230 | 231 | const grid = new OpenGrid('test-grid', data, 400); 232 | 233 | const cells = container.querySelectorAll('.opengridjs-grid-row .opengridjs-grid-column-item'); 234 | expect(cells[1].textContent).toBe('true'); 235 | expect(cells[2].textContent).toBe('false'); 236 | }); 237 | 238 | test('should handle number values (including zero)', () => { 239 | const data = [ 240 | { id: 0, count: 42, price: 0.99 } 241 | ]; 242 | 243 | const grid = new OpenGrid('test-grid', data, 400); 244 | 245 | const cells = container.querySelectorAll('.opengridjs-grid-row .opengridjs-grid-column-item'); 246 | expect(cells[0].textContent).toBe('0'); 247 | expect(cells[1].textContent).toBe('42'); 248 | expect(cells[2].textContent).toBe('0.99'); 249 | }); 250 | 251 | test('should stringify object values', () => { 252 | const data = [ 253 | { id: 1, metadata: { key: 'value' } } 254 | ]; 255 | 256 | const grid = new OpenGrid('test-grid', data, 400); 257 | 258 | const metadataCell = container.querySelector('.opengridjs-grid-row .opengridjs-grid-column-item:nth-child(2)'); 259 | expect(metadataCell.textContent).toBe('[object Object]'); 260 | }); 261 | 262 | test('should stringify array values', () => { 263 | const data = [ 264 | { id: 1, tags: ['tag1', 'tag2', 'tag3'] } 265 | ]; 266 | 267 | const grid = new OpenGrid('test-grid', data, 400); 268 | 269 | const tagsCell = container.querySelector('.opengridjs-grid-row .opengridjs-grid-column-item:nth-child(2)'); 270 | expect(tagsCell.textContent).toBe('tag1,tag2,tag3'); 271 | }); 272 | }); 273 | }); 274 | -------------------------------------------------------------------------------- /tests/column-reorder.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test Suite 3.1: Column Reordering (Drag & Drop) 3 | * Phase 3 - Advanced Features 4 | * 5 | * Coverage targets: 6 | * - Lines 177-239 (drag and drop handlers) 7 | */ 8 | 9 | const OpenGrid = require('../src/opengrid.js'); 10 | 11 | describe('OpenGrid Column Reordering', () => { 12 | let container; 13 | 14 | beforeEach(() => { 15 | container = document.createElement('div'); 16 | container.className = 'test-grid'; 17 | document.body.appendChild(container); 18 | }); 19 | 20 | afterEach(() => { 21 | if (document.body.contains(container)) { 22 | document.body.removeChild(container); 23 | } 24 | }); 25 | 26 | describe('Drag Start', () => { 27 | test('should set draggedColumn on dragstart', () => { 28 | const data = [{ id: 1, name: 'Test', age: 30 }]; 29 | const grid = new OpenGrid('test-grid', data, 400); 30 | 31 | const headerItem = container.querySelector('.opengridjs-grid-header-item'); 32 | const dragStartEvent = new Event('dragstart', { bubbles: true }); 33 | dragStartEvent.dataTransfer = { 34 | effectAllowed: '', 35 | setData: jest.fn() 36 | }; 37 | 38 | headerItem.dispatchEvent(dragStartEvent); 39 | 40 | expect(grid.draggedColumn).toBe(headerItem); 41 | expect(headerItem.classList.contains('opengridjs-dragging')).toBe(true); 42 | }); 43 | 44 | test('should set dataTransfer properties on dragstart', () => { 45 | const data = [{ id: 1, name: 'Test' }]; 46 | const grid = new OpenGrid('test-grid', data, 400); 47 | 48 | const headerItem = container.querySelector('.opengridjs-grid-header-item'); 49 | const setDataMock = jest.fn(); 50 | const dragStartEvent = new Event('dragstart', { bubbles: true }); 51 | dragStartEvent.dataTransfer = { 52 | effectAllowed: '', 53 | setData: setDataMock 54 | }; 55 | 56 | headerItem.dispatchEvent(dragStartEvent); 57 | 58 | expect(dragStartEvent.dataTransfer.effectAllowed).toBe('move'); 59 | expect(setDataMock).toHaveBeenCalledWith('text/html', headerItem.outerHTML); 60 | }); 61 | }); 62 | 63 | describe('Drag Over', () => { 64 | test('should prevent default and set dropEffect', () => { 65 | const data = [{ id: 1, name: 'Test', age: 30 }]; 66 | const grid = new OpenGrid('test-grid', data, 400); 67 | 68 | const headerItem = container.querySelector('.opengridjs-grid-header-item'); 69 | const dragOverEvent = new Event('dragover', { bubbles: true }); 70 | dragOverEvent.dataTransfer = { dropEffect: '' }; 71 | 72 | const preventDefaultSpy = jest.spyOn(dragOverEvent, 'preventDefault'); 73 | headerItem.dispatchEvent(dragOverEvent); 74 | 75 | expect(preventDefaultSpy).toHaveBeenCalled(); 76 | expect(dragOverEvent.dataTransfer.dropEffect).toBe('move'); 77 | }); 78 | }); 79 | 80 | describe('Drag Enter', () => { 81 | test('should add drag-over class on dragenter', () => { 82 | const data = [{ id: 1, name: 'Test', age: 30, status: 'active' }]; 83 | const grid = new OpenGrid('test-grid', data, 400); 84 | 85 | const headers = container.querySelectorAll('.opengridjs-grid-header-item'); 86 | const firstHeader = headers[0]; 87 | const secondHeader = headers[1]; 88 | 89 | // Start drag on first header 90 | const dragStartEvent = new Event('dragstart', { bubbles: true }); 91 | dragStartEvent.dataTransfer = { 92 | effectAllowed: '', 93 | setData: jest.fn() 94 | }; 95 | firstHeader.dispatchEvent(dragStartEvent); 96 | 97 | // Drag enter second header 98 | const dragEnterEvent = new Event('dragenter', { bubbles: true }); 99 | Object.defineProperty(dragEnterEvent, 'target', { value: secondHeader, writable: false }); 100 | 101 | const preventDefaultSpy = jest.spyOn(dragEnterEvent, 'preventDefault'); 102 | secondHeader.dispatchEvent(dragEnterEvent); 103 | 104 | expect(preventDefaultSpy).toHaveBeenCalled(); 105 | expect(secondHeader.classList.contains('opengridjs-drag-over')).toBe(true); 106 | }); 107 | }); 108 | 109 | describe('Drag Leave', () => { 110 | test('should remove drag-over class on dragleave', () => { 111 | const data = [{ id: 1, name: 'Test', age: 30 }]; 112 | const grid = new OpenGrid('test-grid', data, 400); 113 | 114 | const headerItem = container.querySelector('.opengridjs-grid-header-item'); 115 | headerItem.classList.add('opengridjs-drag-over'); 116 | 117 | const dragLeaveEvent = new Event('dragleave', { bubbles: true }); 118 | Object.defineProperty(dragLeaveEvent, 'relatedTarget', { value: null, writable: false }); 119 | headerItem.dispatchEvent(dragLeaveEvent); 120 | 121 | expect(headerItem.classList.contains('opengridjs-drag-over')).toBe(false); 122 | }); 123 | }); 124 | 125 | describe('Drop', () => { 126 | test('should reorder columns on drop', () => { 127 | const data = [{ id: 1, col1: 'A', col2: 'B', col3: 'C' }]; 128 | const grid = new OpenGrid('test-grid', data, 400); 129 | 130 | const headers = container.querySelectorAll('.opengridjs-grid-header-item'); 131 | const firstHeader = headers[0]; 132 | const lastHeader = headers[headers.length - 1]; 133 | 134 | // Get initial order 135 | const initialFirstHeader = grid.headerData[0].data; 136 | const initialLastHeader = grid.headerData[grid.headerData.length - 1].data; 137 | 138 | // Start drag on first header 139 | const dragStartEvent = new Event('dragstart', { bubbles: true }); 140 | dragStartEvent.dataTransfer = { 141 | effectAllowed: '', 142 | setData: jest.fn() 143 | }; 144 | firstHeader.dispatchEvent(dragStartEvent); 145 | 146 | // Drop on last header 147 | const dropEvent = new Event('drop', { bubbles: true }); 148 | const preventDefaultSpy = jest.spyOn(dropEvent, 'preventDefault'); 149 | lastHeader.dispatchEvent(dropEvent); 150 | 151 | expect(preventDefaultSpy).toHaveBeenCalled(); 152 | 153 | // Verify headers were reordered 154 | expect(grid.headerData[0].data).not.toBe(initialFirstHeader); 155 | }); 156 | 157 | test('should handle invalid drop indices gracefully', () => { 158 | const data = [{ id: 1, name: 'Test' }]; 159 | const grid = new OpenGrid('test-grid', data, 400); 160 | 161 | const headerItem = container.querySelector('.opengridjs-grid-header-item'); 162 | 163 | // Remove data-order to create invalid index 164 | headerItem.removeAttribute('data-order'); 165 | 166 | const dragStartEvent = new Event('dragstart', { bubbles: true }); 167 | dragStartEvent.dataTransfer = { 168 | effectAllowed: '', 169 | setData: jest.fn() 170 | }; 171 | headerItem.dispatchEvent(dragStartEvent); 172 | 173 | const dropEvent = new Event('drop', { bubbles: true }); 174 | jest.spyOn(dropEvent, 'preventDefault'); 175 | 176 | // Should not throw error 177 | expect(() => headerItem.dispatchEvent(dropEvent)).not.toThrow(); 178 | }); 179 | 180 | test('should not reorder when dropping on same column', () => { 181 | const data = [{ id: 1, name: 'Test', age: 30 }]; 182 | const grid = new OpenGrid('test-grid', data, 400); 183 | 184 | const headerItem = container.querySelector('.opengridjs-grid-header-item'); 185 | const initialHeaderData = [...grid.headerData]; 186 | 187 | // Start drag 188 | const dragStartEvent = new Event('dragstart', { bubbles: true }); 189 | dragStartEvent.dataTransfer = { 190 | effectAllowed: '', 191 | setData: jest.fn() 192 | }; 193 | headerItem.dispatchEvent(dragStartEvent); 194 | 195 | // Drop on same element 196 | const dropEvent = new Event('drop', { bubbles: true }); 197 | headerItem.dispatchEvent(dropEvent); 198 | 199 | // Header data should remain unchanged 200 | expect(grid.headerData).toEqual(initialHeaderData); 201 | }); 202 | }); 203 | 204 | describe('Drag End', () => { 205 | test('should cleanup drag state on dragend', () => { 206 | const data = [{ id: 1, name: 'Test', age: 30 }]; 207 | const grid = new OpenGrid('test-grid', data, 400); 208 | 209 | const headers = container.querySelectorAll('.opengridjs-grid-header-item'); 210 | headers.forEach(h => { 211 | h.classList.add('opengridjs-dragging'); 212 | h.classList.add('opengridjs-drag-over'); 213 | }); 214 | 215 | const dragEndEvent = new Event('dragend', { bubbles: true }); 216 | headers[0].dispatchEvent(dragEndEvent); 217 | 218 | // All drag classes should be removed 219 | headers.forEach(h => { 220 | expect(h.classList.contains('opengridjs-dragging')).toBe(false); 221 | expect(h.classList.contains('opengridjs-drag-over')).toBe(false); 222 | }); 223 | 224 | expect(grid.draggedColumn).toBeNull(); 225 | }); 226 | }); 227 | 228 | describe('Full Drag and Drop Sequence', () => { 229 | test('should successfully reorder columns through full drag-drop cycle', () => { 230 | const data = [{ id: 1, first: 'A', second: 'B', third: 'C' }]; 231 | const grid = new OpenGrid('test-grid', data, 400); 232 | 233 | const headers = container.querySelectorAll('.opengridjs-grid-header-item'); 234 | const firstHeader = headers[0]; 235 | const thirdHeader = headers[2]; 236 | 237 | // Record initial state 238 | const initialOrder = grid.headerData.map(h => h.data); 239 | 240 | // 1. Drag start 241 | const dragStartEvent = new Event('dragstart', { bubbles: true }); 242 | dragStartEvent.dataTransfer = { 243 | effectAllowed: '', 244 | setData: jest.fn() 245 | }; 246 | firstHeader.dispatchEvent(dragStartEvent); 247 | expect(grid.draggedColumn).toBe(firstHeader); 248 | 249 | // 2. Drag over target 250 | const dragOverEvent = new Event('dragover', { bubbles: true }); 251 | dragOverEvent.dataTransfer = { dropEffect: '' }; 252 | jest.spyOn(dragOverEvent, 'preventDefault'); 253 | thirdHeader.dispatchEvent(dragOverEvent); 254 | 255 | // 3. Drop 256 | const dropEvent = new Event('drop', { bubbles: true }); 257 | jest.spyOn(dropEvent, 'preventDefault'); 258 | thirdHeader.dispatchEvent(dropEvent); 259 | 260 | // 4. Drag end 261 | const dragEndEvent = new Event('dragend', { bubbles: true }); 262 | firstHeader.dispatchEvent(dragEndEvent); 263 | 264 | // Verify order changed 265 | const finalOrder = grid.headerData.map(h => h.data); 266 | expect(finalOrder).not.toEqual(initialOrder); 267 | expect(grid.draggedColumn).toBeNull(); 268 | }); 269 | }); 270 | }); 271 | -------------------------------------------------------------------------------- /tests/column-resize.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test Suite 3.2: Column Resizing 3 | * Phase 3 - Advanced Features 4 | * 5 | * Coverage targets: 6 | * - Lines 241-306 (resize event handlers) 7 | * - Lines 308-481 (width calculation methods) 8 | */ 9 | 10 | const OpenGrid = require('../src/opengrid.js'); 11 | 12 | describe('OpenGrid Column Resizing', () => { 13 | let container; 14 | 15 | beforeEach(() => { 16 | container = document.createElement('div'); 17 | container.className = 'test-grid'; 18 | document.body.appendChild(container); 19 | }); 20 | 21 | afterEach(() => { 22 | if (document.body.contains(container)) { 23 | document.body.removeChild(container); 24 | } 25 | }); 26 | 27 | describe('Resize Handle Events', () => { 28 | test('should start resize on mousedown', () => { 29 | const data = [{ id: 1, name: 'Test', age: 30 }]; 30 | const grid = new OpenGrid('test-grid', data, 400); 31 | 32 | const resizeHandle = container.querySelector('.opengridjs-resize-handle'); 33 | const headerItem = resizeHandle.closest('.opengridjs-grid-header-item'); 34 | 35 | const mouseDownEvent = new MouseEvent('mousedown', { 36 | bubbles: true, 37 | clientX: 100 38 | }); 39 | 40 | resizeHandle.dispatchEvent(mouseDownEvent); 41 | 42 | // Header should get resizing class 43 | expect(headerItem.classList.contains('opengridjs-resizing')).toBe(true); 44 | // Draggable should be disabled during resize 45 | expect(headerItem.getAttribute('draggable')).toBe('false'); 46 | }); 47 | 48 | test('should update width on mousemove during resize', (done) => { 49 | const data = [{ id: 1, name: 'Test', age: 30 }]; 50 | const grid = new OpenGrid('test-grid', data, 400); 51 | 52 | const resizeHandle = container.querySelector('.opengridjs-resize-handle'); 53 | const headerItem = resizeHandle.closest('.opengridjs-grid-header-item'); 54 | const initialWidth = headerItem.offsetWidth; 55 | 56 | // Start resize 57 | const mouseDownEvent = new MouseEvent('mousedown', { 58 | bubbles: true, 59 | clientX: 100 60 | }); 61 | resizeHandle.dispatchEvent(mouseDownEvent); 62 | 63 | // Simulate mouse move 64 | setTimeout(() => { 65 | const mouseMoveEvent = new MouseEvent('mousemove', { 66 | bubbles: true, 67 | clientX: 150 // 50px increase 68 | }); 69 | document.dispatchEvent(mouseMoveEvent); 70 | 71 | // Width should be updated (with minimum constraint) 72 | const headerIndex = parseInt(headerItem.getAttribute('data-order')); 73 | expect(grid.headerData[headerIndex].width).toContain('min-width:'); 74 | 75 | done(); 76 | }, 10); 77 | }); 78 | 79 | test('should end resize on mouseup', (done) => { 80 | const data = [{ id: 1, name: 'Test' }]; 81 | const grid = new OpenGrid('test-grid', data, 400); 82 | 83 | const resizeHandle = container.querySelector('.opengridjs-resize-handle'); 84 | const headerItem = resizeHandle.closest('.opengridjs-grid-header-item'); 85 | 86 | // Start resize 87 | resizeHandle.dispatchEvent(new MouseEvent('mousedown', { 88 | bubbles: true, 89 | clientX: 100 90 | })); 91 | 92 | expect(headerItem.classList.contains('opengridjs-resizing')).toBe(true); 93 | 94 | // End resize 95 | setTimeout(() => { 96 | document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); 97 | 98 | setTimeout(() => { 99 | expect(headerItem.classList.contains('opengridjs-resizing')).toBe(false); 100 | expect(headerItem.getAttribute('draggable')).toBe('true'); 101 | done(); 102 | }, 20); 103 | }, 10); 104 | }); 105 | 106 | test('should enforce minimum width constraint during resize', (done) => { 107 | const data = [{ id: 1, name: 'Test' }]; 108 | const grid = new OpenGrid('test-grid', data, 400); 109 | 110 | const resizeHandle = container.querySelector('.opengridjs-resize-handle'); 111 | const headerItem = resizeHandle.closest('.opengridjs-grid-header-item'); 112 | 113 | // Start resize 114 | resizeHandle.dispatchEvent(new MouseEvent('mousedown', { 115 | bubbles: true, 116 | clientX: 200 117 | })); 118 | 119 | // Try to resize to very small width 120 | setTimeout(() => { 121 | document.dispatchEvent(new MouseEvent('mousemove', { 122 | bubbles: true, 123 | clientX: 50 // Would make it negative 124 | })); 125 | 126 | const headerIndex = parseInt(headerItem.getAttribute('data-order')); 127 | const widthMatch = grid.headerData[headerIndex].width.match(/min-width:(\d+)px/); 128 | 129 | if (widthMatch) { 130 | const width = parseInt(widthMatch[1]); 131 | // Should be at least 80px (default minimum) 132 | expect(width).toBeGreaterThanOrEqual(80); 133 | } 134 | 135 | done(); 136 | }, 10); 137 | }); 138 | 139 | test('should trigger autoResize on double-click', () => { 140 | const data = [{ id: 1, name: 'Test', description: 'Long text' }]; 141 | const grid = new OpenGrid('test-grid', data, 400); 142 | 143 | const resizeHandle = container.querySelector('.opengridjs-resize-handle'); 144 | const autoResizeSpy = jest.spyOn(grid, 'autoResizeColumns'); 145 | 146 | const dblClickEvent = new MouseEvent('dblclick', { bubbles: true }); 147 | resizeHandle.dispatchEvent(dblClickEvent); 148 | 149 | expect(autoResizeSpy).toHaveBeenCalled(); 150 | }); 151 | 152 | test('should prevent sort after resize (wasResizing flag)', (done) => { 153 | const data = [{ id: 1, name: 'Test' }]; 154 | const grid = new OpenGrid('test-grid', data, 400); 155 | 156 | const resizeHandle = container.querySelector('.opengridjs-resize-handle'); 157 | const headerItem = resizeHandle.closest('.opengridjs-grid-header-item'); 158 | 159 | // Start resize 160 | resizeHandle.dispatchEvent(new MouseEvent('mousedown', { 161 | bubbles: true, 162 | clientX: 100 163 | })); 164 | 165 | // End resize 166 | setTimeout(() => { 167 | document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); 168 | 169 | // wasResizing flag should be set 170 | expect(headerItem._wasResizing()).toBe(true); 171 | 172 | // After 10ms delay, it should reset 173 | setTimeout(() => { 174 | expect(headerItem._wasResizing()).toBe(false); 175 | done(); 176 | }, 15); 177 | }, 10); 178 | }); 179 | }); 180 | 181 | describe('Width Calculation Methods', () => { 182 | test('should calculate content minimum widths', () => { 183 | const data = [ 184 | { id: 1, name: 'Short', veryLongColumnName: 'This is a very long text value' } 185 | ]; 186 | const grid = new OpenGrid('test-grid', data, 400); 187 | 188 | grid.calculateContentMinWidths(); 189 | 190 | // Headers should have contentMinWidth set 191 | grid.headerData.forEach(header => { 192 | expect(header.contentMinWidth).toBeDefined(); 193 | expect(header.contentMinWidth).toBeGreaterThanOrEqual(80); // Minimum 80px 194 | }); 195 | }); 196 | 197 | test('should measure text width using estimateTextWidth fallback', () => { 198 | const data = [{ id: 1, name: 'Test' }]; 199 | const grid = new OpenGrid('test-grid', data, 400); 200 | 201 | const testElement = document.createElement('span'); 202 | testElement.textContent = 'Sample Text'; 203 | 204 | // estimateTextWidth is the fallback when canvas fails (which it does in jsdom) 205 | const width = grid.estimateTextWidth('Sample Text', testElement); 206 | 207 | expect(width).toBeGreaterThan(0); 208 | expect(typeof width).toBe('number'); 209 | }); 210 | 211 | test('should handle empty text in width measurement', () => { 212 | const data = [{ id: 1, name: 'Test' }]; 213 | const grid = new OpenGrid('test-grid', data, 400); 214 | 215 | const width = grid.estimateTextWidth('', document.createElement('span')); 216 | 217 | expect(width).toBe(0); 218 | }); 219 | 220 | test('should use character-based estimation when DOM fails', () => { 221 | const data = [{ id: 1, name: 'Test' }]; 222 | const grid = new OpenGrid('test-grid', data, 400); 223 | 224 | // Create element without appending to DOM 225 | const tempElement = document.createElement('span'); 226 | 227 | const text = 'ABCDEFGHIJ'; // 10 characters 228 | const width = grid.estimateTextWidth(text, tempElement); 229 | 230 | // Should estimate ~7px per character (70px for 10 chars) 231 | expect(width).toBeGreaterThan(0); 232 | }); 233 | }); 234 | 235 | describe('Column Style Methods', () => { 236 | test('should generate correct column style for fixed width', () => { 237 | const data = [{ id: 1, name: 'Test' }]; 238 | const setup = { 239 | columnHeaderNames: [ 240 | { columnName: 'id', columnWidth: '100px' } 241 | ] 242 | }; 243 | const grid = new OpenGrid('test-grid', data, 400, setup); 244 | 245 | const header = grid.headerData.find(h => h.data === 'id'); 246 | const style = grid.getColumnStyle(header); 247 | 248 | expect(style).toContain('flex-grow: 0'); 249 | expect(style).toContain('flex-shrink: 0'); 250 | expect(style).toContain('box-sizing: border-box'); 251 | }); 252 | 253 | test('should generate correct column style for percentage width', () => { 254 | const data = [{ id: 1, name: 'Test' }]; 255 | const grid = new OpenGrid('test-grid', data, 400); 256 | 257 | const header = grid.headerData[0]; 258 | const style = grid.getColumnStyle(header); 259 | 260 | expect(style).toContain('box-sizing: border-box'); 261 | }); 262 | 263 | test('should include contentMinWidth in column style when set', () => { 264 | const data = [{ id: 1, name: 'Test' }]; 265 | const grid = new OpenGrid('test-grid', data, 400); 266 | 267 | // Manually set contentMinWidth 268 | grid.headerData[0].contentMinWidth = 150; 269 | 270 | const style = grid.getColumnStyle(grid.headerData[0]); 271 | 272 | expect(style).toContain('min-width: 150px'); 273 | }); 274 | }); 275 | 276 | describe('Auto Resize Columns', () => { 277 | test('should reset all columns to equal width', () => { 278 | const data = [{ id: 1, col1: 'A', col2: 'B', col3: 'C' }]; 279 | const grid = new OpenGrid('test-grid', data, 400); 280 | 281 | grid.autoResizeColumns(); 282 | 283 | // All columns should have equal width 284 | const widths = grid.headerData.map(h => h.width); 285 | const firstWidth = widths[0]; 286 | 287 | widths.forEach(width => { 288 | expect(width).toBe(firstWidth); 289 | }); 290 | }); 291 | 292 | test('should calculate equal width based on container width', () => { 293 | const data = [{ id: 1, name: 'Test', age: 30 }]; 294 | const grid = new OpenGrid('test-grid', data, 400); 295 | 296 | const gridHeader = container.querySelector('.opengridjs-grid-header'); 297 | const containerWidth = gridHeader.offsetWidth; 298 | const columnCount = grid.headerData.length; 299 | 300 | grid.autoResizeColumns(); 301 | 302 | const expectedWidth = Math.floor(containerWidth / columnCount); 303 | 304 | grid.headerData.forEach(header => { 305 | expect(header.width).toContain(`${expectedWidth}px`); 306 | }); 307 | }); 308 | }); 309 | }); 310 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenGridJs 2 | 3 | A lightweight, high-performance JavaScript grid framework for modern web applications. OpenGridJs delivers fast, responsive data grids with virtual scrolling, advanced column management, and extensive customization options. 4 | 5 | [![npm version](https://img.shields.io/npm/v/opengridjs.svg)](https://www.npmjs.com/package/opengridjs) 6 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 7 | 8 | ## ✨ Features 9 | 10 | ### Performance & Scalability 11 | - **Virtual Scrolling**: Efficiently handles large datasets by rendering only visible rows 12 | - **Lightweight**: Minimal footprint with no external dependencies 13 | - **Optimized Rendering**: Smart DOM updates for smooth scrolling and interactions 14 | 15 | ### Column Management 16 | - **Auto-Detection**: Automatically generates columns from data structure 17 | - **Custom Headers**: Define custom column names, display names, and formatting 18 | - **Drag & Drop Reordering**: Intuitive column reordering via drag and drop 19 | - **Resizable Columns**: Interactive column resizing with visual feedback 20 | - **Flexible Widths**: Support for both percentage and pixel-based column sizing 21 | 22 | ### Data Handling 23 | - **Asynchronous Loading**: Promise-based data loading with loading states 24 | - **Infinite Scrolling**: Load more data automatically as users scroll 25 | - **Real-time Updates**: Update records by ID with visual animations and position preservation 26 | - **Dynamic Updates**: Append, filter, and refresh data without full re-renders 27 | - **GUID Generation**: Automatic ID assignment for data items without identifiers 28 | 29 | ### User Interactions 30 | - **Sorting**: Click-to-sort columns with visual indicators 31 | - **Filtering**: Built-in search and filter capabilities 32 | - **Context Menus**: Configurable right-click context menus 33 | - **CSV Export**: Export grid data to CSV format 34 | - **Row Selection**: Single and multi-row selection support 35 | 36 | ### Developer Experience 37 | - **TypeScript Ready**: Full TypeScript support with type definitions 38 | - **Modern Browsers**: Compatible with all modern browsers 39 | - **Easy Integration**: Simple API with minimal setup required 40 | - **Extensible**: Flexible configuration and customization options 41 | 42 | ## 📦 Installation 43 | 44 | ### NPM 45 | ```bash 46 | npm install opengridjs 47 | ``` 48 | 49 | ### CDN 50 | ```html 51 | 52 | 53 | ``` 54 | 55 | ### Direct Download 56 | Download the latest release from the [GitHub releases page](https://github.com/amurgola/OpenGridJs/releases). 57 | 58 | ## 🚀 Quick Start 59 | 60 | ```html 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 |
69 | 70 | 81 | 82 | 83 | ``` 84 | 85 | ## 📊 Demo 86 | 87 | ![OpenGridJs Demo](https://github.com/amurgola/OpenGridJs/blob/main/docs/Demo.gif) 88 | 89 | [Live Demo on CodePen](https://codepen.io/amurgola/pen/RweqdMo) 90 | 91 | ## 🔧 Configuration 92 | 93 | ### Constructor Parameters 94 | 95 | ```javascript 96 | new OpenGrid(containerId, data, height, setup, loadMoreFunction) 97 | ``` 98 | 99 | | Parameter | Type | Required | Description | 100 | |-----------|------|----------|-------------| 101 | | `containerId` | string | ✅ | ID of the container element | 102 | | `data` | Array/Function | ✅ | Initial data or async loading function | 103 | | `height` | number | ✅ | Grid height in pixels | 104 | | `setup` | Object | ❌ | Configuration options | 105 | | `loadMoreFunction` | Function | ❌ | Function for infinite scrolling | 106 | 107 | ### Setup Object 108 | 109 | ```javascript 110 | const setup = { 111 | columnHeaderNames: [ 112 | { 113 | columnName: "name", 114 | columnNameDisplay: "Full Name", 115 | columnWidth: "200px", // Optional: specific width 116 | format: (value) => value.toUpperCase() // Optional: custom formatter 117 | } 118 | ], 119 | contextMenuTitle: "Actions", 120 | contextMenuOptions: [ 121 | { 122 | actionName: "Edit", 123 | actionFunctionName: "editRow", 124 | className: "edit-action" 125 | }, 126 | { 127 | actionName: "Delete", 128 | actionFunctionName: "deleteRow", 129 | className: "delete-action" 130 | } 131 | ] 132 | }; 133 | ``` 134 | 135 | #### Column Configuration 136 | 137 | | Property | Type | Description | 138 | |----------|------|-------------| 139 | | `columnName` | string | Data field name | 140 | | `columnNameDisplay` | string | Display name in header | 141 | | `columnWidth` | string | CSS width value (e.g., "200px", "20%") | 142 | | `format` | function | Custom formatting function | 143 | 144 | #### Context Menu Configuration 145 | 146 | | Property | Type | Description | 147 | |----------|------|-------------| 148 | | `actionName` | string | Display text for menu item | 149 | | `actionFunctionName` | string | Global function name to call | 150 | | `className` | string | CSS class for styling | 151 | 152 | ## 🎯 Advanced Usage 153 | 154 | ### Asynchronous Data Loading 155 | 156 | ```javascript 157 | async function loadData() { 158 | const response = await fetch('/api/data'); 159 | return response.json(); 160 | } 161 | 162 | const grid = new OpenGrid("myGrid", loadData, 400, setup); 163 | ``` 164 | 165 | ### Infinite Scrolling 166 | 167 | ```javascript 168 | async function loadMoreData() { 169 | const response = await fetch(`/api/data?page=${currentPage++}`); 170 | const newData = await response.json(); 171 | grid.appendData(newData); 172 | } 173 | 174 | const grid = new OpenGrid("myGrid", loadData, 400, setup, loadMoreData); 175 | ``` 176 | 177 | ### Custom Formatting 178 | 179 | ```javascript 180 | const setup = { 181 | columnHeaderNames: [ 182 | { 183 | columnName: "price", 184 | columnNameDisplay: "Price", 185 | format: (value) => `$${value.toFixed(2)}` 186 | }, 187 | { 188 | columnName: "date", 189 | columnNameDisplay: "Created", 190 | format: (value) => new Date(value).toLocaleDateString() 191 | }, 192 | { 193 | columnName: "email", 194 | columnNameDisplay: "Email", 195 | format: (value) => `${value}` 196 | } 197 | ] 198 | }; 199 | ``` 200 | 201 | ## 🛠️ API Reference 202 | 203 | ### Methods 204 | 205 | | Method | Parameters | Description | 206 | |--------|------------|-------------| 207 | | `appendData(data)` | Array | Add new data to the grid | 208 | | `updateRecordData(data, options)` | Array/Object, Options | Update records by ID with animations | 209 | | `rerender()` | none | Force grid re-render | 210 | | `reset()` | none | Reset grid to initial state | 211 | | `exportToCSV()` | none | Download grid data as CSV | 212 | | `searchFilter(term)` | string | Filter data by search term | 213 | | `stopLoadingMoreData()` | none | Disable infinite scrolling | 214 | 215 | ### Usage Examples 216 | 217 | ```javascript 218 | // Add new data 219 | grid.appendData([ 220 | { id: 4, name: "Alice Brown", email: "alice@example.com", age: 28 } 221 | ]); 222 | 223 | // Real-time updates with animations 224 | // Update single record 225 | grid.updateRecordData({ id: 2, score: 95, status: "Active" }); 226 | 227 | // Update multiple records 228 | grid.updateRecordData([ 229 | { id: 1, score: 88 }, // Green animation if score increased 230 | { id: 3, score: 72 } // Red animation if score decreased 231 | ]); 232 | 233 | // Update with custom options 234 | grid.updateRecordData( 235 | { id: 4, name: "John Updated", score: 90 }, 236 | { 237 | animate: true, // Enable animations (default: true) 238 | preservePosition: true, // Prevent row reordering (default: true) 239 | highlightDuration: 3000 // Animation duration in ms (default: 2000) 240 | } 241 | ); 242 | 243 | // Filter data 244 | grid.searchFilter("john"); 245 | 246 | // Export to CSV 247 | grid.exportToCSV(); 248 | 249 | // Reset grid 250 | grid.reset(); 251 | ``` 252 | 253 | ## 🔄 Real-time Updates 254 | 255 | OpenGridJs now supports real-time data updates with visual animations and intelligent positioning: 256 | 257 | ### Features 258 | - **ID-based Updates**: Update records by matching their unique ID 259 | - **Visual Animations**: 260 | - 🟢 Green for numeric increases 261 | - 🔴 Red for numeric decreases 262 | - 🔵 Blue for non-numeric changes 263 | - **Position Preservation**: Updates don't cause data bouncing or reordering 264 | - **Batch Updates**: Update multiple records simultaneously 265 | - **Centered Filter Menus**: Filter menus now appear centered under column headers 266 | 267 | ### Animation Types 268 | 269 | ```javascript 270 | // Numeric increase (green animation) 271 | grid.updateRecordData({ id: 1, score: 95 }); // if previous score was lower 272 | 273 | // Numeric decrease (red animation) 274 | grid.updateRecordData({ id: 1, score: 75 }); // if previous score was higher 275 | 276 | // Non-numeric change (blue animation) 277 | grid.updateRecordData({ id: 1, name: "New Name", status: "Updated" }); 278 | ``` 279 | 280 | ### Options 281 | 282 | | Option | Type | Default | Description | 283 | |--------|------|---------|-------------| 284 | | `animate` | boolean | `true` | Enable/disable animations | 285 | | `preservePosition` | boolean | `true` | Prevent row reordering during updates | 286 | | `highlightDuration` | number | `2000` | Animation duration in milliseconds | 287 | 288 | ### Demo 289 | 290 | Try the interactive demo at `demo-realtime-updates.html` to see all features in action! 291 | 292 | ## 🎨 Styling & Theming 293 | 294 | OpenGridJs provides extensive CSS customization options: 295 | 296 | ```css 297 | /* Custom theme example */ 298 | .opengridjs-grid { 299 | --primary-color: #007bff; 300 | --background-color: #ffffff; 301 | --border-color: #dee2e6; 302 | --hover-color: #f8f9fa; 303 | --selected-color: #e3f2fd; 304 | } 305 | 306 | .opengridjs-grid-header { 307 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 308 | color: white; 309 | } 310 | 311 | .opengridjs-grid-row:hover { 312 | background-color: var(--hover-color); 313 | transform: translateY(-1px); 314 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); 315 | } 316 | ``` 317 | 318 | ## 🧪 Testing 319 | 320 | OpenGridJs includes comprehensive test coverage: 321 | 322 | ```bash 323 | # Run tests 324 | npm test 325 | 326 | # Run tests with coverage 327 | npm run test:coverage 328 | 329 | # Watch mode 330 | npm run test:watch 331 | ``` 332 | 333 | ## 🏗️ Development 334 | 335 | ```bash 336 | # Clone repository 337 | git clone https://github.com/amurgola/OpenGridJs.git 338 | cd OpenGridJs 339 | 340 | # Install dependencies 341 | npm install 342 | 343 | # Run tests 344 | npm test 345 | 346 | # Build minified versions 347 | npm run build 348 | ``` 349 | 350 | ## 🤝 Contributing 351 | 352 | Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests. 353 | 354 | 1. Fork the repository 355 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 356 | 3. Commit your changes (`git commit -m 'Add amazing feature'`) 357 | 4. Push to the branch (`git push origin feature/amazing-feature`) 358 | 5. Open a Pull Request 359 | 360 | ## 📋 Browser Support 361 | 362 | | Browser | Version | 363 | |---------|---------| 364 | | Chrome | ≥ 60 | 365 | | Firefox | ≥ 55 | 366 | | Safari | ≥ 12 | 367 | | Edge | ≥ 79 | 368 | 369 | ## 📄 License 370 | 371 | OpenGridJs is released under the [MIT License](LICENSE). 372 | 373 | ## 🙏 Acknowledgments 374 | 375 | - Built with performance and developer experience in mind 376 | - Inspired by modern data grid requirements 377 | - Community feedback and contributions 378 | 379 | --- 380 | 381 | Made with ❤️ by the OpenGridJs team -------------------------------------------------------------------------------- /examples/theme-demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | OpenGrid Theme Demo 7 | 8 | 9 | 163 | 164 | 165 |
166 |
167 |

OpenGrid Theme Demo

168 |

Modern, clean data grid with beautiful dark and light themes

169 |
170 | 171 |
172 | 175 | 178 | 181 |
182 | 183 |
184 |

Sample Data Grid

185 |
186 |
187 | 188 |
189 |
190 |

🎨 Modern Design

191 |

Clean, modern interface inspired by OpenAI's design language with subtle shadows, smooth transitions, and perfect typography.

192 |
193 |
194 |

🌙 Dark/Light Themes

195 |

Automatic theme detection based on system preferences, plus manual override options for both dark and light modes.

196 |
197 |
198 |

⚡ High Performance

199 |

Virtual scrolling, optimized rendering, and hardware-accelerated transitions ensure smooth performance with large datasets.

200 |
201 |
202 |

♿ Accessibility

203 |

Full keyboard navigation, focus indicators, high contrast support, and reduced motion preferences respected.

204 |
205 |
206 |
207 | 208 | 317 | 318 | -------------------------------------------------------------------------------- /opengrid.min.css: -------------------------------------------------------------------------------- 1 | :root{--og-background:#ffffff;--og-surface:#f8f9fa;--og-surface-hover:#e9ecef;--og-border:#e1e5e9;--og-text-primary:#1a1a1a;--og-text-secondary:#6b7280;--og-text-muted:#9ca3af;--og-header-bg:#ffffff;--og-header-border:#e1e5e9;--og-selected:#f0f9ff;--og-selected-border:#0ea5e9;--og-accent:#0ea5e9;--og-accent-hover:#0284c7;--og-shadow:0 1px 3px rgba(0, 0, 0, 0.05),0 1px 2px rgba(0, 0, 0, 0.1);--og-shadow-hover:0 4px 6px rgba(0, 0, 0, 0.07),0 2px 4px rgba(0, 0, 0, 0.06);--og-scrollbar-track:#f1f5f9;--og-scrollbar-thumb:#cbd5e1;--og-scrollbar-thumb-hover:#94a3b8;--og-context-bg:#ffffff;--og-context-border:#e1e5e9;--og-context-hover:#f8f9fa;--og-resize-handle:#cbd5e1;--og-resize-handle-hover:#0ea5e9;--og-font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;--og-font-size:14px;--og-font-weight-normal:400;--og-font-weight-medium:500;--og-font-weight-semibold:600;--og-line-height:1.5;--og-spacing-xs:4px;--og-spacing-sm:8px;--og-spacing-md:12px;--og-spacing-lg:16px;--og-spacing-xl:24px;--og-radius-sm:4px;--og-radius-md:8px;--og-radius-lg:12px;--og-transition:all 0.15s ease-in-out;--og-transition-fast:all 0.1s ease-in-out;--og-min-row-height:35px;--og-min-column-width:50px;--og-context-menu-min-width:200px;--og-filter-menu-min-width:250px;--og-filter-menu-max-width:300px;--og-filter-options-max-height:250px}[data-theme=dark]{--og-background:#1a1a1a;--og-surface:#262626;--og-surface-hover:#404040;--og-border:#404040;--og-text-primary:#ffffff;--og-text-secondary:#a3a3a3;--og-text-muted:#737373;--og-header-bg:#1a1a1a;--og-header-border:#404040;--og-selected:#1e3a8a;--og-selected-border:#3b82f6;--og-accent:#3b82f6;--og-accent-hover:#2563eb;--og-shadow:0 1px 3px rgba(0, 0, 0, 0.3),0 1px 2px rgba(0, 0, 0, 0.4);--og-shadow-hover:0 4px 6px rgba(0, 0, 0, 0.4),0 2px 4px rgba(0, 0, 0, 0.3);--og-scrollbar-track:#262626;--og-scrollbar-thumb:#525252;--og-scrollbar-thumb-hover:#737373;--og-context-bg:#1a1a1a;--og-context-border:#404040;--og-context-hover:#262626;--og-resize-handle:#525252;--og-resize-handle-hover:#3b82f6}@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--og-background:#1a1a1a;--og-surface:#262626;--og-surface-hover:#404040;--og-border:#404040;--og-text-primary:#ffffff;--og-text-secondary:#a3a3a3;--og-text-muted:#737373;--og-header-bg:#1a1a1a;--og-header-border:#404040;--og-selected:#1e3a8a;--og-selected-border:#3b82f6;--og-accent:#3b82f6;--og-accent-hover:#2563eb;--og-shadow:0 1px 3px rgba(0, 0, 0, 0.3),0 1px 2px rgba(0, 0, 0, 0.4);--og-shadow-hover:0 4px 6px rgba(0, 0, 0, 0.4),0 2px 4px rgba(0, 0, 0, 0.3);--og-scrollbar-track:#262626;--og-scrollbar-thumb:#525252;--og-scrollbar-thumb-hover:#737373;--og-context-bg:#1a1a1a;--og-context-border:#404040;--og-context-hover:#262626;--og-resize-handle:#525252;--og-resize-handle-hover:#3b82f6}}.opengridjs-grid{font-family:var(--og-font-family);font-size:var(--og-font-size);font-weight:var(--og-font-weight-normal);line-height:var(--og-line-height);color:var(--og-text-primary);background-color:var(--og-background);border:1px solid var(--og-border);border-radius:var(--og-radius-lg);box-shadow:var(--og-shadow);transition:var(--og-transition);user-select:none;display:flex;flex-direction:column;position:relative;overflow:visible}.opengridjs-grid::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;border-radius:var(--og-radius-lg);pointer-events:none;z-index:-1;background:var(--og-background)}.opengridjs-grid-additional{position:absolute;top:0;left:0;width:0;height:0;pointer-events:none;z-index:10000;overflow:visible}.opengridjs-grid-additional>*{pointer-events:auto}.opengridjs-grid:hover{box-shadow:var(--og-shadow-hover)}.opengridjs-grid-header{display:flex;background-color:var(--og-header-bg);border-bottom:1px solid var(--og-header-border);position:sticky;top:0;z-index:10;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);overflow:hidden;border-top-left-radius:var(--og-radius-lg);border-top-right-radius:var(--og-radius-lg)}.opengridjs-grid-header-item{flex-grow:1;padding:var(--og-spacing-lg);font-weight:var(--og-font-weight-semibold);color:var(--og-text-primary);border-right:1px solid var(--og-border);cursor:pointer;transition:var(--og-transition);position:relative;display:flex;align-items:center;min-height:var(--og-min-row-height);box-sizing:border-box}.opengridjs-grid-header-item:first-child{border-top-left-radius:var(--og-radius-lg)}.opengridjs-grid-header-item:last-child{border-right:none;border-top-right-radius:var(--og-radius-lg)}.opengridjs-grid-header-item:hover{background-color:var(--og-surface-hover)}.opengridjs-grid-header-item:active{background-color:var(--og-surface)}.opengridjs-header-text{flex-grow:1;display:flex;align-items:center}.opengridjs-header-actions{display:flex;align-items:center;margin-left:auto;gap:4px}.opengridjs-grid-rows-container{overflow-y:auto;overflow-x:hidden;position:relative;flex-grow:1;background-color:var(--og-background);min-height:200px;border-bottom-left-radius:var(--og-radius-lg);border-bottom-right-radius:var(--og-radius-lg)}.opengridjs-grid-row{display:flex;background-color:var(--og-background);border-bottom:1px solid var(--og-border);align-items:center;position:absolute;width:100%;box-sizing:border-box;cursor:pointer;transition:var(--og-transition-fast);min-height:var(--og-min-row-height)}.opengridjs-grid-row:hover{background-color:var(--og-surface-hover)}.opengridjs-grid-row:nth-child(2n){background-color:var(--og-surface)}.opengridjs-grid-row:nth-child(2n):hover{background-color:var(--og-surface-hover)}.opengridjs-selected-grid-row{background-color:var(--og-selected)!important;border-left:3px solid var(--og-selected-border);padding-left:calc(var(--og-spacing-lg) - 3px)}.opengridjs-grid-row:last-child{border-bottom-left-radius:var(--og-radius-lg);border-bottom-right-radius:var(--og-radius-lg);border-bottom:none}.opengridjs-grid-column-item{flex-grow:1;padding:var(--og-spacing-lg);color:var(--og-text-primary);border-right:1px solid var(--og-border);min-width:var(--og-min-column-width);word-wrap:break-word;overflow-wrap:break-word;display:flex;align-items:center;height:2px;box-sizing:border-box}.opengridjs-grid-column-item:last-child{border-right:none}.opengridjs-grid-row:last-child .opengridjs-grid-column-item:first-child{border-bottom-left-radius:var(--og-radius-lg)}.opengridjs-grid-row:last-child .opengridjs-grid-column-item:last-child{border-bottom-right-radius:var(--og-radius-lg)}.opengridjs-sort-indicator{margin-left:var(--og-spacing-sm);display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:var(--og-radius-sm);transition:var(--og-transition);position:relative}.opengridjs-sort-indicator::after{content:'';width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;opacity:.5;transition:var(--og-transition)}.opengridjs-sort-asc .opengridjs-sort-indicator::after{border-bottom:5px solid var(--og-accent);opacity:1}.opengridjs-sort-desc .opengridjs-sort-indicator::after{border-top:5px solid var(--og-accent);opacity:1}.opengridjs-sort-asc .opengridjs-sort-indicator,.opengridjs-sort-desc .opengridjs-sort-indicator{background-color:var(--og-selected)}.opengridjs-resize-handle{position:absolute;right:0;top:0;width:4px;height:100%;cursor:col-resize;background-color:transparent;border-right:2px solid transparent;transition:var(--og-transition);z-index:5}.opengridjs-resize-handle:hover{border-right-color:var(--og-resize-handle-hover);background-color:var(--og-resize-handle-hover);opacity:.3}.opengridjs-grid-header-item.opengridjs-resizing{user-select:none;cursor:col-resize}.opengridjs-grid-header-item.opengridjs-resizing .opengridjs-resize-handle{border-right-color:var(--og-resize-handle-hover);background-color:var(--og-resize-handle-hover);opacity:.6}.opengridjs-grid-header-item[draggable=true]{cursor:grab}.opengridjs-grid-header-item[draggable=true]:active{cursor:grabbing}.opengridjs-grid-header-item.opengridjs-dragging{opacity:.7;background-color:var(--og-accent);color:#fff;cursor:grabbing;z-index:1000}.opengridjs-grid-header-item.opengridjs-drag-over{background-color:var(--og-selected);border-left:3px solid var(--og-accent);border-right:3px solid var(--og-accent);transform:scale(1.02)}.opengridjs-filter-button{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:var(--og-radius-sm);cursor:pointer;font-size:10px;color:var(--og-text-secondary);transition:var(--og-transition);background-color:transparent;border:1px solid transparent}.opengridjs-filter-button:hover{background-color:var(--og-surface);color:var(--og-accent);border-color:var(--og-border)}.opengridjs-filter-button.opengridjs-filter-active{background-color:var(--og-accent);color:#fff;border-color:var(--og-accent)}.opengridjs-filter-button.opengridjs-filter-active:hover{background-color:var(--og-accent-hover);border-color:var(--og-accent-hover)}.opengridjs-filter-menu{background-color:var(--og-context-bg);border:1px solid var(--og-context-border);border-radius:var(--og-radius-md);box-shadow:var(--og-shadow-hover);min-width:var(--og-filter-menu-min-width);max-width:var(--og-filter-menu-max-width);z-index:10000;animation:fadeIn .15s ease-out;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);isolation:isolate}.opengridjs-filter-menu-header{display:flex;gap:var(--og-spacing-sm);padding:var(--og-spacing-md);border-bottom:1px solid var(--og-border);background-color:var(--og-surface)}.opengridjs-filter-menu-footer button,.opengridjs-filter-menu-header button{flex:1;padding:var(--og-spacing-sm) var(--og-spacing-md);border:1px solid var(--og-border);border-radius:var(--og-radius-sm);background-color:var(--og-background);color:var(--og-text-primary);cursor:pointer;font-size:12px;font-family:var(--og-font-family);transition:var(--og-transition)}.opengridjs-filter-menu-header button:hover{background-color:var(--og-surface-hover);border-color:var(--og-accent)}.opengridjs-filter-menu-footer button{padding:var(--og-spacing-sm) var(--og-spacing-lg);font-size:var(--og-font-size);font-weight:var(--og-font-weight-medium)}.opengridjs-filter-search{padding:var(--og-spacing-md);border-bottom:1px solid var(--og-border)}.opengridjs-filter-search-input{width:90%;padding:var(--og-spacing-sm) var(--og-spacing-md);border:1px solid var(--og-border);border-radius:var(--og-radius-sm);background-color:var(--og-background);color:var(--og-text-primary);font-size:var(--og-font-size);font-family:var(--og-font-family);transition:var(--og-transition)}.opengridjs-filter-search-input:focus{outline:0;border-color:var(--og-accent);box-shadow:0 0 0 2px rgba(14,165,233,.1)}.opengridjs-filter-options{max-height:var(--og-filter-options-max-height);overflow-y:auto;padding:var(--og-spacing-sm) 0}.opengridjs-filter-option{display:flex;align-items:center;padding:var(--og-spacing-sm) var(--og-spacing-md);cursor:pointer;transition:var(--og-transition);gap:var(--og-spacing-sm)}.opengridjs-filter-option:hover{background-color:var(--og-surface-hover)}.opengridjs-filter-option input[type=checkbox]{margin:0;width:16px;height:16px;cursor:pointer;accent-color:var(--og-accent)}.opengridjs-filter-option span{flex:1;font-size:var(--og-font-size);color:var(--og-text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.opengridjs-filter-menu-footer{display:flex;gap:var(--og-spacing-sm);padding:var(--og-spacing-md);border-top:1px solid var(--og-border);background-color:var(--og-surface)}.opengridjs-filter-apply{background-color:var(--og-accent);color:#fff;border:1px solid var(--og-accent)}.opengridjs-filter-apply:hover{background-color:var(--og-accent-hover);border-color:var(--og-accent-hover)}.opengridjs-filter-cancel{background-color:var(--og-background);color:var(--og-text-primary);border:1px solid var(--og-border)}.opengridjs-filter-cancel:hover{background-color:var(--og-surface-hover);border-color:var(--og-accent)}.opengridjs-contextMenu{background-color:var(--og-context-bg);border:1px solid var(--og-context-border);border-radius:var(--og-radius-md);box-shadow:var(--og-shadow-hover);position:absolute;min-width:var(--og-context-menu-min-width);z-index:10000;overflow:hidden;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);animation:fadeIn .15s ease-out;isolation:isolate}.opengridjs-contextMenu .opengridjs-title{padding:var(--og-spacing-md) var(--og-spacing-lg);font-weight:var(--og-font-weight-semibold);color:var(--og-text-primary);border-bottom:1px solid var(--og-border);background-color:var(--og-surface);font-size:13px;text-transform:uppercase;letter-spacing:.5px}.opengridjs-contextMenu .opengridjs-btn{width:100%;padding:var(--og-spacing-md) var(--og-spacing-lg);border:none;background:0 0;color:var(--og-text-primary);cursor:pointer;transition:var(--og-transition-fast);text-align:left;font-size:var(--og-font-size);font-family:var(--og-font-family);display:flex;align-items:center;min-height:40px}.opengridjs-contextMenu .opengridjs-btn:hover{background-color:var(--og-context-hover);color:var(--og-accent)}.opengridjs-contextMenu .opengridjs-btn:active{background-color:var(--og-surface)}.opengridjs-contextMenu hr{border:none;border-top:1px solid var(--og-border);margin:var(--og-spacing-xs) 0}.opengridjs-grid-column-item.opengridjs-field-increased{background-color:rgba(34,197,94,.2);border-left:3px solid #22c55e;animation:fieldIncreased 2s ease-out}.opengridjs-grid-column-item.opengridjs-field-decreased{background-color:rgba(239,68,68,.2);border-left:3px solid #ef4444;animation:fieldDecreased 2s ease-out}.opengridjs-grid-column-item.opengridjs-field-updated{background-color:rgba(59,130,246,.15);border-left:3px solid #3b82f6;animation:fieldUpdated 2s ease-out}.opengridjs-filter-options::-webkit-scrollbar,.opengridjs-grid-rows-container::-webkit-scrollbar{width:8px}.opengridjs-filter-options::-webkit-scrollbar{width:6px}.opengridjs-filter-options::-webkit-scrollbar-track,.opengridjs-grid-rows-container::-webkit-scrollbar-track{background:var(--og-scrollbar-track);border-radius:var(--og-radius-sm)}.opengridjs-filter-options::-webkit-scrollbar-thumb,.opengridjs-grid-rows-container::-webkit-scrollbar-thumb{background:var(--og-scrollbar-thumb);border-radius:var(--og-radius-sm);transition:var(--og-transition)}.opengridjs-filter-options::-webkit-scrollbar-thumb:hover,.opengridjs-grid-rows-container::-webkit-scrollbar-thumb:hover{background:var(--og-scrollbar-thumb-hover)}.opengridjs-grid a{color:var(--og-accent);text-decoration:none;transition:var(--og-transition);border-radius:var(--og-radius-sm);padding:2px 4px;margin:-2px -4px}.opengridjs-grid a:hover{color:var(--og-accent-hover);background-color:var(--og-selected)}.opengridjs-grid a:focus{outline:2px solid var(--og-accent);outline-offset:2px}.opengridjs-contextMenu .opengridjs-btn:focus-visible,.opengridjs-grid-header-item:focus-visible,.opengridjs-grid-row:focus-visible{outline:2px solid var(--og-accent);outline-offset:-2px}@keyframes fadeIn{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}@keyframes fieldIncreased{0%{background-color:rgba(34,197,94,.4);border-left-color:#22c55e;transform:scale(1.02)}100%{background-color:rgba(34,197,94,.1);border-left-color:rgba(34,197,94,.3);transform:scale(1)}}@keyframes fieldDecreased{0%{background-color:rgba(239,68,68,.4);border-left-color:#ef4444;transform:scale(1.02)}100%{background-color:rgba(239,68,68,.1);border-left-color:rgba(239,68,68,.3);transform:scale(1)}}@keyframes fieldUpdated{0%{background-color:rgba(59,130,246,.3);border-left-color:#3b82f6;transform:scale(1.01)}100%{background-color:rgba(59,130,246,.05);border-left-color:rgba(59,130,246,.2);transform:scale(1)}}@media (max-width:768px){.opengridjs-grid{font-size:13px}.opengridjs-grid-column-item,.opengridjs-grid-header-item{padding:var(--og-spacing-sm) var(--og-spacing-md)}.opengridjs-grid-header-item,.opengridjs-grid-row{min-height:40px}}@media (prefers-contrast:high){.opengridjs-grid{--og-border:#000000;--og-shadow:0 0 0 1px #000000}[data-theme=dark] .opengridjs-grid{--og-border:#ffffff;--og-shadow:0 0 0 1px #ffffff}}@media (prefers-reduced-motion:reduce){.opengridjs-grid,.opengridjs-grid *{transition:none!important;animation:none!important}} -------------------------------------------------------------------------------- /dist/opengrid.min.css: -------------------------------------------------------------------------------- 1 | :root{--og-background:#ffffff;--og-surface:#f8f9fa;--og-surface-hover:#e9ecef;--og-border:#e1e5e9;--og-text-primary:#1a1a1a;--og-text-secondary:#6b7280;--og-text-muted:#9ca3af;--og-header-bg:#ffffff;--og-header-border:#e1e5e9;--og-selected:#f0f9ff;--og-selected-border:#0ea5e9;--og-accent:#0ea5e9;--og-accent-hover:#0284c7;--og-shadow:0 1px 3px rgba(0, 0, 0, 0.05),0 1px 2px rgba(0, 0, 0, 0.1);--og-shadow-hover:0 4px 6px rgba(0, 0, 0, 0.07),0 2px 4px rgba(0, 0, 0, 0.06);--og-scrollbar-track:#f1f5f9;--og-scrollbar-thumb:#cbd5e1;--og-scrollbar-thumb-hover:#94a3b8;--og-context-bg:#ffffff;--og-context-border:#e1e5e9;--og-context-hover:#f8f9fa;--og-resize-handle:#cbd5e1;--og-resize-handle-hover:#0ea5e9;--og-font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;--og-font-size:14px;--og-font-weight-normal:400;--og-font-weight-medium:500;--og-font-weight-semibold:600;--og-line-height:1.5;--og-spacing-xs:4px;--og-spacing-sm:8px;--og-spacing-md:12px;--og-spacing-lg:16px;--og-spacing-xl:24px;--og-radius-sm:4px;--og-radius-md:8px;--og-radius-lg:12px;--og-transition:all 0.15s ease-in-out;--og-transition-fast:all 0.1s ease-in-out;--og-min-row-height:35px;--og-min-column-width:50px;--og-context-menu-min-width:200px;--og-filter-menu-min-width:250px;--og-filter-menu-max-width:300px;--og-filter-options-max-height:250px}[data-theme=dark]{--og-background:#1a1a1a;--og-surface:#262626;--og-surface-hover:#404040;--og-border:#404040;--og-text-primary:#ffffff;--og-text-secondary:#a3a3a3;--og-text-muted:#737373;--og-header-bg:#1a1a1a;--og-header-border:#404040;--og-selected:#1e3a8a;--og-selected-border:#3b82f6;--og-accent:#3b82f6;--og-accent-hover:#2563eb;--og-shadow:0 1px 3px rgba(0, 0, 0, 0.3),0 1px 2px rgba(0, 0, 0, 0.4);--og-shadow-hover:0 4px 6px rgba(0, 0, 0, 0.4),0 2px 4px rgba(0, 0, 0, 0.3);--og-scrollbar-track:#262626;--og-scrollbar-thumb:#525252;--og-scrollbar-thumb-hover:#737373;--og-context-bg:#1a1a1a;--og-context-border:#404040;--og-context-hover:#262626;--og-resize-handle:#525252;--og-resize-handle-hover:#3b82f6}@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--og-background:#1a1a1a;--og-surface:#262626;--og-surface-hover:#404040;--og-border:#404040;--og-text-primary:#ffffff;--og-text-secondary:#a3a3a3;--og-text-muted:#737373;--og-header-bg:#1a1a1a;--og-header-border:#404040;--og-selected:#1e3a8a;--og-selected-border:#3b82f6;--og-accent:#3b82f6;--og-accent-hover:#2563eb;--og-shadow:0 1px 3px rgba(0, 0, 0, 0.3),0 1px 2px rgba(0, 0, 0, 0.4);--og-shadow-hover:0 4px 6px rgba(0, 0, 0, 0.4),0 2px 4px rgba(0, 0, 0, 0.3);--og-scrollbar-track:#262626;--og-scrollbar-thumb:#525252;--og-scrollbar-thumb-hover:#737373;--og-context-bg:#1a1a1a;--og-context-border:#404040;--og-context-hover:#262626;--og-resize-handle:#525252;--og-resize-handle-hover:#3b82f6}}.opengridjs-grid{font-family:var(--og-font-family);font-size:var(--og-font-size);font-weight:var(--og-font-weight-normal);line-height:var(--og-line-height);color:var(--og-text-primary);background-color:var(--og-background);border:1px solid var(--og-border);border-radius:var(--og-radius-lg);box-shadow:var(--og-shadow);transition:var(--og-transition);user-select:none;display:flex;flex-direction:column;position:relative;overflow:visible}.opengridjs-grid::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;border-radius:var(--og-radius-lg);pointer-events:none;z-index:-1;background:var(--og-background)}.opengridjs-grid-additional{position:absolute;top:0;left:0;width:0;height:0;pointer-events:none;z-index:10000;overflow:visible}.opengridjs-grid-additional>*{pointer-events:auto}.opengridjs-grid:hover{box-shadow:var(--og-shadow-hover)}.opengridjs-grid-header{display:flex;background-color:var(--og-header-bg);border-bottom:1px solid var(--og-header-border);position:sticky;top:0;z-index:10;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);overflow:hidden;border-top-left-radius:var(--og-radius-lg);border-top-right-radius:var(--og-radius-lg)}.opengridjs-grid-header-item{flex-grow:1;padding:var(--og-spacing-lg);font-weight:var(--og-font-weight-semibold);color:var(--og-text-primary);border-right:1px solid var(--og-border);cursor:pointer;transition:var(--og-transition);position:relative;display:flex;align-items:center;min-height:var(--og-min-row-height);box-sizing:border-box}.opengridjs-grid-header-item:first-child{border-top-left-radius:var(--og-radius-lg)}.opengridjs-grid-header-item:last-child{border-right:none;border-top-right-radius:var(--og-radius-lg)}.opengridjs-grid-header-item:hover{background-color:var(--og-surface-hover)}.opengridjs-grid-header-item:active{background-color:var(--og-surface)}.opengridjs-header-text{flex-grow:1;display:flex;align-items:center}.opengridjs-header-actions{display:flex;align-items:center;margin-left:auto;gap:4px}.opengridjs-grid-rows-container{overflow-y:auto;overflow-x:hidden;position:relative;flex-grow:1;background-color:var(--og-background);min-height:200px;border-bottom-left-radius:var(--og-radius-lg);border-bottom-right-radius:var(--og-radius-lg)}.opengridjs-grid-row{display:flex;background-color:var(--og-background);border-bottom:1px solid var(--og-border);align-items:center;position:absolute;width:100%;box-sizing:border-box;cursor:pointer;transition:var(--og-transition-fast);min-height:var(--og-min-row-height)}.opengridjs-grid-row:hover{background-color:var(--og-surface-hover)}.opengridjs-grid-row:nth-child(2n){background-color:var(--og-surface)}.opengridjs-grid-row:nth-child(2n):hover{background-color:var(--og-surface-hover)}.opengridjs-selected-grid-row{background-color:var(--og-selected)!important;border-left:3px solid var(--og-selected-border);padding-left:calc(var(--og-spacing-lg) - 3px)}.opengridjs-grid-row:last-child{border-bottom-left-radius:var(--og-radius-lg);border-bottom-right-radius:var(--og-radius-lg);border-bottom:none}.opengridjs-grid-column-item{flex-grow:1;padding:var(--og-spacing-lg);color:var(--og-text-primary);border-right:1px solid var(--og-border);min-width:var(--og-min-column-width);word-wrap:break-word;overflow-wrap:break-word;display:flex;align-items:center;height:2px;box-sizing:border-box}.opengridjs-grid-column-item:last-child{border-right:none}.opengridjs-grid-row:last-child .opengridjs-grid-column-item:first-child{border-bottom-left-radius:var(--og-radius-lg)}.opengridjs-grid-row:last-child .opengridjs-grid-column-item:last-child{border-bottom-right-radius:var(--og-radius-lg)}.opengridjs-sort-indicator{margin-left:var(--og-spacing-sm);display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:var(--og-radius-sm);transition:var(--og-transition);position:relative}.opengridjs-sort-indicator::after{content:'';width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;opacity:.5;transition:var(--og-transition)}.opengridjs-sort-asc .opengridjs-sort-indicator::after{border-bottom:5px solid var(--og-accent);opacity:1}.opengridjs-sort-desc .opengridjs-sort-indicator::after{border-top:5px solid var(--og-accent);opacity:1}.opengridjs-sort-asc .opengridjs-sort-indicator,.opengridjs-sort-desc .opengridjs-sort-indicator{background-color:var(--og-selected)}.opengridjs-resize-handle{position:absolute;right:0;top:0;width:4px;height:100%;cursor:col-resize;background-color:transparent;border-right:2px solid transparent;transition:var(--og-transition);z-index:5}.opengridjs-resize-handle:hover{border-right-color:var(--og-resize-handle-hover);background-color:var(--og-resize-handle-hover);opacity:.3}.opengridjs-grid-header-item.opengridjs-resizing{user-select:none;cursor:col-resize}.opengridjs-grid-header-item.opengridjs-resizing .opengridjs-resize-handle{border-right-color:var(--og-resize-handle-hover);background-color:var(--og-resize-handle-hover);opacity:.6}.opengridjs-grid-header-item[draggable=true]{cursor:grab}.opengridjs-grid-header-item[draggable=true]:active{cursor:grabbing}.opengridjs-grid-header-item.opengridjs-dragging{opacity:.7;background-color:var(--og-accent);color:#fff;cursor:grabbing;z-index:1000}.opengridjs-grid-header-item.opengridjs-drag-over{background-color:var(--og-selected);border-left:3px solid var(--og-accent);border-right:3px solid var(--og-accent);transform:scale(1.02)}.opengridjs-filter-button{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:var(--og-radius-sm);cursor:pointer;font-size:10px;color:var(--og-text-secondary);transition:var(--og-transition);background-color:transparent;border:1px solid transparent}.opengridjs-filter-button:hover{background-color:var(--og-surface);color:var(--og-accent);border-color:var(--og-border)}.opengridjs-filter-button.opengridjs-filter-active{background-color:var(--og-accent);color:#fff;border-color:var(--og-accent)}.opengridjs-filter-button.opengridjs-filter-active:hover{background-color:var(--og-accent-hover);border-color:var(--og-accent-hover)}.opengridjs-filter-menu{background-color:var(--og-context-bg);border:1px solid var(--og-context-border);border-radius:var(--og-radius-md);box-shadow:var(--og-shadow-hover);min-width:var(--og-filter-menu-min-width);max-width:var(--og-filter-menu-max-width);z-index:10000;animation:fadeIn .15s ease-out;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);isolation:isolate}.opengridjs-filter-menu-header{display:flex;gap:var(--og-spacing-sm);padding:var(--og-spacing-md);border-bottom:1px solid var(--og-border);background-color:var(--og-surface)}.opengridjs-filter-menu-footer button,.opengridjs-filter-menu-header button{flex:1;padding:var(--og-spacing-sm) var(--og-spacing-md);border:1px solid var(--og-border);border-radius:var(--og-radius-sm);background-color:var(--og-background);color:var(--og-text-primary);cursor:pointer;font-size:12px;font-family:var(--og-font-family);transition:var(--og-transition)}.opengridjs-filter-menu-header button:hover{background-color:var(--og-surface-hover);border-color:var(--og-accent)}.opengridjs-filter-menu-footer button{padding:var(--og-spacing-sm) var(--og-spacing-lg);font-size:var(--og-font-size);font-weight:var(--og-font-weight-medium)}.opengridjs-filter-search{padding:var(--og-spacing-md);border-bottom:1px solid var(--og-border)}.opengridjs-filter-search-input{width:90%;padding:var(--og-spacing-sm) var(--og-spacing-md);border:1px solid var(--og-border);border-radius:var(--og-radius-sm);background-color:var(--og-background);color:var(--og-text-primary);font-size:var(--og-font-size);font-family:var(--og-font-family);transition:var(--og-transition)}.opengridjs-filter-search-input:focus{outline:0;border-color:var(--og-accent);box-shadow:0 0 0 2px rgba(14,165,233,.1)}.opengridjs-filter-options{max-height:var(--og-filter-options-max-height);overflow-y:auto;padding:var(--og-spacing-sm) 0}.opengridjs-filter-option{display:flex;align-items:center;padding:var(--og-spacing-sm) var(--og-spacing-md);cursor:pointer;transition:var(--og-transition);gap:var(--og-spacing-sm)}.opengridjs-filter-option:hover{background-color:var(--og-surface-hover)}.opengridjs-filter-option input[type=checkbox]{margin:0;width:16px;height:16px;cursor:pointer;accent-color:var(--og-accent)}.opengridjs-filter-option span{flex:1;font-size:var(--og-font-size);color:var(--og-text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.opengridjs-filter-menu-footer{display:flex;gap:var(--og-spacing-sm);padding:var(--og-spacing-md);border-top:1px solid var(--og-border);background-color:var(--og-surface)}.opengridjs-filter-apply{background-color:var(--og-accent);color:#fff;border:1px solid var(--og-accent)}.opengridjs-filter-apply:hover{background-color:var(--og-accent-hover);border-color:var(--og-accent-hover)}.opengridjs-filter-cancel{background-color:var(--og-background);color:var(--og-text-primary);border:1px solid var(--og-border)}.opengridjs-filter-cancel:hover{background-color:var(--og-surface-hover);border-color:var(--og-accent)}.opengridjs-contextMenu{background-color:var(--og-context-bg);border:1px solid var(--og-context-border);border-radius:var(--og-radius-md);box-shadow:var(--og-shadow-hover);position:absolute;min-width:var(--og-context-menu-min-width);z-index:10000;overflow:hidden;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);animation:fadeIn .15s ease-out;isolation:isolate}.opengridjs-contextMenu .opengridjs-title{padding:var(--og-spacing-md) var(--og-spacing-lg);font-weight:var(--og-font-weight-semibold);color:var(--og-text-primary);border-bottom:1px solid var(--og-border);background-color:var(--og-surface);font-size:13px;text-transform:uppercase;letter-spacing:.5px}.opengridjs-contextMenu .opengridjs-btn{width:100%;padding:var(--og-spacing-md) var(--og-spacing-lg);border:none;background:0 0;color:var(--og-text-primary);cursor:pointer;transition:var(--og-transition-fast);text-align:left;font-size:var(--og-font-size);font-family:var(--og-font-family);display:flex;align-items:center;min-height:40px}.opengridjs-contextMenu .opengridjs-btn:hover{background-color:var(--og-context-hover);color:var(--og-accent)}.opengridjs-contextMenu .opengridjs-btn:active{background-color:var(--og-surface)}.opengridjs-contextMenu hr{border:none;border-top:1px solid var(--og-border);margin:var(--og-spacing-xs) 0}.opengridjs-grid-column-item.opengridjs-field-increased{background-color:rgba(34,197,94,.2);border-left:3px solid #22c55e;animation:fieldIncreased 2s ease-out}.opengridjs-grid-column-item.opengridjs-field-decreased{background-color:rgba(239,68,68,.2);border-left:3px solid #ef4444;animation:fieldDecreased 2s ease-out}.opengridjs-grid-column-item.opengridjs-field-updated{background-color:rgba(59,130,246,.15);border-left:3px solid #3b82f6;animation:fieldUpdated 2s ease-out}.opengridjs-filter-options::-webkit-scrollbar,.opengridjs-grid-rows-container::-webkit-scrollbar{width:8px}.opengridjs-filter-options::-webkit-scrollbar{width:6px}.opengridjs-filter-options::-webkit-scrollbar-track,.opengridjs-grid-rows-container::-webkit-scrollbar-track{background:var(--og-scrollbar-track);border-radius:var(--og-radius-sm)}.opengridjs-filter-options::-webkit-scrollbar-thumb,.opengridjs-grid-rows-container::-webkit-scrollbar-thumb{background:var(--og-scrollbar-thumb);border-radius:var(--og-radius-sm);transition:var(--og-transition)}.opengridjs-filter-options::-webkit-scrollbar-thumb:hover,.opengridjs-grid-rows-container::-webkit-scrollbar-thumb:hover{background:var(--og-scrollbar-thumb-hover)}.opengridjs-grid a{color:var(--og-accent);text-decoration:none;transition:var(--og-transition);border-radius:var(--og-radius-sm);padding:2px 4px;margin:-2px -4px}.opengridjs-grid a:hover{color:var(--og-accent-hover);background-color:var(--og-selected)}.opengridjs-grid a:focus{outline:2px solid var(--og-accent);outline-offset:2px}.opengridjs-contextMenu .opengridjs-btn:focus-visible,.opengridjs-grid-header-item:focus-visible,.opengridjs-grid-row:focus-visible{outline:2px solid var(--og-accent);outline-offset:-2px}@keyframes fadeIn{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}@keyframes fieldIncreased{0%{background-color:rgba(34,197,94,.4);border-left-color:#22c55e;transform:scale(1.02)}100%{background-color:rgba(34,197,94,.1);border-left-color:rgba(34,197,94,.3);transform:scale(1)}}@keyframes fieldDecreased{0%{background-color:rgba(239,68,68,.4);border-left-color:#ef4444;transform:scale(1.02)}100%{background-color:rgba(239,68,68,.1);border-left-color:rgba(239,68,68,.3);transform:scale(1)}}@keyframes fieldUpdated{0%{background-color:rgba(59,130,246,.3);border-left-color:#3b82f6;transform:scale(1.01)}100%{background-color:rgba(59,130,246,.05);border-left-color:rgba(59,130,246,.2);transform:scale(1)}}@media (max-width:768px){.opengridjs-grid{font-size:13px}.opengridjs-grid-column-item,.opengridjs-grid-header-item{padding:var(--og-spacing-sm) var(--og-spacing-md)}.opengridjs-grid-header-item,.opengridjs-grid-row{min-height:40px}}@media (prefers-contrast:high){.opengridjs-grid{--og-border:#000000;--og-shadow:0 0 0 1px #000000}[data-theme=dark] .opengridjs-grid{--og-border:#ffffff;--og-shadow:0 0 0 1px #ffffff}}@media (prefers-reduced-motion:reduce){.opengridjs-grid,.opengridjs-grid *{transition:none!important;animation:none!important}} --------------------------------------------------------------------------------