diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c844038..3cceffc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,9 +1,31 @@ -name: Tests +name: CI on: pull_request: jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Run linter + run: pnpm lint + test: runs-on: ubuntu-latest diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..acd310a --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +pnpm lint +pnpm test diff --git a/README.md b/README.md index 18b57d8..a30e5ba 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,31 @@ # Highcharts Grid React -Monorepo containing React packages for [Highcharts Grid Lite](https://www.highcharts.com/docs/grid/getting-started/grid-lite) and [Highcharts Grid Pro](https://www.highcharts.com/docs/grid/getting-started/grid-pro). +
-## Packages +Official Highcharts Grid for React + + + +

Official React packages for Highcharts Grid Lite and Highcharts Grid Pro. Built for React patterns, with a JSX-native API, TypeScript types, and the Grid CSS included.

+ +Grid Lite React NPM Version +Grid Pro React NPM Version +Discord + +
+ +This is the working repository for the Grid React packages. If you want to use Grid in a React app, install a distribution package from npm rather than this repo. + +## Why Highcharts Grid React? + +- **Options or JSX** - Pass a Grid `options` object, compose with React components such as `Data`, `Column`, `Caption`, and `Pagination`, or mix both +- **Lite and Pro** - Start with free Grid Lite, or use Grid Pro for editing, validation, sparklines, and events +- **Self-Contained Packages** - Grid setup, cleanup, and CSS are handled for you +- **Built for Large Tables** - Row virtualization keeps scrolling smooth with thousands of records +- **Accessibility First** - Semantic HTML tables with keyboard navigation and screen reader support +- **TypeScript Ready** - First-class types for options, refs, events, and component props -This monorepo contains the following packages: +## Packages ### Published Packages @@ -15,9 +36,7 @@ This monorepo contains the following packages: - **[@highcharts/grid-shared-react](./packages/grid-shared-react/)** - Shared core functionality used by both Grid Lite and Grid Pro React packages -## Quick Start - -### Installation +## Installation ```bash # For Grid Lite @@ -27,17 +46,24 @@ npm install @highcharts/grid-lite-react npm install @highcharts/grid-pro-react ``` -### Usage +> **Note:** The matching Grid Core package is included as a dependency. `react` and `react-dom` are peer dependencies and are installed automatically with npm v7+. Requires React 18 or higher. + +## Quick Start + +Components are optional. You can pass a Grid `options` object to `` the same way as before, use JSX components, or mix both. -#### Grid Lite +### Grid Lite -```tsx -import React, { useState } from 'react'; +Using options: + +```jsx +import { useState } from 'react'; import { Grid, type GridOptions } from '@highcharts/grid-lite-react'; -function App() { +export function App() { const [options] = useState({ - dataTable: { + caption: { text: 'Team directory' }, + data: { columns: { name: ['Alice', 'Bob', 'Charlie'], age: [23, 34, 45] @@ -49,15 +75,40 @@ function App() { } ``` -#### Grid Pro +Using components: + +```jsx +import { Grid, Caption, Data, Column } from '@highcharts/grid-lite-react'; + +export function App() { + return ( + + Team directory + + + + + ); +} +``` + +### Grid Pro + +Using options: -```tsx -import React, { useState } from 'react'; +```jsx +import { useState } from 'react'; import { Grid, type GridOptions } from '@highcharts/grid-pro-react'; -function App() { +export function App() { const [options] = useState({ - dataTable: { + caption: { text: 'Team directory' }, + data: { columns: { name: ['Alice', 'Bob', 'Charlie'], age: [23, 34, 45] @@ -65,10 +116,37 @@ function App() { } }); - return ; + return ; +} +``` + +Using components: + +```jsx +import { Grid, Caption, Data, Column } from '@highcharts/grid-pro-react'; + +export function App() { + return ( + + Team directory + + + + + ); } ``` +See the package READMEs for TypeScript, refs, Next.js, and more: + +- [Grid Lite React](./packages/grid-lite-react/README.md) +- [Grid Pro React](./packages/grid-pro-react/README.md) + ## Repository Structure ``` @@ -76,30 +154,19 @@ highcharts-grid-react/ ├── packages/ # Source packages │ ├── grid-lite-react/ # Grid Lite React package │ ├── grid-pro-react/ # Grid Pro React package -│ └── grid-shared-react/ # Shared core functionality +│ └── grid-shared-react/ # Shared core functionality ├── examples/ # Example applications │ ├── grid-lite/ # Grid Lite examples │ │ ├── minimal-react/ # Minimal React example (Vite) +│ │ ├── components-react/ # JSX component API example (Vite) │ │ └── minimal-nextjs/ # Minimal Next.js example │ └── grid-pro/ # Grid Pro examples │ ├── minimal-react/ # Minimal React example (Vite) -│ └── minimal-nextjs/ # Minimal Next.js example +│ ├── components-react/ # JSX component API example (Vite) +│ └── minimal-nextjs/ # Minimal Next.js example └── README.md # This file ``` -### Packages - -- **`packages/grid-lite-react/`** - React component package for Highcharts Grid Lite. See [README](./packages/grid-lite-react/README.md) for details. -- **`packages/grid-pro-react/`** - React component package for Highcharts Grid Pro. See [README](./packages/grid-pro-react/README.md) for details. -- **`packages/grid-shared-react/`** - Internal package containing shared React components and hooks used by both packages. - -### Examples - -- **`examples/grid-lite/minimal-react/`** - Minimal React application (Vite) demonstrating how to use `@highcharts/grid-lite-react` -- **`examples/grid-lite/minimal-nextjs/`** - Minimal Next.js application demonstrating how to use `@highcharts/grid-lite-react` -- **`examples/grid-pro/minimal-react/`** - Minimal React application (Vite) demonstrating how to use `@highcharts/grid-pro-react` -- **`examples/grid-pro/minimal-nextjs/`** - Minimal Next.js application demonstrating how to use `@highcharts/grid-pro-react` - ## Development This is a monorepo managed with [pnpm workspaces](https://pnpm.io/workspaces). @@ -131,6 +198,10 @@ To run the example applications: cd examples/grid-lite/minimal-react pnpm dev +# Run Grid Lite JSX components example +cd examples/grid-lite/components-react +pnpm dev + # Run Grid Lite Next.js example cd examples/grid-lite/minimal-nextjs pnpm dev @@ -139,6 +210,10 @@ pnpm dev cd examples/grid-pro/minimal-react pnpm dev +# Run Grid Pro JSX components example +cd examples/grid-pro/components-react +pnpm dev + # Run Grid Pro Next.js example cd examples/grid-pro/minimal-nextjs pnpm dev @@ -148,64 +223,25 @@ Note: Since all examples are part of the pnpm workspace, dependencies are instal ## Next.js Integration -Highcharts Grid React components can be used in Next.js applications. Since the Grid components require browser APIs, they need to be rendered on the client side only (without Server-Side Rendering). - -### Setup - -1. Install the required packages: - -```bash -npm install @highcharts/grid-lite-react @highcharts/grid-lite -# or -npm install @highcharts/grid-pro-react @highcharts/grid-pro -``` - -2. Import the Grid component dynamically with SSR disabled: - -```tsx -'use client'; - -import { useState } from 'react'; -import dynamic from 'next/dynamic'; -import { type GridOptions } from '@highcharts/grid-lite-react'; -import '@highcharts/grid-lite/css/grid-lite.css'; - -// Disable SSR for the Grid component -const Grid = dynamic( - () => import('@highcharts/grid-lite-react').then((mod) => mod.Grid), - { ssr: false } -); +Highcharts Grid React components can be used in Next.js applications. Grid uses browser APIs, so it must render on the client. See the [Next.js guide](https://www.highcharts.com/docs/grid/frameworks/nextjs) and the package READMEs for a complete example. -export default function Page() { - const [options] = useState({ - dataTable: { - columns: { - name: ['Alice', 'Bob', 'Charlie'], - age: [23, 34, 45] - } - } - }); - - return ; -} -``` +## Documentation -### Important Notes +- [Grid Lite React](./packages/grid-lite-react/README.md) +- [Grid Pro React](./packages/grid-pro-react/README.md) +- [Highcharts Grid with React](https://www.highcharts.com/docs/grid/frameworks/react) +- [Highcharts Grid Lite](https://www.highcharts.com/docs/grid/getting-started/grid-lite) +- [Highcharts Grid Pro](https://www.highcharts.com/docs/grid/getting-started/grid-pro) +- [Changelog](./CHANGELOG.md) +- [Releasing](./RELEASING.md) -- **SSR is disabled**: The Grid components require browser APIs and cannot be rendered on the server. They are dynamically imported with `ssr: false` to ensure client-side only rendering. -- **Client Component**: The page or component using the Grid must be marked with `'use client'` directive. -- **CSS Import**: Don't forget to import the required CSS file for the Grid component. +## Support and feedback -See the [Next.js examples](./examples/) for complete working implementations. +We love to learn how you are using Highcharts, and what you would like to see from us in the future. -## Documentation +Join our vibrant community on [GitHub](https://github.com/highcharts/grid-react), [Stack Overflow](https://stackoverflow.com/tags/highcharts/), [Discord](https://discord.com/invite/xHxxcyyy6K), and the [Highcharts Forums](https://www.highcharts.com/forum/). -- [Grid Lite React Documentation](./packages/grid-lite-react/README.md) -- [Grid Pro React Documentation](./packages/grid-pro-react/README.md) -- [Highcharts Grid Lite Documentation](https://www.highcharts.com/docs/grid/getting-started/grid-lite) -- [Highcharts Grid Pro Documentation](https://www.highcharts.com/docs/grid/getting-started/grid-pro) -- [Changelog](./CHANGELOG.md) -- [Releasing](./RELEASING.md) +Commercial support packages are available, see [Highcharts Advantage](https://www.highcharts.com/highcharts-advantage/). ## License diff --git a/eslint.config.js b/eslint.config.js index a14c2bd..5e17dfa 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,12 @@ import globals from 'globals'; export default defineConfig( { - ignores: ['**/dist/**', '**/build/**'], + ignores: [ + '**/dist/**', + '**/build/**', + '**/.next/**', + '**/node_modules/**' + ], }, eslint.configs.recommended, tseslint.configs.recommended, @@ -17,15 +22,24 @@ export default defineConfig( }, rules: { 'curly': ['error', 'all'], + '@typescript-eslint/no-unused-vars': ['error', { + argsIgnorePattern: '^_' + }], '@stylistic/semi': ['error', 'always'], '@stylistic/quotes': ['error', 'single', { avoidEscape: true }], '@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: true }], '@stylistic/eol-last': ['error', 'always'], - '@stylistic/no-trailing-spaces': ['error'] + '@stylistic/no-trailing-spaces': ['error'], + '@stylistic/max-len': ['error', { + code: 80, + ignoreUrls: true, + ignoreStrings: true, + ignoreTemplateLiterals: true + }] }, }, { - files: ['scripts/**/*.js'], + files: ['scripts/**/*.js', '**/next.config.js'], languageOptions: { globals: { ...globals.node, diff --git a/examples/grid-lite/components-react/index.html b/examples/grid-lite/components-react/index.html new file mode 100644 index 0000000..7f1e258 --- /dev/null +++ b/examples/grid-lite/components-react/index.html @@ -0,0 +1,13 @@ + + + + + + Highcharts Grid Lite - React Example + + +
+ + + + diff --git a/examples/grid-lite/components-react/package.json b/examples/grid-lite/components-react/package.json new file mode 100644 index 0000000..794c1e7 --- /dev/null +++ b/examples/grid-lite/components-react/package.json @@ -0,0 +1,27 @@ +{ + "name": "grid-lite-minimal-react", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "clean": "rimraf dist node_modules" + }, + "dependencies": { + "@highcharts/grid-lite": "3.1.0", + "@highcharts/grid-lite-react": "workspace:*", + "react": ">=18", + "react-dom": ">=18" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.2", + "@types/react": ">=18", + "@types/react-dom": ">=18", + "@vitejs/plugin-react": "^4.2.0", + "tailwindcss": "^4.3.2", + "typescript": "^5.0.0", + "vite": "^5.0.0" + } +} diff --git a/examples/grid-lite/components-react/src/App.tsx b/examples/grid-lite/components-react/src/App.tsx new file mode 100644 index 0000000..7ec87da --- /dev/null +++ b/examples/grid-lite/components-react/src/App.tsx @@ -0,0 +1,213 @@ +import { + useState, + // useRef +} from 'react'; +import { + type GridInstance, + // type GridRefHandle, + type GridOptions, + Grid, + Caption, + Data, + // DataTable, + ColumnDefaults, + Column, + Description, + Pagination, + Header +} from '@highcharts/grid-lite-react'; + +function App() { + // const grid = useRef | null>(null); + + // ==== OPTIONS ==== + // const [options] = useState({ + // dataTable: { + // columns: { + // name: ['1111Alice', 'Bob', 'Charlie', 'David', 'Eve'], + // age: [23, 34, 45, 56, 67], + // city: ['New York', 'Oslo', 'Paris', 'Tokyo', 'London'], + // salary: [50000, 60000, 70000, 80000, 90000] + // } + // } + // }); + + // ==== DATA ==== + // Data Columns + const [dataSource] = useState({ + name: [ + 'Alice Nguyen', 'Bob Berg', 'Charlie Dupont', 'David Sato', 'Eve Shaw', + 'John Hale', 'Jane Ortiz', 'Jim Novak', 'Jill Meyer', 'Jack Quinn', + 'Nora Ellis', 'Omar Khan', 'Priya Shah', 'Quinn Blake', 'Ruth Adler', + 'Sam Okonkwo', 'Tina Rossi', 'Uma Patel', 'Victor Lang', 'Wendy Cho' + ], + age: [ + 23, 34, 45, 56, 67, 30, 25, 35, 40, 45, + 28, 31, 39, 42, 51, 27, 33, 36, 44, 48 + ], + city: [ + 'New York', 'Oslo', 'Paris', 'Tokyo', 'London', + 'New York', 'Oslo', 'Paris', 'Tokyo', 'London', + 'Berlin', 'Toronto', 'Mumbai', 'Sydney', 'Zurich', + 'Lagos', 'Rome', 'Lisbon', 'Seoul', 'Chicago' + ], + salary: [ + 50000, 60000, 70000, 80000, 90000, + 40000, 35000, 45000, 50000, 55000, + 62000, 71000, 48000, 53000, 88000, + 41000, 59000, 64000, 76000, 82000 + ] + }); + + // Data Table + // const dataTable = new DataTable({ + // columns: { + // name: ['DATATABLE', 'Bob', 'Charlie', 'David', 'Eve'], + // age: [23, 34, 45, 56, 67], + // city: ['New York', 'Oslo', 'Paris', 'Tokyo', 'London'], + // salary: [50000, 60000, 70000, 80000, 90000] + // } + // }); + + // ==== ACTIONS ==== + // const onButtonClick = () => { + // // console.info('(ref) grid:', grid.current?.grid); + // setDataSource({ + // name: ['John', 'Jane', 'Jim', 'Jill', 'Jack'], + // age: [30, 25, 35, 40, 45], + // city: ['New York', 'Los Angeles', 'Chicago', 'Houston', 'Miami'], + // salary: [40000, 35000, 45000, 50000, 55000] + // }); + // }; + + const onGridCallback = (grid: GridInstance) => { + console.info('(callback) grid:', grid); + }; + + // Pagination + // const [paginationEnabled, setPaginationEnabled] = useState(false); + + // const onPaginationClick = () => { + // setPaginationEnabled(true); + // }; + + return ( +
+
+ + + + Team directory +
+ + + + + + + Filter, sort, and page through sample employee rows styled with + utility classes. + + + + {/*
+ + +
*/} +
+
+ ); +} + +export default App; diff --git a/examples/grid-lite/components-react/src/index.css b/examples/grid-lite/components-react/src/index.css new file mode 100644 index 0000000..93cfec9 --- /dev/null +++ b/examples/grid-lite/components-react/src/index.css @@ -0,0 +1,97 @@ +@import "tailwindcss"; + +/* Sample viewer theme toggle + system preference */ +@custom-variant dark { + &:where(.highcharts-dark, .highcharts-dark *) { + @slot; + } + + @media (prefers-color-scheme: dark) { + &:where(:not(.highcharts-light):not(.highcharts-light *)) { + @slot; + } + } +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.hcg-container { + width: 100%; + height: 600px; +} + +/* + * Pagination buttons / select — Core internals, styled via cascade. + */ +.hcg-pagination-controls { + @apply border-l border-r border-slate-200 dark:border-slate-700; +} + +.hcg-pagination-controls .hcg-pagination-pages { + @apply gap-2; +} + +.hcg-pagination-controls .hcg-button { + @apply size-7 rounded border border-transparent text-sm leading-none + text-slate-700 dark:text-slate-200; +} + +.hcg-pagination-controls .hcg-button:hover:not(:disabled) { + @apply bg-slate-200 dark:bg-slate-700; +} + +.hcg-pagination-controls .hcg-button-selected, +.hcg-pagination-controls .hcg-button-selected:hover:not(:disabled) { + @apply bg-teal-700 text-white dark:bg-teal-600; +} + +.demo-pag-size select.hcg-input { + @apply rounded border border-slate-300 bg-white py-1 pl-2 pr-5 text-sm + text-slate-700 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-200; +} + +/* + * Filter popup — Core internals, styled via cascade. + */ +.demo-grid .hcg-container .hcg-popup { + @apply rounded-lg border border-slate-200 bg-white text-sm text-slate-700 + shadow-lg dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 + dark:shadow-black/40; +} + +.demo-grid .hcg-container .hcg-menu-header { + @apply mb-2 px-1 text-xs font-semibold text-slate-500 dark:text-slate-400; +} + +.demo-grid .hcg-container .hcg-column-filter-wrapper { + @apply gap-2; +} + +.demo-grid .hcg-container .hcg-column-filter-wrapper .hcg-input { + @apply w-full rounded border border-slate-300 bg-white px-2 py-1.5 text-sm + text-slate-700 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-200; +} + +.demo-grid .hcg-container .hcg-column-filter-wrapper select.hcg-input { + @apply pr-5; +} + +.demo-grid .hcg-container .hcg-clear-filter-button { + @apply text-xs font-medium text-slate-600 no-underline dark:text-slate-300; +} + +.demo-grid .hcg-container .hcg-clear-filter-button:hover:not(:disabled) { + @apply text-slate-900 underline dark:text-slate-50; +} + +@container hcg (max-width: 800px) { + .hcg-pagination-controls { + @apply justify-center border-r-0 border-l-0 mt-2 mb-2; + } +} diff --git a/examples/grid-lite/components-react/src/main.tsx b/examples/grid-lite/components-react/src/main.tsx new file mode 100644 index 0000000..0c657b5 --- /dev/null +++ b/examples/grid-lite/components-react/src/main.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); + diff --git a/examples/grid-lite/components-react/tsconfig.json b/examples/grid-lite/components-react/tsconfig.json new file mode 100644 index 0000000..78a6daf --- /dev/null +++ b/examples/grid-lite/components-react/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": [ + "DOM", + "ES2016", + "ES2017.Object" + ], + "jsx": "react-jsx", + "module": "ES6", + "moduleResolution": "node", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitThis": true, + "noFallthroughCasesInSwitch": true, + "skipDefaultLibCheck": true, + "skipLibCheck": true, + "ignoreDeprecations": "5.0", + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} + diff --git a/examples/grid-lite/components-react/tsconfig.node.json b/examples/grid-lite/components-react/tsconfig.node.json new file mode 100644 index 0000000..f7c2070 --- /dev/null +++ b/examples/grid-lite/components-react/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "types": ["node"] + }, + "include": ["vite.config.ts"] +} + diff --git a/examples/grid-lite/components-react/vite.config.ts b/examples/grid-lite/components-react/vite.config.ts new file mode 100644 index 0000000..40dd699 --- /dev/null +++ b/examples/grid-lite/components-react/vite.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: [ + { + // Exact match — string aliases are prefix-based and break subpath ids. + find: /^@highcharts\/grid-lite-react$/, + replacement: resolve(__dirname, '../../../packages/grid-lite-react/src/index.ts') + }, + { + find: /^@highcharts\/grid-shared-react$/, + replacement: resolve(__dirname, '../../../packages/grid-shared-react/src/index.ts') + }, + { + find: /^@highcharts\/grid-lite(\/.*)?$/, + replacement: resolve(__dirname, 'node_modules/@highcharts/grid-lite$1') + } + ] + }, + server: { + host: true, + port: 3000 + } +}); diff --git a/examples/grid-lite/minimal-nextjs/app/page.tsx b/examples/grid-lite/minimal-nextjs/app/page.tsx index 1d82cc4..5d5de97 100644 --- a/examples/grid-lite/minimal-nextjs/app/page.tsx +++ b/examples/grid-lite/minimal-nextjs/app/page.tsx @@ -48,8 +48,12 @@ export default function Home() { }; return ( - <> - + <> + ); diff --git a/examples/grid-lite/minimal-nextjs/package.json b/examples/grid-lite/minimal-nextjs/package.json index 0437b25..17c1988 100644 --- a/examples/grid-lite/minimal-nextjs/package.json +++ b/examples/grid-lite/minimal-nextjs/package.json @@ -10,7 +10,7 @@ "clean": "rimraf .next node_modules" }, "dependencies": { - "@highcharts/grid-lite": ">=3.0.0", + "@highcharts/grid-lite": "3.1.0", "@highcharts/grid-lite-react": "workspace:*", "next": "^14.0.0", "react": ">=18", @@ -23,4 +23,3 @@ "typescript": "^5.0.0" } } - diff --git a/examples/grid-lite/minimal-react/package.json b/examples/grid-lite/minimal-react/package.json index cd5b45a..2a56a5c 100644 --- a/examples/grid-lite/minimal-react/package.json +++ b/examples/grid-lite/minimal-react/package.json @@ -10,7 +10,7 @@ "clean": "rimraf dist node_modules" }, "dependencies": { - "@highcharts/grid-lite": ">=3.0.0", + "@highcharts/grid-lite": "3.1.0", "@highcharts/grid-lite-react": "workspace:*", "react": ">=18", "react-dom": ">=18" @@ -23,4 +23,3 @@ "vite": "^5.0.0" } } - diff --git a/examples/grid-pro/components-react/index.html b/examples/grid-pro/components-react/index.html new file mode 100644 index 0000000..b07a6f3 --- /dev/null +++ b/examples/grid-pro/components-react/index.html @@ -0,0 +1,12 @@ + + + + + + Highcharts Grid Pro - React Example + + +
+ + + diff --git a/examples/grid-pro/components-react/package.json b/examples/grid-pro/components-react/package.json new file mode 100644 index 0000000..4166d1a --- /dev/null +++ b/examples/grid-pro/components-react/package.json @@ -0,0 +1,25 @@ +{ + "name": "grid-pro-components-react", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "clean": "rimraf dist node_modules" + }, + "dependencies": { + "@highcharts/grid-pro": "3.1.0", + "@highcharts/grid-pro-react": "workspace:*", + "react": ">=18", + "react-dom": ">=18" + }, + "devDependencies": { + "@types/react": ">=18", + "@types/react-dom": ">=18", + "@vitejs/plugin-react": "^4.2.0", + "typescript": "^5.0.0", + "vite": "^5.0.0" + } +} diff --git a/examples/grid-pro/components-react/src/App.tsx b/examples/grid-pro/components-react/src/App.tsx new file mode 100644 index 0000000..2d7f839 --- /dev/null +++ b/examples/grid-pro/components-react/src/App.tsx @@ -0,0 +1,98 @@ +import { useState } from 'react'; +import { + type GridInstance, + type GridOptions, + Grid, + Caption, + Data, + ColumnDefaults, + Column, + Description, + Pagination +} from '@highcharts/grid-pro-react'; + +const GRID_KEY = 'AAAA-BBBB-CCCC-DDDD-EEEE-FFFF'; + +function App() { + const [dataSource, setDataSource] = useState({ + name: ['Alice', 'Bob', 'Charlie', 'David', 'Eve'], + age: [23, 34, 45, 56, 67], + city: ['New York', 'Oslo', 'Paris', 'Tokyo', 'London'], + salary: [50000, 60000, 70000, 80000, 90000] + }); + + const onButtonClick = () => { + setDataSource({ + name: ['John', 'Jane', 'Jim', 'Jill', 'Jack'], + age: [30, 25, 35, 40, 45], + city: ['New York', 'Los Angeles', 'Chicago', 'Houston', 'Miami'], + salary: [40000, 35000, 45000, 50000, 55000] + }); + }; + + const onGridCallback = (grid: GridInstance) => { + console.info('(callback) grid:', grid); + }; + + return ( + <> + + + + Grid Pro Components + Declarative API with gridKey and event props + + + + + + +
+ +
+ + ); +} + +export default App; diff --git a/examples/grid-pro/components-react/src/index.css b/examples/grid-pro/components-react/src/index.css new file mode 100644 index 0000000..16edde1 --- /dev/null +++ b/examples/grid-pro/components-react/src/index.css @@ -0,0 +1,26 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#root { + width: 100%; + min-height: 100vh; + padding: 20px; +} + +#controls { + margin-top: 20px; + display: flex; + gap: 10px; +} + +@media (prefers-color-scheme: dark) { + body { + background-color: #121212; + color: #ffffff; + } +} diff --git a/examples/grid-pro/components-react/src/main.tsx b/examples/grid-pro/components-react/src/main.tsx new file mode 100644 index 0000000..2339d59 --- /dev/null +++ b/examples/grid-pro/components-react/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/examples/grid-pro/components-react/tsconfig.json b/examples/grid-pro/components-react/tsconfig.json new file mode 100644 index 0000000..561b165 --- /dev/null +++ b/examples/grid-pro/components-react/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": [ + "DOM", + "ES2016", + "ES2017.Object" + ], + "jsx": "react-jsx", + "module": "ES6", + "moduleResolution": "node", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitThis": true, + "noFallthroughCasesInSwitch": true, + "skipDefaultLibCheck": true, + "skipLibCheck": true, + "ignoreDeprecations": "5.0", + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/examples/grid-pro/components-react/tsconfig.node.json b/examples/grid-pro/components-react/tsconfig.node.json new file mode 100644 index 0000000..b940375 --- /dev/null +++ b/examples/grid-pro/components-react/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "types": ["node"] + }, + "include": ["vite.config.ts"] +} diff --git a/examples/grid-pro/components-react/vite.config.ts b/examples/grid-pro/components-react/vite.config.ts new file mode 100644 index 0000000..a28be86 --- /dev/null +++ b/examples/grid-pro/components-react/vite.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: [ + { + // Exact match — string aliases are prefix-based and break subpath ids. + find: /^@highcharts\/grid-pro-react$/, + replacement: resolve(__dirname, '../../../packages/grid-pro-react/src/index.ts') + }, + { + find: /^@highcharts\/grid-shared-react$/, + replacement: resolve(__dirname, '../../../packages/grid-shared-react/src/index.ts') + }, + { + find: /^@highcharts\/grid-pro(\/.*)?$/, + replacement: resolve(__dirname, 'node_modules/@highcharts/grid-pro$1') + } + ] + }, + server: { + port: 3002 + } +}); diff --git a/examples/grid-pro/minimal-nextjs/app/page.tsx b/examples/grid-pro/minimal-nextjs/app/page.tsx index 0397a91..28cbb2b 100644 --- a/examples/grid-pro/minimal-nextjs/app/page.tsx +++ b/examples/grid-pro/minimal-nextjs/app/page.tsx @@ -65,7 +65,12 @@ export default function Home() { return ( <> - + ); diff --git a/examples/grid-pro/minimal-nextjs/package.json b/examples/grid-pro/minimal-nextjs/package.json index 9a126ce..da4ca30 100644 --- a/examples/grid-pro/minimal-nextjs/package.json +++ b/examples/grid-pro/minimal-nextjs/package.json @@ -10,7 +10,7 @@ "clean": "rimraf .next node_modules" }, "dependencies": { - "@highcharts/grid-pro": ">=3.0.0", + "@highcharts/grid-pro": "3.1.0", "@highcharts/grid-pro-react": "workspace:*", "next": "^14.0.0", "react": ">=18", @@ -23,4 +23,3 @@ "typescript": "^5.0.0" } } - diff --git a/examples/grid-pro/minimal-react/package.json b/examples/grid-pro/minimal-react/package.json index 915fd3f..edc3b3b 100644 --- a/examples/grid-pro/minimal-react/package.json +++ b/examples/grid-pro/minimal-react/package.json @@ -10,7 +10,7 @@ "clean": "rimraf dist node_modules" }, "dependencies": { - "@highcharts/grid-pro": ">=3.0.0", + "@highcharts/grid-pro": "3.1.0", "@highcharts/grid-pro-react": "workspace:*", "react": ">=18", "react-dom": ">=18" @@ -23,4 +23,3 @@ "vite": "^5.0.0" } } - diff --git a/examples/grid-pro/minimal-react/src/App.tsx b/examples/grid-pro/minimal-react/src/App.tsx index 8abbbe0..0e7b1aa 100644 --- a/examples/grid-pro/minimal-react/src/App.tsx +++ b/examples/grid-pro/minimal-react/src/App.tsx @@ -55,7 +55,12 @@ function App() { return ( <> - + ); diff --git a/package.json b/package.json index 5d1eb21..cdde1fa 100644 --- a/package.json +++ b/package.json @@ -1,36 +1,39 @@ { - "name": "highcharts-grid-react", - "description": "Monorepo for Highcharts Grid Pro & Lite React libraries.", - "private": true, - "type": "module", - "scripts": { - "test": "vitest run", - "test:watch": "vitest", - "pretest:e2e": "pnpm build", - "test:e2e": "vitest run --config vitest.e2e.config.ts", - "test:all": "pnpm test && pnpm test:e2e", - "check": "pnpm lint && pnpm test", - "build": "pnpm -r --filter './packages/*' run build", - "lint": "pnpm -r --filter './packages/*' run lint", - "clean": "pnpm -r --filter './{packages,examples}/*' run clean && rimraf node_modules", - "release:preflight": "pnpm check && pnpm build", - "release:prepare": "node scripts/release.js", - "release": "pnpm release:preflight && pnpm publish -r --access public" - }, - "packageManager": "pnpm@10.27.0", - "devDependencies": { - "@eslint/js": "^9.39.1", - "@stylistic/eslint-plugin": "^5.6.1", - "@testing-library/react": "^16.3.1", - "@types/node": "^20.0.0", - "@vitest/browser": "^4.0.16", - "@vitest/browser-playwright": "^4.0.16", - "eslint": "^9.39.1", - "globals": "^17.0.0", - "playwright": "^1.57.0", - "rimraf": "^6.1.2", - "typescript": "^5.9.3", - "typescript-eslint": "^8.48.0", - "vitest": "^4.0.16" - } + "name": "highcharts-grid-react", + "description": "Monorepo for Highcharts Grid Pro & Lite React libraries.", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "pretest": "pnpm build", + "test:watch": "vitest", + "pretest:e2e": "pnpm build", + "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:all": "pnpm test && pnpm test:e2e", + "check": "pnpm lint && pnpm test", + "build": "pnpm -r --filter './packages/*' run build", + "lint": "eslint packages examples scripts --ext .ts,.tsx,.js", + "clean": "pnpm -r --filter './{packages,examples}/*' run clean && rimraf node_modules", + "release:preflight": "pnpm check && pnpm build", + "release:prepare": "node scripts/release.js", + "release": "pnpm release:preflight && pnpm publish -r --access public", + "prepare": "husky" + }, + "packageManager": "pnpm@10.27.0", + "devDependencies": { + "@eslint/js": "^9.39.1", + "@stylistic/eslint-plugin": "^5.6.1", + "@testing-library/react": "^16.3.1", + "@types/node": "^20.0.0", + "@vitest/browser": "^4.0.16", + "@vitest/browser-playwright": "^4.0.16", + "eslint": "^9.39.1", + "globals": "^17.0.0", + "husky": "^9.1.7", + "playwright": "^1.57.0", + "rimraf": "^6.1.2", + "typescript": "^5.9.3", + "typescript-eslint": "^8.48.0", + "vitest": "^4.0.16" + } } diff --git a/packages/grid-lite-react/README.md b/packages/grid-lite-react/README.md index 74f0632..0131af7 100644 --- a/packages/grid-lite-react/README.md +++ b/packages/grid-lite-react/README.md @@ -1,45 +1,73 @@ -# @highcharts/grid-lite-react +# Highcharts Grid Lite React -React integration for [Highcharts Grid Lite](https://www.highcharts.com/docs/grid/general). +
-## Links +Official Highcharts Grid Lite for React -* Official website: [www.highcharts.com](https://www.highcharts.com) -* Product page: [www.highcharts.com/products/grid](https://www.highcharts.com/products/grid) -* Download: [www.highcharts.com/download](https://www.highcharts.com/download) -* License: [www.highcharts.com/license](https://www.highcharts.com/license) -* Documentation: [www.highcharts.com/docs](https://www.highcharts.com/docs/grid/frameworks/grid-with-react) -* Support: [www.highcharts.com/support](https://www.highcharts.com/support) -* Issues: [Working repo](https://github.com/highcharts/highcharts/issues) + +

Highcharts Grid Lite for React makes integrating interactive data tables into your React projects intuitive and aligned with your React workflow, built with an API refined for React patterns.

+ +NPM Version +NPM Downloads +Discord + +
+ +## Why Highcharts Grid Lite React? + +- **Options or JSX** - Pass a Grid `options` object, compose with React components such as `Data`, `Column`, `Caption`, and `Pagination`, or mix both +- **Self-Contained Package** - Grid setup, cleanup, and CSS are handled for you +- **Built for Large Tables** - Row virtualization keeps scrolling smooth with thousands of records +- **Interactive by Default** - Sorting, filtering, and pagination without extra libraries +- **Accessibility First** - Renders a semantic HTML table with keyboard navigation and screen reader support +- **CSS Theming** - Customize appearance with CSS variables and class names that fit your app +- **TypeScript Ready** - First-class types for options, refs, and component props + +## License + +Highcharts Grid Lite is free to use. Review the license terms at the links below: + +- [Standard License Terms](https://www.highcharts.com/license) +- [Product page](https://www.highcharts.com/products/grid) + +Need editing, validation, sparklines, or events? See [@highcharts/grid-pro-react](https://www.npmjs.com/package/@highcharts/grid-pro-react). ## Installation +Install Highcharts Grid Lite React from npm: + ```bash npm install @highcharts/grid-lite-react ``` -## Requirements +Or using yarn: -- React 18 or higher +```bash +yarn add @highcharts/grid-lite-react +``` + +> **Note:** `@highcharts/grid-lite` is included as a dependency. `react` and `react-dom` are peer dependencies and are installed automatically with npm v7+. Requires React 18 or higher. ## Quick Start -```tsx -import React, { useState } from 'react'; +Components are optional. You can pass a Grid `options` object to `` the same way as before, use JSX components, or mix both. + +### Using options + +```jsx +import { useState } from 'react'; import { Grid, type GridOptions } from '@highcharts/grid-lite-react'; -function App() { +export function App() { const [options] = useState({ - dataTable: { + caption: { text: 'Team directory' }, + data: { columns: { name: ['Alice', 'Bob', 'Charlie'], age: [23, 34, 45], city: ['New York', 'Oslo', 'Paris'] } - }, - caption: { - text: 'My Grid' } }); @@ -47,90 +75,110 @@ function App() { } ``` -## API - -### `Grid` - -React component that wraps Highcharts Grid Lite. - -#### Props - -- `options` (required): Configuration options for the grid. Type: `GridOptions` -- `gridRef` (optional): React ref to access the underlying grid instance. Type: `RefObject>` -- `callback` (optional): Callback function called when the grid is initialized. Receives the grid instance as parameter. Type: `(grid: GridInstance) => void` +### Using components -### `GridOptions` +```jsx +import { Grid, Caption, Data, Column, Pagination } from '@highcharts/grid-lite-react'; -Type exported from the package for TypeScript support. - -```tsx -import type { GridOptions } from '@highcharts/grid-lite-react'; +export function App() { + return ( + + Team directory + + + + + + + ); +} ``` -### `GridRefHandle` - -Type for the gridRef handle that provides access to the underlying grid instance. +## Grid props -```tsx -import type { GridRefHandle } from '@highcharts/grid-lite-react'; +The grid is rendered inside a container. You can pass layout and theme props directly to `Grid`: -const gridRef = useRef | null>(null); -// Access the grid instance via gridRef.current?.grid +```jsx + + Full-width grid + + ``` -### `GridInstance` +- `className` applies to the React mount container +- `tableClassName` applies to the rendered table +- `theme` sets the Grid theme (`rendering.theme`) -Type for the grid instance returned by gridRef or callback. +## TypeScript -```tsx -import type { GridInstance } from '@highcharts/grid-lite-react'; -``` - -### Using gridRef and Callback - -You can access the grid instance in two ways: +Use `GridOptions` for the `Grid` component `options` prop. -**Using gridRef:** ```tsx -import { useRef } from 'react'; -import { Grid, type GridRefHandle, type GridOptions } from '@highcharts/grid-lite-react'; +import { useState } from 'react'; +import { Grid, type GridOptions } from '@highcharts/grid-lite-react'; -function App() { - const gridRef = useRef | null>(null); - - const handleClick = () => { - // Access the grid instance - const gridInstance = gridRef.current?.grid; - if (gridInstance) { - console.log('Grid instance:', gridInstance); +export function App() { + const [options] = useState({ + data: { + columns: { + name: ['Alice', 'Bob', 'Charlie'], + age: [23, 34, 45] + } } - }; + }); - return ( - <> - - - - ); + return ; } ``` -**Using callback:** +Use `GridRefHandle` and `GridInstance` when you need access to the underlying Grid instance. + ```tsx -import { Grid, type GridInstance, type GridOptions } from '@highcharts/grid-lite-react'; +import { useRef } from 'react'; +import { + Grid, + type GridOptions, + type GridRefHandle, + type GridInstance +} from '@highcharts/grid-lite-react'; + +export function App() { + const gridRef = useRef | null>(null); -function App() { - const handleGridReady = (grid: GridInstance) => { - console.log('Grid initialized:', grid); + const onGridReady = (grid: GridInstance) => { + console.log('Grid instance:', grid); }; - return ; + return ( + + ); } ``` -### Next.js Integration +## Next.js -When using this package with Next.js, you need to disable Server-Side Rendering (SSR) for the Grid component: +Grid uses browser APIs, so it must render on the client. Use a dynamic import with SSR disabled: ```tsx 'use client'; @@ -138,9 +186,7 @@ When using this package with Next.js, you need to disable Server-Side Rendering import { useState } from 'react'; import dynamic from 'next/dynamic'; import { type GridOptions } from '@highcharts/grid-lite-react'; -import '@highcharts/grid-lite/css/grid-lite.css'; -// Disable SSR for the Grid component const Grid = dynamic( () => import('@highcharts/grid-lite-react').then((mod) => mod.Grid), { ssr: false } @@ -148,7 +194,7 @@ const Grid = dynamic( export default function Page() { const [options] = useState({ - dataTable: { + data: { columns: { name: ['Alice', 'Bob', 'Charlie'], age: [23, 34, 45] @@ -160,12 +206,19 @@ export default function Page() { } ``` -**Important:** The Grid component must be rendered client-side only. Always use `dynamic` import with `ssr: false` and mark your component with `'use client'` directive. +The React package loads Grid CSS automatically. See the [Next.js guide](https://www.highcharts.com/docs/grid/frameworks/nextjs) for more detail. ## Documentation -For detailed documentation on available options and features, see the [Highcharts Grid Lite documentation](https://www.highcharts.com/docs/grid/general). +For comprehensive guides and API documentation, visit the [Highcharts Grid React documentation](https://www.highcharts.com/docs/grid/frameworks/react). -## License +- [Grid Lite getting started](https://www.highcharts.com/docs/grid/getting-started/grid-lite) +- [Highcharts Grid overview](https://www.highcharts.com/docs/grid/general) + +## Support and feedback + +We love to learn how you are using Highcharts, and what you would like to see from us in the future. + +Join our vibrant community on [GitHub](https://github.com/highcharts/grid-react), [Stack Overflow](https://stackoverflow.com/tags/highcharts/), [Discord](https://discord.com/invite/xHxxcyyy6K), and the [Highcharts Forums](https://www.highcharts.com/forum/). -SEE LICENSE IN [LICENSE](https://github.com/highcharts/grid-react/blob/main/packages/grid-lite-react/LICENSE). +Commercial support packages are available, see [Highcharts Advantage](https://www.highcharts.com/highcharts-advantage/). diff --git a/packages/grid-lite-react/package.json b/packages/grid-lite-react/package.json index 36ee2cc..a24a3d1 100644 --- a/packages/grid-lite-react/package.json +++ b/packages/grid-lite-react/package.json @@ -37,7 +37,7 @@ "prepublishOnly": "pnpm build" }, "dependencies": { - "@highcharts/grid-lite": ">=3.0.0" + "@highcharts/grid-lite": "3.1.0" }, "devDependencies": { "@highcharts/grid-shared-react": "workspace:*", diff --git a/packages/grid-lite-react/rollup.config.js b/packages/grid-lite-react/rollup.config.js index 24ea9c0..ad3b59f 100644 --- a/packages/grid-lite-react/rollup.config.js +++ b/packages/grid-lite-react/rollup.config.js @@ -6,7 +6,7 @@ const isExternal = (id) => [ 'react', 'react-dom', '@highcharts/grid-lite' -].some(pattern => id.startsWith(pattern)); +].some((pattern) => id.startsWith(pattern)) || id.endsWith('.css'); export default [{ input: 'src/index.ts', diff --git a/packages/grid-lite-react/src/Grid.tsx b/packages/grid-lite-react/src/Grid.tsx index f4d21fb..d6aec44 100644 --- a/packages/grid-lite-react/src/Grid.tsx +++ b/packages/grid-lite-react/src/Grid.tsx @@ -9,12 +9,44 @@ import { BaseGrid, - GridProps + useDeclarativeGridOptions } from '@highcharts/grid-shared-react'; import Grid from '@highcharts/grid-lite/es-modules/masters/grid-lite.src'; import '@highcharts/grid-lite/css/grid-lite.css'; import type { Options } from '@highcharts/grid-lite/es-modules/Grid/Core/Options'; +import type { GridProps } from '@highcharts/grid-shared-react'; +import { buildGridOptions } from './utils/buildGridOptions'; -export default function GridLite({ options, gridRef, callback }: GridProps) { - return ; +export default function GridLite(props: GridProps) { + const { + gridRef, + children, + options, + callback, + theme, + className, + tableClassName + } = props; + const { gridOptions, columnKey } = useDeclarativeGridOptions( + children, + options, + (childOptions, opts) => buildGridOptions( + childOptions, + opts, + theme, + tableClassName + ), + [theme, tableClassName] + ); + + return ( + + ); } diff --git a/packages/grid-lite-react/src/index.ts b/packages/grid-lite-react/src/index.ts index 93cb0e5..79cbac6 100644 --- a/packages/grid-lite-react/src/index.ts +++ b/packages/grid-lite-react/src/index.ts @@ -11,6 +11,33 @@ import GridLite from '@highcharts/grid-lite'; export { default as Grid } from './Grid'; export { default as GridLite } from './Grid'; -export type { GridInstance } from '@highcharts/grid-shared-react'; -export type { GridRefHandle } from '@highcharts/grid-shared-react'; +export { + Caption, + Data, + ColumnDefaults, + Column, + Description, + Pagination, + Header +} from '@highcharts/grid-shared-react'; +export { DataTable, DataConnector } from '@highcharts/grid-lite'; +export { merge } from '@highcharts/grid-lite/es-modules/Shared/Utilities.js'; +export type { + GridInstance, + GridRefHandle, + CaptionProps, + DescriptionProps, + DataProps, + DataColumns, + DataColumnValue, + ColumnProps, + ColumnOptionsProps, + ColumnDataType, + ColumnSortingOrder, + CellValueGetterContext, + PaginationProps, + HeaderProps, + GroupedHeaderOptions, + HeaderCellAccessibilityProps +} from '@highcharts/grid-shared-react'; export type GridOptions = GridLite.Options; diff --git a/packages/grid-lite-react/src/utils/buildGridOptions.ts b/packages/grid-lite-react/src/utils/buildGridOptions.ts new file mode 100644 index 0000000..5bfd183 --- /dev/null +++ b/packages/grid-lite-react/src/utils/buildGridOptions.ts @@ -0,0 +1,45 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +import { normalizeChildOptions } from '@highcharts/grid-shared-react'; +import { merge } from '@highcharts/grid-lite/es-modules/Shared/Utilities.js'; +import type { Options } from '@highcharts/grid-lite/es-modules/Grid/Core/Options'; + +/** + * Builds final Grid Lite options from raw declarative child options. + * + * `theme` → `rendering.theme` + * `tableClassName` → `rendering.table.className` (`.hcg-table`) + * + * `className` is React-only on the mount container + * (parent of `.hcg-container`). + */ +export function buildGridOptions( + childOptions: Record, + options?: Options, + theme?: string, + tableClassName?: string +): Options { + const rendering: Record = {}; + + if (theme !== void 0) { + rendering.theme = theme; + } + if (tableClassName !== void 0) { + rendering.table = { className: tableClassName }; + } + + return merge( + normalizeChildOptions(childOptions), + options ?? {}, + // Skip empty `{ rendering: {} }` so merge does not inject a blank + // rendering block when theme / tableClassName were omitted. + Object.keys(rendering).length ? { rendering } : {} + ) as Options; +} diff --git a/packages/grid-lite-react/src/__tests__/Grid.test.tsx b/packages/grid-lite-react/tests/Grid.test.tsx similarity index 72% rename from packages/grid-lite-react/src/__tests__/Grid.test.tsx rename to packages/grid-lite-react/tests/Grid.test.tsx index 770322a..140e4eb 100644 --- a/packages/grid-lite-react/src/__tests__/Grid.test.tsx +++ b/packages/grid-lite-react/tests/Grid.test.tsx @@ -1,5 +1,5 @@ -import { createGridTests } from '@highcharts/grid-shared-react/src/test/createGridTests'; -import { Grid, GridOptions } from '../index'; +import { createGridTests } from '@highcharts/grid-shared-react/tests/createGridTests'; +import { Grid, GridOptions } from '../src/index'; createGridTests( 'Grid Lite', diff --git a/packages/grid-lite-react/tests/buildGridOptions.test.ts b/packages/grid-lite-react/tests/buildGridOptions.test.ts new file mode 100644 index 0000000..6fe9dcd --- /dev/null +++ b/packages/grid-lite-react/tests/buildGridOptions.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { buildGridOptions } from '../src/utils/buildGridOptions'; + +describe('buildGridOptions theme', () => { + it('omits rendering.theme when theme prop is undefined', () => { + const options = buildGridOptions({}); + + expect(options.rendering?.theme).toBeUndefined(); + }); + + it('passes empty theme to disable Core default', () => { + const options = buildGridOptions({}, void 0, ''); + + expect(options.rendering?.theme).toBe(''); + }); + + it('passes custom theme as-is', () => { + const options = buildGridOptions({}, void 0, 'myTheme'); + + expect(options.rendering?.theme).toBe('myTheme'); + }); + + it('does not put className into Core options', () => { + // className is React-only on the mount container + const options = buildGridOptions({}, void 0, 'hcg-theme-default'); + + expect(options.rendering?.theme).toBe('hcg-theme-default'); + expect( + (options.rendering as { className?: string } | undefined)?.className + ).toBeUndefined(); + }); + + it('maps tableClassName to rendering.table.className', () => { + const options = buildGridOptions( + {}, + void 0, + '', + 'border border-slate-300 w-full' + ); + + expect(options.rendering?.theme).toBe(''); + expect(options.rendering?.table?.className).toBe( + 'border border-slate-300 w-full' + ); + }); +}); diff --git a/packages/grid-pro-react/README.md b/packages/grid-pro-react/README.md index c5848f5..900396c 100644 --- a/packages/grid-pro-react/README.md +++ b/packages/grid-pro-react/README.md @@ -1,135 +1,198 @@ -# @highcharts/grid-pro-react +# Highcharts Grid Pro React -React integration for [Highcharts Grid Pro](https://www.highcharts.com/docs/grid/general). +
-## Links +Official Highcharts Grid Pro for React -* Official website: [www.highcharts.com](https://www.highcharts.com) -* Product page: [www.highcharts.com/products/grid](https://www.highcharts.com/products/grid) -* Download: [www.highcharts.com/download](https://www.highcharts.com/download) -* License: [www.highcharts.com/license](https://www.highcharts.com/license) -* Documentation: [www.highcharts.com/docs](https://www.highcharts.com/docs/grid/frameworks/grid-with-react) -* Support: [www.highcharts.com/support](https://www.highcharts.com/support) -* Issues: [Working repo](https://github.com/highcharts/highcharts/issues) + + +

Highcharts Grid Pro for React makes integrating editable, interactive data tables into your React projects intuitive and aligned with your React workflow, built with an API refined for React patterns.

+ +NPM Version +NPM Downloads +Discord + +
+ +## Why Highcharts Grid Pro React? + +- **Options or JSX** - Pass a Grid `options` object, compose with React components such as `Data`, `Column`, `Caption`, and `Pagination`, or mix both +- **Everything in Grid Lite** - Sorting, filtering, pagination, virtualization, theming, and accessibility +- **Interactive Data Editing** - Built-in editors for text, numbers, dates, and more +- **Validation** - Keep data clean with configurable rules and custom business logic +- **Sparklines** - Show trends in-cell, including Highcharts-powered visualizations +- **React Event Props** - Hook into load, update, sort, click, and pagination events with `on*` props +- **TypeScript Ready** - First-class types for options, refs, events, and component props + +## License + +Grid Pro is a commercial product. Getting licensed for commercial use makes you production-ready: license, updates and support for business-critical grids. To learn more, please contact our sales team at sales@highcharts.com. You can also review our Standard License Terms and our Annual License at the links below: + +- [Standard License Terms](https://www.highcharts.com/license) +- [Terms & Conditions for Annual Subscription](https://shop.highcharts.com/license-annual-3.0) +- [Product page](https://www.highcharts.com/products/grid) + +Looking for the free edition? See [@highcharts/grid-lite-react](https://www.npmjs.com/package/@highcharts/grid-lite-react). ## Installation +Install Highcharts Grid Pro React from npm: + ```bash npm install @highcharts/grid-pro-react ``` -## Requirements +Or using yarn: -- React 18 or higher +```bash +yarn add @highcharts/grid-pro-react +``` + +> **Note:** `@highcharts/grid-pro` is included as a dependency. `react` and `react-dom` are peer dependencies and are installed automatically with npm v7+. Requires React 18 or higher. ## Quick Start -```tsx -import React, { useState } from 'react'; +Pass your Grid Pro license key with `gridKey`. Components are optional. You can pass a Grid `options` object to `` the same way as before, use JSX components, or mix both. + +### Using options + +```jsx +import { useState } from 'react'; import { Grid, type GridOptions } from '@highcharts/grid-pro-react'; -function App() { +export function App() { const [options] = useState({ - dataTable: { + caption: { text: 'Team directory' }, + data: { columns: { name: ['Alice', 'Bob', 'Charlie'], age: [23, 34, 45], city: ['New York', 'Oslo', 'Paris'] } - }, - caption: { - text: 'My Grid' } }); - return ; + return ; } ``` -## API - -### `Grid` - -React component that wraps Highcharts Grid Pro. - -#### Props - -- `options` (required): Configuration options for the grid. Type: `GridOptions` -- `gridRef` (optional): React ref to access the underlying grid instance. Type: `RefObject>` -- `callback` (optional): Callback function called when the grid is initialized. Receives the grid instance as parameter. Type: `(grid: GridInstance) => void` +### Using components -### `GridOptions` +```jsx +import { + Grid, + Caption, + Data, + Column, + Pagination +} from '@highcharts/grid-pro-react'; -Type exported from the package for TypeScript support. - -```tsx -import type { GridOptions } from '@highcharts/grid-pro-react'; +export function App() { + return ( + + Team directory + + + + + + + ); +} ``` -### `GridRefHandle` - -Type for the gridRef handle that provides access to the underlying grid instance. - -```tsx -import type { GridRefHandle } from '@highcharts/grid-pro-react'; - -const gridRef = useRef | null>(null); -// Access the grid instance via gridRef.current?.grid +## Grid props + +The grid is rendered inside a container. You can pass layout, theme, and Pro event props directly to `Grid`: + +```jsx + + Full-width grid + + ``` -### `GridInstance` +- `gridKey` is required and sets your Grid Pro license key +- `className` applies to the React mount container +- `tableClassName` applies to the rendered table +- `theme` sets the Grid theme (`rendering.theme`) +- `onAfterLoad` and other `on*` props map to Grid Pro events -Type for the grid instance returned by gridRef or callback. +## TypeScript -```tsx -import type { GridInstance } from '@highcharts/grid-pro-react'; -``` +Use `GridOptions` for the `Grid` component `options` prop. -### Using gridRef and Callback - -You can access the grid instance in two ways: - -**Using gridRef:** ```tsx -import { useRef } from 'react'; -import { Grid, type GridRefHandle, type GridOptions } from '@highcharts/grid-pro-react'; +import { useState } from 'react'; +import { Grid, type GridOptions } from '@highcharts/grid-pro-react'; -function App() { - const gridRef = useRef | null>(null); - - const handleClick = () => { - // Access the grid instance - const gridInstance = gridRef.current?.grid; - if (gridInstance) { - console.log('Grid instance:', gridInstance); +export function App() { + const [options] = useState({ + data: { + columns: { + name: ['Alice', 'Bob', 'Charlie'], + age: [23, 34, 45] + } } - }; + }); - return ( - <> - - - - ); + return ; } ``` -**Using callback:** +Use `GridRefHandle` and `GridInstance` when you need access to the underlying Grid instance. + ```tsx -import { Grid, type GridInstance, type GridOptions } from '@highcharts/grid-pro-react'; +import { useRef } from 'react'; +import { + Grid, + type GridOptions, + type GridRefHandle, + type GridInstance +} from '@highcharts/grid-pro-react'; + +export function App() { + const gridRef = useRef | null>(null); -function App() { - const handleGridReady = (grid: GridInstance) => { - console.log('Grid initialized:', grid); + const onGridReady = (grid: GridInstance) => { + console.log('Grid instance:', grid); }; - return ; + return ( + + ); } ``` -### Next.js Integration +## Next.js -When using this package with Next.js, you need to disable Server-Side Rendering (SSR) for the Grid component: +Grid uses browser APIs, so it must render on the client. Use a dynamic import with SSR disabled: ```tsx 'use client'; @@ -137,9 +200,7 @@ When using this package with Next.js, you need to disable Server-Side Rendering import { useState } from 'react'; import dynamic from 'next/dynamic'; import { type GridOptions } from '@highcharts/grid-pro-react'; -import '@highcharts/grid-pro/css/grid-pro.css'; -// Disable SSR for the Grid component const Grid = dynamic( () => import('@highcharts/grid-pro-react').then((mod) => mod.Grid), { ssr: false } @@ -147,7 +208,7 @@ const Grid = dynamic( export default function Page() { const [options] = useState({ - dataTable: { + data: { columns: { name: ['Alice', 'Bob', 'Charlie'], age: [23, 34, 45] @@ -155,16 +216,23 @@ export default function Page() { } }); - return ; + return ; } ``` -**Important:** The Grid component must be rendered client-side only. Always use `dynamic` import with `ssr: false` and mark your component with `'use client'` directive. +The React package loads Grid CSS automatically. See the [Next.js guide](https://www.highcharts.com/docs/grid/frameworks/nextjs) for more detail. ## Documentation -For detailed documentation on available options and features, see the [Highcharts Grid Pro documentation](https://www.highcharts.com/docs/grid/general). +For comprehensive guides and API documentation, visit the [Highcharts Grid React documentation](https://www.highcharts.com/docs/grid/frameworks/react). -## License +- [Grid Pro getting started](https://www.highcharts.com/docs/grid/getting-started/grid-pro) +- [Highcharts Grid overview](https://www.highcharts.com/docs/grid/general) + +## Support and feedback + +We love to learn how you are using Highcharts, and what you would like to see from us in the future. + +Join our vibrant community on [GitHub](https://github.com/highcharts/grid-react), [Stack Overflow](https://stackoverflow.com/tags/highcharts/), [Discord](https://discord.com/invite/xHxxcyyy6K), and the [Highcharts Forums](https://www.highcharts.com/forum/). -SEE LICENSE IN [LICENSE](https://github.com/highcharts/grid-react/blob/main/packages/grid-pro-react/LICENSE). +Commercial support packages are available, see [Highcharts Advantage](https://www.highcharts.com/highcharts-advantage/). diff --git a/packages/grid-pro-react/package.json b/packages/grid-pro-react/package.json index 7012236..ef043ee 100644 --- a/packages/grid-pro-react/package.json +++ b/packages/grid-pro-react/package.json @@ -38,7 +38,7 @@ "prepublishOnly": "pnpm build" }, "dependencies": { - "@highcharts/grid-pro": ">=3.0.0" + "@highcharts/grid-pro": "3.1.0" }, "devDependencies": { "@highcharts/grid-shared-react": "workspace:*", diff --git a/packages/grid-pro-react/src/Grid.tsx b/packages/grid-pro-react/src/Grid.tsx index 398edea..7400e61 100644 --- a/packages/grid-pro-react/src/Grid.tsx +++ b/packages/grid-pro-react/src/Grid.tsx @@ -9,12 +9,38 @@ import { BaseGrid, - GridProps + useDeclarativeGridOptions } from '@highcharts/grid-shared-react'; import Grid from '@highcharts/grid-pro/es-modules/masters/grid-pro.src'; import '@highcharts/grid-pro/css/grid-pro.css'; -import type { Options } from '@highcharts/grid-pro/es-modules/Grid/Core/Options'; +import type { GridProProps } from './utils/mappers/grid'; +import { + getGridEventPropDeps +} from './utils/mappers/grid'; +import { buildGridOptions } from './utils/buildGridOptions'; + +export default function GridPro(props: GridProProps) { + const { gridRef, children, options, callback, className } = props; + const { gridOptions, columnKey } = useDeclarativeGridOptions( + children, + options, + (childOptions, opts) => buildGridOptions( + props.gridKey, + childOptions, + opts, + props + ), + getGridEventPropDeps(props) + ); -export default function GridPro({ options, gridRef, callback }: GridProps) { - return ; + return ( + + ); } diff --git a/packages/grid-pro-react/src/index.ts b/packages/grid-pro-react/src/index.ts index a44be66..01e6fee 100644 --- a/packages/grid-pro-react/src/index.ts +++ b/packages/grid-pro-react/src/index.ts @@ -7,10 +7,59 @@ * */ -import GridPro from '@highcharts/grid-pro'; +import type { ComponentType } from 'react'; +import { + Column as SharedColumn, + Data as SharedData, + Pagination as SharedPagination, + Caption, + ColumnDefaults, + Description, + Header +} from '@highcharts/grid-shared-react'; +import type { ProColumnProps } from './utils/mappers/column'; +import type { ProPaginationProps } from './utils/mappers/pagination'; export { default as Grid } from './Grid'; export { default as GridPro } from './Grid'; -export type { GridInstance } from '@highcharts/grid-shared-react'; -export type { GridRefHandle } from '@highcharts/grid-shared-react'; -export type GridOptions = GridPro.Options; +export { Caption, ColumnDefaults, Description, Header }; +export const Column = SharedColumn as ComponentType; +export const Data = SharedData; +export const Pagination = SharedPagination as ComponentType; +export { DataTable, DataConnector } from '@highcharts/grid-pro'; +export { merge } from '@highcharts/grid-pro/es-modules/Shared/Utilities.js'; +export type { + GridInstance, + GridRefHandle, + CaptionProps, + DescriptionProps, + DataProps, + DataColumns, + DataColumnValue, + ColumnOptionsProps, + ColumnDataType, + ColumnSortingOrder, + CellValueGetterContext, + HeaderProps, + GroupedHeaderOptions, + HeaderCellAccessibilityProps +} from '@highcharts/grid-shared-react'; +export type { + GridProProps, + GridProOptions, + GridOptions, + GridEventProps, + GridLevelEventProps, + RowPinningEventProps +} from './utils/mappers/grid'; +export type { + ProColumnProps, + ProColumnEventProps, + ColumnLevelEventProps, + CellLevelEventProps, + HeaderLevelEventProps +} from './utils/mappers/column'; +export type { + ProPaginationProps, + PaginationEventProps +} from './utils/mappers/pagination'; diff --git a/packages/grid-pro-react/src/utils/buildGridOptions.ts b/packages/grid-pro-react/src/utils/buildGridOptions.ts new file mode 100644 index 0000000..107b096 --- /dev/null +++ b/packages/grid-pro-react/src/utils/buildGridOptions.ts @@ -0,0 +1,60 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +import { normalizeChildOptions } from '@highcharts/grid-shared-react'; +import { merge } from '@highcharts/grid-pro/es-modules/Shared/Utilities.js'; +import { mergeColumnEventProps } from './mappers/column'; +import { mergePaginationEventProps } from './mappers/pagination'; +import { + normalizeGridEventProps, + type GridProOptions, + type GridProProps +} from './mappers/grid/gridOptions'; + +/** + * Builds final Grid Pro options from raw declarative child options. + * + * `theme` → `rendering.theme` + * `tableClassName` → `rendering.table.className` (`.hcg-table`) + * + * `className` is React-only on the mount container + * (parent of `.hcg-container`). + */ +export function buildGridOptions( + gridKey: string, + childOptions: Record, + options: GridProOptions | undefined, + props: GridProProps +): GridProOptions { + const declarativeOptions = mergePaginationEventProps( + mergeColumnEventProps(normalizeChildOptions(childOptions)) + ); + const rendering: Record = {}; + + if (props.theme !== void 0) { + rendering.theme = props.theme; + } + if (props.tableClassName !== void 0) { + rendering.table = { className: props.tableClassName }; + } + + const result = merge( + true, + {}, + merge(declarativeOptions, options ?? {}), + // Skip empty `{ rendering: {} }` so merge does not inject a blank + // rendering block when theme / tableClassName were omitted. + Object.keys(rendering).length ? { rendering } : {}, + normalizeGridEventProps(props) + ) as GridProOptions; + + result.gridKey = gridKey; + + return result; +} diff --git a/packages/grid-pro-react/src/utils/mapEventsProps.ts b/packages/grid-pro-react/src/utils/mapEventsProps.ts new file mode 100644 index 0000000..0da23d7 --- /dev/null +++ b/packages/grid-pro-react/src/utils/mapEventsProps.ts @@ -0,0 +1,81 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +import { isObject } from '@highcharts/grid-shared-react'; + +function setNestedValue( + target: Record, + path: readonly string[], + value: unknown +): void { + if (path.length === 0) { + return; + } + + let current = target; + + for (let i = 0; i < path.length - 1; i++) { + const key = path[i]; + + if (key === void 0) { + continue; + } + + const next = current[key]; + + if (!isObject(next)) { + current[key] = {}; + } else { + current[key] = { ...next }; + } + + current = current[key] as Record; + } + + const lastKey = path.at(-1); + + if (lastKey !== void 0) { + current[lastKey] = value; + } +} + +/** + * Maps flat event props onto nested option paths. + * + * When `source` is omitted, handlers are read from `target` and flat props + * are removed after mapping. When `source` is provided, handlers are copied + * onto `target` without mutating `source`. + * + * @example + * mapEventsProps(column, { + * onCellClick: ['cells', 'events', 'click'] + * }); + */ +export function mapEventsProps( + target: Record, + aliases: Record, + source?: Record +): void { + const props = source ?? target; + const removeFlatProps = source === void 0; + + for (const [propName, path] of Object.entries(aliases)) { + const handler = props[propName]; + + if (typeof handler !== 'function') { + continue; + } + + if (removeFlatProps) { + delete target[propName]; + } + + setNestedValue(target, path, handler); + } +} diff --git a/packages/grid-pro-react/src/utils/mappers/column/columnOptions.ts b/packages/grid-pro-react/src/utils/mappers/column/columnOptions.ts new file mode 100644 index 0000000..a1e3bce --- /dev/null +++ b/packages/grid-pro-react/src/utils/mappers/column/columnOptions.ts @@ -0,0 +1,108 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +import { isObject } from '@highcharts/grid-shared-react'; +import { mapEventsProps } from '../../mapEventsProps'; +import type { ColumnProps } from '@highcharts/grid-shared-react'; +import type { + CellEventCallback, + ColumnEventCallback +} from '@highcharts/grid-pro/es-modules/Grid/Pro/GridEvents.js'; + +/** + * Column-level event props mapped to `columns[].events`. + */ +export interface ColumnLevelEventProps { + onAfterResize?: ColumnEventCallback; + onBeforeSort?: ColumnEventCallback; + onAfterSort?: ColumnEventCallback; + onBeforeFilter?: ColumnEventCallback; + onAfterFilter?: ColumnEventCallback; +} + +/** + * Cell-level event props mapped to `columns[].cells.events`. + */ +export interface CellLevelEventProps { + onCellClick?: CellEventCallback; + onCellDblClick?: CellEventCallback; + onCellMouseOver?: CellEventCallback; + onCellMouseOut?: CellEventCallback; + onCellAfterRender?: CellEventCallback; + onCellAfterEdit?: CellEventCallback; +} + +/** + * Header-level event props mapped to `columns[].header.events`. + */ +export interface HeaderLevelEventProps { + onHeaderClick?: ColumnEventCallback; + onHeaderAfterRender?: ColumnEventCallback; +} + +export type ProColumnEventProps = ( + ColumnLevelEventProps & + CellLevelEventProps & + HeaderLevelEventProps +); + +/** + * Column props for Grid Pro, including event handlers. + */ +export type ProColumnProps = ColumnProps & ProColumnEventProps; + +/** Flat event prop → nested Grid option path for columns. */ +const COLUMN_EVENT_ALIASES = { + onAfterResize: ['events', 'afterResize'], + onBeforeSort: ['events', 'beforeSort'], + onAfterSort: ['events', 'afterSort'], + onBeforeFilter: ['events', 'beforeFilter'], + onAfterFilter: ['events', 'afterFilter'], + onCellClick: ['cells', 'events', 'click'], + onCellDblClick: ['cells', 'events', 'dblClick'], + onCellMouseOver: ['cells', 'events', 'mouseOver'], + onCellMouseOut: ['cells', 'events', 'mouseOut'], + onCellAfterRender: ['cells', 'events', 'afterRender'], + onCellAfterEdit: ['cells', 'events', 'afterEdit'], + onHeaderClick: ['header', 'events', 'click'], + onHeaderAfterRender: ['header', 'events', 'afterRender'] +} as const satisfies Record; + +/** + * Maps Pro column event props onto nested Grid option paths. + */ +export function mapColumnEventProps( + props: Record +): Record { + const options = { ...props }; + + mapEventsProps(options, COLUMN_EVENT_ALIASES); + + return options; +} + +/** + * Maps Pro column event props on declarative `options.columns`. + */ +export function mergeColumnEventProps( + options: Record +): Record { + const columns = options.columns; + + if (!Array.isArray(columns)) { + return options; + } + + return { + ...options, + columns: columns.map((column) => ( + isObject(column) ? mapColumnEventProps(column) : column + )) + }; +} diff --git a/packages/grid-pro-react/src/utils/mappers/column/index.ts b/packages/grid-pro-react/src/utils/mappers/column/index.ts new file mode 100644 index 0000000..6c7abfd --- /dev/null +++ b/packages/grid-pro-react/src/utils/mappers/column/index.ts @@ -0,0 +1,21 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +export { + mapColumnEventProps, + mergeColumnEventProps +} from './columnOptions'; + +export type { + ColumnLevelEventProps, + CellLevelEventProps, + HeaderLevelEventProps, + ProColumnEventProps, + ProColumnProps +} from './columnOptions'; diff --git a/packages/grid-pro-react/src/utils/mappers/grid/gridOptions.ts b/packages/grid-pro-react/src/utils/mappers/grid/gridOptions.ts new file mode 100644 index 0000000..4d62502 --- /dev/null +++ b/packages/grid-pro-react/src/utils/mappers/grid/gridOptions.ts @@ -0,0 +1,113 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +import type { GridProps as BaseGridProps } from '@highcharts/grid-shared-react'; +import type GridPro from '@highcharts/grid-pro'; +import type { GridEventCallback } from '@highcharts/grid-pro/es-modules/Grid/Pro/GridEvents.js'; +import type { + RowPinningChangeEventCallback +} from '@highcharts/grid-pro/es-modules/Grid/Pro/RowPinning/RowPinningController.js'; +import type { + AfterTreeRowToggleEvent, + BeforeTreeRowToggleEvent +} from '@highcharts/grid-pro/es-modules/Grid/Pro/TreeView/Projection/TreeProjectionController.js'; +import { mapEventsProps } from '../../mapEventsProps'; + +/** + * Grid Pro options, including license key support from the Pro bundle. + */ +export type GridProOptions = GridPro.Options & { + gridKey?: string; +}; + +export type GridOptions = GridProOptions; + +/** + * Grid-level event props mapped to `options.events`. + */ +export interface GridLevelEventProps { + onBeforeLoad?: GridEventCallback; + onAfterLoad?: GridEventCallback; + onBeforeUpdate?: GridEventCallback; + onAfterUpdate?: GridEventCallback; + onBeforeRedraw?: GridEventCallback; + onAfterRedraw?: GridEventCallback; + onBeforeTreeRowToggle?: (e: BeforeTreeRowToggleEvent) => void; + onAfterTreeRowToggle?: (e: AfterTreeRowToggleEvent) => void; +} + +/** + * Row pinning event props mapped to + * `options.rendering.rows.pinning.events`. + */ +export interface RowPinningEventProps { + onBeforeRowPin?: RowPinningChangeEventCallback; + onAfterRowPin?: RowPinningChangeEventCallback; +} + +export type GridEventProps = GridLevelEventProps & RowPinningEventProps; + +/** + * Props for the Grid Pro React component. + */ +export interface GridProProps + extends BaseGridProps, GridEventProps { + /** + * Grid Pro license key. + */ + gridKey: string; +} + +/** Flat event prop → nested Grid option path for grid props. */ +const GRID_EVENT_ALIASES = { + onBeforeLoad: ['events', 'beforeLoad'], + onAfterLoad: ['events', 'afterLoad'], + onBeforeUpdate: ['events', 'beforeUpdate'], + onAfterUpdate: ['events', 'afterUpdate'], + onBeforeRedraw: ['events', 'beforeRedraw'], + onAfterRedraw: ['events', 'afterRedraw'], + onBeforeTreeRowToggle: ['events', 'beforeTreeRowToggle'], + onAfterTreeRowToggle: ['events', 'afterTreeRowToggle'], + onBeforeRowPin: ['rendering', 'rows', 'pinning', 'events', 'beforeRowPin'], + onAfterRowPin: ['rendering', 'rows', 'pinning', 'events', 'afterRowPin'] +} as const satisfies Record; + +export const GRID_EVENT_PROP_KEYS = Object.keys( + GRID_EVENT_ALIASES +) as (keyof GridEventProps)[]; + +/** + * Maps Pro event props from declarative `` props. + */ +export function normalizeGridEventProps( + props: GridProProps +): Record { + const options: Record = {}; + + mapEventsProps( + options, + GRID_EVENT_ALIASES, + props as unknown as Record + ); + + return options; +} + +/** + * Event handler values from Grid props, for `useMemo` dependency lists. + */ +export function getGridEventPropDeps(props: GridProProps): unknown[] { + return [ + props.gridKey, + props.theme, + props.className, + props.tableClassName, + ...GRID_EVENT_PROP_KEYS.map((key) => props[key]) + ]; +} diff --git a/packages/grid-pro-react/src/utils/mappers/grid/index.ts b/packages/grid-pro-react/src/utils/mappers/grid/index.ts new file mode 100644 index 0000000..6f8427a --- /dev/null +++ b/packages/grid-pro-react/src/utils/mappers/grid/index.ts @@ -0,0 +1,23 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +export { + normalizeGridEventProps, + getGridEventPropDeps, + GRID_EVENT_PROP_KEYS +} from './gridOptions'; + +export type { + GridProOptions, + GridOptions, + GridLevelEventProps, + RowPinningEventProps, + GridEventProps, + GridProProps +} from './gridOptions'; diff --git a/packages/grid-pro-react/src/utils/mappers/pagination/index.ts b/packages/grid-pro-react/src/utils/mappers/pagination/index.ts new file mode 100644 index 0000000..0cde40f --- /dev/null +++ b/packages/grid-pro-react/src/utils/mappers/pagination/index.ts @@ -0,0 +1,18 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +export { + mapPaginationEventProps, + mergePaginationEventProps +} from './paginationOptions'; + +export type { + PaginationEventProps, + ProPaginationProps +} from './paginationOptions'; diff --git a/packages/grid-pro-react/src/utils/mappers/pagination/paginationOptions.ts b/packages/grid-pro-react/src/utils/mappers/pagination/paginationOptions.ts new file mode 100644 index 0000000..855a0c8 --- /dev/null +++ b/packages/grid-pro-react/src/utils/mappers/pagination/paginationOptions.ts @@ -0,0 +1,72 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +import { isObject } from '@highcharts/grid-shared-react'; +import { mapEventsProps } from '../../mapEventsProps'; +import type { PaginationProps } from '@highcharts/grid-shared-react'; +import type { + AfterPageChangeEvent, + AfterPageSizeChangeEvent, + BeforePageChangeEvent, + BeforePageSizeChangeEvent +} from '@highcharts/grid-pro/es-modules/Grid/Pro/Pagination/PaginationComposition.js'; + +/** + * Pagination event props mapped to `pagination.events`. + */ +export interface PaginationEventProps { + onBeforePageChange?: (e: BeforePageChangeEvent) => void; + onAfterPageChange?: (e: AfterPageChangeEvent) => void; + onBeforePageSizeChange?: (e: BeforePageSizeChangeEvent) => void; + onAfterPageSizeChange?: (e: AfterPageSizeChangeEvent) => void; +} + +/** + * Pagination props for Grid Pro, including event handlers. + */ +export type ProPaginationProps = PaginationProps & PaginationEventProps; + +/** Flat event prop → nested Grid option path for pagination. */ +const PAGINATION_EVENT_ALIASES = { + onBeforePageChange: ['events', 'beforePageChange'], + onAfterPageChange: ['events', 'afterPageChange'], + onBeforePageSizeChange: ['events', 'beforePageSizeChange'], + onAfterPageSizeChange: ['events', 'afterPageSizeChange'] +} as const satisfies Record; + +/** + * Maps Pro pagination event props onto nested Grid option paths. + */ +export function mapPaginationEventProps( + props: Record +): Record { + const options = { ...props }; + + mapEventsProps(options, PAGINATION_EVENT_ALIASES); + + return options; +} + +/** + * Maps Pro pagination event props on declarative `options.pagination`. + */ +export function mergePaginationEventProps( + options: Record +): Record { + const pagination = options.pagination; + + if (!isObject(pagination)) { + return options; + } + + return { + ...options, + pagination: mapPaginationEventProps({ ...pagination }) + }; +} diff --git a/packages/grid-pro-react/src/__tests__/Grid.test.tsx b/packages/grid-pro-react/tests/Grid.test.tsx similarity index 54% rename from packages/grid-pro-react/src/__tests__/Grid.test.tsx rename to packages/grid-pro-react/tests/Grid.test.tsx index e44d6d9..7cbdcb2 100644 --- a/packages/grid-pro-react/src/__tests__/Grid.test.tsx +++ b/packages/grid-pro-react/tests/Grid.test.tsx @@ -1,7 +1,9 @@ -import { createGridTests } from '@highcharts/grid-shared-react/src/test/createGridTests'; -import { Grid, GridOptions } from '../index'; +import { createGridTests } from '@highcharts/grid-shared-react/tests/createGridTests'; +import { Grid, GridOptions } from '../src/index'; -createGridTests( +const GRID_KEY = 'AAAA-BBBB-CCCC-DDDD-EEEE-FFFF'; + +createGridTests( 'Grid Pro', Grid, { @@ -19,5 +21,8 @@ createGridTests( age: [40, 35] } } + }, + { + gridKey: GRID_KEY } ); diff --git a/packages/grid-pro-react/tests/mappers/columnOptions.test.tsx b/packages/grid-pro-react/tests/mappers/columnOptions.test.tsx new file mode 100644 index 0000000..4562165 --- /dev/null +++ b/packages/grid-pro-react/tests/mappers/columnOptions.test.tsx @@ -0,0 +1,90 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Column, Data } from '../../src/index'; +import { + mergeColumnEventProps, + mapColumnEventProps +} from '../../src/utils/mappers/column'; +import { getChildProps, normalizeChildOptions } from '@highcharts/grid-shared-react'; + +describe('mapColumnEventProps', () => { + it('maps column event props onto nested option paths', () => { + const onAfterSort = vi.fn(); + const onCellClick = vi.fn(); + + expect(mapColumnEventProps({ + columnId: 'name', + onAfterSort, + onCellClick + })).toEqual({ + columnId: 'name', + events: { + afterSort: onAfterSort + }, + cells: { + events: { + click: onCellClick + } + } + }); + }); + + it('maps header event props onto nested option paths', () => { + const onHeaderClick = vi.fn(); + const onHeaderAfterRender = vi.fn(); + + expect(mapColumnEventProps({ + columnId: 'name', + onHeaderClick, + onHeaderAfterRender + })).toEqual({ + columnId: 'name', + header: { + events: { + click: onHeaderClick, + afterRender: onHeaderAfterRender + } + } + }); + }); +}); + +describe('mergeColumnEventProps', () => { + it('maps event props on declarative column options', () => { + const onAfterSort = vi.fn(); + const onCellClick = vi.fn(); + + const options = mergeColumnEventProps( + normalizeChildOptions( + getChildProps( + <> + + + + ) + ) + ); + + expect(options.columns).toEqual([ + { + id: 'name', + events: { + afterSort: onAfterSort + }, + cells: { + events: { + click: onCellClick + } + } + } + ]); + }); +}); diff --git a/packages/grid-pro-react/tests/mappers/gridOptions.test.tsx b/packages/grid-pro-react/tests/mappers/gridOptions.test.tsx new file mode 100644 index 0000000..dc07360 --- /dev/null +++ b/packages/grid-pro-react/tests/mappers/gridOptions.test.tsx @@ -0,0 +1,171 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Column } from '../../src/index'; +import { buildGridOptions } from '../../src/utils/buildGridOptions'; +import { + getGridEventPropDeps, + GRID_EVENT_PROP_KEYS, + normalizeGridEventProps +} from '../../src/utils/mappers/grid'; +import { getChildProps } from '@highcharts/grid-shared-react'; +import type { GridProOptions, GridProProps } from '../../src/utils/mappers/grid'; + +describe('normalizeGridEventProps', () => { + it('maps grid-level and row pinning event props', () => { + const onAfterLoad = vi.fn(); + const onAfterRowPin = vi.fn(); + + expect(normalizeGridEventProps({ + gridKey: 'GRID-KEY', + onAfterLoad, + onAfterRowPin + } as GridProProps)).toEqual({ + events: { + afterLoad: onAfterLoad + }, + rendering: { + rows: { + pinning: { + events: { + afterRowPin: onAfterRowPin + } + } + } + } + }); + }); +}); + +describe('buildGridOptions', () => { + it('merges gridKey and grid-level events into options', () => { + const onAfterLoad = vi.fn(); + const options = buildGridOptions( + 'GRID-KEY', + { + data: { + columns: { + name: ['Alice'] + } + } + }, + { + gridKey: 'OLD-KEY', + events: { + beforeLoad: vi.fn() + } + } as GridProOptions, + { + gridKey: 'GRID-KEY', + onAfterLoad + } as GridProProps + ); + + expect(options.gridKey).toBe('GRID-KEY'); + expect(options.events?.beforeLoad).toBeTypeOf('function'); + expect(options.events?.afterLoad).toBe(onAfterLoad); + }); + + it('maps declarative children and builds full grid options', () => { + const options = buildGridOptions( + 'GRID-KEY', + getChildProps(), + void 0, + { gridKey: 'GRID-KEY' } as GridProProps + ); + + expect(options.gridKey).toBe('GRID-KEY'); + expect(options.columns).toEqual([{ id: 'name' }]); + }); + + it('omits rendering.theme when theme prop is undefined', () => { + const options = buildGridOptions( + 'GRID-KEY', + {}, + void 0, + { + gridKey: 'GRID-KEY', + className: 'rounded-md border' + } as GridProProps + ); + + expect(options.rendering?.theme).toBeUndefined(); + expect( + (options.rendering as { className?: string } | undefined)?.className + ).toBeUndefined(); + }); + + it('passes empty theme to disable Core default', () => { + const options = buildGridOptions( + 'GRID-KEY', + {}, + void 0, + { + gridKey: 'GRID-KEY', + theme: '', + className: 'rounded-md border' + } as GridProProps + ); + + expect(options.rendering?.theme).toBe(''); + expect( + (options.rendering as { className?: string } | undefined)?.className + ).toBeUndefined(); + }); + + it('passes custom theme without putting className into Core options', () => { + const options = buildGridOptions( + 'GRID-KEY', + {}, + void 0, + { + gridKey: 'GRID-KEY', + theme: 'myTheme', + className: 'rounded-md border' + } as GridProProps + ); + + expect(options.rendering?.theme).toBe('myTheme'); + expect( + (options.rendering as { className?: string } | undefined)?.className + ).toBeUndefined(); + }); + + it('maps tableClassName to rendering.table.className', () => { + const options = buildGridOptions( + 'GRID-KEY', + {}, + void 0, + { + gridKey: 'GRID-KEY', + theme: '', + className: 'p-8', + tableClassName: 'border border-slate-300' + } as GridProProps + ); + + expect(options.rendering?.theme).toBe(''); + expect(options.rendering?.table?.className).toBe( + 'border border-slate-300' + ); + }); +}); + +describe('getGridEventPropDeps', () => { + it('exposes stable dep keys for every grid event prop', () => { + const onAfterLoad = vi.fn(); + const props = { + gridKey: 'KEY', + onAfterLoad + } as GridProProps; + + expect(GRID_EVENT_PROP_KEYS).toContain('onAfterLoad'); + expect(getGridEventPropDeps(props)).toEqual([ + 'KEY', + void 0, + void 0, + void 0, + ...GRID_EVENT_PROP_KEYS.map( + (key: keyof GridProProps) => props[key] + ) + ]); + }); +}); diff --git a/packages/grid-pro-react/tests/mappers/paginationOptions.test.tsx b/packages/grid-pro-react/tests/mappers/paginationOptions.test.tsx new file mode 100644 index 0000000..b4da27d --- /dev/null +++ b/packages/grid-pro-react/tests/mappers/paginationOptions.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Data, Pagination } from '../../src/index'; +import { + mapPaginationEventProps, + mergePaginationEventProps +} from '../../src/utils/mappers/pagination'; +import { getChildProps, normalizeChildOptions } from '@highcharts/grid-shared-react'; + +describe('mapPaginationEventProps', () => { + it('maps pagination event props onto nested option paths', () => { + const onBeforePageChange = vi.fn(); + + expect(mapPaginationEventProps({ + page: 1, + pageSize: 2, + onBeforePageChange + })).toEqual({ + page: 1, + pageSize: 2, + events: { + beforePageChange: onBeforePageChange + } + }); + }); +}); + +describe('mergePaginationEventProps', () => { + it('maps pagination event props from declarative child options', () => { + const onBeforePageChange = vi.fn(); + const children = ( + <> + + + + ); + + const options = mergePaginationEventProps( + normalizeChildOptions(getChildProps(children)) + ); + + expect(options.pagination).toEqual({ + enabled: true, + page: 1, + pageSize: 2, + position: 'bottom', + events: { + beforePageChange: onBeforePageChange + } + }); + }); +}); diff --git a/packages/grid-pro-react/tests/utils/mapEventsProps.test.ts b/packages/grid-pro-react/tests/utils/mapEventsProps.test.ts new file mode 100644 index 0000000..1a329fb --- /dev/null +++ b/packages/grid-pro-react/tests/utils/mapEventsProps.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi } from 'vitest'; +import { mapEventsProps } from '../../src/utils/mapEventsProps'; + +describe('mapEventsProps', () => { + it('maps flat event props onto nested option paths', () => { + const onCellClick = vi.fn(); + const column: Record = { + id: 'name', + onCellClick + }; + + mapEventsProps(column, { + onCellClick: ['cells', 'events', 'click'] + }); + + expect(column).toEqual({ + id: 'name', + cells: { + events: { + click: onCellClick + } + } + }); + }); + + it('copies handlers from a source object onto nested target paths', () => { + const onBeforePageChange = vi.fn(); + const pagination: Record = { enabled: true }; + + mapEventsProps( + pagination, + { + onBeforePageChange: ['events', 'beforePageChange'] + }, + { page: 1, onBeforePageChange } + ); + + expect(pagination).toEqual({ + enabled: true, + events: { + beforePageChange: onBeforePageChange + } + }); + }); +}); diff --git a/packages/grid-shared-react/src/components/BaseGrid.tsx b/packages/grid-shared-react/src/components/BaseGrid.tsx index 437459e..b9df08f 100644 --- a/packages/grid-shared-react/src/components/BaseGrid.tsx +++ b/packages/grid-shared-react/src/components/BaseGrid.tsx @@ -7,7 +7,7 @@ * */ -import { useRef, useImperativeHandle, forwardRef, ForwardedRef } from 'react'; +import { useRef, useImperativeHandle, forwardRef, ForwardedRef, ReactNode } from 'react'; import { useGrid, GridType, @@ -31,7 +31,27 @@ export interface GridProps { /** * Grid configuration options */ - options: TOptions; + options?: TOptions; + /** + * Optional CSS class names on the React mount container (parent of + * `.hcg-container`). Independent of `theme`. + */ + className?: string; + /** + * Optional CSS class names mapped to Core `rendering.table.className` on + * `.hcg-table`. Independent of `className` / `theme`. + */ + tableClassName?: string; + /** + * Optional theme name passed to Grid Core as `rendering.theme`. + * Omitted → Core default (`hcg-theme-default`). + * Defined (including `''`) → that value only. + */ + theme?: string; + /** + * Declarative option components (e.g. Caption) passed as children. + */ + children?: ReactNode; /** * Optional ref to access the grid instance */ @@ -45,18 +65,18 @@ export interface GridProps { /** * Props for BaseGrid component */ -export interface BaseGridProps extends GridProps { - /** - * Grid instance (from @highcharts/grid-lite or @highcharts/grid-pro) - */ +export interface BaseGridProps { + options?: TOptions; Grid: GridType; + callback?: (grid: GridInstance) => void; + className?: string; } export const BaseGrid = forwardRef(function BaseGrid( props: BaseGridProps, ref: ForwardedRef> ) { - const { options, Grid, callback } = props; + const { options, Grid, callback, className } = props; const containerRef = useRef(null); const currGridRef = useGrid({ @@ -76,5 +96,5 @@ export const BaseGrid = forwardRef(function BaseGrid( [] ); - return
; + return
; }); diff --git a/packages/grid-shared-react/src/components/BaseGridOptions.ts b/packages/grid-shared-react/src/components/BaseGridOptions.ts new file mode 100644 index 0000000..aabeb10 --- /dev/null +++ b/packages/grid-shared-react/src/components/BaseGridOptions.ts @@ -0,0 +1,34 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +/** + * Metadata attached to declarative option components + * rendered as BaseGrid children. + */ +export interface BaseGridOptions { + type: 'Grid_Option'; + /** + * Dot-notation path of the Grid option (e.g. `caption`). + */ + gridOption: string; + /** + * Sub-option that receives string children (e.g. `text`). + */ + childOption?: string; + defaultOptions?: Record; + isArrayType?: boolean; +} + +/** + * A React component that maps JSX props to a Grid options path + * via `_GridReact`. + */ +export interface BaseGridOptionsComponent { + _GridReact: BaseGridOptions; +} diff --git a/packages/grid-shared-react/src/components/options/caption/Caption.tsx b/packages/grid-shared-react/src/components/options/caption/Caption.tsx new file mode 100644 index 0000000..99c9199 --- /dev/null +++ b/packages/grid-shared-react/src/components/options/caption/Caption.tsx @@ -0,0 +1,33 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +import { ReactNode } from 'react'; + +export interface CaptionProps { + /** + * The custom CSS class name for the table caption. + */ + className?: string; + /** + * The HTML tag to use for the caption. + */ + htmlTag?: string; + children?: ReactNode; +} + +export function Caption(_props: CaptionProps) { + return null; +} + +Caption._GridReact = { + type: 'Grid_Option', + gridOption: 'caption', + childOption: 'text', + isArrayType: false +}; diff --git a/packages/grid-shared-react/src/components/options/caption/index.ts b/packages/grid-shared-react/src/components/options/caption/index.ts new file mode 100644 index 0000000..3e96b78 --- /dev/null +++ b/packages/grid-shared-react/src/components/options/caption/index.ts @@ -0,0 +1,11 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +export { Caption } from './Caption'; +export type { CaptionProps } from './Caption'; diff --git a/packages/grid-shared-react/src/components/options/columns/Column.tsx b/packages/grid-shared-react/src/components/options/columns/Column.tsx new file mode 100644 index 0000000..f5cd44a --- /dev/null +++ b/packages/grid-shared-react/src/components/options/columns/Column.tsx @@ -0,0 +1,20 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +import type { ColumnProps } from './columnProps'; + +export function Column(_props: ColumnProps) { + return null; +} + +Column._GridReact = { + type: 'Grid_Option', + gridOption: 'columns', + isArrayType: true +}; diff --git a/packages/grid-shared-react/src/components/options/columns/ColumnDefaults.tsx b/packages/grid-shared-react/src/components/options/columns/ColumnDefaults.tsx new file mode 100644 index 0000000..d5a1faf --- /dev/null +++ b/packages/grid-shared-react/src/components/options/columns/ColumnDefaults.tsx @@ -0,0 +1,36 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +import type { ColumnOptionsProps } from './columnProps'; + +/** + * ColumnDefaults props include shared column options plus grid-level row + * class hooks (lifted to `rendering.rows` during normalize). + */ +export interface ColumnDefaultsProps extends ColumnOptionsProps { + /** + * CSS class names on every body ``. + * Maps to Core `rendering.rows.className`. + */ + rowClassName?: string; + /** + * CSS class names on even body `` (Core `.hcg-row-even` parity). + * Maps to Core `rendering.rows.evenClassName`. + */ + evenRowClassName?: string; +} + +export function ColumnDefaults(_props: ColumnDefaultsProps) { + return null; +} + +ColumnDefaults._GridReact = { + type: 'Grid_Option', + gridOption: 'columnDefaults' +}; diff --git a/packages/grid-shared-react/src/components/options/columns/columnProps.ts b/packages/grid-shared-react/src/components/options/columns/columnProps.ts new file mode 100644 index 0000000..1452f99 --- /dev/null +++ b/packages/grid-shared-react/src/components/options/columns/columnProps.ts @@ -0,0 +1,74 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +export type ColumnDataType = 'string' | 'number' | 'boolean' | 'datetime'; + +export type ColumnSortingOrder = 'asc' | 'desc' | null; + +/** + * `this` context passed to `cellValueGetter` by Grid Core. + */ +export interface CellValueGetterContext { + row: { + index: number; + }; +} + +/** + * Shared column options (`columnDefaults` and per-column overrides). + */ +export interface ColumnOptionsProps { + dataType?: ColumnDataType; + width?: number | string; + sortingEnabled?: boolean; + sortingOrder?: ColumnSortingOrder; + sortingPriority?: number; + sortingOrderSequence?: ColumnSortingOrder[]; + sortingCompare?: (a: unknown, b: unknown) => number; + filteringEnabled?: boolean; + filteringInline?: boolean; + filteringCondition?: string; + filteringValue?: string | number | boolean | null; + headerClassName?: string; + headerFormat?: string; + headerFormatter?: (this: unknown) => string; + headerStyle?: unknown; + cellRowHeader?: boolean; + cellClassName?: string; + cellFormat?: string; + cellFormatter?: (this: unknown) => string; + /** + * Custom cell value resolver. `this` is the Grid table cell (`row.index` + * is the row index in the presentation data). + */ + cellValueGetter?: (this: CellValueGetterContext) => unknown; + cellContextMenu?: { + enabled?: boolean; + items?: unknown[]; + }; + cellStyle?: unknown; + style?: unknown; + exportable?: boolean; +} + +export interface ColumnProps extends ColumnOptionsProps { + /** + * HTML `id` attribute for styling hooks. Not passed to Grid options. + */ + id?: string; + /** + * References the column to configure (data field id). Maps header, cells, + * sorting, filtering, etc. to Grid Core column options. + * + * Becomes `options.columns[].id` in Grid Core (same identifier). + */ + columnId?: string; + className?: string; + enabled?: boolean; +} diff --git a/packages/grid-shared-react/src/components/options/data/Data.tsx b/packages/grid-shared-react/src/components/options/data/Data.tsx new file mode 100644 index 0000000..7ec764a --- /dev/null +++ b/packages/grid-shared-react/src/components/options/data/Data.tsx @@ -0,0 +1,64 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +export type DataColumnValue = boolean | null | number | string | undefined; + +export type DataColumns = Record>; + +export interface DataProps { + /** + * The type of the data provider. + * + * @default 'local' + */ + providerType?: 'local' | string; + /** + * Whether columns should be generated automatically from data source + * column ids. + * + * Defaults to `true`. When declarative `` components are used, + * the React wrapper sets this to `false` unless you pass this prop + * explicitly. + * + * @default true + */ + autogenerateColumns?: boolean; + /** + * Columns data to initialize the Grid with. + */ + columns?: DataColumns; + /** + * Data table as a source of data for the grid. + */ + dataTable?: unknown; + /** + * Connector instance or options used to populate the data table. + */ + connector?: unknown; + /** + * Automatically update the grid when the data table changes. + * + * @default false + */ + updateOnChange?: boolean; + /** + * The column ID that contains the stable, unique row IDs. + */ + idColumn?: string; +} + +export function Data(_props: DataProps) { + return null; +} + +Data._GridReact = { + type: 'Grid_Option', + gridOption: 'data', + isArrayType: false +}; diff --git a/packages/grid-shared-react/src/components/options/description/Description.tsx b/packages/grid-shared-react/src/components/options/description/Description.tsx new file mode 100644 index 0000000..c6ee575 --- /dev/null +++ b/packages/grid-shared-react/src/components/options/description/Description.tsx @@ -0,0 +1,29 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +import { ReactNode } from 'react'; + +export interface DescriptionProps { + /** + * The custom CSS class name for the description. + */ + className?: string; + children?: ReactNode; +} + +export function Description(_props: DescriptionProps) { + return null; +} + +Description._GridReact = { + type: 'Grid_Option', + gridOption: 'description', + childOption: 'text', + isArrayType: false +}; diff --git a/packages/grid-shared-react/src/components/options/description/index.ts b/packages/grid-shared-react/src/components/options/description/index.ts new file mode 100644 index 0000000..65cb487 --- /dev/null +++ b/packages/grid-shared-react/src/components/options/description/index.ts @@ -0,0 +1,11 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +export { Description } from './Description'; +export type { DescriptionProps } from './Description'; diff --git a/packages/grid-shared-react/src/components/options/header/Header.tsx b/packages/grid-shared-react/src/components/options/header/Header.tsx new file mode 100644 index 0000000..b952ef3 --- /dev/null +++ b/packages/grid-shared-react/src/components/options/header/Header.tsx @@ -0,0 +1,19 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +import type { HeaderProps } from './headerProps'; + +export function Header(_props: HeaderProps) { + return null; +} + +Header._GridReact = { + type: 'Grid_Option', + gridOption: 'header' +}; diff --git a/packages/grid-shared-react/src/components/options/header/headerProps.ts b/packages/grid-shared-react/src/components/options/header/headerProps.ts new file mode 100644 index 0000000..86f57bb --- /dev/null +++ b/packages/grid-shared-react/src/components/options/header/headerProps.ts @@ -0,0 +1,36 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +/** + * Accessibility options for a header cell in the header tree. + */ +export interface HeaderCellAccessibilityProps { + description?: string; +} + +/** + * Header node in the `header` tree. A group (with `columns`) or a leaf + * (with `columnId`). Mirrors Grid Core `GroupedHeaderOptions`. + */ +export interface GroupedHeaderOptions { + accessibility?: HeaderCellAccessibilityProps; + format?: string; + className?: string; + columnId?: string; + columns?: Array; +} + +export interface HeaderProps { + /** + * Header tree: column order, inclusion, and grouping. + * Each entry is a column id (`string`) or a {@link GroupedHeaderOptions} + * object. Maps to Grid Core `options.header`. + */ + header?: Array; +} diff --git a/packages/grid-shared-react/src/components/options/header/index.ts b/packages/grid-shared-react/src/components/options/header/index.ts new file mode 100644 index 0000000..e25312d --- /dev/null +++ b/packages/grid-shared-react/src/components/options/header/index.ts @@ -0,0 +1,15 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +export { Header } from './Header'; +export type { + HeaderProps, + GroupedHeaderOptions, + HeaderCellAccessibilityProps +} from './headerProps'; diff --git a/packages/grid-shared-react/src/components/options/index.ts b/packages/grid-shared-react/src/components/options/index.ts new file mode 100644 index 0000000..6966a89 --- /dev/null +++ b/packages/grid-shared-react/src/components/options/index.ts @@ -0,0 +1,33 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +export { Caption } from './caption'; +export type { CaptionProps } from './caption'; +export { Data } from './data/Data'; +export type { DataProps, DataColumns, DataColumnValue } from './data/Data'; +export { ColumnDefaults } from './columns/ColumnDefaults'; +export type { ColumnDefaultsProps } from './columns/ColumnDefaults'; +export { Column } from './columns/Column'; +export type { + ColumnProps, + ColumnOptionsProps, + ColumnDataType, + ColumnSortingOrder, + CellValueGetterContext +} from './columns/columnProps'; +export { Description } from './description'; +export type { DescriptionProps } from './description'; +export { Pagination } from './pagination'; +export type { PaginationProps } from './pagination'; +export { Header } from './header'; +export type { + HeaderProps, + GroupedHeaderOptions, + HeaderCellAccessibilityProps +} from './header'; diff --git a/packages/grid-shared-react/src/components/options/pagination/Pagination.tsx b/packages/grid-shared-react/src/components/options/pagination/Pagination.tsx new file mode 100644 index 0000000..8a169d7 --- /dev/null +++ b/packages/grid-shared-react/src/components/options/pagination/Pagination.tsx @@ -0,0 +1,19 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +import type { PaginationProps } from './paginationProps'; + +export function Pagination(_props: PaginationProps) { + return null; +} + +Pagination._GridReact = { + type: 'Grid_Option', + gridOption: 'pagination' +}; diff --git a/packages/grid-shared-react/src/components/options/pagination/index.ts b/packages/grid-shared-react/src/components/options/pagination/index.ts new file mode 100644 index 0000000..d5d79cb --- /dev/null +++ b/packages/grid-shared-react/src/components/options/pagination/index.ts @@ -0,0 +1,11 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +export { Pagination } from './Pagination'; +export type { PaginationProps } from './paginationProps'; diff --git a/packages/grid-shared-react/src/components/options/pagination/paginationProps.ts b/packages/grid-shared-react/src/components/options/pagination/paginationProps.ts new file mode 100644 index 0000000..3e31579 --- /dev/null +++ b/packages/grid-shared-react/src/components/options/pagination/paginationProps.ts @@ -0,0 +1,77 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +export interface PaginationProps { + /** + * Whether pagination should be rendered. + * Defaults to `true` when the `` component is used. + * Pass `false` to disable pagination while keeping other options. + */ + enabled?: boolean; + /** + * Additional CSS class name(s) for the pagination container + * (`.hcg-pagination`). + */ + className?: string; + /** + * Additional CSS class name(s) for the page info element + * (`.hcg-pagination-info`). + */ + infoClassName?: string; + /** + * Additional CSS class name(s) for the controls container + * (`.hcg-pagination-controls`). + */ + controlsClassName?: string; + /** + * Additional CSS class name(s) for the page size container + * (`.hcg-pagination-page-size`). + */ + sizeClassName?: string; + /** + * The current page number. + */ + page?: number; + /** + * Number of rows per page. + */ + pageSize?: number; + /** + * Alignment of pagination elements within the wrapper. + */ + align?: 'left' | 'center' | 'right' | 'distributed'; + /** + * Whether to show the page information text. + */ + pageInfo?: boolean; + /** + * Whether to show the page size selector. + */ + pageSizeSelector?: boolean; + /** + * Available options for the page size selector dropdown. + */ + pageSizeOptions?: number[]; + /** + * Whether to show numbered page buttons. + */ + pageButtons?: boolean; + /** + * Maximum number of page number buttons to show before using ellipsis. + */ + pageButtonsCount?: number; + /** + * Whether to show the first and last page navigation buttons. + */ + firstLast?: boolean; + /** + * Whether to show the previous and next page navigation buttons. + */ + previousNext?: boolean; +} diff --git a/packages/grid-shared-react/src/hooks/useDeclarativeGridOptions.ts b/packages/grid-shared-react/src/hooks/useDeclarativeGridOptions.ts new file mode 100644 index 0000000..be657fe --- /dev/null +++ b/packages/grid-shared-react/src/hooks/useDeclarativeGridOptions.ts @@ -0,0 +1,60 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +import { useMemo, type ReactNode } from 'react'; +import { getChildProps } from '../utils/getChildProps'; + +export interface OptionsBuildFn { + (childOptions: Record, options?: TOptions): TOptions; +} + +export type DeclarativeGridOptionsState = { + gridOptions: TOptions; + columnKey: string; +}; + +/** + * Builds a React key that remounts the grid when declarative column ids change. + */ +function getColumnKey(childOptions: Record): string { + const columns = childOptions.columns as Array<{ id?: string }> | undefined; + + return columns?.map((column) => column.id).join('\0') ?? ''; +} + +export interface UseDeclarativeGridOptionsFn { + ( + children: ReactNode | undefined, + options: T | undefined, + build: OptionsBuildFn, + buildDeps?: unknown[] + ): DeclarativeGridOptionsState; +} + +export const useDeclarativeGridOptions: UseDeclarativeGridOptionsFn = ( + children, + options, + build, + buildDeps = [] +) => { + const childOptions = useMemo( + () => (children != null ? getChildProps(children) : {}), + [children] + ); + const columnKey = useMemo( + () => getColumnKey(childOptions), + [childOptions] + ); + const gridOptions = useMemo( + () => build(childOptions, options), + [childOptions, options, ...buildDeps] + ); + + return { gridOptions, columnKey }; +}; diff --git a/packages/grid-shared-react/src/hooks/useGrid.test.tsx b/packages/grid-shared-react/src/hooks/useGrid.test.tsx index 774a6a1..53e86d1 100644 --- a/packages/grid-shared-react/src/hooks/useGrid.test.tsx +++ b/packages/grid-shared-react/src/hooks/useGrid.test.tsx @@ -60,9 +60,10 @@ describe('useGrid', () => { expect(initQueue).toHaveLength(1); }); - const [firstInit] = initQueue; + const firstInit = initQueue[0]; - await firstInit.resolve(); + expect(firstInit).toBeDefined(); + await firstInit!.resolve(); await waitFor(() => { expect(container.querySelector('[data-grid-id="1"]')).not.toBeNull(); diff --git a/packages/grid-shared-react/src/hooks/useGrid.ts b/packages/grid-shared-react/src/hooks/useGrid.ts index 327e3fb..f5e1756 100644 --- a/packages/grid-shared-react/src/hooks/useGrid.ts +++ b/packages/grid-shared-react/src/hooks/useGrid.ts @@ -8,14 +8,13 @@ */ import { useEffect, RefObject, useRef } from 'react'; -import { BaseGridProps } from '../components/BaseGrid'; /** * Interface describing the shape of a Grid instance returned by Grid.grid() */ export interface GridInstance { destroy(): void; - update(options: TOptions, redraw?: boolean): void; + update(options: TOptions, redraw?: boolean, oneToOne?: boolean): void; } /** @@ -25,11 +24,18 @@ export interface GridInstance { * directly depending on their types. */ export interface GridType { - grid(container: HTMLDivElement, options: TOptions, async?: boolean): GridInstance | Promise>; + grid( + container: HTMLDivElement, + options?: TOptions, + async?: boolean + ): GridInstance | Promise>; } -export interface UseGridOptions extends BaseGridProps { +export interface UseGridOptions { containerRef: RefObject; + options?: TOptions; + Grid: GridType; + callback?: (grid: GridInstance) => void; } export function useGrid({ @@ -40,7 +46,7 @@ export function useGrid({ }: UseGridOptions) { const currGridRef = useRef | null>(null); const callbackRef = useRef(callback); - const pendingOptionsRef = useRef(null); + const pendingOptionsRef = useRef(void 0); const initStartedRef = useRef(false); // StrictMode runs effects twice: mount → cleanup → mount. @@ -60,7 +66,8 @@ export function useGrid({ return; } - // StrictMode cleanup runs before re-mount; allow init to complete if re-mounted. + // StrictMode cleanup runs before re-mount; + // allow init to complete if re-mounted. destroyOnInitRef.current = false; // Prevent double initialization @@ -71,24 +78,27 @@ export function useGrid({ const initGrid = async () => { try { - // Use pending options if available (from rapid updates during init) + // Use pending options if available + // (from rapid updates during init) const initOptions = pendingOptionsRef.current ?? options; - pendingOptionsRef.current = null; + pendingOptionsRef.current = void 0; const grid = await Grid.grid(container, initOptions, true); if (destroyOnInitRef.current) { - // Component unmounted while we were initializing - destroy immediately + // Component unmounted while initializing - + // destroy immediately grid.destroy(); return; } currGridRef.current = grid; - // Apply any pending options that came in while we were initializing - if (pendingOptionsRef.current) { - grid.update(pendingOptionsRef.current, true); - pendingOptionsRef.current = null; + // Apply pending options that came in + // while we were initializing + if (pendingOptionsRef.current !== void 0) { + grid.update(pendingOptionsRef.current, true, true); + pendingOptionsRef.current = void 0; } callbackRef.current?.(grid); @@ -115,9 +125,14 @@ export function useGrid({ // Effect for options updates - separate from init useEffect(() => { + if (options === void 0) { + return; + } + if (currGridRef.current) { - // Grid exists, update it directly - currGridRef.current.update(options, true); + // Declarative React options replace the previous + // snapshot (oneToOne). + currGridRef.current.update(options, true, true); } else { // Grid still initializing, queue the update pendingOptionsRef.current = options; diff --git a/packages/grid-shared-react/src/index.ts b/packages/grid-shared-react/src/index.ts index 6a47b1b..23f0e41 100644 --- a/packages/grid-shared-react/src/index.ts +++ b/packages/grid-shared-react/src/index.ts @@ -12,4 +12,35 @@ import { GridType, GridInstance } from './hooks/useGrid'; import type { GridProps, GridRefHandle } from './components/BaseGrid'; export { BaseGrid }; +export { + Caption, + Data, + ColumnDefaults, + Column, + Description, + Pagination, + Header +} from './components/options'; +export { getChildProps } from './utils/getChildProps'; +export { isObject } from './utils/isObject'; +export { mergeClassNames } from './utils/mergeClassNames'; +export { normalizeChildOptions } from './utils/normalizeChildOptions'; +export { useDeclarativeGridOptions } from './hooks/useDeclarativeGridOptions'; +export type { + CaptionProps, + DescriptionProps, + DataProps, + DataColumns, + DataColumnValue, + ColumnProps, + ColumnOptionsProps, + ColumnDefaultsProps, + ColumnDataType, + ColumnSortingOrder, + CellValueGetterContext, + PaginationProps, + HeaderProps, + GroupedHeaderOptions, + HeaderCellAccessibilityProps +} from './components/options'; export type { GridType, GridInstance, GridProps, GridRefHandle }; diff --git a/packages/grid-shared-react/src/utils/getChildProps.ts b/packages/grid-shared-react/src/utils/getChildProps.ts new file mode 100644 index 0000000..aca622a --- /dev/null +++ b/packages/grid-shared-react/src/utils/getChildProps.ts @@ -0,0 +1,343 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +import { Fragment, isValidElement, ReactElement, ReactNode } from 'react'; +import type { BaseGridOptionsComponent, BaseGridOptions } from '../components/BaseGridOptions'; +import { isObject } from './isObject'; + +function flattenChildren(childNodes: ReactNode): ReactNode[] { + if (childNodes == null || childNodes === false) { + return []; + } + + if (Array.isArray(childNodes)) { + return childNodes.flatMap((child) => flattenChildren(child)); + } + + if (isValidElement(childNodes) && childNodes.type === Fragment) { + const fragmentProps = childNodes.props as { children?: ReactNode }; + return flattenChildren(fragmentProps.children); + } + + return [childNodes]; +} + +function objInsert( + obj: Record, + path: string, + value: unknown +): Record { + const keys = path.split('.'); + let current = obj; + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + + if (key === void 0) { + continue; + } + + if (!isObject(current[key])) { + current[key] = {}; + } + current = current[key] as Record; + } + + const lastKey = keys.at(-1); + + if (lastKey !== void 0) { + current[lastKey] = value; + } + return obj; +} + +function isReactElement(value: unknown): value is ReactElement { + return isValidElement(value); +} + +function getOptionComponent(type: unknown): BaseGridOptionsComponent | null { + if (typeof type !== 'function' && (typeof type !== 'object' || type === null)) { + return null; + } + + const component = type as Partial; + + return component._GridReact ? type as BaseGridOptionsComponent : null; +} + +function getChildPropsFromElement( + child: ReactElement +): Record { + return (child.props ?? {}) as Record; +} + +function renderChildren(children: ReactNode): string { + if (typeof children === 'string' || typeof children === 'number') { + return String(children); + } + + if (Array.isArray(children)) { + return children + .map((child) => renderChildren(child)) + .join(''); + } + + return ''; +} + +function getEffectiveMeta( + component: BaseGridOptionsComponent, + parentMeta?: BaseGridOptions +): BaseGridOptions { + const meta = component._GridReact; + + if (!parentMeta) { + return meta; + } + + return { + ...meta, + childOption: parentMeta.childOption + ? `${parentMeta.childOption}.${meta.childOption ?? ''}` + : meta.childOption, + gridOption: parentMeta.gridOption + ? `${parentMeta.gridOption}.${meta.gridOption}` + : meta.gridOption + }; +} + +function parseColumnElement(child: ReactElement): Record { + const { + children, + id, + columnId, + ...props + } = getChildPropsFromElement(child); + void children; + void id; + + // columnId selects the column; Core expects the same value as `id`. + if (columnId !== void 0) { + props.id = columnId; + } + + return props; +} + +function pushColumn( + optionsFromChildren: Record, + child: ReactElement +): void { + const columns = (optionsFromChildren.columns ?? ( + optionsFromChildren.columns = [] + )) as Record[]; + + columns.push(parseColumnElement(child)); +} + +export function getChildProps(children: ReactNode): Record { + const optionsFromChildren: Record = {}; + const resolvedChildren = flattenChildren(children) + .map((child) => resolveOptionChild(child)) + .filter((child): child is ReactElement => child !== null); + + function handleChildren( + childNodes: ReactNode, + obj: Record, + meta: BaseGridOptions + ): void { + if (childNodes == null || childNodes === false) { + return; + } + + const nonOptionChildren: ReactNode[] = []; + + if (Array.isArray(childNodes)) { + for (const child of childNodes) { + if (isReactElement(child) && isOptionElement(child)) { + handleChild(child, meta); + continue; + } + + nonOptionChildren.push(child); + } + } else if (isReactElement(childNodes) && isOptionElement(childNodes)) { + handleChild(childNodes, meta); + } else { + nonOptionChildren.push(childNodes); + } + + if (meta.childOption) { + const childrenToRender = nonOptionChildren.length > 0 ? + nonOptionChildren : + [childNodes]; + + objInsert(obj, meta.childOption, renderChildren(childrenToRender)); + } + } + + function handleChild( + child: ReactElement, + parentMeta?: BaseGridOptions + ): void { + const component = getOptionComponent(child.type); + + if (!component) { + return; + } + + const meta = getEffectiveMeta(component, parentMeta); + + if (!meta.gridOption) { + return; + } + + const childProps = getChildPropsFromElement(child); + const { children: childChildren, ...props } = childProps; + + if (meta.gridOption === 'columnDefaults') { + optionsFromChildren.columnDefaults = props; + return; + } + + if (meta.gridOption === 'columns') { + pushColumn(optionsFromChildren, child); + return; + } + + if (meta.gridOption === 'pagination') { + optionsFromChildren.pagination = { + ...props, + position: isTopPaginationChild( + child, + resolvedChildren + ) ? 'top' : 'bottom' + }; + return; + } + + if (meta.gridOption === 'header') { + if (props.header !== void 0) { + optionsFromChildren.header = props.header; + } + return; + } + + const optionParent = optionsFromChildren[meta.gridOption] ?? ( + optionsFromChildren[meta.gridOption] = meta.isArrayType ? [] : {} + ); + const parentIsArray = Array.isArray(optionParent); + const insertInto = parentIsArray + ? {} + : optionParent as Record; + + if (meta.defaultOptions) { + Object.assign(insertInto, meta.defaultOptions); + } + + Object.assign(insertInto, props); + + if (typeof childChildren === 'string' || typeof childChildren === 'number') { + if (meta.childOption) { + objInsert(insertInto, meta.childOption, String(childChildren)); + } + } else if (childChildren != null) { + handleChildren(childChildren as ReactNode, insertInto, meta); + } + + if (parentIsArray) { + const optionItems = optionsFromChildren[ + meta.gridOption + ] as unknown[]; + optionItems.push(insertInto); + } + } + + for (const child of resolvedChildren) { + handleChild(child); + } + + applyDeclarativeColumnDefaults(optionsFromChildren); + + return optionsFromChildren; +} + +/** + * When declarative `` components are present, only those columns + * should render unless `data.autogenerateColumns` is set + * explicitly on ``. + */ +function applyDeclarativeColumnDefaults( + optionsFromChildren: Record +): void { + const columns = optionsFromChildren.columns; + + if (!Array.isArray(columns) || columns.length === 0) { + return; + } + + const data = isObject(optionsFromChildren.data) ? + { ...optionsFromChildren.data } : + {}; + + if (!('autogenerateColumns' in data)) { + data.autogenerateColumns = false; + optionsFromChildren.data = data; + } +} + +function isTopPaginationChild( + child: ReactElement, + children: ReactElement[] +): boolean { + const childIndex = children.indexOf(child); + + if (childIndex === -1) { + return false; + } + + return children + .slice(0, childIndex) + .every((candidate) => { + const gridOption = getOptionComponent(candidate.type) + ?._GridReact.gridOption; + return gridOption === 'pagination'; + }); +} + +function isOptionElement(child: ReactElement): boolean { + return getOptionComponent(child.type) !== null; +} + +function resolveOptionChild(child: ReactNode): ReactElement | null { + if (!isReactElement(child)) { + return null; + } + + const component = getOptionComponent(child.type); + + if (component) { + return child; + } + + if (typeof child.type !== 'function') { + return null; + } + + const renderChild = child.type as ( + props: Record + ) => ReactNode; + const rendered = renderChild(getChildPropsFromElement(child)); + + if (isReactElement(rendered) && getOptionComponent(rendered.type)) { + return rendered; + } + + return null; +} diff --git a/packages/grid-shared-react/src/utils/isObject.ts b/packages/grid-shared-react/src/utils/isObject.ts new file mode 100644 index 0000000..636cdf0 --- /dev/null +++ b/packages/grid-shared-react/src/utils/isObject.ts @@ -0,0 +1,14 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +export function isObject( + value: unknown +): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/grid-shared-react/src/utils/mappers/column/columnOptions.ts b/packages/grid-shared-react/src/utils/mappers/column/columnOptions.ts new file mode 100644 index 0000000..f1ae833 --- /dev/null +++ b/packages/grid-shared-react/src/utils/mappers/column/columnOptions.ts @@ -0,0 +1,24 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +import { mapPrefixedProps } from '../mapPrefixedProps'; + +/** Flat prop prefix → nested Grid option key for columns. */ +const COLUMN_PROP_PREFIXES = { + sorting: 'sorting', + filtering: 'filtering', + header: 'header', + cell: 'cells' +} as const; + +export function normalizeColumnOptions( + props: Record +): Record { + return mapPrefixedProps(props, COLUMN_PROP_PREFIXES); +} diff --git a/packages/grid-shared-react/src/utils/mappers/column/index.ts b/packages/grid-shared-react/src/utils/mappers/column/index.ts new file mode 100644 index 0000000..01a620a --- /dev/null +++ b/packages/grid-shared-react/src/utils/mappers/column/index.ts @@ -0,0 +1,10 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +export { normalizeColumnOptions } from './columnOptions'; diff --git a/packages/grid-shared-react/src/utils/mappers/mapPrefixedProps.ts b/packages/grid-shared-react/src/utils/mappers/mapPrefixedProps.ts new file mode 100644 index 0000000..82418e3 --- /dev/null +++ b/packages/grid-shared-react/src/utils/mappers/mapPrefixedProps.ts @@ -0,0 +1,61 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +/** + * Maps flat props with a shared prefix into nested Grid option objects. + * + * Convention: `{prefix}{OptionKey}` → `{groupKey}.{optionKey}` + * + * @example + * mapPrefixedProps( + * { sortingEnabled: true, sortingOrder: 'asc', width: 120 }, + * { sorting: 'sorting' } + * ); + * // => { width: 120, sorting: { enabled: true, order: 'asc' } } + */ +export type PrefixedPropMap = Record; + +export function mapPrefixedProps( + props: Record, + prefixToGroup: PrefixedPropMap +): Record { + const result = { ...props }; + const groups: Record> = {}; + const prefixes = Object.keys(prefixToGroup) + .sort((a, b) => b.length - a.length); + + for (const flatKey of Object.keys(result)) { + const prefix = prefixes.find( + (candidate) => ( + flatKey.startsWith(candidate) + && flatKey.length > candidate.length + ) + ); + + if (!prefix) { + continue; + } + + const groupKey = prefixToGroup[prefix]; + const nestedKey = toNestedKey(flatKey.slice(prefix.length)); + + (groups[groupKey] ??= {})[nestedKey] = result[flatKey]; + delete result[flatKey]; + } + + for (const [groupKey, nested] of Object.entries(groups)) { + result[groupKey] = nested; + } + + return result; +} + +function toNestedKey(segment: string): string { + return segment.charAt(0).toLowerCase() + segment.slice(1); +} diff --git a/packages/grid-shared-react/src/utils/mappers/pagination/index.ts b/packages/grid-shared-react/src/utils/mappers/pagination/index.ts new file mode 100644 index 0000000..de3e9f4 --- /dev/null +++ b/packages/grid-shared-react/src/utils/mappers/pagination/index.ts @@ -0,0 +1,10 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +export { normalizePaginationOptions } from './paginationOptions'; diff --git a/packages/grid-shared-react/src/utils/mappers/pagination/paginationOptions.ts b/packages/grid-shared-react/src/utils/mappers/pagination/paginationOptions.ts new file mode 100644 index 0000000..6730758 --- /dev/null +++ b/packages/grid-shared-react/src/utils/mappers/pagination/paginationOptions.ts @@ -0,0 +1,133 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +function withClassName( + value: unknown, + className: string | undefined +): unknown { + if (className === void 0) { + return value; + } + + // Disabled control is not rendered — keep boolean, drop className. + if (value === false) { + return false; + } + + if (value !== null && typeof value === 'object') { + return { ...value as Record, className }; + } + + return { + enabled: value === void 0 ? true : Boolean(value), + className + }; +} + +export function normalizePaginationOptions( + props: Record +): Record { + const { + pageInfo, + pageSizeSelector, + pageSizeOptions, + pageButtons, + pageButtonsCount, + firstLast, + previousNext, + enabled, + page, + pageSize, + align, + className, + infoClassName, + controlsClassName, + sizeClassName, + ...rest + } = props; + + const result: Record = { + enabled: enabled ?? true + }; + + if (page !== void 0) { + result.page = page; + } + if (pageSize !== void 0) { + result.pageSize = pageSize; + } + if (align !== void 0) { + result.align = align; + } + if (typeof className === 'string') { + result.className = className; + } + + const controls: Record = {}; + + if (typeof controlsClassName === 'string') { + controls.className = controlsClassName; + } + + const pageInfoValue = withClassName(pageInfo, asString(infoClassName)); + if (pageInfoValue !== void 0) { + controls.pageInfo = pageInfoValue; + } + + let pageSizeSelectorValue: unknown = pageSizeSelector; + + if (pageSizeSelector === false) { + pageSizeSelectorValue = false; + } else if (pageSizeOptions !== void 0) { + pageSizeSelectorValue = { + enabled: true, + options: pageSizeOptions + }; + } else if (pageSizeSelector !== void 0) { + pageSizeSelectorValue = pageSizeSelector; + } + + pageSizeSelectorValue = withClassName( + pageSizeSelectorValue, + asString(sizeClassName) + ); + + if (pageSizeSelectorValue !== void 0) { + controls.pageSizeSelector = pageSizeSelectorValue; + } + + if (pageButtons === false) { + controls.pageButtons = false; + } else if (pageButtonsCount !== void 0) { + controls.pageButtons = { + enabled: true, + count: pageButtonsCount + }; + } else if (pageButtons !== void 0) { + controls.pageButtons = pageButtons; + } + + if (firstLast !== void 0) { + controls.firstLastButtons = firstLast; + } + + if (previousNext !== void 0) { + controls.previousNextButtons = previousNext; + } + + if (Object.keys(controls).length > 0) { + result.controls = controls; + } + + return { ...result, ...rest }; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : void 0; +} diff --git a/packages/grid-shared-react/src/utils/mergeClassNames.ts b/packages/grid-shared-react/src/utils/mergeClassNames.ts new file mode 100644 index 0000000..d4f6737 --- /dev/null +++ b/packages/grid-shared-react/src/utils/mergeClassNames.ts @@ -0,0 +1,32 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +/** + * Joins CSS class tokens from defaults and overrides without duplicates. + * Used so ColumnDefaults `*ClassName` still applies when a Column sets its + * own `*ClassName`. + */ +export function mergeClassNames( + ...classNames: Array<(string | undefined | null)> +): string | undefined { + const tokens: string[] = []; + + for (const value of classNames) { + if (typeof value !== 'string' || !value.trim()) { + continue; + } + for (const token of value.trim().split(/\s+/)) { + if (token && !tokens.includes(token)) { + tokens.push(token); + } + } + } + + return tokens.length ? tokens.join(' ') : void 0; +} diff --git a/packages/grid-shared-react/src/utils/normalizeChildOptions.ts b/packages/grid-shared-react/src/utils/normalizeChildOptions.ts new file mode 100644 index 0000000..5eb09b7 --- /dev/null +++ b/packages/grid-shared-react/src/utils/normalizeChildOptions.ts @@ -0,0 +1,142 @@ +/** + * Grid React integration. + * Copyright (c) 2025, Highsoft + * + * A valid license is required for using this software. + * See highcharts.com/license + * + */ + +import { isObject } from './isObject'; +import { mergeClassNames } from './mergeClassNames'; +import { normalizeColumnOptions } from './mappers/column'; +import { normalizePaginationOptions } from './mappers/pagination'; + +/** + * Maps raw declarative child options onto nested Grid option paths. + * Used by lite and pro build pipelines after `getChildProps`. + * + * Also merges `columnDefaults` `className` / `header.className` / + * `cells.className` into each column so Column overrides do not wipe + * defaults (same semantics as Core `createOptionsProxy`). + * + * Lifts `rowClassName` / `evenRowClassName` from ColumnDefaults onto + * `rendering.rows` (not column options). + */ +export function normalizeChildOptions( + raw: Record +): Record { + const result = { ...raw }; + + if (isObject(result.columnDefaults)) { + const defaults = { ...result.columnDefaults }; + const rowClassName = defaults.rowClassName; + const evenRowClassName = defaults.evenRowClassName; + delete defaults.rowClassName; + delete defaults.evenRowClassName; + + result.columnDefaults = normalizeColumnOptions(defaults); + + const rows: Record = {}; + if (typeof rowClassName === 'string') { + rows.className = rowClassName; + } + if (typeof evenRowClassName === 'string') { + rows.evenClassName = evenRowClassName; + } + + if (Object.keys(rows).length) { + const rendering = isObject(result.rendering) ? + { ...result.rendering } : + {}; + const existingRows = isObject(rendering.rows) ? + { ...rendering.rows as Record } : + {}; + rendering.rows = { ...existingRows, ...rows }; + result.rendering = rendering; + } + } + + if (Array.isArray(result.columns)) { + const defaults = isObject(result.columnDefaults) ? + result.columnDefaults as Record : + void 0; + + result.columns = result.columns.map((column) => { + if (!isObject(column)) { + return column; + } + + const normalized = normalizeColumnOptions({ ...column }); + return defaults ? + mergeColumnClassNames(normalized, defaults) : + normalized; + }); + } + + if (isObject(result.pagination)) { + const pagination = { ...result.pagination }; + const { position, ...props } = pagination; + const normalized = normalizePaginationOptions(props); + + if (position !== void 0) { + normalized.position = position; + } + + result.pagination = normalized; + } + + return result; +} + +function mergeColumnClassNames( + column: Record, + defaults: Record +): Record { + const result = { ...column }; + const className = mergeClassNames( + asString(defaults.className), + asString(column.className) + ); + + if (className !== void 0) { + result.className = className; + } + + result.header = mergeNestedClassName( + column.header, + defaults.header + ); + result.cells = mergeNestedClassName( + column.cells, + defaults.cells + ); + + return result; +} + +function mergeNestedClassName( + target: unknown, + defaults: unknown +): Record | unknown { + const targetObj = isObject(target) ? { ...target } : {}; + const defaultsObj = isObject(defaults) ? defaults : {}; + const className = mergeClassNames( + asString(defaultsObj.className), + asString(targetObj.className) + ); + + if (className === void 0 && !isObject(target)) { + return target; + } + + if (className !== void 0) { + targetObj.className = className; + } + + return targetObj; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : void 0; +} diff --git a/packages/grid-shared-react/src/test/createGridTests.tsx b/packages/grid-shared-react/tests/createGridTests.tsx similarity index 73% rename from packages/grid-shared-react/src/test/createGridTests.tsx rename to packages/grid-shared-react/tests/createGridTests.tsx index 1faed12..29a1c7d 100644 --- a/packages/grid-shared-react/src/test/createGridTests.tsx +++ b/packages/grid-shared-react/tests/createGridTests.tsx @@ -5,18 +5,23 @@ import { type ComponentType } from 'react'; import { describe, it, expect, vi } from 'vitest'; -import { GridProps, GridRefHandle } from '../components/BaseGrid'; -import { GridInstance } from '../hooks/useGrid'; +import { GridProps, GridRefHandle } from '../src/components/BaseGrid'; +import { GridInstance } from '../src/hooks/useGrid'; /** * Creates a standard test suite for a Grid component. - * Use this to avoid duplicating tests between grid-lite-react and grid-pro-react. + * Use this to avoid duplicating tests between + * grid-lite-react and grid-pro-react. */ -export function createGridTests( +export function createGridTests< + TOptions, + TComponentProps extends Record = Record +>( name: string, - GridComponent: ComponentType>, + GridComponent: ComponentType & TComponentProps>, testOptions: TOptions, - updatedOptions: TOptions + updatedOptions: TOptions, + componentProps?: TComponentProps ) { describe(name, () => { @@ -28,7 +33,11 @@ export function createGridTests( }; const { container } = render( - + ); expect(container.firstChild).toBeInstanceOf(HTMLDivElement); @@ -46,6 +55,7 @@ export function createGridTests( gridRef = useRef>(null); return ( { initialized = true; }} @@ -63,7 +73,13 @@ export function createGridTests( it('calls callback when grid is initialized', async () => { const callback = vi.fn(); - render(); + render( + + ); await waitFor(() => { expect(callback).toHaveBeenCalled(); @@ -82,7 +98,11 @@ export function createGridTests( return ( <> - +