├── dist ├── index.d.ts ├── src │ ├── utils.d.ts │ ├── hooks │ │ ├── use-resize-observer.d.ts │ │ ├── use-data-table.d.ts │ │ ├── use-drag-drop.d.ts │ │ └── use-table.d.ts │ ├── index.d.ts │ ├── types │ │ └── table-type.d.ts │ └── models │ │ └── data-table-model.d.ts ├── index.js └── index.mjs ├── src ├── vite-env.d.ts ├── utils.ts ├── App.tsx ├── main.tsx ├── index.ts ├── types │ └── table-type.ts ├── components │ ├── landing-page.tsx │ ├── footer-section.tsx │ ├── demo-section.tsx │ ├── portal-component.tsx │ ├── hero-section.tsx │ ├── features-section.tsx │ ├── employee-columns.tsx │ ├── dropdown-portal.tsx │ └── employee-table.tsx ├── hooks │ ├── use-data-table.ts │ ├── use-resize-observer.ts │ ├── use-drag-drop.ts │ └── use-table.ts ├── index.css ├── data │ └── sample-employees.ts ├── models │ └── data-table-model.ts └── App.css ├── snaptable-react-2.0.0.tgz ├── tsconfig.json ├── index.html ├── .gitignore ├── tsconfig.node.json ├── tsconfig.build.json ├── .eslintrc.cjs ├── tsconfig.app.json ├── vite.config.ts ├── package.json ├── CHANGELOG.md └── README.md /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './src/index' 2 | export {} 3 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /snaptable-react-2.0.0.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gilitz/snaptable-react/HEAD/snaptable-react-2.0.0.tgz -------------------------------------------------------------------------------- /dist/src/utils.d.ts: -------------------------------------------------------------------------------- 1 | export declare const dataAttr: (flag: boolean | undefined, value?: string) => string | null; 2 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const dataAttr = (flag: boolean | undefined, value?: string) => { 2 | if (!flag) { 3 | return null; 4 | } 5 | return value ?? ''; 6 | }; 7 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import './App.css'; 2 | import { LandingPage } from './components/landing-page'; 3 | 4 | function App() { 5 | return ; 6 | } 7 | 8 | export default App; 9 | -------------------------------------------------------------------------------- /dist/src/hooks/use-resize-observer.d.ts: -------------------------------------------------------------------------------- 1 | import { RefObject } from 'react'; 2 | export declare const useResizeObserver: (ref: RefObject, callback: (entry: ResizeObserverEntry) => void) => void; 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.app.json" 6 | }, 7 | { 8 | "path": "./tsconfig.node.json" 9 | } 10 | ], 11 | } 12 | -------------------------------------------------------------------------------- /dist/src/hooks/use-data-table.d.ts: -------------------------------------------------------------------------------- 1 | import { default as DataTableModel, DataTableLiteType } from '../models/data-table-model'; 2 | export declare const useDataTable: ({ key, columns, ...props }: DataTableLiteType) => DataTableModel; 3 | export default useDataTable; 4 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | // @ts-expect-error 2 | import React from 'react' 3 | // @ts-expect-error 4 | import ReactDOM from 'react-dom/client' 5 | import App from './App'; 6 | import './index.css' 7 | 8 | ReactDOM.createRoot(document.getElementById('root')!).render( 9 | 10 | 11 | , 12 | ) 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist-ssr 12 | *.local 13 | 14 | # Editor directories and files 15 | .vscode/* 16 | !.vscode/extensions.json 17 | .idea 18 | .DS_Store 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 5 | "skipLibCheck": true, 6 | "module": "ESNext", 7 | "moduleResolution": "bundler", 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "noEmit": true 11 | }, 12 | "include": ["vite.config.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /dist/src/index.d.ts: -------------------------------------------------------------------------------- 1 | export { default as useDataTable } from './hooks/use-data-table'; 2 | export { useTable } from './hooks/use-table'; 3 | export { default as useDragAndDrop } from './hooks/use-drag-drop'; 4 | export { useResizeObserver } from './hooks/use-resize-observer'; 5 | export type { DataTableType, TableColumnType } from './models/data-table-model'; 6 | export type { default as SnapTableType } from './types/table-type'; 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useDataTable } from './hooks/use-data-table'; 2 | export { useTable } from './hooks/use-table'; 3 | export { default as useDragAndDrop } from './hooks/use-drag-drop'; 4 | export { useResizeObserver } from './hooks/use-resize-observer'; 5 | 6 | // Types only 7 | export type { DataTableType, TableColumnType } from './models/data-table-model'; 8 | export type { default as SnapTableType } from './types/table-type'; -------------------------------------------------------------------------------- /src/types/table-type.ts: -------------------------------------------------------------------------------- 1 | import { DataTableType } from "../models/data-table-model"; 2 | 3 | export type SnapTableType = { 4 | dataTable: DataTableType; 5 | data: unknown[]; 6 | tableContainerClass?: string; 7 | tableClass?: string; 8 | bodyClass?: string; 9 | headerRowClass?: string; 10 | rowClass?: string; 11 | headerCellClass?: string; 12 | nestedHeaderCellClass?:string; 13 | cellClass?: string; 14 | }; 15 | 16 | export default SnapTableType; -------------------------------------------------------------------------------- /src/components/landing-page.tsx: -------------------------------------------------------------------------------- 1 | import { HeroSection } from './hero-section'; 2 | import { DemoSection } from './demo-section'; 3 | import { FeaturesSection } from './features-section'; 4 | import { FooterSection } from './footer-section'; 5 | 6 | export const LandingPage = () => { 7 | return ( 8 |
9 | 10 | 11 | 12 | 13 |
14 | ); 15 | }; -------------------------------------------------------------------------------- /dist/src/types/table-type.d.ts: -------------------------------------------------------------------------------- 1 | import { DataTableType } from '../models/data-table-model'; 2 | export type SnapTableType = { 3 | dataTable: DataTableType; 4 | data: unknown[]; 5 | tableContainerClass?: string; 6 | tableClass?: string; 7 | bodyClass?: string; 8 | headerRowClass?: string; 9 | rowClass?: string; 10 | headerCellClass?: string; 11 | nestedHeaderCellClass?: string; 12 | cellClass?: string; 13 | }; 14 | export default SnapTableType; 15 | -------------------------------------------------------------------------------- /src/components/footer-section.tsx: -------------------------------------------------------------------------------- 1 | export const FooterSection = () => { 2 | return ( 3 |
4 |

5 | Made with ❤️ for the React community • 6 | GitHub • 7 | npm 8 |

9 |

snaptable-react v3.3.0

10 |
11 | ); 12 | }; -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.app.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "declaration": true, 6 | "emitDeclarationOnly": true, 7 | "outDir": "./dist" 8 | }, 9 | "include": [ 10 | "src/index.ts", 11 | "src/hooks/**/*", 12 | "src/models/**/*", 13 | "src/types/**/*", 14 | "src/utils.ts" 15 | ], 16 | "exclude": [ 17 | "src/main.tsx", 18 | "src/App.tsx", 19 | "src/App.css", 20 | "src/index.css", 21 | "examples/**/*", 22 | "node_modules", 23 | "dist" 24 | ] 25 | } -------------------------------------------------------------------------------- /dist/src/hooks/use-drag-drop.d.ts: -------------------------------------------------------------------------------- 1 | declare const useDragAndDrop: (onDragStart?: (item: T, index: number) => void, onDragEnd?: (fromIndex: number, toIndex: number) => void) => { 2 | draggedItem: T | null; 3 | draggedIndex: number | null; 4 | hoveredIndex: number | null; 5 | handleDragStart: (item: T, index: number) => void; 6 | handleDragOver: (e: DragEvent) => void; 7 | handleDragEnter: (index: number) => void; 8 | handleDragLeave: () => void; 9 | handleDrop: (e: DragEvent, toIndex: number) => void; 10 | }; 11 | export default useDragAndDrop; 12 | -------------------------------------------------------------------------------- /src/hooks/use-data-table.ts: -------------------------------------------------------------------------------- 1 | import DataTableModel, { DataTableLiteType } from "../models/data-table-model"; 2 | import { useRef } from "react"; 3 | 4 | export const useDataTable = ({ key, columns, ...props }: DataTableLiteType) => { 5 | // Keep the model stable to preserve MobX reactivity 6 | const modelRef = useRef(null); 7 | 8 | // Only create the model once 9 | if (!modelRef.current) { 10 | modelRef.current = new DataTableModel({ key, columns, ...props }); 11 | } 12 | 13 | return modelRef.current; 14 | } 15 | 16 | export default useDataTable; -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | color-scheme: light dark; 6 | color: rgba(255, 255, 255, 0.87); 7 | background-color: #242424; 8 | 9 | font-synthesis: none; 10 | text-rendering: optimizeLegibility; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | } 14 | 15 | body { 16 | margin: 0; 17 | display: flex; 18 | min-width: 320px; 19 | min-height: 100vh; 20 | } 21 | 22 | * { 23 | box-sizing: border-box; 24 | } 25 | -------------------------------------------------------------------------------- /src/hooks/use-resize-observer.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, RefObject } from 'react'; 2 | 3 | export const useResizeObserver = ( 4 | ref: RefObject, 5 | callback: (entry: ResizeObserverEntry) => void 6 | ) => { 7 | useLayoutEffect(() => { 8 | const element = ref?.current; 9 | if (!element) return; 10 | 11 | const observer = new ResizeObserver((entries) => { 12 | const entry = entries[0]; 13 | if (entry) { 14 | callback(entry); 15 | } 16 | }); 17 | 18 | observer.observe(element); 19 | return () => observer.disconnect(); 20 | }, [ref, callback]); 21 | }; -------------------------------------------------------------------------------- /src/components/demo-section.tsx: -------------------------------------------------------------------------------- 1 | import { EmployeeTable } from './employee-table'; 2 | 3 | export const DemoSection = () => { 4 | return ( 5 |
6 |
7 |

8 | Live Demo 9 |

10 |
11 |

Try resizing columns, dragging to reorder, and hiding/showing columns.

12 |

This table is built entirely with SnapTable React hooks.

13 |
14 |
15 | 16 | 17 |
18 | ); 19 | }; -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs', 'examples'], 10 | parser: '@typescript-eslint/parser', 11 | parserOptions: { 12 | project: ['./tsconfig.app.json', './tsconfig.node.json'], 13 | }, 14 | plugins: ['react-refresh'], 15 | rules: { 16 | "@typescript-eslint/ban-ts-comment": "off", 17 | "@typescript-eslint/prefer-nullish-coalescing": "error", 18 | 'react-refresh/only-export-components': [ 19 | 'warn', 20 | { allowConstantExport: true }, 21 | ], 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 5 | "target": "ES2020", 6 | "useDefineForClassFields": true, 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "module": "ESNext", 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "node", 13 | "allowImportingTsExtensions": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "moduleDetection": "force", 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true 25 | }, 26 | "include": ["src"], 27 | "exclude": ["examples"] 28 | } 29 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react-swc' 3 | import dts from 'vite-plugin-dts' 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [ 8 | react(), 9 | dts({ 10 | include: ['src/**/*'], 11 | exclude: ['src/main.tsx', 'src/App.tsx', 'src/App.css', 'src/index.css'], 12 | outDir: 'dist', 13 | insertTypesEntry: true, 14 | tsconfigPath: './tsconfig.build.json' 15 | }) 16 | ], 17 | build: { 18 | lib: { 19 | entry: 'src/index.ts', 20 | name: 'SnapTableReact', 21 | formats: ['es', 'cjs'], 22 | fileName: (format) => `index.${format === 'es' ? 'mjs' : 'js'}` 23 | }, 24 | rollupOptions: { 25 | external: ['react', 'react-dom', 'mobx', 'mobx-react'], 26 | output: { 27 | globals: { 28 | react: 'React', 29 | 'react-dom': 'ReactDOM', 30 | mobx: 'mobx', 31 | 'mobx-react': 'mobxReact' 32 | } 33 | } 34 | } 35 | } 36 | }) 37 | -------------------------------------------------------------------------------- /src/hooks/use-drag-drop.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | const useDragAndDrop = ( 4 | onDragStart?: (item: T, index: number) => void, 5 | onDragEnd?: (fromIndex: number, toIndex: number) => void 6 | ) => { 7 | const [draggedItem, setDraggedItem] = useState(null); 8 | const [draggedIndex, setDraggedIndex] = useState(null); 9 | const [hoveredIndex, setHoveredIndex] = useState(null); 10 | 11 | const handleDragStart = (item: T, index: number) => { 12 | setDraggedItem(item); 13 | setDraggedIndex(index); 14 | onDragStart?.(item, index); 15 | }; 16 | 17 | const handleDragOver = (e: DragEvent) => { 18 | e.preventDefault(); 19 | }; 20 | 21 | const handleDragEnter = (index: number) => { 22 | setHoveredIndex(index); 23 | }; 24 | 25 | const handleDragLeave = () => { 26 | setHoveredIndex(null); 27 | }; 28 | 29 | const handleDrop = (e: DragEvent, toIndex: number) => { 30 | e.preventDefault(); 31 | 32 | if (draggedIndex !== null && draggedIndex !== toIndex) { 33 | onDragEnd?.(draggedIndex, toIndex); 34 | } 35 | 36 | setDraggedItem(null); 37 | setDraggedIndex(null); 38 | setHoveredIndex(null); 39 | }; 40 | 41 | return { 42 | draggedItem, 43 | draggedIndex, 44 | hoveredIndex, 45 | handleDragStart, 46 | handleDragOver, 47 | handleDragEnter, 48 | handleDragLeave, 49 | handleDrop, 50 | }; 51 | }; 52 | 53 | export default useDragAndDrop; -------------------------------------------------------------------------------- /src/components/portal-component.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useState } from 'react'; 2 | import { createPortal } from 'react-dom'; 3 | 4 | interface PortalProps { 5 | children: ReactNode; 6 | containerId?: string; 7 | } 8 | 9 | const PortalComponent: React.FC = ({ children, containerId = 'portal-root' }) => { 10 | const [container, setContainer] = useState(null); 11 | 12 | useEffect(() => { 13 | // Try to find existing container 14 | let containerElement = document.getElementById(containerId); 15 | 16 | // If it doesn't exist, create it 17 | if (!containerElement) { 18 | containerElement = document.createElement('div'); 19 | containerElement.id = containerId; 20 | containerElement.style.position = 'absolute'; 21 | containerElement.style.top = '0'; 22 | containerElement.style.left = '0'; 23 | containerElement.style.zIndex = '10000'; 24 | containerElement.style.pointerEvents = 'none'; 25 | document.body.appendChild(containerElement); 26 | } 27 | 28 | setContainer(containerElement); 29 | 30 | // Cleanup function 31 | return () => { 32 | // Only remove if it's empty and we created it 33 | if (containerElement && containerElement.children.length === 0 && containerElement.id === containerId) { 34 | document.body.removeChild(containerElement); 35 | } 36 | }; 37 | }, [containerId]); 38 | 39 | if (!container) { 40 | return null; 41 | } 42 | 43 | return createPortal(children, container); 44 | }; 45 | 46 | export default PortalComponent; -------------------------------------------------------------------------------- /dist/src/hooks/use-table.d.ts: -------------------------------------------------------------------------------- 1 | import { default as DataTable } from '../models/data-table-model'; 2 | export declare function useTable>(dataTable: DataTable, data: T[]): { 3 | columns: import('../models/data-table-model').TableColumnType[]; 4 | data: T[]; 5 | config: DataTable; 6 | columnWidths: { 7 | key: string; 8 | width?: number; 9 | }[]; 10 | stickyColumns: { 11 | key: string; 12 | sticky: boolean; 13 | }[]; 14 | hiddenColumns: { 15 | key: string; 16 | hidden: boolean; 17 | }[]; 18 | stickyOffsets: { 19 | [key: string]: number; 20 | }; 21 | getColumnProps: (index: number) => { 22 | width: string; 23 | isDraggable: boolean; 24 | isResizable: boolean | undefined; 25 | isSticky: boolean; 26 | stickyOffset: number; 27 | zIndex: number; 28 | onDragStart: (e: DragEvent) => void; 29 | onDragOver: (e: DragEvent) => void; 30 | onDrop: (e: DragEvent) => void; 31 | onResizeStart: (e: MouseEvent, headerElement?: HTMLElement) => void; 32 | onToggleSticky: (headerElement?: HTMLElement) => void; 33 | onToggleHidden: () => void; 34 | registerHeaderRef: (element: HTMLElement | null) => void; 35 | }; 36 | getCellProps: (columnIndex: number) => { 37 | width: string; 38 | isSticky: boolean; 39 | stickyOffset: number; 40 | zIndex: number; 41 | }; 42 | getRowProps: (item: T) => { 43 | onClick: () => void; 44 | }; 45 | updateActualWidths: () => void; 46 | getHiddenColumns: () => import('../models/data-table-model').TableColumnType[]; 47 | toggleColumnHidden: (columnKey: string) => void; 48 | }; 49 | export default useTable; 50 | -------------------------------------------------------------------------------- /src/components/hero-section.tsx: -------------------------------------------------------------------------------- 1 | export const HeroSection = () => { 2 | return ( 3 |
4 |
5 |
6 | v3.3.0 7 | ✨ Truly Headless 8 |
9 | 10 |

11 | SnapTable 12 |
13 |

14 | 15 |

16 | The Ultimate Headless Table Library 17 |

18 | 19 |
20 |

Build powerful, customizable data tables with zero UI constraints.

21 |

Pure hooks, complete control, endless possibilities.

22 |
23 | 24 |
25 |
🎯 Truly Headless
26 |
📏 Column Resizing
27 |
🔄 Drag & Drop
28 |
📌 Sticky Columns
29 |
👁️ Column Visibility
30 |
📱 Mobile Ready
31 |
⚡ Lightweight
32 |
🎨 Zero CSS
33 |
34 | 35 | 45 |
46 |
47 | ); 48 | }; -------------------------------------------------------------------------------- /src/components/features-section.tsx: -------------------------------------------------------------------------------- 1 | export const FeaturesSection = () => { 2 | return ( 3 |
4 |

5 | Why Choose SnapTable? 6 |

7 | 8 |
9 |
10 | 🎯 11 |

Truly Headless

12 |

No predefined UI components. Just pure logic and hooks that give you complete control over styling and behavior.

13 |
14 | 15 |
16 | 17 |

Lightning Fast

18 |

Optimized for performance with minimal bundle size. Only 5.65kB gzipped - perfect for production apps.

19 |
20 | 21 |
22 | 🔧 23 |

Highly Customizable

24 |

Every aspect is configurable. Column resizing, drag & drop, sticky headers, sticky columns, show/hide columns, and more - all optional and customizable.

25 |
26 | 27 |
28 | 📱 29 |

Mobile Ready

30 |

Built with responsive design in mind. Works perfectly on desktop, tablet, and mobile devices out of the box.

31 |
32 | 33 |
34 | 🎨 35 |

Style Freedom

36 |

Bring your own CSS framework. Works with Tailwind, Styled Components, CSS Modules, or plain CSS.

37 |
38 | 39 |
40 | 🔄 41 |

Modern React

42 |

Built with modern React patterns. Hooks, TypeScript support, and excellent developer experience included.

43 |
44 |
45 |
46 | ); 47 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snaptable-react", 3 | "version": "3.3.0", 4 | "type": "module", 5 | "main": "dist/index.js", 6 | "module": "dist/index.mjs", 7 | "types": "dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "import": "./dist/index.mjs", 11 | "require": "./dist/index.js", 12 | "types": "./dist/index.d.ts" 13 | } 14 | }, 15 | "files": [ 16 | "dist", 17 | "CHANGELOG.md" 18 | ], 19 | "scripts": { 20 | "dev": "vite", 21 | "build": "tsc -b && vite build", 22 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 23 | "preview": "vite preview", 24 | "prepublishOnly": "npm run build" 25 | }, 26 | "peerDependencies": { 27 | "mobx": ">=6.0.0", 28 | "mobx-react": ">=9.0.0", 29 | "react": ">=16.8.0", 30 | "react-dom": ">=16.8.0" 31 | }, 32 | "devDependencies": { 33 | "@types/react": "^18.3.3", 34 | "@types/react-dom": "^18.3.0", 35 | "@typescript-eslint/eslint-plugin": "^7.15.0", 36 | "@typescript-eslint/parser": "^7.15.0", 37 | "@vitejs/plugin-react-swc": "^3.5.0", 38 | "eslint": "^8.57.0", 39 | "eslint-plugin-react-hooks": "^4.6.2", 40 | "eslint-plugin-react-refresh": "^0.4.7", 41 | "mobx": "^6.13.1", 42 | "mobx-react": "^9.1.1", 43 | "react": "^18.3.1", 44 | "react-dom": "^18.3.1", 45 | "typescript": "^5.2.2", 46 | "vite": "^5.3.4", 47 | "vite-plugin-dts": "^4.0.3" 48 | }, 49 | "description": "A truly headless React table library providing only hooks and logic for building custom tables. Features column resizing, drag & drop, and layout persistence with zero styling opinions.", 50 | "repository": { 51 | "type": "git", 52 | "url": "git+https://github.com/gilitz/snaptable-react.git" 53 | }, 54 | "keywords": [ 55 | "react", 56 | "headless", 57 | "headless-ui", 58 | "react-table", 59 | "table", 60 | "hooks", 61 | "drag-drop", 62 | "resizable", 63 | "columns", 64 | "sticky-columns", 65 | "mobx", 66 | "typescript", 67 | "unstyled", 68 | "customizable" 69 | ], 70 | "author": "gilitz", 71 | "license": "ISC", 72 | "bugs": { 73 | "url": "https://github.com/gilitz/snaptable-react/issues" 74 | }, 75 | "homepage": "https://github.com/gilitz/snaptable-react#readme" 76 | } 77 | -------------------------------------------------------------------------------- /src/components/employee-columns.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | // Helper functions 4 | export const getEmployeeInitials = (name: string) => { 5 | return name 6 | .split(" ") 7 | .map((n) => n[0]) 8 | .join("") 9 | .toUpperCase(); 10 | }; 11 | 12 | export const getDepartmentClass = (department: string) => { 13 | return department.toLowerCase().replace(/\s+/g, "-"); 14 | }; 15 | 16 | // Column definitions 17 | export const employeeColumns = [ 18 | { 19 | key: "employee", 20 | label: "Employee", 21 | width: 280, 22 | resizeable: true, 23 | Cell: ({ data }: { data: any }) => ( 24 |
25 |
26 | {getEmployeeInitials(data.name)} 27 |
28 |
29 |
{data.name}
30 |
{data.position}
31 |
32 |
33 | ), 34 | }, 35 | { 36 | key: "department", 37 | label: "Department", 38 | width: 140, 39 | resizeable: true, 40 | Cell: ({ data }: { data: any }) => ( 41 | 42 | {data.department} 43 | 44 | ), 45 | }, 46 | { 47 | key: "salary", 48 | label: "Salary", 49 | width: 120, 50 | resizeable: true, 51 | Cell: ({ data }: { data: any }) => ( 52 | {data.salary} 53 | ), 54 | }, 55 | { 56 | key: "location", 57 | label: "Location", 58 | width: 200, 59 | resizeable: true, 60 | Cell: ({ data }: { data: any }) => ( 61 |
62 | 📍 63 | {data.location} 64 |
65 | ), 66 | }, 67 | { 68 | key: "experience", 69 | label: "Experience", 70 | width: 120, 71 | resizeable: true, 72 | Cell: ({ data }: { data: any }) => ( 73 | {data.experience} 74 | ), 75 | }, 76 | { 77 | key: "status", 78 | label: "Status", 79 | width: 100, 80 | resizeable: true, 81 | Cell: ({ data }: { data: any }) => ( 82 |
83 | 84 | {data.status} 85 |
86 | ), 87 | }, 88 | ]; -------------------------------------------------------------------------------- /dist/src/models/data-table-model.d.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | type NestedColumnType = { 3 | key: string; 4 | label?: string | ReactNode; 5 | Cell?: (props: { 6 | data: Record; 7 | }) => ReactNode; 8 | }; 9 | export type TableColumnType = { 10 | key: string; 11 | label: string | ReactNode; 12 | width?: number; 13 | resizeable?: boolean; 14 | sticky?: boolean; 15 | hidden?: boolean; 16 | Cell: (props: { 17 | data: Record; 18 | }) => ReactNode; 19 | nestedColumns?: NestedColumnType[]; 20 | }; 21 | type ColumnWidthType = { 22 | key: string; 23 | width?: number; 24 | }; 25 | type StickyColumnType = { 26 | key: string; 27 | sticky: boolean; 28 | }; 29 | type HiddenColumnType = { 30 | key: string; 31 | hidden: boolean; 32 | }; 33 | export interface DataTableLiteType { 34 | key: string; 35 | columns: TableColumnType[]; 36 | hasDraggableColumns?: boolean; 37 | saveLayoutView?: boolean; 38 | defaultColumnWidth?: number | string; 39 | isStickyHeader?: boolean; 40 | hasStickyColumns?: boolean; 41 | onRowClick?: ({ item }: { 42 | item: Record; 43 | }) => void; 44 | } 45 | export type DataTableType = DataTableLiteType & { 46 | moveColumn: (index: number, toIndex: number) => void; 47 | setColumnsWidth: (widths: ColumnWidthType[]) => void; 48 | columnsWidth: ColumnWidthType[]; 49 | stickyColumns: StickyColumnType[]; 50 | setStickyColumns: (stickyColumns: StickyColumnType[]) => void; 51 | toggleColumnSticky: (columnKey: string, actualWidth?: number) => void; 52 | hiddenColumns: HiddenColumnType[]; 53 | setHiddenColumns: (hiddenColumns: HiddenColumnType[]) => void; 54 | toggleColumnHidden: (columnKey: string) => void; 55 | getVisibleColumns: () => TableColumnType[]; 56 | getHiddenColumns: () => TableColumnType[]; 57 | getStickyColumnsOffsets: () => { 58 | [key: string]: number; 59 | }; 60 | updateActualWidths: (headerElements: { 61 | [key: string]: HTMLElement; 62 | }) => void; 63 | getColumnActualWidth: (columnKey: string, fallbackWidth: number) => number; 64 | actualRenderedWidths: { 65 | [key: string]: number; 66 | }; 67 | }; 68 | declare class DataTable { 69 | key: string; 70 | columns: any; 71 | saveLayoutView: boolean; 72 | hasDraggableColumns: boolean; 73 | isStickyHeader: boolean; 74 | hasStickyColumns: boolean; 75 | onRowClick: (({ item }: { 76 | item: Record; 77 | }) => void) | undefined; 78 | columnsWidth: ColumnWidthType[]; 79 | stickyColumns: StickyColumnType[]; 80 | hiddenColumns: HiddenColumnType[]; 81 | actualRenderedWidths: { 82 | [key: string]: number; 83 | }; 84 | updateActualWidths(headerElements: { 85 | [key: string]: HTMLElement; 86 | }): void; 87 | getColumnActualWidth(columnKey: string, fallbackWidth: number): number; 88 | constructor({ key, columns, saveLayoutView, hasDraggableColumns, isStickyHeader, hasStickyColumns, onRowClick, defaultColumnWidth }: DataTableLiteType); 89 | moveColumn(index: number, toIndex: number): void; 90 | setColumnsWidth(widths: ColumnWidthType[]): void; 91 | setStickyColumns(stickyColumns: StickyColumnType[]): void; 92 | toggleColumnSticky(columnKey: string, actualWidth?: number): void; 93 | getStickyColumnsOffsets(): { 94 | [key: string]: number; 95 | }; 96 | setHiddenColumns(hiddenColumns: HiddenColumnType[]): void; 97 | toggleColumnHidden(columnKey: string): void; 98 | getVisibleColumns(): TableColumnType[]; 99 | getHiddenColumns(): TableColumnType[]; 100 | } 101 | export default DataTable; 102 | -------------------------------------------------------------------------------- /src/components/dropdown-portal.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useState, useRef } from 'react'; 2 | import PortalComponent from './portal-component'; 3 | 4 | interface DropdownPortalProps { 5 | children: ReactNode; 6 | isOpen: boolean; 7 | triggerRef: React.RefObject; 8 | onClose: () => void; 9 | className?: string; 10 | align?: 'left' | 'right'; 11 | } 12 | 13 | const DropdownPortal: React.FC = ({ 14 | children, 15 | isOpen, 16 | triggerRef, 17 | onClose, 18 | className = '', 19 | align = 'right' 20 | }) => { 21 | const [position, setPosition] = useState({ top: 0, left: 0 }); 22 | const dropdownRef = useRef(null); 23 | 24 | useEffect(() => { 25 | if (isOpen && triggerRef.current) { 26 | const updatePosition = () => { 27 | const triggerRect = triggerRef.current!.getBoundingClientRect(); 28 | const scrollX = window.pageXOffset ?? document.documentElement.scrollLeft; 29 | const scrollY = window.pageYOffset ?? document.documentElement.scrollTop; 30 | 31 | let left = triggerRect.left + scrollX; 32 | let top = triggerRect.bottom + scrollY + 4; // 4px margin 33 | 34 | // Align right edge of dropdown with right edge of trigger 35 | if (align === 'right' && dropdownRef.current) { 36 | const dropdownWidth = dropdownRef.current.offsetWidth ?? 120; // fallback width 37 | left = triggerRect.right + scrollX - dropdownWidth; 38 | } 39 | 40 | // Ensure dropdown doesn't go off-screen 41 | const viewportWidth = window.innerWidth; 42 | const viewportHeight = window.innerHeight; 43 | 44 | if (left < 0) left = 0; 45 | if (left + (dropdownRef.current?.offsetWidth ?? 120) > viewportWidth) { 46 | left = viewportWidth - (dropdownRef.current?.offsetWidth ?? 120) - 10; 47 | } 48 | 49 | if (top + (dropdownRef.current?.offsetHeight ?? 100) > viewportHeight + scrollY) { 50 | top = triggerRect.top + scrollY - (dropdownRef.current?.offsetHeight ?? 100) - 4; 51 | } 52 | 53 | setPosition({ top, left }); 54 | }; 55 | 56 | updatePosition(); 57 | 58 | // Update position on scroll/resize 59 | window.addEventListener('scroll', updatePosition); 60 | window.addEventListener('resize', updatePosition); 61 | 62 | return () => { 63 | window.removeEventListener('scroll', updatePosition); 64 | window.removeEventListener('resize', updatePosition); 65 | }; 66 | } 67 | }, [isOpen, triggerRef, align]); 68 | 69 | useEffect(() => { 70 | if (isOpen) { 71 | const handleClickOutside = (event: MouseEvent) => { 72 | if ( 73 | dropdownRef.current && 74 | !dropdownRef.current.contains(event.target as Node) && 75 | triggerRef.current && 76 | !triggerRef.current.contains(event.target as Node) 77 | ) { 78 | onClose(); 79 | } 80 | }; 81 | 82 | document.addEventListener('mousedown', handleClickOutside); 83 | return () => document.removeEventListener('mousedown', handleClickOutside); 84 | } 85 | }, [isOpen, onClose, triggerRef]); 86 | 87 | if (!isOpen) return null; 88 | 89 | return ( 90 | 91 |
102 | {children} 103 |
104 |
105 | ); 106 | }; 107 | 108 | export default DropdownPortal; -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## v3.3.0 9 | 10 | ### Added 11 | 12 | - Automated z-index calculation for sticky columns and headers 13 | - `props.zIndex` property in column header props 14 | - `cellProps.zIndex` property in cell props 15 | - Automatic z-index management based on sticky column position and sticky header state 16 | 17 | ### Changed 18 | 19 | - Z-index calculations are now handled internally by the library 20 | - Simplified user code by removing manual z-index logic requirements 21 | - Enhanced developer experience with automatic layering management 22 | 23 | ### Improved 24 | 25 | - Reduced boilerplate code for sticky column implementations 26 | - Cleaner user components with less complex logic 27 | - Better separation of concerns between library logic and user UI 28 | 29 | ## v3.2.0 30 | 31 | ### Added 32 | 33 | - Show/Hide columns functionality with built-in state management 34 | - `tableState.getHiddenColumns()` method to get array of hidden columns 35 | - `tableState.toggleColumnHidden(columnKey)` method to toggle specific column visibility 36 | - `props.onToggleHidden()` method to hide a column from column header 37 | - `column.hidden` property to set initial hidden state in column definition 38 | - Enhanced layout persistence for hidden column states 39 | 40 | ### Changed 41 | 42 | - Hidden column states are now saved to localStorage when `saveLayoutView` is enabled 43 | - Improved component architecture and naming conventions 44 | 45 | ### Fixed 46 | 47 | - Z-index issues when sticky headers and sticky columns are used together 48 | 49 | ## v3.1.0 50 | 51 | ### Added 52 | 53 | - Sticky columns functionality with `hasStickyColumns` configuration 54 | - `column.sticky` property to mark columns as sticky 55 | - `props.isSticky` and `cellProps.isSticky` properties 56 | - `props.stickyOffset` and `cellProps.stickyOffset` for positioning 57 | - `props.onToggleSticky()` method to dynamically toggle sticky state 58 | - `tableState.stickyColumns` and `tableState.stickyOffsets` properties 59 | 60 | ### Changed 61 | 62 | - Enhanced drag & drop with sticky column constraints 63 | - Improved layout persistence to include sticky column states 64 | 65 | ### Fixed 66 | 67 | - Column reordering with sticky columns 68 | - Layout persistence for sticky column configurations 69 | 70 | ## v3.0.0 71 | 72 | ### Added 73 | 74 | - Complete rewrite as a truly headless library 75 | - `useDataTable` hook for configuration 76 | - `useTable` hook for table state management 77 | - Column resizing functionality 78 | - Drag & drop column reordering 79 | - Layout persistence with localStorage 80 | - Sticky header support 81 | - Row click handlers 82 | - TypeScript support with proper type definitions 83 | 84 | ### Changed 85 | 86 | - **BREAKING**: Removed all UI components - now purely headless 87 | - **BREAKING**: New API based on hooks instead of components 88 | - **BREAKING**: Users must now build their own table markup 89 | 90 | ### Removed 91 | 92 | - **BREAKING**: All built-in components and styling 93 | - **BREAKING**: Previous component-based API 94 | 95 | ## v2.x - Legacy Versions 96 | 97 | ### Note 98 | 99 | - Version 2.x and earlier were component-based with built-in styling 100 | - These versions are no longer maintained 101 | - See migration guide in README for upgrading to v3.x headless architecture 102 | 103 | --- 104 | 105 | ## Migration Guides 106 | 107 | ### From v2.x to v3.x 108 | 109 | See the [Migration Guide](./README.md#migration-from-v2x) in the README for detailed instructions on upgrading from component-based to headless architecture. 110 | 111 | ### From v3.2.x to v3.3.x 112 | 113 | No breaking changes. The z-index calculations are now handled automatically, but the old manual approach will still work if you prefer to override the automatic values. 114 | -------------------------------------------------------------------------------- /src/data/sample-employees.ts: -------------------------------------------------------------------------------- 1 | // Sample data with more realistic content 2 | export const employees = [ 3 | { 4 | key: "1", 5 | name: "Sarah Johnson", 6 | department: "Engineering", 7 | position: "Senior Frontend Developer", 8 | salary: "$125,000", 9 | location: "San Francisco, CA", 10 | experience: "5 years", 11 | status: "Active", 12 | }, 13 | { 14 | key: "2", 15 | name: "Michael Chen", 16 | department: "Engineering", 17 | position: "Full Stack Developer", 18 | salary: "$110,000", 19 | location: "Austin, TX", 20 | experience: "3 years", 21 | status: "Active", 22 | }, 23 | { 24 | key: "3", 25 | name: "Emily Rodriguez", 26 | department: "Design", 27 | position: "UX Designer", 28 | salary: "$95,000", 29 | location: "New York, NY", 30 | experience: "4 years", 31 | status: "Active", 32 | }, 33 | { 34 | key: "4", 35 | name: "David Park", 36 | department: "Marketing", 37 | position: "Growth Marketing Manager", 38 | salary: "$88,000", 39 | location: "Los Angeles, CA", 40 | experience: "2 years", 41 | status: "Active", 42 | }, 43 | { 44 | key: "5", 45 | name: "Jessica Thompson", 46 | department: "Sales", 47 | position: "Account Executive", 48 | salary: "$92,000", 49 | location: "Chicago, IL", 50 | experience: "3 years", 51 | status: "Active", 52 | }, 53 | { 54 | key: "6", 55 | name: "Alex Kumar", 56 | department: "Engineering", 57 | position: "Backend Developer", 58 | salary: "$115,000", 59 | location: "Seattle, WA", 60 | experience: "4 years", 61 | status: "Active", 62 | }, 63 | { 64 | key: "7", 65 | name: "Maria Garcia", 66 | department: "HR", 67 | position: "HR Business Partner", 68 | salary: "$78,000", 69 | location: "Miami, FL", 70 | experience: "6 years", 71 | status: "Active", 72 | }, 73 | { 74 | key: "8", 75 | name: "James Wilson", 76 | department: "Engineering", 77 | position: "DevOps Engineer", 78 | salary: "$120,000", 79 | location: "Denver, CO", 80 | experience: "5 years", 81 | status: "Active", 82 | }, 83 | { 84 | key: "9", 85 | name: "Lisa Chang", 86 | department: "Design", 87 | position: "Product Designer", 88 | salary: "$98,000", 89 | location: "Portland, OR", 90 | experience: "3 years", 91 | status: "Active", 92 | }, 93 | { 94 | key: "10", 95 | name: "Robert Taylor", 96 | department: "Sales", 97 | position: "Sales Director", 98 | salary: "$135,000", 99 | location: "Boston, MA", 100 | experience: "8 years", 101 | status: "Active", 102 | }, 103 | { 104 | key: "11", 105 | name: "Amanda Foster", 106 | department: "Marketing", 107 | position: "Content Marketing Lead", 108 | salary: "$85,000", 109 | location: "Nashville, TN", 110 | experience: "4 years", 111 | status: "Active", 112 | }, 113 | { 114 | key: "12", 115 | name: "Kevin Lee", 116 | department: "Engineering", 117 | position: "Software Architect", 118 | salary: "$140,000", 119 | location: "San Jose, CA", 120 | experience: "7 years", 121 | status: "Active", 122 | }, 123 | { 124 | key: "13", 125 | name: "Rachel Green", 126 | department: "HR", 127 | position: "Talent Acquisition Specialist", 128 | salary: "$72,000", 129 | location: "Atlanta, GA", 130 | experience: "2 years", 131 | status: "Active", 132 | }, 133 | { 134 | key: "14", 135 | name: "Daniel Martinez", 136 | department: "Design", 137 | position: "UI/UX Designer", 138 | salary: "$90,000", 139 | location: "Phoenix, AZ", 140 | experience: "3 years", 141 | status: "Active", 142 | }, 143 | { 144 | key: "15", 145 | name: "Sophie Anderson", 146 | department: "Marketing", 147 | position: "Digital Marketing Specialist", 148 | salary: "$75,000", 149 | location: "Orlando, FL", 150 | experience: "2 years", 151 | status: "Active", 152 | }, 153 | { 154 | key: "16", 155 | name: "Chris Brown", 156 | department: "Sales", 157 | position: "Business Development Rep", 158 | salary: "$68,000", 159 | location: "Dallas, TX", 160 | experience: "1 year", 161 | status: "Active", 162 | }, 163 | { 164 | key: "17", 165 | name: "Nicole Davis", 166 | department: "Engineering", 167 | position: "QA Engineer", 168 | salary: "$95,000", 169 | location: "Minneapolis, MN", 170 | experience: "4 years", 171 | status: "Active", 172 | }, 173 | { 174 | key: "18", 175 | name: "Ryan Miller", 176 | department: "HR", 177 | position: "HR Generalist", 178 | salary: "$70,000", 179 | location: "Salt Lake City, UT", 180 | experience: "3 years", 181 | status: "Active", 182 | }, 183 | { 184 | key: "19", 185 | name: "Emma Johnson", 186 | department: "Design", 187 | position: "Visual Designer", 188 | salary: "$87,000", 189 | location: "Richmond, VA", 190 | experience: "2 years", 191 | status: "Active", 192 | }, 193 | { 194 | key: "20", 195 | name: "Thomas White", 196 | department: "Sales", 197 | position: "Regional Sales Manager", 198 | salary: "$105,000", 199 | location: "Houston, TX", 200 | experience: "6 years", 201 | status: "Active", 202 | }, 203 | ]; -------------------------------------------------------------------------------- /src/components/employee-table.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { useState, useRef } from 'react'; 3 | import { useDataTable, useTable } from '../index'; 4 | import { employees } from '../data/sample-employees'; 5 | import { employeeColumns } from './employee-columns'; 6 | import DropdownPortal from './dropdown-portal'; 7 | 8 | export const EmployeeTable = () => { 9 | const [openKebabMenu, setOpenKebabMenu] = useState(null); 10 | const [showHiddenDropdown, setShowHiddenDropdown] = useState(false); 11 | const hiddenDropdownRef = useRef(null); 12 | const kebabRefs = useRef<{ [key: string]: HTMLButtonElement | null }>({}); 13 | 14 | const dataTable = useDataTable({ 15 | key: "employee-table", 16 | columns: employeeColumns, 17 | hasDraggableColumns: true, 18 | isStickyHeader: true, 19 | hasStickyColumns: true, // Enable sticky columns functionality 20 | saveLayoutView: true, 21 | onRowClick: ({ item }) => { 22 | console.log("Clicked employee:", item); 23 | }, 24 | }); 25 | 26 | const table = useTable(dataTable, employees); 27 | 28 | return ( 29 |
30 | {/* Hidden columns dropdown - always visible */} 31 |
32 | 44 | 0} 46 | triggerRef={hiddenDropdownRef} 47 | onClose={() => setShowHiddenDropdown(false)} 48 | className="hidden-columns-menu" 49 | align="right" 50 | > 51 | {table.getHiddenColumns().map((column) => ( 52 | 62 | ))} 63 | 64 |
65 | 66 |
67 | 68 | 69 | 70 | {table.columns.map((column, index) => { 71 | const props = table.getColumnProps(index); 72 | const isLastColumn = index === table.columns.length - 1; 73 | 74 | return ( 75 | 148 | ); 149 | })} 150 | 151 | 152 | 153 | {table.data.map((employee) => { 154 | const rowProps = table.getRowProps(employee); 155 | return ( 156 | 161 | {table.columns.map((column, columnIndex) => { 162 | const cellProps = table.getCellProps(columnIndex); 163 | const isLastColumn = columnIndex === table.columns.length - 1; 164 | 165 | return ( 166 | 178 | ); 179 | })} 180 | 181 | ); 182 | })} 183 | 184 |
{ 78 | // Register the header element reference for accurate width calculations 79 | props.registerHeaderRef(headerRef); 80 | }} 81 | className={`demo-header ${dataTable.isStickyHeader ? 'sticky' : ''} ${props.isSticky ? 'column-sticky' : ''} ${!isLastColumn ? 'has-border' : ''}`} 82 | style={{ 83 | width: props.width, 84 | left: props.isSticky ? `${Math.floor(props.stickyOffset)}px` : undefined, 85 | position: props.isSticky ? 'sticky' : 'relative', 86 | zIndex: props.zIndex, 87 | }} 88 | draggable={props.isDraggable} 89 | onDragStart={props.onDragStart} 90 | onDragOver={props.onDragOver} 91 | onDrop={props.onDrop} 92 | > 93 |
94 | {column.label} 95 |
96 |
97 | 105 | setOpenKebabMenu(null)} 109 | className="kebab-dropdown" 110 | align="right" 111 | > 112 | 122 | 131 | 132 |
133 | {props.isDraggable && column.key !== 'department' && ( 134 | ⋮⋮ 135 | )} 136 |
137 |
138 | {props.isResizable && ( 139 |
{ 142 | const headerElement = (e.target as HTMLElement).closest('th') as HTMLElement; 143 | props.onResizeStart(e.nativeEvent, headerElement); 144 | }} 145 | /> 146 | )} 147 |
176 | 177 |
185 |
186 |
187 | ); 188 | }; -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | "use strict";var N=Object.defineProperty;var J=(e,t,s)=>t in e?N(e,t,{enumerable:!0,configurable:!0,writable:!0,value:s}):e[t]=s;var v=(e,t,s)=>J(e,typeof t!="symbol"?t+"":t,s);Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const x=require("mobx"),O=require("react"),$=require("mobx-react");class b{constructor({key:t,columns:s,saveLayoutView:i,hasDraggableColumns:c,isStickyHeader:h,hasStickyColumns:k,onRowClick:d,defaultColumnWidth:m="auto"}){v(this,"key");v(this,"columns");v(this,"saveLayoutView");v(this,"hasDraggableColumns");v(this,"isStickyHeader");v(this,"hasStickyColumns");v(this,"onRowClick");v(this,"columnsWidth");v(this,"stickyColumns");v(this,"hiddenColumns");v(this,"actualRenderedWidths",{});x.makeAutoObservable(this);const S=localStorage.getItem(t),g=S?JSON.parse(S):null,a=localStorage.getItem(`${t}_sticky`),u=a?JSON.parse(a):null;this.key=t,this.saveLayoutView=i??!1,this.hasDraggableColumns=c??!0,this.isStickyHeader=h??!1,this.hasStickyColumns=k??!1,this.onRowClick=d,this.columnsWidth=s.map(y=>{const C=g==null?void 0:g.find(({key:n})=>n===y.key),p=typeof m=="number"?m:void 0;return{key:y.key,width:(C==null?void 0:C.width)??y.width??p}}),this.stickyColumns=s.map(y=>{const C=u==null?void 0:u.find(({key:p})=>p===y.key);return{key:y.key,sticky:(C==null?void 0:C.sticky)??y.sticky??!1}});const l=localStorage.getItem(`${t}_hidden`),r=l?JSON.parse(l):null;if(this.hiddenColumns=s.map(y=>{const C=r==null?void 0:r.find(({key:p})=>p===y.key);return{key:y.key,hidden:(C==null?void 0:C.hidden)??y.hidden??!1}}),i)if(g){const y=s.filter(n=>!g.map(({key:f})=>f).includes(n.key)),C=g.reduce((n,f)=>{const I=s.filter(({key:o})=>o===f.key)[0];return I&&(n=[...n,I]),n},[]),p=g.reduce((n,f)=>{const I=s.filter(({key:w})=>w===f.key)[0];if(!I)return n;const o=typeof m=="number"?m:void 0;return n=[...n,{key:I.key,width:f.width??I.width??o}],n},[]);this.columns=C.concat(y),this.columnsWidth=p.concat(y.map(({key:n,width:f})=>({key:n,width:f??m}))),this.stickyColumns=this.columns.map(n=>{const f=u==null?void 0:u.find(({key:I})=>I===n.key);return{key:n.key,sticky:(f==null?void 0:f.sticky)??n.sticky??!1}}),this.hiddenColumns=this.columns.map(n=>{const f=r==null?void 0:r.find(({key:I})=>I===n.key);return{key:n.key,hidden:(f==null?void 0:f.hidden)??n.hidden??!1}})}else localStorage.setItem(t,JSON.stringify(this.columnsWidth)),this.hasStickyColumns&&localStorage.setItem(`${t}_sticky`,JSON.stringify(this.stickyColumns)),localStorage.setItem(`${t}_hidden`,JSON.stringify(this.hiddenColumns)),this.columns=s;else this.columns=s,g||localStorage.setItem(t,JSON.stringify(this.columnsWidth)),this.hasStickyColumns&&!u&&localStorage.setItem(`${t}_sticky`,JSON.stringify(this.stickyColumns)),r||localStorage.setItem(`${t}_hidden`,JSON.stringify(this.hiddenColumns))}updateActualWidths(t){Object.keys(t).forEach(s=>{const i=t[s];if(i){const c=i.getBoundingClientRect().width;this.actualRenderedWidths[s]=c}})}getColumnActualWidth(t,s){var c;if(this.actualRenderedWidths[t])return this.actualRenderedWidths[t];const i=this.columns.findIndex(h=>h.key===t);if(i!==-1){const h=(c=this.columnsWidth[i])==null?void 0:c.width;if(h)return typeof h=="number"?h:parseInt(h)||s}return s}moveColumn(t,s){var C,p;if(!this.hasDraggableColumns)return;const i=this.columns[t].key,c=this.columns[s].key,h=((C=this.stickyColumns.find(n=>n.key===i))==null?void 0:C.sticky)??!1,k=((p=this.stickyColumns.find(n=>n.key===c))==null?void 0:p.sticky)??!1;if(h!==k)return;const d=[...this.columns],m=d.splice(t,1)[0];d.splice(s,0,m),this.columns=d;const S=[...this.columnsWidth],g=S.splice(t,1)[0];S.splice(s,0,g),this.columnsWidth=S;const a=[...this.stickyColumns],u=a.splice(t,1)[0];a.splice(s,0,u),this.stickyColumns=a;const l=[...this.hiddenColumns],r=l.splice(t,1)[0];l.splice(s,0,r),this.hiddenColumns=l;const y=localStorage.getItem(this.key);if(y){const n=JSON.parse(y),f=n.splice(t,1)[0];n.splice(s,0,f),localStorage.setItem(this.key,JSON.stringify(n))}this.hasStickyColumns&&localStorage.setItem(`${this.key}_sticky`,JSON.stringify(this.stickyColumns)),localStorage.setItem(`${this.key}_hidden`,JSON.stringify(this.hiddenColumns))}setColumnsWidth(t){this.columnsWidth=t}setStickyColumns(t){this.stickyColumns=t,this.hasStickyColumns&&localStorage.setItem(`${this.key}_sticky`,JSON.stringify(this.stickyColumns))}toggleColumnSticky(t,s){var d;if(!this.hasStickyColumns)return;const i=this.columns.findIndex(m=>m.key===t);if(i===-1)return;const h=!(((d=this.stickyColumns.find(m=>m.key===t))==null?void 0:d.sticky)??!1);if(h&&s){const m=[...this.columnsWidth];m[i]={...m[i],width:Math.floor(s)},this.setColumnsWidth(m),this.saveLayoutView&&localStorage.setItem(this.key,JSON.stringify(m))}const k=this.stickyColumns.map(m=>m.key===t?{...m,sticky:h}:m);if(h){const S=this.stickyColumns.filter(g=>g.sticky).length;i!==S&&this.moveColumn(i,S)}else{const S=k.filter(g=>g.sticky).length;i!==S&&this.moveColumn(i,S)}this.setStickyColumns(k)}getStickyColumnsOffsets(){var i;if(!this.hasStickyColumns)return{};const t={};let s=0;for(let c=0;cd.key===h.key))==null?void 0:i.sticky)??!1){t[h.key]=s;const d=this.getColumnActualWidth(h.key,h.width??150);s+=d}}return t}setHiddenColumns(t){this.hiddenColumns=t,localStorage.setItem(`${this.key}_hidden`,JSON.stringify(this.hiddenColumns))}toggleColumnHidden(t){const s=this.hiddenColumns.map(i=>i.key===t?{...i,hidden:!i.hidden}:i);this.setHiddenColumns(s)}getVisibleColumns(){return this.columns.filter(t=>{var i;return!(((i=this.hiddenColumns.find(c=>c.key===t.key))==null?void 0:i.hidden)??!1)})}getHiddenColumns(){return this.columns.filter(t=>{var i;return((i=this.hiddenColumns.find(c=>c.key===t.key))==null?void 0:i.hidden)??!1})}}const z=({key:e,columns:t,...s})=>{const i=O.useRef(null);return i.current||(i.current=new b({key:e,columns:t,...s})),i.current};function L(e,t){const s=O.useRef({}),i=O.useCallback((k,d)=>{d?s.current[k]=d:delete s.current[k]},[]),c=O.useCallback(()=>{Object.keys(s.current).length>0&&e.updateActualWidths(s.current)},[e]);O.useEffect(()=>{const k=()=>{c()};requestAnimationFrame(k);const d=setTimeout(k,100);return()=>clearTimeout(d)},[c,e.columns,e.columnsWidth]);const h=O.useCallback((k,d,m,S)=>{const g=u=>{const l=u.clientX-d,r=Math.max(50,m+l),y=[...e.columnsWidth];y[k]={...y[k],width:r},e.setColumnsWidth(y)},a=()=>{S&&setTimeout(()=>{const u=S.getBoundingClientRect().width,l=[...e.columnsWidth];l[k]={...l[k],width:Math.floor(u)},e.setColumnsWidth(l),c(),e.saveLayoutView&&localStorage.setItem(e.key,JSON.stringify(l))},10),document.removeEventListener("mousemove",g),document.removeEventListener("mouseup",a),document.body.style.cursor="",document.body.style.userSelect=""};document.addEventListener("mousemove",g),document.addEventListener("mouseup",a),document.body.style.cursor="col-resize",document.body.style.userSelect="none"},[e,c]);return $.useObserver(()=>{const k=e.getVisibleColumns(),d=e.getStickyColumnsOffsets(),m=a=>{var n,f,I;const u=k[a],l=e.columns.findIndex(o=>o.key===u.key),r=((n=e.columnsWidth[l])==null?void 0:n.width)??u.width??150,y=((f=e.stickyColumns.find(o=>o.key===u.key))==null?void 0:f.sticky)??!1,C=d[u.key]??0;let p=1;if(y){let o=0;for(let W=0;WR.key===D.key))==null?void 0:I.sticky)??!1)&&o++}p=(e.isStickyHeader?20:100)-o}else e.isStickyHeader&&(p=10);return{width:typeof r=="number"?`${r}px`:r,isDraggable:e.hasDraggableColumns,isResizable:u.resizeable,isSticky:y,stickyOffset:C,zIndex:p,onDragStart:o=>{var w;e.hasDraggableColumns&&((w=o.dataTransfer)==null||w.setData("text/plain",l.toString()))},onDragOver:o=>{o.preventDefault()},onDrop:o=>{var w;if(o.preventDefault(),e.hasDraggableColumns){const W=parseInt(((w=o.dataTransfer)==null?void 0:w.getData("text/plain"))??"");!isNaN(W)&&W!==l&&(e.moveColumn(W,l),setTimeout(()=>c(),50))}},onResizeStart:(o,w)=>{if(u.resizeable){o.preventDefault(),o.stopPropagation();const W=typeof r=="number"?r:parseInt(r)??150;h(l,o.clientX,W,w)}},onToggleSticky:o=>{if(e.hasStickyColumns){let w;o&&(w=o.getBoundingClientRect().width),e.toggleColumnSticky(u.key,w),setTimeout(()=>c(),50)}},onToggleHidden:()=>{e.toggleColumnHidden(u.key)},registerHeaderRef:o=>{i(u.key,o)}}},S=a=>{var n,f,I;const u=k[a],l=e.columns.findIndex(o=>o.key===u.key),r=((n=e.columnsWidth[l])==null?void 0:n.width)??u.width??150,y=((f=e.stickyColumns.find(o=>o.key===u.key))==null?void 0:f.sticky)??!1,C=d[u.key]??0;let p=1;if(y){let o=0;for(let W=0;WR.key===D.key))==null?void 0:I.sticky)??!1)&&o++}p=(e.isStickyHeader?8:50)-o}return{width:typeof r=="number"?`${r}px`:r,isSticky:y,stickyOffset:C,zIndex:p}},g=a=>({onClick:()=>{e.onRowClick&&e.onRowClick({item:a})}});return{columns:k,data:t,config:e,columnWidths:e.columnsWidth,stickyColumns:e.stickyColumns,hiddenColumns:e.hiddenColumns,stickyOffsets:d,getColumnProps:m,getCellProps:S,getRowProps:g,updateActualWidths:c,getHiddenColumns:()=>e.getHiddenColumns(),toggleColumnHidden:a=>e.toggleColumnHidden(a)}})}const _=(e,t)=>{const[s,i]=O.useState(null),[c,h]=O.useState(null),[k,d]=O.useState(null);return{draggedItem:s,draggedIndex:c,hoveredIndex:k,handleDragStart:(l,r)=>{i(l),h(r),e==null||e(l,r)},handleDragOver:l=>{l.preventDefault()},handleDragEnter:l=>{d(l)},handleDragLeave:()=>{d(null)},handleDrop:(l,r)=>{l.preventDefault(),c!==null&&c!==r&&(t==null||t(c,r)),i(null),h(null),d(null)}}},A=(e,t)=>{O.useLayoutEffect(()=>{const s=e==null?void 0:e.current;if(!s)return;const i=new ResizeObserver(c=>{const h=c[0];h&&t(h)});return i.observe(s),()=>i.disconnect()},[e,t])};exports.useDataTable=z;exports.useDragAndDrop=_;exports.useResizeObserver=A;exports.useTable=L; 2 | -------------------------------------------------------------------------------- /src/hooks/use-table.ts: -------------------------------------------------------------------------------- 1 | import { useObserver } from 'mobx-react'; 2 | import { useCallback, useRef, useEffect } from 'react'; 3 | import DataTable from '../models/data-table-model'; 4 | 5 | type StickyColumnType = { 6 | key: string; 7 | sticky: boolean; 8 | }; 9 | 10 | export function useTable>( 11 | dataTable: DataTable, 12 | data: T[] 13 | ) { 14 | // Store header element references 15 | const headerElementsRef = useRef<{ [key: string]: HTMLElement }>({}); 16 | 17 | // Function to register header element reference 18 | const registerHeaderElement = useCallback((columnKey: string, element: HTMLElement | null) => { 19 | if (element) { 20 | headerElementsRef.current[columnKey] = element; 21 | } else { 22 | delete headerElementsRef.current[columnKey]; 23 | } 24 | }, []); 25 | 26 | // Function to update actual widths from DOM elements 27 | const updateActualWidths = useCallback(() => { 28 | if (Object.keys(headerElementsRef.current).length > 0) { 29 | dataTable.updateActualWidths(headerElementsRef.current); 30 | } 31 | }, [dataTable]); 32 | 33 | // Update actual widths whenever columns change or after resize 34 | useEffect(() => { 35 | // Use requestAnimationFrame to ensure DOM is updated 36 | const updateWidths = () => { 37 | updateActualWidths(); 38 | }; 39 | 40 | // Update on next frame 41 | requestAnimationFrame(updateWidths); 42 | 43 | // Also update after a short delay to catch any CSS transitions 44 | const timeoutId = setTimeout(updateWidths, 100); 45 | 46 | return () => clearTimeout(timeoutId); 47 | }, [updateActualWidths, dataTable.columns, dataTable.columnsWidth]); 48 | 49 | const handleColumnResize = useCallback((columnIndex: number, startX: number, startWidth: number, headerElement?: HTMLElement) => { 50 | const handleMouseMove = (e: MouseEvent) => { 51 | const diff = e.clientX - startX; 52 | const newWidth = Math.max(50, startWidth + diff); // Minimum width of 50px 53 | 54 | // Update the column width in the model 55 | const newWidths = [...dataTable.columnsWidth]; 56 | newWidths[columnIndex] = { 57 | ...newWidths[columnIndex], 58 | width: newWidth 59 | }; 60 | dataTable.setColumnsWidth(newWidths); 61 | }; 62 | 63 | const handleMouseUp = () => { 64 | // Get the actual rendered width after resize is complete 65 | if (headerElement) { 66 | // Use a small timeout to ensure the DOM has updated 67 | setTimeout(() => { 68 | const actualWidth = headerElement.getBoundingClientRect().width; 69 | 70 | // Update with the actual rendered width 71 | const newWidths = [...dataTable.columnsWidth]; 72 | newWidths[columnIndex] = { 73 | ...newWidths[columnIndex], 74 | width: Math.floor(actualWidth) 75 | }; 76 | dataTable.setColumnsWidth(newWidths); 77 | 78 | // Update the actual rendered widths in the model 79 | updateActualWidths(); 80 | 81 | // Save to localStorage if saveLayoutView is enabled 82 | if (dataTable.saveLayoutView) { 83 | localStorage.setItem(dataTable.key, JSON.stringify(newWidths)); 84 | } 85 | }, 10); 86 | } 87 | 88 | document.removeEventListener('mousemove', handleMouseMove); 89 | document.removeEventListener('mouseup', handleMouseUp); 90 | document.body.style.cursor = ''; 91 | document.body.style.userSelect = ''; 92 | }; 93 | 94 | document.addEventListener('mousemove', handleMouseMove); 95 | document.addEventListener('mouseup', handleMouseUp); 96 | document.body.style.cursor = 'col-resize'; 97 | document.body.style.userSelect = 'none'; 98 | }, [dataTable, updateActualWidths]); 99 | 100 | return useObserver(() => { 101 | const visibleColumns = dataTable.getVisibleColumns(); 102 | const stickyOffsets = dataTable.getStickyColumnsOffsets(); 103 | 104 | const getColumnProps = (index: number) => { 105 | const column = visibleColumns[index]; 106 | const originalColumnIndex = dataTable.columns.findIndex((col: { key: string }) => col.key === column.key); 107 | const width = dataTable.columnsWidth[originalColumnIndex]?.width ?? column.width ?? 150; 108 | const isSticky = dataTable.stickyColumns.find((col: StickyColumnType) => col.key === column.key)?.sticky ?? false; 109 | const stickyOffset = stickyOffsets[column.key] ?? 0; 110 | 111 | // Calculate z-index for sticky columns based on their sticky position 112 | let zIndex = 1; 113 | if (isSticky) { 114 | // Count how many sticky columns come before this one 115 | let stickyIndex = 0; 116 | for (let i = 0; i < index; i++) { 117 | const prevColumn = visibleColumns[i]; 118 | const prevIsSticky = dataTable.stickyColumns.find((col: StickyColumnType) => col.key === prevColumn.key)?.sticky ?? false; 119 | if (prevIsSticky) { 120 | stickyIndex++; 121 | } 122 | } 123 | // When both sticky header and sticky columns are active, 124 | // sticky columns need higher z-index than sticky header (10) 125 | // First sticky column gets highest z-index 126 | const baseZIndex = dataTable.isStickyHeader ? 20 : 100; 127 | zIndex = baseZIndex - stickyIndex; 128 | } else if (dataTable.isStickyHeader) { 129 | // Non-sticky columns in sticky header get base z-index 130 | zIndex = 10; 131 | } 132 | 133 | return { 134 | width: typeof width === 'number' ? `${width}px` : width, 135 | isDraggable: dataTable.hasDraggableColumns, 136 | isResizable: column.resizeable, 137 | isSticky: isSticky, 138 | stickyOffset: stickyOffset, 139 | zIndex: zIndex, 140 | onDragStart: (e: DragEvent) => { 141 | if (dataTable.hasDraggableColumns) { 142 | e.dataTransfer?.setData('text/plain', originalColumnIndex.toString()); 143 | } 144 | }, 145 | onDragOver: (e: DragEvent) => { 146 | e.preventDefault(); 147 | }, 148 | onDrop: (e: DragEvent) => { 149 | e.preventDefault(); 150 | if (dataTable.hasDraggableColumns) { 151 | const draggedIndex = parseInt(e.dataTransfer?.getData('text/plain') ?? ''); 152 | if (!isNaN(draggedIndex) && draggedIndex !== originalColumnIndex) { 153 | dataTable.moveColumn(draggedIndex, originalColumnIndex); 154 | // Update actual widths after column move 155 | setTimeout(() => updateActualWidths(), 50); 156 | } 157 | } 158 | }, 159 | onResizeStart: (e: MouseEvent, headerElement?: HTMLElement) => { 160 | if (column.resizeable) { 161 | e.preventDefault(); 162 | e.stopPropagation(); 163 | const currentWidth = typeof width === 'number' ? width : parseInt(width) ?? 150; 164 | handleColumnResize(originalColumnIndex, e.clientX, currentWidth, headerElement); 165 | } 166 | }, 167 | onToggleSticky: (headerElement?: HTMLElement) => { 168 | if (dataTable.hasStickyColumns) { 169 | let actualWidth; 170 | if (headerElement) { 171 | actualWidth = headerElement.getBoundingClientRect().width; 172 | } 173 | dataTable.toggleColumnSticky(column.key, actualWidth); 174 | // Update actual widths after sticky toggle 175 | setTimeout(() => updateActualWidths(), 50); 176 | } 177 | }, 178 | onToggleHidden: () => { 179 | dataTable.toggleColumnHidden(column.key); 180 | }, 181 | // New: function to register header element reference 182 | registerHeaderRef: (element: HTMLElement | null) => { 183 | registerHeaderElement(column.key, element); 184 | } 185 | }; 186 | }; 187 | 188 | const getCellProps = (columnIndex: number) => { 189 | const column = visibleColumns[columnIndex]; 190 | const originalColumnIndex = dataTable.columns.findIndex((col: { key: string }) => col.key === column.key); 191 | const width = dataTable.columnsWidth[originalColumnIndex]?.width ?? column.width ?? 150; 192 | const isSticky = dataTable.stickyColumns.find((col: StickyColumnType) => col.key === column.key)?.sticky ?? false; 193 | const stickyOffset = stickyOffsets[column.key] ?? 0; 194 | 195 | // Calculate z-index for sticky cells based on their sticky position 196 | let cellZIndex = 1; 197 | if (isSticky) { 198 | // Count how many sticky columns come before this one 199 | let stickyIndex = 0; 200 | for (let i = 0; i < columnIndex; i++) { 201 | const prevColumn = visibleColumns[i]; 202 | const prevIsSticky = dataTable.stickyColumns.find((col: StickyColumnType) => col.key === prevColumn.key)?.sticky ?? false; 203 | if (prevIsSticky) { 204 | stickyIndex++; 205 | } 206 | } 207 | // When both sticky header and sticky columns are active, 208 | // sticky columns need higher z-index than sticky header (10) 209 | // First sticky column gets highest z-index 210 | const baseCellZIndex = dataTable.isStickyHeader ? 8 : 50; 211 | cellZIndex = baseCellZIndex - stickyIndex; 212 | } 213 | 214 | return { 215 | width: typeof width === 'number' ? `${width}px` : width, 216 | isSticky: isSticky, 217 | stickyOffset: stickyOffset, 218 | zIndex: cellZIndex, 219 | }; 220 | }; 221 | 222 | const getRowProps = (item: T) => ({ 223 | onClick: () => { 224 | if (dataTable.onRowClick) { 225 | dataTable.onRowClick({ item }); 226 | } 227 | } 228 | }); 229 | 230 | return { 231 | columns: visibleColumns, 232 | data, 233 | config: dataTable, 234 | columnWidths: dataTable.columnsWidth, 235 | stickyColumns: dataTable.stickyColumns, 236 | hiddenColumns: dataTable.hiddenColumns, 237 | stickyOffsets, 238 | getColumnProps, 239 | getCellProps, 240 | getRowProps, 241 | // New: function to manually trigger actual width updates 242 | updateActualWidths, 243 | // Hidden columns methods 244 | getHiddenColumns: () => dataTable.getHiddenColumns(), 245 | toggleColumnHidden: (columnKey: string) => dataTable.toggleColumnHidden(columnKey) 246 | }; 247 | }); 248 | } 249 | 250 | export default useTable; -------------------------------------------------------------------------------- /dist/index.mjs: -------------------------------------------------------------------------------- 1 | var x = Object.defineProperty; 2 | var $ = (s, t, e) => t in s ? x(s, t, { enumerable: !0, configurable: !0, writable: !0, value: e }) : s[t] = e; 3 | var v = (s, t, e) => $(s, typeof t != "symbol" ? t + "" : t, e); 4 | import { makeAutoObservable as z } from "mobx"; 5 | import { useRef as N, useCallback as R, useEffect as L, useState as H, useLayoutEffect as _ } from "react"; 6 | import { useObserver as A } from "mobx-react"; 7 | class M { 8 | constructor({ key: t, columns: e, saveLayoutView: i, hasDraggableColumns: c, isStickyHeader: h, hasStickyColumns: k, onRowClick: d, defaultColumnWidth: m = "auto" }) { 9 | v(this, "key"); 10 | v(this, "columns"); 11 | v(this, "saveLayoutView"); 12 | v(this, "hasDraggableColumns"); 13 | v(this, "isStickyHeader"); 14 | v(this, "hasStickyColumns"); 15 | v(this, "onRowClick"); 16 | v(this, "columnsWidth"); 17 | v(this, "stickyColumns"); 18 | v(this, "hiddenColumns"); 19 | // nestedColumnsWidth?: ColumnWidthType[] | null; 20 | // Store actual rendered widths from DOM elements 21 | v(this, "actualRenderedWidths", {}); 22 | z(this); 23 | const S = localStorage.getItem(t), g = S ? JSON.parse(S) : null, C = localStorage.getItem(`${t}_sticky`), r = C ? JSON.parse(C) : null; 24 | this.key = t, this.saveLayoutView = i ?? !1, this.hasDraggableColumns = c ?? !0, this.isStickyHeader = h ?? !1, this.hasStickyColumns = k ?? !1, this.onRowClick = d, this.columnsWidth = e.map((y) => { 25 | const a = g == null ? void 0 : g.find(({ key: n }) => n === y.key), p = typeof m == "number" ? m : void 0; 26 | return { key: y.key, width: (a == null ? void 0 : a.width) ?? y.width ?? p }; 27 | }), this.stickyColumns = e.map((y) => { 28 | const a = r == null ? void 0 : r.find(({ key: p }) => p === y.key); 29 | return { key: y.key, sticky: (a == null ? void 0 : a.sticky) ?? y.sticky ?? !1 }; 30 | }); 31 | const l = localStorage.getItem(`${t}_hidden`), u = l ? JSON.parse(l) : null; 32 | if (this.hiddenColumns = e.map((y) => { 33 | const a = u == null ? void 0 : u.find(({ key: p }) => p === y.key); 34 | return { key: y.key, hidden: (a == null ? void 0 : a.hidden) ?? y.hidden ?? !1 }; 35 | }), i) 36 | if (g) { 37 | const y = e.filter((n) => !g.map(({ key: f }) => f).includes(n.key)), a = g.reduce((n, f) => { 38 | const I = e.filter(({ key: o }) => o === f.key)[0]; 39 | return I && (n = [...n, I]), n; 40 | }, []), p = g.reduce((n, f) => { 41 | const I = e.filter(({ key: w }) => w === f.key)[0]; 42 | if (!I) 43 | return n; 44 | const o = typeof m == "number" ? m : void 0; 45 | return n = [...n, { key: I.key, width: f.width ?? I.width ?? o }], n; 46 | }, []); 47 | this.columns = a.concat(y), this.columnsWidth = p.concat(y.map(({ key: n, width: f }) => ({ key: n, width: f ?? m }))), this.stickyColumns = this.columns.map((n) => { 48 | const f = r == null ? void 0 : r.find(({ key: I }) => I === n.key); 49 | return { key: n.key, sticky: (f == null ? void 0 : f.sticky) ?? n.sticky ?? !1 }; 50 | }), this.hiddenColumns = this.columns.map((n) => { 51 | const f = u == null ? void 0 : u.find(({ key: I }) => I === n.key); 52 | return { key: n.key, hidden: (f == null ? void 0 : f.hidden) ?? n.hidden ?? !1 }; 53 | }); 54 | } else 55 | localStorage.setItem(t, JSON.stringify(this.columnsWidth)), this.hasStickyColumns && localStorage.setItem(`${t}_sticky`, JSON.stringify(this.stickyColumns)), localStorage.setItem(`${t}_hidden`, JSON.stringify(this.hiddenColumns)), this.columns = e; 56 | else 57 | this.columns = e, g || localStorage.setItem(t, JSON.stringify(this.columnsWidth)), this.hasStickyColumns && !r && localStorage.setItem(`${t}_sticky`, JSON.stringify(this.stickyColumns)), u || localStorage.setItem(`${t}_hidden`, JSON.stringify(this.hiddenColumns)); 58 | } 59 | // Method to update actual rendered widths from DOM elements 60 | updateActualWidths(t) { 61 | Object.keys(t).forEach((e) => { 62 | const i = t[e]; 63 | if (i) { 64 | const c = i.getBoundingClientRect().width; 65 | this.actualRenderedWidths[e] = c; 66 | } 67 | }); 68 | } 69 | // Method to get the most accurate width for a column 70 | getColumnActualWidth(t, e) { 71 | var c; 72 | if (this.actualRenderedWidths[t]) 73 | return this.actualRenderedWidths[t]; 74 | const i = this.columns.findIndex((h) => h.key === t); 75 | if (i !== -1) { 76 | const h = (c = this.columnsWidth[i]) == null ? void 0 : c.width; 77 | if (h) 78 | return typeof h == "number" ? h : parseInt(h) || e; 79 | } 80 | return e; 81 | } 82 | moveColumn(t, e) { 83 | var a, p; 84 | if (!this.hasDraggableColumns) 85 | return; 86 | const i = this.columns[t].key, c = this.columns[e].key, h = ((a = this.stickyColumns.find((n) => n.key === i)) == null ? void 0 : a.sticky) ?? !1, k = ((p = this.stickyColumns.find((n) => n.key === c)) == null ? void 0 : p.sticky) ?? !1; 87 | if (h !== k) 88 | return; 89 | const d = [...this.columns], m = d.splice(t, 1)[0]; 90 | d.splice(e, 0, m), this.columns = d; 91 | const S = [...this.columnsWidth], g = S.splice(t, 1)[0]; 92 | S.splice(e, 0, g), this.columnsWidth = S; 93 | const C = [...this.stickyColumns], r = C.splice(t, 1)[0]; 94 | C.splice(e, 0, r), this.stickyColumns = C; 95 | const l = [...this.hiddenColumns], u = l.splice(t, 1)[0]; 96 | l.splice(e, 0, u), this.hiddenColumns = l; 97 | const y = localStorage.getItem(this.key); 98 | if (y) { 99 | const n = JSON.parse(y), f = n.splice(t, 1)[0]; 100 | n.splice(e, 0, f), localStorage.setItem(this.key, JSON.stringify(n)); 101 | } 102 | this.hasStickyColumns && localStorage.setItem(`${this.key}_sticky`, JSON.stringify(this.stickyColumns)), localStorage.setItem(`${this.key}_hidden`, JSON.stringify(this.hiddenColumns)); 103 | } 104 | setColumnsWidth(t) { 105 | this.columnsWidth = t; 106 | } 107 | setStickyColumns(t) { 108 | this.stickyColumns = t, this.hasStickyColumns && localStorage.setItem(`${this.key}_sticky`, JSON.stringify(this.stickyColumns)); 109 | } 110 | toggleColumnSticky(t, e) { 111 | var d; 112 | if (!this.hasStickyColumns) 113 | return; 114 | const i = this.columns.findIndex((m) => m.key === t); 115 | if (i === -1) 116 | return; 117 | const h = !(((d = this.stickyColumns.find((m) => m.key === t)) == null ? void 0 : d.sticky) ?? !1); 118 | if (h && e) { 119 | const m = [...this.columnsWidth]; 120 | m[i] = { 121 | ...m[i], 122 | width: Math.floor(e) 123 | }, this.setColumnsWidth(m), this.saveLayoutView && localStorage.setItem(this.key, JSON.stringify(m)); 124 | } 125 | const k = this.stickyColumns.map( 126 | (m) => m.key === t ? { ...m, sticky: h } : m 127 | ); 128 | if (h) { 129 | const S = this.stickyColumns.filter((g) => g.sticky).length; 130 | i !== S && this.moveColumn(i, S); 131 | } else { 132 | const S = k.filter((g) => g.sticky).length; 133 | i !== S && this.moveColumn(i, S); 134 | } 135 | this.setStickyColumns(k); 136 | } 137 | getStickyColumnsOffsets() { 138 | var i; 139 | if (!this.hasStickyColumns) 140 | return {}; 141 | const t = {}; 142 | let e = 0; 143 | for (let c = 0; c < this.columns.length; c++) { 144 | const h = this.columns[c]; 145 | if (((i = this.stickyColumns.find((d) => d.key === h.key)) == null ? void 0 : i.sticky) ?? !1) { 146 | t[h.key] = e; 147 | const d = this.getColumnActualWidth(h.key, h.width ?? 150); 148 | e += d; 149 | } 150 | } 151 | return t; 152 | } 153 | setHiddenColumns(t) { 154 | this.hiddenColumns = t, localStorage.setItem(`${this.key}_hidden`, JSON.stringify(this.hiddenColumns)); 155 | } 156 | toggleColumnHidden(t) { 157 | const e = this.hiddenColumns.map( 158 | (i) => i.key === t ? { ...i, hidden: !i.hidden } : i 159 | ); 160 | this.setHiddenColumns(e); 161 | } 162 | getVisibleColumns() { 163 | return this.columns.filter((t) => { 164 | var i; 165 | return !(((i = this.hiddenColumns.find((c) => c.key === t.key)) == null ? void 0 : i.hidden) ?? !1); 166 | }); 167 | } 168 | getHiddenColumns() { 169 | return this.columns.filter((t) => { 170 | var i; 171 | return ((i = this.hiddenColumns.find((c) => c.key === t.key)) == null ? void 0 : i.hidden) ?? !1; 172 | }); 173 | } 174 | } 175 | const B = ({ key: s, columns: t, ...e }) => { 176 | const i = N(null); 177 | return i.current || (i.current = new M({ key: s, columns: t, ...e })), i.current; 178 | }; 179 | function b(s, t) { 180 | const e = N({}), i = R((k, d) => { 181 | d ? e.current[k] = d : delete e.current[k]; 182 | }, []), c = R(() => { 183 | Object.keys(e.current).length > 0 && s.updateActualWidths(e.current); 184 | }, [s]); 185 | L(() => { 186 | const k = () => { 187 | c(); 188 | }; 189 | requestAnimationFrame(k); 190 | const d = setTimeout(k, 100); 191 | return () => clearTimeout(d); 192 | }, [c, s.columns, s.columnsWidth]); 193 | const h = R((k, d, m, S) => { 194 | const g = (r) => { 195 | const l = r.clientX - d, u = Math.max(50, m + l), y = [...s.columnsWidth]; 196 | y[k] = { 197 | ...y[k], 198 | width: u 199 | }, s.setColumnsWidth(y); 200 | }, C = () => { 201 | S && setTimeout(() => { 202 | const r = S.getBoundingClientRect().width, l = [...s.columnsWidth]; 203 | l[k] = { 204 | ...l[k], 205 | width: Math.floor(r) 206 | }, s.setColumnsWidth(l), c(), s.saveLayoutView && localStorage.setItem(s.key, JSON.stringify(l)); 207 | }, 10), document.removeEventListener("mousemove", g), document.removeEventListener("mouseup", C), document.body.style.cursor = "", document.body.style.userSelect = ""; 208 | }; 209 | document.addEventListener("mousemove", g), document.addEventListener("mouseup", C), document.body.style.cursor = "col-resize", document.body.style.userSelect = "none"; 210 | }, [s, c]); 211 | return A(() => { 212 | const k = s.getVisibleColumns(), d = s.getStickyColumnsOffsets(), m = (C) => { 213 | var n, f, I; 214 | const r = k[C], l = s.columns.findIndex((o) => o.key === r.key), u = ((n = s.columnsWidth[l]) == null ? void 0 : n.width) ?? r.width ?? 150, y = ((f = s.stickyColumns.find((o) => o.key === r.key)) == null ? void 0 : f.sticky) ?? !1, a = d[r.key] ?? 0; 215 | let p = 1; 216 | if (y) { 217 | let o = 0; 218 | for (let W = 0; W < C; W++) { 219 | const O = k[W]; 220 | (((I = s.stickyColumns.find((D) => D.key === O.key)) == null ? void 0 : I.sticky) ?? !1) && o++; 221 | } 222 | p = (s.isStickyHeader ? 20 : 100) - o; 223 | } else s.isStickyHeader && (p = 10); 224 | return { 225 | width: typeof u == "number" ? `${u}px` : u, 226 | isDraggable: s.hasDraggableColumns, 227 | isResizable: r.resizeable, 228 | isSticky: y, 229 | stickyOffset: a, 230 | zIndex: p, 231 | onDragStart: (o) => { 232 | var w; 233 | s.hasDraggableColumns && ((w = o.dataTransfer) == null || w.setData("text/plain", l.toString())); 234 | }, 235 | onDragOver: (o) => { 236 | o.preventDefault(); 237 | }, 238 | onDrop: (o) => { 239 | var w; 240 | if (o.preventDefault(), s.hasDraggableColumns) { 241 | const W = parseInt(((w = o.dataTransfer) == null ? void 0 : w.getData("text/plain")) ?? ""); 242 | !isNaN(W) && W !== l && (s.moveColumn(W, l), setTimeout(() => c(), 50)); 243 | } 244 | }, 245 | onResizeStart: (o, w) => { 246 | if (r.resizeable) { 247 | o.preventDefault(), o.stopPropagation(); 248 | const W = typeof u == "number" ? u : parseInt(u) ?? 150; 249 | h(l, o.clientX, W, w); 250 | } 251 | }, 252 | onToggleSticky: (o) => { 253 | if (s.hasStickyColumns) { 254 | let w; 255 | o && (w = o.getBoundingClientRect().width), s.toggleColumnSticky(r.key, w), setTimeout(() => c(), 50); 256 | } 257 | }, 258 | onToggleHidden: () => { 259 | s.toggleColumnHidden(r.key); 260 | }, 261 | // New: function to register header element reference 262 | registerHeaderRef: (o) => { 263 | i(r.key, o); 264 | } 265 | }; 266 | }, S = (C) => { 267 | var n, f, I; 268 | const r = k[C], l = s.columns.findIndex((o) => o.key === r.key), u = ((n = s.columnsWidth[l]) == null ? void 0 : n.width) ?? r.width ?? 150, y = ((f = s.stickyColumns.find((o) => o.key === r.key)) == null ? void 0 : f.sticky) ?? !1, a = d[r.key] ?? 0; 269 | let p = 1; 270 | if (y) { 271 | let o = 0; 272 | for (let W = 0; W < C; W++) { 273 | const O = k[W]; 274 | (((I = s.stickyColumns.find((D) => D.key === O.key)) == null ? void 0 : I.sticky) ?? !1) && o++; 275 | } 276 | p = (s.isStickyHeader ? 8 : 50) - o; 277 | } 278 | return { 279 | width: typeof u == "number" ? `${u}px` : u, 280 | isSticky: y, 281 | stickyOffset: a, 282 | zIndex: p 283 | }; 284 | }, g = (C) => ({ 285 | onClick: () => { 286 | s.onRowClick && s.onRowClick({ item: C }); 287 | } 288 | }); 289 | return { 290 | columns: k, 291 | data: t, 292 | config: s, 293 | columnWidths: s.columnsWidth, 294 | stickyColumns: s.stickyColumns, 295 | hiddenColumns: s.hiddenColumns, 296 | stickyOffsets: d, 297 | getColumnProps: m, 298 | getCellProps: S, 299 | getRowProps: g, 300 | // New: function to manually trigger actual width updates 301 | updateActualWidths: c, 302 | // Hidden columns methods 303 | getHiddenColumns: () => s.getHiddenColumns(), 304 | toggleColumnHidden: (C) => s.toggleColumnHidden(C) 305 | }; 306 | }); 307 | } 308 | const j = (s, t) => { 309 | const [e, i] = H(null), [c, h] = H(null), [k, d] = H(null); 310 | return { 311 | draggedItem: e, 312 | draggedIndex: c, 313 | hoveredIndex: k, 314 | handleDragStart: (l, u) => { 315 | i(l), h(u), s == null || s(l, u); 316 | }, 317 | handleDragOver: (l) => { 318 | l.preventDefault(); 319 | }, 320 | handleDragEnter: (l) => { 321 | d(l); 322 | }, 323 | handleDragLeave: () => { 324 | d(null); 325 | }, 326 | handleDrop: (l, u) => { 327 | l.preventDefault(), c !== null && c !== u && (t == null || t(c, u)), i(null), h(null), d(null); 328 | } 329 | }; 330 | }, F = (s, t) => { 331 | _(() => { 332 | const e = s == null ? void 0 : s.current; 333 | if (!e) return; 334 | const i = new ResizeObserver((c) => { 335 | const h = c[0]; 336 | h && t(h); 337 | }); 338 | return i.observe(e), () => i.disconnect(); 339 | }, [s, t]); 340 | }; 341 | export { 342 | B as useDataTable, 343 | j as useDragAndDrop, 344 | F as useResizeObserver, 345 | b as useTable 346 | }; 347 | -------------------------------------------------------------------------------- /src/models/data-table-model.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from 'mobx'; 2 | import { ReactNode } from "react"; 3 | 4 | type NestedColumnType = { 5 | key: string; 6 | label?: string | ReactNode; 7 | Cell?: (props: { data: Record }) => ReactNode; 8 | }; 9 | 10 | export type TableColumnType = { 11 | key: string; 12 | label: string | ReactNode; 13 | width?: number; 14 | resizeable?: boolean; 15 | sticky?: boolean; 16 | hidden?: boolean; 17 | Cell: (props: { data: Record }) => ReactNode; 18 | nestedColumns?: NestedColumnType[]; 19 | } 20 | 21 | type ColumnWidthType = { 22 | key: string; 23 | width?: number; 24 | } 25 | 26 | type StickyColumnType = { 27 | key: string; 28 | sticky: boolean; 29 | } 30 | 31 | type HiddenColumnType = { 32 | key: string; 33 | hidden: boolean; 34 | } 35 | 36 | export interface DataTableLiteType { 37 | key: string; 38 | columns: TableColumnType[]; 39 | hasDraggableColumns?: boolean; 40 | saveLayoutView?: boolean; 41 | defaultColumnWidth?: number | string; 42 | isStickyHeader?: boolean; 43 | hasStickyColumns?: boolean; 44 | onRowClick?: ({ item }: { item: Record }) => void 45 | } 46 | 47 | export type DataTableType = DataTableLiteType & { 48 | moveColumn: (index: number, toIndex: number) => void; 49 | setColumnsWidth: (widths: ColumnWidthType[]) => void; 50 | columnsWidth: ColumnWidthType[]; 51 | stickyColumns: StickyColumnType[]; 52 | setStickyColumns: (stickyColumns: StickyColumnType[]) => void; 53 | toggleColumnSticky: (columnKey: string, actualWidth?: number) => void; 54 | hiddenColumns: HiddenColumnType[]; 55 | setHiddenColumns: (hiddenColumns: HiddenColumnType[]) => void; 56 | toggleColumnHidden: (columnKey: string) => void; 57 | getVisibleColumns: () => TableColumnType[]; 58 | getHiddenColumns: () => TableColumnType[]; 59 | getStickyColumnsOffsets: () => { [key: string]: number }; 60 | updateActualWidths: (headerElements: { [key: string]: HTMLElement }) => void; 61 | getColumnActualWidth: (columnKey: string, fallbackWidth: number) => number; 62 | actualRenderedWidths: { [key: string]: number }; 63 | } 64 | 65 | class DataTable { 66 | key; 67 | columns; 68 | saveLayoutView; 69 | hasDraggableColumns; 70 | isStickyHeader; 71 | hasStickyColumns; 72 | onRowClick; 73 | columnsWidth: ColumnWidthType[]; 74 | stickyColumns: StickyColumnType[]; 75 | hiddenColumns: HiddenColumnType[]; 76 | // nestedColumnsWidth?: ColumnWidthType[] | null; 77 | 78 | // Store actual rendered widths from DOM elements 79 | actualRenderedWidths: { [key: string]: number } = {}; 80 | 81 | // Method to update actual rendered widths from DOM elements 82 | updateActualWidths(headerElements: { [key: string]: HTMLElement }) { 83 | Object.keys(headerElements).forEach(columnKey => { 84 | const element = headerElements[columnKey]; 85 | if (element) { 86 | const actualWidth = element.getBoundingClientRect().width; 87 | this.actualRenderedWidths[columnKey] = actualWidth; 88 | } 89 | }); 90 | } 91 | 92 | // Method to get the most accurate width for a column 93 | getColumnActualWidth(columnKey: string, fallbackWidth: number): number { 94 | // First try to get the actual rendered width 95 | if (this.actualRenderedWidths[columnKey]) { 96 | return this.actualRenderedWidths[columnKey]; 97 | } 98 | 99 | // Fall back to stored width 100 | const columnIndex = this.columns.findIndex((col: TableColumnType) => col.key === columnKey); 101 | if (columnIndex !== -1) { 102 | const storedWidth = this.columnsWidth[columnIndex]?.width; 103 | if (storedWidth) { 104 | return typeof storedWidth === 'number' ? storedWidth : parseInt(storedWidth) || fallbackWidth; 105 | } 106 | } 107 | 108 | // Final fallback 109 | return fallbackWidth; 110 | } 111 | 112 | constructor({ key, columns, saveLayoutView, hasDraggableColumns, isStickyHeader, hasStickyColumns, onRowClick, defaultColumnWidth = 'auto' }: DataTableLiteType) { 113 | makeAutoObservable(this); 114 | const savedColumnsStr = localStorage.getItem(key); 115 | const savedColumns = savedColumnsStr ? JSON.parse(savedColumnsStr) : null; 116 | 117 | // Load saved sticky state 118 | const savedStickyStr = localStorage.getItem(`${key}_sticky`); 119 | const savedSticky = savedStickyStr ? JSON.parse(savedStickyStr) : null; 120 | 121 | this.key = key; 122 | this.saveLayoutView = saveLayoutView ?? false; 123 | this.hasDraggableColumns = hasDraggableColumns ?? true; 124 | this.isStickyHeader = isStickyHeader ?? false; 125 | this.hasStickyColumns = hasStickyColumns ?? false; 126 | this.onRowClick = onRowClick; 127 | 128 | // Always load saved column widths if they exist 129 | this.columnsWidth = columns.map((column) => { 130 | const savedColumn = savedColumns?.find(({ key }: ColumnWidthType) => key === column.key); 131 | const fallbackWidth = typeof defaultColumnWidth === 'number' ? defaultColumnWidth : undefined; 132 | return ({ key: column.key, width: savedColumn?.width ?? column.width ?? fallbackWidth }); 133 | }); 134 | 135 | // Initialize sticky columns state 136 | this.stickyColumns = columns.map((column) => { 137 | const savedStickyColumn = savedSticky?.find(({ key }: StickyColumnType) => key === column.key); 138 | return { key: column.key, sticky: savedStickyColumn?.sticky ?? column.sticky ?? false }; 139 | }); 140 | 141 | // Load saved hidden state 142 | const savedHiddenStr = localStorage.getItem(`${key}_hidden`); 143 | const savedHidden = savedHiddenStr ? JSON.parse(savedHiddenStr) : null; 144 | 145 | // Initialize hidden columns state 146 | this.hiddenColumns = columns.map((column) => { 147 | const savedHiddenColumn = savedHidden?.find(({ key }: HiddenColumnType) => key === column.key); 148 | return { key: column.key, hidden: savedHiddenColumn?.hidden ?? column.hidden ?? false }; 149 | }); 150 | 151 | // load initial view if exists and saveLayoutView is enabled 152 | if (saveLayoutView) { 153 | if (savedColumns) { 154 | const newFilteredColumns = columns.filter(column => !savedColumns.map(({ key }: TableColumnType) => key).includes(column.key)); 155 | const updatedColumns = savedColumns.reduce((result: TableColumnType[], savedColumn: ColumnWidthType) => { 156 | const currentColumn = columns.filter(({ key }) => key === savedColumn.key)[0]; 157 | if (!currentColumn) { 158 | return result; 159 | } 160 | result = [...result, currentColumn]; 161 | return result; 162 | }, []); 163 | 164 | const updatedColumnsWidth = savedColumns.reduce((result: ColumnWidthType[], savedColumn: ColumnWidthType) => { 165 | const currentColumn = columns.filter(({ key }) => key === savedColumn.key)[0]; 166 | if (!currentColumn) { 167 | return result; 168 | } 169 | const fallbackWidth = typeof defaultColumnWidth === 'number' ? defaultColumnWidth : undefined; 170 | result = [...result, { key: currentColumn.key, width: savedColumn.width ?? currentColumn.width ?? fallbackWidth }]; 171 | return result; 172 | }, []) 173 | 174 | this.columns = updatedColumns.concat(newFilteredColumns); 175 | this.columnsWidth = updatedColumnsWidth.concat(newFilteredColumns.map(({ key, width }) => ({ key, width: width ?? defaultColumnWidth }))); 176 | 177 | // Update sticky columns based on reordered columns 178 | this.stickyColumns = this.columns.map((column: TableColumnType) => { 179 | const savedStickyColumn = savedSticky?.find(({ key }: StickyColumnType) => key === column.key); 180 | return { key: column.key, sticky: savedStickyColumn?.sticky ?? column.sticky ?? false }; 181 | }); 182 | 183 | // Update hidden columns based on reordered columns 184 | this.hiddenColumns = this.columns.map((column: TableColumnType) => { 185 | const savedHiddenColumn = savedHidden?.find(({ key }: HiddenColumnType) => key === column.key); 186 | return { key: column.key, hidden: savedHiddenColumn?.hidden ?? column.hidden ?? false }; 187 | }); 188 | } 189 | else { 190 | // Initialize localStorage with current column widths and sticky state 191 | localStorage.setItem(key, JSON.stringify(this.columnsWidth)); 192 | if (this.hasStickyColumns) { 193 | localStorage.setItem(`${key}_sticky`, JSON.stringify(this.stickyColumns)); 194 | } 195 | localStorage.setItem(`${key}_hidden`, JSON.stringify(this.hiddenColumns)); 196 | this.columns = columns; 197 | } 198 | } 199 | else { 200 | this.columns = columns; 201 | // Even if saveLayoutView is false, still initialize localStorage for resize functionality 202 | if (!savedColumns) { 203 | localStorage.setItem(key, JSON.stringify(this.columnsWidth)); 204 | } 205 | if (this.hasStickyColumns && !savedSticky) { 206 | localStorage.setItem(`${key}_sticky`, JSON.stringify(this.stickyColumns)); 207 | } 208 | if (!savedHidden) { 209 | localStorage.setItem(`${key}_hidden`, JSON.stringify(this.hiddenColumns)); 210 | } 211 | } 212 | } 213 | 214 | moveColumn(index: number, toIndex: number) { 215 | if (!this.hasDraggableColumns) { 216 | return; 217 | } 218 | 219 | // Check if we're trying to move between sticky and non-sticky zones 220 | const draggedColumnKey = this.columns[index].key; 221 | const targetColumnKey = this.columns[toIndex].key; 222 | const draggedIsSticky = this.stickyColumns.find(col => col.key === draggedColumnKey)?.sticky ?? false; 223 | const targetIsSticky = this.stickyColumns.find(col => col.key === targetColumnKey)?.sticky ?? false; 224 | 225 | // Prevent moving between sticky and non-sticky zones 226 | if (draggedIsSticky !== targetIsSticky) { 227 | return; 228 | } 229 | 230 | // update table model columns 231 | const customColumns = [...this.columns]; 232 | const item = customColumns.splice(index, 1)[0]; 233 | customColumns.splice(toIndex, 0, item); 234 | this.columns = customColumns; 235 | 236 | // update columnsWidth saved columns 237 | const customColumnsWidth = [...this.columnsWidth] 238 | const columnWidthItem = customColumnsWidth.splice(index, 1)[0]; 239 | customColumnsWidth.splice(toIndex, 0, columnWidthItem); 240 | this.columnsWidth = customColumnsWidth; 241 | 242 | // update stickyColumns saved columns 243 | const customStickyColumns = [...this.stickyColumns] 244 | const stickyColumnItem = customStickyColumns.splice(index, 1)[0]; 245 | customStickyColumns.splice(toIndex, 0, stickyColumnItem); 246 | this.stickyColumns = customStickyColumns; 247 | 248 | // update hiddenColumns saved columns 249 | const customHiddenColumns = [...this.hiddenColumns] 250 | const hiddenColumnItem = customHiddenColumns.splice(index, 1)[0]; 251 | customHiddenColumns.splice(toIndex, 0, hiddenColumnItem); 252 | this.hiddenColumns = customHiddenColumns; 253 | 254 | // update localstorage saved columns 255 | const savedColumnsStr = localStorage.getItem(this.key); 256 | if (savedColumnsStr) { 257 | const savedColumns = JSON.parse(savedColumnsStr); 258 | const savedItem = savedColumns.splice(index, 1)[0]; 259 | savedColumns.splice(toIndex, 0, savedItem); 260 | localStorage.setItem(this.key, JSON.stringify(savedColumns)); 261 | } 262 | 263 | // update localstorage saved sticky state 264 | if (this.hasStickyColumns) { 265 | localStorage.setItem(`${this.key}_sticky`, JSON.stringify(this.stickyColumns)); 266 | } 267 | 268 | // update localstorage saved hidden state 269 | localStorage.setItem(`${this.key}_hidden`, JSON.stringify(this.hiddenColumns)); 270 | } 271 | 272 | setColumnsWidth(widths: ColumnWidthType[]) { 273 | this.columnsWidth = widths; 274 | } 275 | 276 | setStickyColumns(stickyColumns: StickyColumnType[]) { 277 | this.stickyColumns = stickyColumns; 278 | if (this.hasStickyColumns) { 279 | localStorage.setItem(`${this.key}_sticky`, JSON.stringify(this.stickyColumns)); 280 | } 281 | } 282 | 283 | toggleColumnSticky(columnKey: string, actualWidth?: number) { 284 | if (!this.hasStickyColumns) { 285 | return; 286 | } 287 | 288 | const columnIndex = this.columns.findIndex((col: TableColumnType) => col.key === columnKey); 289 | if (columnIndex === -1) { 290 | return; 291 | } 292 | 293 | const currentStickyState = this.stickyColumns.find((col: StickyColumnType) => col.key === columnKey)?.sticky ?? false; 294 | const newStickyState = !currentStickyState; 295 | 296 | // If we're making a column sticky and we have the actual rendered width, update it 297 | if (newStickyState && actualWidth) { 298 | const newWidths = [...this.columnsWidth]; 299 | newWidths[columnIndex] = { 300 | ...newWidths[columnIndex], 301 | width: Math.floor(actualWidth) 302 | }; 303 | this.setColumnsWidth(newWidths); 304 | 305 | // Save to localStorage if saveLayoutView is enabled 306 | if (this.saveLayoutView) { 307 | localStorage.setItem(this.key, JSON.stringify(newWidths)); 308 | } 309 | } 310 | 311 | // Update sticky state first 312 | const newStickyColumns = this.stickyColumns.map((col: StickyColumnType) => 313 | col.key === columnKey ? { ...col, sticky: newStickyState } : col 314 | ); 315 | 316 | if (newStickyState) { 317 | // Making column sticky - move it to the end of sticky columns (rightmost sticky position) 318 | const currentStickyCount = this.stickyColumns.filter((col: StickyColumnType) => col.sticky).length; 319 | const targetIndex = currentStickyCount; // This will be the new rightmost sticky position 320 | 321 | if (columnIndex !== targetIndex) { 322 | this.moveColumn(columnIndex, targetIndex); 323 | } 324 | } else { 325 | // Making column non-sticky - move it to the first non-sticky position 326 | const stickyCount = newStickyColumns.filter((col: StickyColumnType) => col.sticky).length; 327 | const targetIndex = stickyCount; // First position after all sticky columns 328 | 329 | if (columnIndex !== targetIndex) { 330 | this.moveColumn(columnIndex, targetIndex); 331 | } 332 | } 333 | 334 | this.setStickyColumns(newStickyColumns); 335 | } 336 | 337 | getStickyColumnsOffsets(): { [key: string]: number } { 338 | if (!this.hasStickyColumns) { 339 | return {}; 340 | } 341 | 342 | const offsets: { [key: string]: number } = {}; 343 | let cumulativeWidth = 0; 344 | 345 | // PRECISE RULE: Each sticky column's left = sum of all previous sticky column ACTUAL RENDERED widths 346 | for (let i = 0; i < this.columns.length; i++) { 347 | const column = this.columns[i]; 348 | const isSticky = this.stickyColumns.find((col: StickyColumnType) => col.key === column.key)?.sticky ?? false; 349 | 350 | if (isSticky) { 351 | // This sticky column's left position = sum of all previous sticky column actual widths 352 | offsets[column.key] = cumulativeWidth; 353 | 354 | // Get the ACTUAL rendered width of this column (most accurate) 355 | const actualWidth = this.getColumnActualWidth(column.key, column.width ?? 150); 356 | 357 | // Add this column's ACTUAL width to the cumulative total for the next sticky column 358 | cumulativeWidth += actualWidth; 359 | } 360 | } 361 | return offsets; 362 | } 363 | 364 | setHiddenColumns(hiddenColumns: HiddenColumnType[]) { 365 | this.hiddenColumns = hiddenColumns; 366 | localStorage.setItem(`${this.key}_hidden`, JSON.stringify(this.hiddenColumns)); 367 | } 368 | 369 | toggleColumnHidden(columnKey: string) { 370 | const newHiddenColumns = this.hiddenColumns.map((col: HiddenColumnType) => 371 | col.key === columnKey ? { ...col, hidden: !col.hidden } : col 372 | ); 373 | this.setHiddenColumns(newHiddenColumns); 374 | } 375 | 376 | getVisibleColumns(): TableColumnType[] { 377 | return this.columns.filter((column: TableColumnType) => { 378 | const isHidden = this.hiddenColumns.find((col: HiddenColumnType) => col.key === column.key)?.hidden ?? false; 379 | return !isHidden; 380 | }); 381 | } 382 | 383 | getHiddenColumns(): TableColumnType[] { 384 | return this.columns.filter((column: TableColumnType) => { 385 | const isHidden = this.hiddenColumns.find((col: HiddenColumnType) => col.key === column.key)?.hidden ?? false; 386 | return isHidden; 387 | }); 388 | } 389 | } 390 | 391 | export default DataTable; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SnapTable React v3.3.0 2 | 3 | **A Truly Headless React Table Library** 4 | 5 | SnapTable React is a completely headless table library that provides only hooks and logic - no components, no HTML structure, no CSS. You have 100% control over your table's appearance and behavior. 6 | 7 | ## 🎯 What is "Headless"? 8 | 9 | - **No UI components** - Only hooks that return state and handlers 10 | - **No HTML structure** - You build your own ``, `
`, or any markup 11 | - **No CSS** - Zero styling opinions, complete visual control 12 | - **Pure logic** - Column resizing, drag & drop, persistence, and table state management 13 | 14 | ## 📦 Installation 15 | 16 | ```bash 17 | npm install snaptable-react 18 | ``` 19 | 20 | ## 🚀 Quick Start 21 | 22 | ```tsx 23 | import { useDataTable, useTable } from "snaptable-react"; 24 | 25 | function MyTable() { 26 | // Configure your table behavior 27 | const dataTable = useDataTable({ 28 | key: "my-table", 29 | columns: [ 30 | { 31 | key: "name", 32 | label: "Name", 33 | Cell: ({ data }) =>
, 34 | resizeable: true, 35 | }, 36 | { 37 | key: "email", 38 | label: "Email", 39 | Cell: ({ data }) => , 40 | resizeable: true, 41 | }, 42 | ], 43 | hasDraggableColumns: true, 44 | isStickyHeader: true, 45 | saveLayoutView: true, 46 | }); 47 | 48 | // Get table state and handlers 49 | const tableState = useTable(dataTable, myData); 50 | 51 | // Build your own table with complete control 52 | return ( 53 |
{data.name}{data.email}
54 | 55 | 56 | {tableState.columns.map((column, index) => { 57 | const props = tableState.getColumnProps(index); 58 | return ( 59 | 82 | ); 83 | })} 84 | 85 | 86 | 87 | {tableState.data.map((item) => { 88 | const rowProps = tableState.getRowProps(item); 89 | return ( 90 | 91 | {tableState.columns.map(({ key, Cell }) => ( 92 | 93 | ))} 94 | 95 | ); 96 | })} 97 | 98 |
67 | {column.label} 68 | {props.isResizable && ( 69 |
props.onResizeStart(e.nativeEvent)} 79 | /> 80 | )} 81 |
99 | ); 100 | } 101 | ``` 102 | 103 | ## 🔧 Core Hooks 104 | 105 | ### `useDataTable(config)` 106 | 107 | Configure your table's behavior and structure. 108 | 109 | ```tsx 110 | const dataTable = useDataTable({ 111 | key: 'unique-table-id', // For layout persistence 112 | columns: [...], // Column definitions 113 | hasDraggableColumns: true, // Enable column reordering 114 | isStickyHeader: true, // Sticky header behavior 115 | hasStickyColumns: true, // Enable sticky columns 116 | saveLayoutView: true, // Persist column widths/order 117 | onRowClick: ({ item }) => {...} // Row click handler 118 | }); 119 | ``` 120 | 121 | ### `useTable(dataTable, data)` 122 | 123 | Get table state and event handlers for your markup. 124 | 125 | ```tsx 126 | const tableState = useTable(dataTable, data); 127 | 128 | // Available properties: 129 | tableState.columns; // Column definitions 130 | tableState.data; // Table data 131 | tableState.config; // Table configuration 132 | tableState.columnWidths; // Current column widths 133 | tableState.stickyColumns; // Sticky column states 134 | tableState.stickyOffsets; // Sticky column positioning offsets 135 | 136 | // Available methods: 137 | tableState.getColumnProps(index); // Get all props for a column header 138 | tableState.getCellProps(columnIndex); // Get all props for a cell 139 | tableState.getRowProps(item); // Get all props for a row 140 | ``` 141 | 142 | ## 📋 Column Definition 143 | 144 | ```tsx 145 | { 146 | key: 'field-name', // Data field key 147 | label: 'Display Name', // Column header text 148 | Cell: ({ data, ...props }) => {data.field}, // Cell renderer 149 | resizeable: true, // Enable column resizing 150 | sticky: false, // Make column sticky (requires hasStickyColumns: true) 151 | hidden: false, // Start column hidden (optional) 152 | width: 200, // Initial width (optional) 153 | minWidth: 100, // Minimum width (optional) 154 | maxWidth: 500 // Maximum width (optional) 155 | } 156 | ``` 157 | 158 | ## 📌 Sticky Columns 159 | 160 | Enable sticky columns to pin important columns to the left side of the table during horizontal scrolling. 161 | 162 | ### Basic Sticky Columns Setup 163 | 164 | ```tsx 165 | const dataTable = useDataTable({ 166 | key: "my-table", 167 | hasStickyColumns: true, // Enable sticky columns feature 168 | columns: [ 169 | { 170 | key: "name", 171 | label: "Name", 172 | sticky: true, // Pin this column to the left 173 | Cell: ({ data }) => {data.name}, 174 | resizeable: true, 175 | }, 176 | { 177 | key: "id", 178 | label: "ID", 179 | sticky: true, // This will be the second sticky column 180 | Cell: ({ data }) => {data.id}, 181 | resizeable: true, 182 | }, 183 | { 184 | key: "email", 185 | label: "Email", 186 | Cell: ({ data }) => {data.email}, 187 | resizeable: true, 188 | }, 189 | // ... more columns 190 | ], 191 | }); 192 | ``` 193 | 194 | ### Implementing Sticky Columns in Your Table 195 | 196 | ```tsx 197 | function StickyTable() { 198 | const tableState = useTable(dataTable, data); 199 | 200 | return ( 201 |
202 | 203 | 204 | 205 | {tableState.columns.map((column, index) => { 206 | const props = tableState.getColumnProps(index); 207 | return ( 208 | 245 | ); 246 | })} 247 | 248 | 249 | 250 | {tableState.data.map((item) => { 251 | const rowProps = tableState.getRowProps(item); 252 | return ( 253 | 254 | {tableState.columns.map((column, columnIndex) => { 255 | const cellProps = tableState.getCellProps(columnIndex); 256 | return ( 257 | 273 | ); 274 | })} 275 | 276 | ); 277 | })} 278 | 279 |
222 | {column.label} 223 | {/* Toggle sticky button */} 224 | 230 | {/* Resize handle */} 231 | {props.isResizable && ( 232 |
props.onResizeStart(e.nativeEvent)} 242 | /> 243 | )} 244 |
271 | 272 |
280 |
281 | ); 282 | } 283 | ``` 284 | 285 | ### Sticky Columns Features 286 | 287 | - **Multiple Sticky Columns** - Pin multiple columns that stack from left to right 288 | - **Dynamic Toggle** - Use `onToggleSticky()` to dynamically pin/unpin columns 289 | - **Automatic Positioning** - Precise positioning with `stickyOffset` values 290 | - **Resize Support** - Sticky columns work seamlessly with column resizing 291 | - **Drag & Drop Constraints** - Sticky columns can only be reordered among other sticky columns 292 | - **State Persistence** - Sticky states are saved to localStorage when `saveLayoutView` is enabled 293 | 294 | ### CSS Tips for Sticky Columns 295 | 296 | ```css 297 | /* Ensure smooth scrolling */ 298 | .table-container { 299 | overflow-x: auto; 300 | scroll-behavior: smooth; 301 | } 302 | 303 | /* Add visual distinction for sticky columns */ 304 | .sticky-column { 305 | background-color: #f8f9fa; 306 | border-right: 2px solid #dee2e6; 307 | box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1); 308 | } 309 | 310 | /* Hover effects for sticky columns */ 311 | .sticky-column:hover { 312 | background-color: #e9ecef; 313 | } 314 | ``` 315 | 316 | ## 👁️ Show/Hide Columns 317 | 318 | Control column visibility dynamically with built-in state management and persistence. 319 | 320 | ### Basic Show/Hide Setup 321 | 322 | ```tsx 323 | const dataTable = useDataTable({ 324 | key: "my-table", 325 | columns: [ 326 | { 327 | key: "name", 328 | label: "Name", 329 | Cell: ({ data }) => {data.name}, 330 | resizeable: true, 331 | }, 332 | { 333 | key: "email", 334 | label: "Email", 335 | Cell: ({ data }) => {data.email}, 336 | resizeable: true, 337 | hidden: true, // Start hidden 338 | }, 339 | { 340 | key: "phone", 341 | label: "Phone", 342 | Cell: ({ data }) => {data.phone}, 343 | resizeable: true, 344 | }, 345 | ], 346 | saveLayoutView: true, // Persist hidden state 347 | }); 348 | ``` 349 | 350 | ### Implementing Show/Hide Controls 351 | 352 | ```tsx 353 | function TableWithHideShow() { 354 | const tableState = useTable(dataTable, data); 355 | 356 | return ( 357 |
358 | {/* Hidden columns dropdown */} 359 |
360 | 366 | {tableState.getHiddenColumns().length > 0 && ( 367 |
368 | {tableState.getHiddenColumns().map((column) => ( 369 | 375 | ))} 376 |
377 | )} 378 |
379 | 380 | 381 | 382 | 383 | {tableState.columns.map((column, index) => { 384 | const props = tableState.getColumnProps(index); 385 | return ( 386 | 410 | ); 411 | })} 412 | 413 | 414 | 415 | {tableState.data.map((item) => { 416 | const rowProps = tableState.getRowProps(item); 417 | return ( 418 | 419 | {tableState.columns.map(({ key, Cell }) => ( 420 | 421 | ))} 422 | 423 | ); 424 | })} 425 | 426 |
387 | {column.label} 388 | {/* Hide column button */} 389 | 395 | {/* Resize handle */} 396 | {props.isResizable && ( 397 |
props.onResizeStart(e.nativeEvent)} 399 | style={{ 400 | position: "absolute", 401 | right: 0, 402 | top: 0, 403 | width: "5px", 404 | height: "100%", 405 | cursor: "col-resize", 406 | }} 407 | /> 408 | )} 409 |
427 |
428 | ); 429 | } 430 | ``` 431 | 432 | ### Show/Hide Features 433 | 434 | - **Hidden State Management** - Automatic state tracking for hidden columns 435 | - **Persistence** - Hidden states are saved to localStorage when `saveLayoutView` is enabled 436 | - **Dynamic Toggle** - Use `onToggleHidden()` to hide columns and `toggleColumnHidden()` to show them 437 | - **Hidden Columns List** - Get all hidden columns with `getHiddenColumns()` 438 | - **Flexible UI** - Build your own show/hide controls with complete styling control 439 | - **Integration** - Works seamlessly with sticky columns, resizing, and drag & drop 440 | 441 | ## 🎨 Styling Examples 442 | 443 | ### Basic Table 444 | 445 | ```tsx 446 | // Your CSS 447 | .my-table { 448 | width: 100%; 449 | border-collapse: collapse; 450 | } 451 | 452 | .my-header { 453 | background: #f5f5f5; 454 | padding: 12px; 455 | border: 1px solid #ddd; 456 | } 457 | 458 | .my-cell { 459 | padding: 12px; 460 | border: 1px solid #ddd; 461 | } 462 | ``` 463 | 464 | ### Advanced Styling 465 | 466 | ```tsx 467 | // Complete control over appearance 468 | const StyledCell = ({ data, ...props }) => ( 469 | 479 |
480 | {data.name} 481 | {data.description} 482 |
483 | 484 | ); 485 | ``` 486 | 487 | ### Grid Layout (Non-Table) 488 | 489 | ```tsx 490 | // Use divs instead of table elements 491 | return ( 492 |
493 |
494 | {tableState.columns.map((column, index) => { 495 | const props = tableState.getColumnProps(index); 496 | return ( 497 |
505 | {column.label} 506 |
507 | ); 508 | })} 509 |
510 |
511 | {tableState.data.map((item) => ( 512 |
513 | {tableState.columns.map(({ key, Cell }) => ( 514 | 515 | ))} 516 |
517 | ))} 518 |
519 |
520 | ); 521 | ``` 522 | 523 | ## ⚡ Features 524 | 525 | - **Column Resizing** - Drag column borders to resize 526 | - **Column Reordering** - Drag & drop column headers to reorder 527 | - **Sticky Headers** - Keep headers visible while scrolling 528 | - **Sticky Columns** - Pin columns to the left side during horizontal scrolling 529 | - **Show/Hide Columns** - Toggle column visibility with built-in state management 530 | - **Layout Persistence** - Save column widths, order, sticky states, and visibility to localStorage 531 | - **Row Click Handlers** - Handle row interactions 532 | - **Flexible Data** - Works with any data structure 533 | - **TypeScript** - Full TypeScript support with proper types 534 | - **Zero Dependencies** - No external dependencies except React 535 | - **Tiny Bundle** - Only the logic you need, no UI bloat 536 | 537 | ## 📋 Recent Changes 538 | 539 | ### v3.3.0 (Latest) 540 | 541 | **Developer Experience Improvements:** 542 | 543 | - 🎯 **Automated Z-Index Management** - Z-index calculations for sticky columns and headers are now handled automatically by the library 544 | - 🧹 **Cleaner User Code** - Users no longer need to implement complex z-index logic in their components 545 | - 📦 **Built-in Logic** - All sticky column layering logic is now internal to the hooks 546 | - 🔧 **Simplified Implementation** - Reduced boilerplate code for sticky column implementations 547 | 548 | **API Enhancements:** 549 | 550 | - `props.zIndex` - Column headers now include calculated z-index values 551 | - `cellProps.zIndex` - Table cells now include calculated z-index values 552 | - Automatic z-index calculation based on sticky column position and sticky header state 553 | 554 | ### v3.2.0 555 | 556 | **New Features:** 557 | 558 | - ✨ **Show/Hide Columns** - Toggle column visibility with built-in state management 559 | - 🔧 **Enhanced Layout Persistence** - Hidden column states are now saved to localStorage 560 | - 🎯 **Improved Developer Experience** - Better component architecture and naming conventions 561 | 562 | **API Additions:** 563 | 564 | - `tableState.getHiddenColumns()` - Get array of hidden columns 565 | - `tableState.toggleColumnHidden(columnKey)` - Toggle specific column visibility 566 | - `props.onToggleHidden()` - Hide a column from column header 567 | - `column.hidden` - Set initial hidden state in column definition 568 | 569 | --- 570 | 571 | 📖 **[View complete changelog](./CHANGELOG.md)** for all version history and detailed changes. 572 | 573 | ## 🔄 Migration from v2.x 574 | 575 | **v2.x had components:** 576 | 577 | ```tsx 578 | // OLD - Had built-in components 579 | import { SnapTable } from "snaptable-react"; 580 | ; 581 | ``` 582 | 583 | **v3.x is purely headless:** 584 | 585 | ```tsx 586 | // NEW - Only hooks, you build the UI 587 | import { useDataTable, useTable } from "snaptable-react"; 588 | const tableState = useTable(dataTable, data); 589 | // Build your own or
structure 590 | ``` 591 | 592 | ## 📚 Examples 593 | 594 | Check the `/examples` folder for complete implementation examples: 595 | 596 | - **Basic Table** - Simple table with resizing and drag & drop 597 | - **Advanced Styling** - Custom cell renderers and complex layouts 598 | - **Grid Layout** - Using divs instead of table elements 599 | - **Responsive Design** - Mobile-friendly implementations 600 | 601 | ## 🤝 Contributing 602 | 603 | 1. Fork the repository 604 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 605 | 3. Commit your changes (`git commit -m 'Add amazing feature'`) 606 | 4. Push to the branch (`git push origin feature/amazing-feature`) 607 | 5. Open a Pull Request 608 | 609 | ## 📄 License 610 | 611 | MIT License - see the [LICENSE](LICENSE) file for details. 612 | 613 | --- 614 | 615 | **Remember:** This is a headless library. We provide the logic, you provide the UI. Build tables that perfectly match your design system! 🎨 616 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | /* Reset and base styles */ 2 | * { 3 | margin: 0; 4 | padding: 0; 5 | box-sizing: border-box; 6 | } 7 | 8 | body { 9 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 10 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 11 | sans-serif; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | line-height: 1.6; 15 | } 16 | 17 | /* Landing Page Container */ 18 | .landing-page { 19 | min-height: 100vh; 20 | width: 100vw; 21 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 22 | position: relative; 23 | overflow-x: hidden; 24 | } 25 | 26 | /* Hero Section */ 27 | .hero-section { 28 | min-height: 100vh; 29 | width: 100vw; 30 | display: flex; 31 | align-items: center; 32 | justify-content: center; 33 | position: relative; 34 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 35 | padding: 2rem 1rem; 36 | } 37 | 38 | .hero-section::before { 39 | content: ''; 40 | position: absolute; 41 | top: 0; 42 | left: 0; 43 | right: 0; 44 | bottom: 0; 45 | background: 46 | radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.3) 0%, transparent 50%), 47 | radial-gradient(circle at 80% 20%, rgba(255, 119, 198, 0.3) 0%, transparent 50%); 48 | pointer-events: none; 49 | } 50 | 51 | .hero-content { 52 | text-align: center; 53 | max-width: 1060px; 54 | width: 100%; 55 | padding: 1rem; 56 | position: relative; 57 | z-index: 1; 58 | } 59 | 60 | .hero-badge { 61 | display: flex; 62 | align-items: center; 63 | justify-content: center; 64 | gap: 0.5rem; 65 | margin-bottom: 2rem; 66 | flex-wrap: wrap; 67 | } 68 | 69 | .version-badge { 70 | background: rgba(255, 255, 255, 0.2); 71 | backdrop-filter: blur(10px); 72 | border: 1px solid rgba(255, 255, 255, 0.3); 73 | padding: 0.5rem 1rem; 74 | border-radius: 50px; 75 | color: white; 76 | font-weight: 600; 77 | font-size: 0.9rem; 78 | } 79 | 80 | .new-badge { 81 | background: linear-gradient(135deg, #ff6b6b, #ee5a24); 82 | padding: 0.5rem 1rem; 83 | border-radius: 50px; 84 | color: white; 85 | font-weight: 600; 86 | font-size: 0.9rem; 87 | box-shadow: 0 4px 15px rgba(255, 107, 107, 0.3); 88 | animation: pulse 2s infinite; 89 | } 90 | 91 | @keyframes pulse { 92 | 0%, 100% { transform: scale(1); } 93 | 50% { transform: scale(1.05); } 94 | } 95 | 96 | .hero-title { 97 | font-size: clamp(2.5rem, 8vw, 6rem); 98 | font-weight: 800; 99 | margin-bottom: 1rem; 100 | line-height: 1.1; 101 | position: relative; 102 | display: inline-block; 103 | } 104 | 105 | .gradient-text { 106 | background: linear-gradient(45deg, 107 | #ff6b6b 0%, 108 | #4ecdc4 14%, 109 | #45b7d1 28%, 110 | #96ceb4 42%, 111 | #feca57 56%, 112 | #ff9ff3 70%, 113 | #54a0ff 84%, 114 | #5f27cd 100%); 115 | background-size: 400% 400%; 116 | -webkit-background-clip: text; 117 | -webkit-text-fill-color: transparent; 118 | background-clip: text; 119 | animation: rainbowShift 4s ease infinite, textGlow 2s ease-in-out infinite alternate; 120 | position: relative; 121 | z-index: 3; 122 | } 123 | 124 | .gradient-text::before { 125 | content: 'SnapTable'; 126 | position: absolute; 127 | top: 0; 128 | left: 0; 129 | right: 0; 130 | background: linear-gradient(45deg, 131 | #ff6b6b 0%, 132 | #4ecdc4 14%, 133 | #45b7d1 28%, 134 | #96ceb4 42%, 135 | #feca57 56%, 136 | #ff9ff3 70%, 137 | #54a0ff 84%, 138 | #5f27cd 100%); 139 | background-size: 400% 400%; 140 | -webkit-background-clip: text; 141 | -webkit-text-fill-color: transparent; 142 | background-clip: text; 143 | animation: rainbowShift 4s ease infinite reverse; 144 | filter: blur(3px); 145 | opacity: 0.6; 146 | z-index: 1; 147 | } 148 | 149 | .gradient-text::after { 150 | content: ''; 151 | position: absolute; 152 | top: -15px; 153 | left: -15px; 154 | right: -15px; 155 | bottom: -15px; 156 | background: linear-gradient(45deg, 157 | #ff6b6b 0%, 158 | #4ecdc4 14%, 159 | #45b7d1 28%, 160 | #96ceb4 42%, 161 | #feca57 56%, 162 | #ff9ff3 70%, 163 | #54a0ff 84%, 164 | #5f27cd 100%); 165 | background-size: 400% 400%; 166 | animation: rainbowShift 4s ease infinite; 167 | filter: blur(30px); 168 | opacity: 0.4; 169 | z-index: 0; 170 | border-radius: 30px; 171 | } 172 | 173 | @keyframes rainbowShift { 174 | 0% { background-position: 0% 50%; } 175 | 50% { background-position: 100% 50%; } 176 | 100% { background-position: 0% 50%; } 177 | } 178 | 179 | @keyframes textGlow { 180 | 0% { 181 | filter: drop-shadow(0 0 5px rgba(255, 107, 107, 0.5)) 182 | drop-shadow(0 0 10px rgba(78, 205, 196, 0.3)) 183 | drop-shadow(0 0 15px rgba(69, 183, 209, 0.2)); 184 | } 185 | 100% { 186 | filter: drop-shadow(0 0 20px rgba(255, 107, 107, 0.8)) 187 | drop-shadow(0 0 30px rgba(78, 205, 196, 0.6)) 188 | drop-shadow(0 0 40px rgba(69, 183, 209, 0.4)); 189 | } 190 | } 191 | 192 | .hero-subtitle { 193 | font-size: clamp(1.1rem, 4vw, 1.8rem); 194 | color: rgba(255, 255, 255, 0.9); 195 | margin-bottom: 1rem; 196 | font-weight: 600; 197 | } 198 | 199 | .hero-description { 200 | font-size: clamp(0.95rem, 3vw, 1.2rem); 201 | color: rgba(255, 255, 255, 0.8); 202 | margin-bottom: 2rem; 203 | max-width: 600px; 204 | margin-left: auto; 205 | margin-right: auto; 206 | line-height: 1.7; 207 | } 208 | 209 | .hero-description p { 210 | margin: 0; 211 | margin-bottom: 0.5rem; 212 | } 213 | 214 | .hero-description p:last-child { 215 | margin-bottom: 0; 216 | } 217 | 218 | .hero-features { 219 | display: flex; 220 | flex-wrap: wrap; 221 | justify-content: center; 222 | align-items: center; 223 | /* display: grid; */ 224 | /* grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); */ 225 | gap: 1rem; 226 | margin-bottom: 2rem; 227 | width: 700px; 228 | margin-left: auto; 229 | margin-right: auto; 230 | } 231 | 232 | .feature-pill { 233 | background: rgba(255, 255, 255, 0.15); 234 | backdrop-filter: blur(10px); 235 | border: 1px solid rgba(255, 255, 255, 0.2); 236 | padding: 0.6rem 1rem; 237 | border-radius: 50px; 238 | color: white; 239 | font-weight: 500; 240 | font-size: 0.85rem; 241 | text-align: center; 242 | align-content: center; 243 | height: 60px; 244 | width: 160px; 245 | } 246 | 247 | .feature-pill:hover { 248 | background: rgba(255, 255, 255, 0.25); 249 | transform: translateY(-2px); 250 | box-shadow: 0 8px 25px rgba(255, 255, 255, 0.1); 251 | } 252 | 253 | .hero-actions { 254 | display: flex; 255 | align-items: center; 256 | justify-content: center; 257 | gap: 1rem; 258 | max-width: 800px; 259 | margin: 0 auto; 260 | } 261 | 262 | .primary-button, .secondary-button { 263 | display: flex; 264 | align-items: center; 265 | justify-content: center; 266 | gap: 0.5rem; 267 | padding: 1rem 2rem; 268 | border-radius: 50px; 269 | font-weight: 600; 270 | font-size: 1rem; 271 | border: none; 272 | cursor: pointer; 273 | text-decoration: none; 274 | width: 100%; 275 | max-width: 320px; 276 | } 277 | 278 | .primary-button { 279 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 280 | color: white; 281 | box-shadow: 0 8px 30px rgba(102, 126, 234, 0.4); 282 | border: 2px solid rgba(255, 255, 255, 0.3); 283 | } 284 | 285 | .primary-button:hover { 286 | transform: translateY(-3px); 287 | box-shadow: 0 12px 40px rgba(102, 126, 234, 0.6); 288 | } 289 | 290 | .secondary-button { 291 | background: rgba(255, 255, 255, 0.1); 292 | color: white; 293 | border: 2px solid rgba(255, 255, 255, 0.3); 294 | backdrop-filter: blur(10px); 295 | } 296 | 297 | .secondary-button:hover { 298 | background: rgba(255, 255, 255, 0.2); 299 | transform: translateY(-3px); 300 | box-shadow: 0 8px 25px rgba(255, 255, 255, 0.2); 301 | } 302 | 303 | .button-icon { 304 | font-size: 1.2rem; 305 | } 306 | 307 | /* Demo Section */ 308 | .demo-section { 309 | background: linear-gradient(180deg, #f8f9ff 0%, #ffffff 100%); 310 | padding: 2rem; 311 | position: relative; 312 | width: 100vw; 313 | min-height: 100vh; 314 | } 315 | 316 | .demo-header { 317 | width: 100%; 318 | margin-bottom: 24px; 319 | } 320 | 321 | .demo-title { 322 | font-size: clamp(2rem, 5vw, 3rem); 323 | font-weight: 700; 324 | margin-bottom: 1rem; 325 | color: #1e293b; 326 | } 327 | 328 | .demo-title .gradient-text { 329 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 330 | -webkit-background-clip: text; 331 | -webkit-text-fill-color: transparent; 332 | background-clip: text; 333 | animation: none !important; 334 | filter: none !important; 335 | position: static !important; 336 | z-index: auto !important; 337 | } 338 | 339 | .demo-title .gradient-text::before, 340 | .demo-title .gradient-text::after { 341 | display: none !important; 342 | } 343 | 344 | .demo-description { 345 | font-size: clamp(1rem, 3vw, 1.2rem); 346 | color: #64748b; 347 | border-bottom: 1px solid #e5e7eb; 348 | padding-bottom: 18px; 349 | max-width: 700px; 350 | line-height: 1.6; 351 | } 352 | 353 | .demo-description p { 354 | margin: 0; 355 | margin-bottom: 0.5rem; 356 | } 357 | 358 | .demo-description p:last-child { 359 | margin-bottom: 0; 360 | } 361 | 362 | 363 | 364 | /* Table Wrapper */ 365 | .table-wrapper { 366 | max-width: 1400px; 367 | margin: 0 auto; 368 | position: relative; 369 | } 370 | 371 | /* Table Container */ 372 | .table-container { 373 | background: white; 374 | border-radius: 12px; 375 | box-shadow: 0 10px 40px rgba(0, 0, 0, 0.08); 376 | border: 1px solid #e2e8f0; 377 | max-height: 70vh; 378 | overflow: auto; 379 | position: relative; 380 | } 381 | 382 | /* Table Styles */ 383 | .demo-table { 384 | width: max-content; 385 | min-width: 100%; 386 | border-collapse: separate; 387 | border-spacing: 0; 388 | font-size: 0.9rem; 389 | table-layout: fixed; 390 | } 391 | 392 | .demo-thead { 393 | background: #f8fafc; 394 | position: sticky; 395 | top: 0; 396 | z-index: 10; 397 | } 398 | 399 | .demo-thead.sticky { 400 | position: sticky; 401 | top: 0; 402 | z-index: 10; 403 | } 404 | 405 | .demo-header { 406 | background: #f8fafc; 407 | color: #374151; 408 | padding: 1rem 0.75rem; 409 | font-weight: 600; 410 | text-align: left; 411 | position: relative; 412 | user-select: none; 413 | font-size: 0.875rem; 414 | text-transform: uppercase; 415 | letter-spacing: 0.05em; 416 | white-space: nowrap; 417 | overflow: hidden; 418 | text-overflow: ellipsis; 419 | box-sizing: border-box; 420 | } 421 | 422 | .demo-header.sticky { 423 | position: sticky; 424 | top: 0; 425 | z-index: 10; 426 | } 427 | 428 | .demo-header.dragging { 429 | opacity: 0.5; 430 | transform: scale(0.95); 431 | } 432 | 433 | .demo-header.hovered { 434 | background: #f1f5f9; 435 | } 436 | 437 | .header-content { 438 | display: flex; 439 | align-items: center; 440 | justify-content: space-between; 441 | width: 100%; 442 | min-width: 0; /* Allow content to shrink */ 443 | max-width: 100%; /* Prevent content overflow */ 444 | } 445 | 446 | .header-label { 447 | font-weight: 600; 448 | font-size: 0.875rem; 449 | text-transform: uppercase; 450 | letter-spacing: 0.05em; 451 | color: #374151; 452 | min-width: 0; /* Allow text to shrink */ 453 | overflow: hidden; 454 | text-overflow: ellipsis; 455 | flex: 1; /* Take available space */ 456 | } 457 | 458 | .header-actions { 459 | display: flex; 460 | align-items: center; 461 | gap: 0.5rem; 462 | flex-shrink: 0; /* Prevent actions from shrinking */ 463 | } 464 | 465 | .sticky-toggle { 466 | background: none; 467 | border: none; 468 | cursor: pointer; 469 | font-size: 0.9rem; 470 | opacity: 0.6; 471 | transition: opacity 0.2s ease; 472 | padding: 0.25rem; 473 | border-radius: 3px; 474 | } 475 | 476 | .sticky-toggle:hover { 477 | opacity: 1; 478 | background: rgba(0, 0, 0, 0.05); 479 | } 480 | 481 | .sticky-toggle.active { 482 | opacity: 1; 483 | } 484 | 485 | /* Header cell borders */ 486 | .demo-header.has-border { 487 | border-right: 1px solid #e5e7eb; 488 | } 489 | 490 | /* Sticky column styling */ 491 | .demo-header.column-sticky { 492 | background: #f1f5f9; 493 | box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1); 494 | min-width: 0; /* Prevent content overflow */ 495 | } 496 | 497 | .demo-cell.cell-sticky { 498 | background: #f8fafc; 499 | box-shadow: 2px 0 4px rgba(0, 0, 0, 0.05); 500 | min-width: 0; /* Prevent content overflow */ 501 | } 502 | 503 | /* Ensure sticky columns don't overlap */ 504 | .demo-header.column-sticky, 505 | .demo-cell.cell-sticky { 506 | box-sizing: border-box; 507 | flex-shrink: 0; 508 | position: relative; 509 | } 510 | 511 | .demo-cell.has-border { 512 | border-right: 1px solid #f1f5f9; 513 | } 514 | 515 | .drag-indicator { 516 | opacity: 0.4; 517 | font-size: 0.875rem; 518 | cursor: grab; 519 | color: #9ca3af; 520 | } 521 | 522 | .demo-header:hover .drag-indicator { 523 | opacity: 0.8; 524 | } 525 | 526 | .resize-handle { 527 | position: absolute; 528 | right: 0; 529 | top: 0; 530 | width: 4px; 531 | height: 100%; 532 | cursor: col-resize; 533 | background: transparent; 534 | opacity: 0; 535 | } 536 | 537 | .demo-header:hover .resize-handle { 538 | opacity: 1; 539 | background: #d1d5db; 540 | } 541 | 542 | .resize-handle:hover { 543 | background: #9ca3af; 544 | } 545 | 546 | /* Table Body */ 547 | .demo-tbody { 548 | background: white; 549 | } 550 | 551 | .demo-row { 552 | cursor: pointer; 553 | border-bottom: 1px solid #f1f5f9; 554 | } 555 | 556 | .demo-row:hover { 557 | background: #f8fafc; 558 | } 559 | 560 | .demo-row:last-child { 561 | border-bottom: none; 562 | } 563 | 564 | .demo-cell { 565 | padding: 0.875rem 0.75rem; 566 | vertical-align: middle; 567 | border-bottom: 1px solid #f1f5f9; 568 | white-space: nowrap; 569 | overflow: hidden; 570 | text-overflow: ellipsis; 571 | box-sizing: border-box; 572 | } 573 | 574 | /* Employee Info Styles */ 575 | .employee-info { 576 | display: flex; 577 | align-items: center; 578 | gap: 0.75rem; 579 | } 580 | 581 | .employee-avatar { 582 | width: 36px; 583 | height: 36px; 584 | border-radius: 50%; 585 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 586 | display: flex; 587 | align-items: center; 588 | justify-content: center; 589 | color: white; 590 | font-weight: bold; 591 | font-size: 0.8rem; 592 | flex-shrink: 0; 593 | } 594 | 595 | .employee-details { 596 | min-width: 0; 597 | flex: 1; 598 | } 599 | 600 | .employee-name { 601 | font-weight: 600; 602 | color: #1e293b; 603 | margin-bottom: 0.25rem; 604 | font-size: 0.9rem; 605 | } 606 | 607 | .employee-position { 608 | font-size: 0.8rem; 609 | color: #64748b; 610 | } 611 | 612 | /* Department Badges */ 613 | .department-badge { 614 | padding: 0.25rem 0.75rem; 615 | border-radius: 12px; 616 | font-size: 0.75rem; 617 | font-weight: 600; 618 | text-transform: uppercase; 619 | letter-spacing: 0.025em; 620 | white-space: nowrap; 621 | } 622 | 623 | .department-badge.engineering { 624 | background: #dbeafe; 625 | color: #1e40af; 626 | } 627 | 628 | .department-badge.design { 629 | background: #fce7f3; 630 | color: #be185d; 631 | } 632 | 633 | .department-badge.marketing { 634 | background: #fef3c7; 635 | color: #d97706; 636 | } 637 | 638 | .department-badge.sales { 639 | background: #d1fae5; 640 | color: #059669; 641 | } 642 | 643 | .department-badge.hr { 644 | background: #e9d5ff; 645 | color: #7c3aed; 646 | } 647 | 648 | /* Salary Cell */ 649 | .salary-cell { 650 | font-weight: 600; 651 | color: #059669; 652 | font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; 653 | font-size: 0.875rem; 654 | } 655 | 656 | /* Location Info */ 657 | .location-info { 658 | display: flex; 659 | align-items: center; 660 | gap: 0.5rem; 661 | font-size: 0.875rem; 662 | color: #64748b; 663 | } 664 | 665 | .location-icon { 666 | font-size: 0.875rem; 667 | flex-shrink: 0; 668 | } 669 | 670 | /* Experience Cell */ 671 | .experience-cell { 672 | color: #64748b; 673 | font-weight: 500; 674 | font-size: 0.875rem; 675 | } 676 | 677 | /* Status Badge */ 678 | .status-badge { 679 | width: fit-content; 680 | display: flex; 681 | align-items: center; 682 | gap: 0.375rem; 683 | padding: 0.25rem 0.75rem; 684 | border-radius: 12px; 685 | font-size: 0.75rem; 686 | font-weight: 600; 687 | white-space: nowrap; 688 | } 689 | 690 | .status-badge.active { 691 | background: #d1fae5; 692 | color: #059669; 693 | } 694 | 695 | .status-dot { 696 | width: 6px; 697 | height: 6px; 698 | border-radius: 50%; 699 | background: currentColor; 700 | flex-shrink: 0; 701 | } 702 | 703 | /* Features Section */ 704 | .features-section { 705 | background: linear-gradient(180deg, #ffffff 0%, #f8f9ff 100%); 706 | padding: 4rem 2rem; 707 | width: 100vw; 708 | } 709 | 710 | .features-title { 711 | text-align: center; 712 | font-size: clamp(2rem, 6vw, 4rem); 713 | font-weight: 800; 714 | margin-bottom: 3rem; 715 | } 716 | 717 | .features-title .gradient-text { 718 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 719 | -webkit-background-clip: text; 720 | -webkit-text-fill-color: transparent; 721 | background-clip: text; 722 | animation: none !important; 723 | filter: none !important; 724 | position: static !important; 725 | z-index: auto !important; 726 | } 727 | 728 | .features-title .gradient-text::before, 729 | .features-title .gradient-text::after { 730 | display: none !important; 731 | } 732 | 733 | .features-grid { 734 | display: grid; 735 | grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); 736 | gap: 1.5rem; 737 | max-width: 1100px; 738 | margin: 0 auto; 739 | } 740 | 741 | .feature-card { 742 | background: white; 743 | padding: 2rem; 744 | border-radius: 20px; 745 | text-align: center; 746 | box-shadow: 0 10px 40px rgba(0, 0, 0, 0.08); 747 | border: 1px solid rgba(102, 126, 234, 0.1); 748 | } 749 | 750 | .feature-card:hover { 751 | transform: translateY(-10px); 752 | box-shadow: 0 20px 60px rgba(102, 126, 234, 0.15); 753 | } 754 | 755 | .feature-icon { 756 | font-size: clamp(2rem, 6vw, 3rem); 757 | margin-bottom: 1rem; 758 | display: block; 759 | } 760 | 761 | .feature-card h3 { 762 | font-size: clamp(1.2rem, 4vw, 1.5rem); 763 | font-weight: 700; 764 | margin-bottom: 1rem; 765 | color: #1e293b; 766 | } 767 | 768 | .feature-card p { 769 | color: #64748b; 770 | line-height: 1.7; 771 | font-size: clamp(0.9rem, 3vw, 1rem); 772 | } 773 | 774 | /* Footer */ 775 | .footer { 776 | background: linear-gradient(135deg, #1e293b 0%, #334155 100%); 777 | color: white; 778 | text-align: center; 779 | padding: 2rem 1rem; 780 | width: 100vw; 781 | } 782 | 783 | .footer p { 784 | margin-bottom: 1rem; 785 | opacity: 0.9; 786 | font-size: clamp(0.85rem, 3vw, 1rem); 787 | } 788 | 789 | .footer-link { 790 | color: #67e8f9; 791 | text-decoration: none; 792 | font-weight: 500; 793 | } 794 | 795 | .footer-link:hover { 796 | color: #22d3ee; 797 | } 798 | 799 | .footer-version { 800 | color: #94a3b8; 801 | font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; 802 | } 803 | 804 | /* Mobile Specific Improvements */ 805 | @media (max-width: 768px) { 806 | .hero-section { 807 | min-height: auto; 808 | padding: 3rem 1rem; 809 | } 810 | 811 | .hero-content { 812 | padding: 0; 813 | } 814 | 815 | .hero-badge { 816 | gap: 0.5rem; 817 | } 818 | 819 | .version-badge, .new-badge { 820 | padding: 0.4rem 0.8rem; 821 | font-size: 0.8rem; 822 | } 823 | 824 | .hero-description { 825 | margin-bottom: 1.5rem; 826 | } 827 | 828 | .hero-features { 829 | grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); 830 | gap: 0.5rem; 831 | margin-bottom: 1.5rem; 832 | } 833 | 834 | .feature-pill { 835 | padding: 0.5rem 0.8rem; 836 | font-size: 0.75rem; 837 | } 838 | 839 | .demo-section { 840 | padding: 3rem 1rem; 841 | } 842 | 843 | .table-container { 844 | border-radius: 8px; 845 | max-height: 60vh; 846 | margin: 0 1rem; 847 | } 848 | 849 | .demo-table { 850 | min-width: 600px; 851 | } 852 | 853 | .demo-header { 854 | padding: 0.75rem 0.5rem; 855 | font-size: 0.75rem; 856 | } 857 | 858 | .demo-cell { 859 | padding: 0.75rem 0.5rem; 860 | } 861 | 862 | .employee-avatar { 863 | width: 32px; 864 | height: 32px; 865 | font-size: 0.75rem; 866 | } 867 | 868 | .employee-info { 869 | gap: 0.5rem; 870 | } 871 | 872 | .features-section { 873 | padding: 3rem 1rem; 874 | } 875 | 876 | .features-grid { 877 | grid-template-columns: 1fr; 878 | gap: 1rem; 879 | } 880 | 881 | .feature-card { 882 | padding: 1.5rem; 883 | } 884 | 885 | .footer { 886 | padding: 1.5rem 1rem; 887 | } 888 | } 889 | 890 | @media (max-width: 480px) { 891 | .hero-section { 892 | padding: 2rem 0.5rem; 893 | } 894 | 895 | .hero-features { 896 | grid-template-columns: repeat(2, 1fr); 897 | } 898 | 899 | .demo-section, .features-section { 900 | padding: 2rem 0.5rem; 901 | } 902 | 903 | .table-container { 904 | border-radius: 6px; 905 | max-height: 50vh; 906 | margin: 0 0.5rem; 907 | } 908 | 909 | .demo-table { 910 | min-width: 500px; 911 | } 912 | 913 | .demo-header { 914 | padding: 0.5rem 0.375rem; 915 | } 916 | 917 | .demo-cell { 918 | padding: 0.5rem 0.375rem; 919 | } 920 | 921 | .employee-info { 922 | flex-direction: row; 923 | align-items: center; 924 | } 925 | 926 | .employee-details { 927 | text-align: left; 928 | } 929 | 930 | .location-info { 931 | flex-direction: column; 932 | align-items: flex-start; 933 | gap: 0.2rem; 934 | } 935 | } 936 | 937 | /* Scrollbar Styling */ 938 | .table-container::-webkit-scrollbar { 939 | width: 6px; 940 | height: 6px; 941 | } 942 | 943 | .table-container::-webkit-scrollbar-track { 944 | background: #f1f5f9; 945 | } 946 | 947 | .table-container::-webkit-scrollbar-thumb { 948 | background: #cbd5e1; 949 | border-radius: 10px; 950 | } 951 | 952 | .table-container::-webkit-scrollbar-thumb:hover { 953 | background: #94a3b8; 954 | } 955 | 956 | /* Hidden columns dropdown */ 957 | .hidden-columns-dropdown { 958 | position: absolute; 959 | top: -40px; 960 | right: 0; 961 | z-index: 9999; 962 | } 963 | 964 | /* Portal dropdown base styles */ 965 | .dropdown-portal { 966 | background: white; 967 | border: 1px solid #e2e8f0; 968 | border-radius: 8px; 969 | box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); 970 | overflow: hidden; 971 | } 972 | 973 | .hidden-columns-toggle { 974 | background: rgba(255, 255, 255, 0.95); 975 | border: 1px solid #e0e0e0; 976 | border-radius: 8px; 977 | padding: 8px 12px; 978 | font-size: 12px; 979 | font-weight: 500; 980 | color: #666; 981 | cursor: pointer; 982 | transition: all 0.2s ease; 983 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 984 | } 985 | 986 | .hidden-columns-toggle:hover:not(.disabled) { 987 | background: white; 988 | border-color: #667eea; 989 | color: #667eea; 990 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 991 | } 992 | 993 | .hidden-columns-toggle.disabled { 994 | background: rgba(255, 255, 255, 0.7); 995 | border-color: #e0e0e0; 996 | color: #999; 997 | cursor: not-allowed; 998 | opacity: 0.6; 999 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); 1000 | } 1001 | 1002 | .hidden-columns-toggle:disabled { 1003 | cursor: not-allowed; 1004 | } 1005 | 1006 | .hidden-columns-menu { 1007 | position: absolute; 1008 | top: 100%; 1009 | right: 0; 1010 | margin-top: 4px; 1011 | background: white; 1012 | border: 1px solid #e0e0e0; 1013 | border-radius: 8px; 1014 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); 1015 | min-width: 130px; 1016 | z-index: 10000; 1017 | overflow: hidden; 1018 | } 1019 | 1020 | .hidden-column-item { 1021 | display: block; 1022 | width: 100%; 1023 | padding: 10px 12px; 1024 | background: none; 1025 | border: none; 1026 | text-align: left; 1027 | font-size: 13px; 1028 | color: #333; 1029 | cursor: pointer; 1030 | transition: background-color 0.2s ease; 1031 | } 1032 | 1033 | .hidden-column-item:hover { 1034 | background-color: #f8f9fa; 1035 | } 1036 | 1037 | .hidden-column-item:not(:last-child) { 1038 | border-bottom: 1px solid #f0f0f0; 1039 | } 1040 | 1041 | /* Kebab menu styles */ 1042 | .kebab-menu { 1043 | position: relative; 1044 | display: inline-block; 1045 | } 1046 | 1047 | .kebab-toggle { 1048 | background: none; 1049 | border: none; 1050 | font-size: 14px; 1051 | color: #666; 1052 | cursor: pointer; 1053 | padding: 4px 6px; 1054 | border-radius: 4px; 1055 | transition: all 0.2s ease; 1056 | line-height: 1; 1057 | } 1058 | 1059 | .kebab-toggle:hover { 1060 | background-color: rgba(102, 126, 234, 0.1); 1061 | color: #667eea; 1062 | } 1063 | 1064 | .kebab-dropdown { 1065 | position: absolute; 1066 | top: 100%; 1067 | right: 0; 1068 | margin-top: 4px; 1069 | background: white; 1070 | border: 1px solid #e0e0e0; 1071 | border-radius: 8px; 1072 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); 1073 | min-width: 120px; 1074 | z-index: 10000; 1075 | overflow: hidden; 1076 | } 1077 | 1078 | .kebab-item { 1079 | display: block; 1080 | width: 100%; 1081 | padding: 8px 12px; 1082 | background: none; 1083 | border: none; 1084 | text-align: left; 1085 | font-size: 13px; 1086 | color: #333; 1087 | cursor: pointer; 1088 | transition: background-color 0.2s ease; 1089 | } 1090 | 1091 | .kebab-item:hover { 1092 | background-color: #f8f9fa; 1093 | } 1094 | 1095 | .kebab-item:not(:last-child) { 1096 | border-bottom: 1px solid #f0f0f0; 1097 | } 1098 | 1099 | /* Update header actions to accommodate kebab menu */ 1100 | .header-actions { 1101 | display: flex; 1102 | align-items: center; 1103 | gap: 4px; 1104 | } 1105 | 1106 | /* Remove old sticky-toggle styles since we're using kebab menu now */ 1107 | .sticky-toggle { 1108 | display: none; 1109 | } --------------------------------------------------------------------------------