├── .npmrc ├── apps ├── docs │ ├── pages │ │ ├── getting-started │ │ │ ├── vue.mdx │ │ │ ├── solid.mdx │ │ │ ├── svelte.mdx │ │ │ ├── _meta.json │ │ │ └── react.mdx │ │ ├── api-reference │ │ │ ├── _meta.json │ │ │ └── react │ │ │ │ ├── _meta.json │ │ │ │ ├── grid.mdx │ │ │ │ ├── load-more-trigger.mdx │ │ │ │ └── use-grid.mdx │ │ ├── index.mdx │ │ ├── _meta.json │ │ └── _app.tsx │ ├── .eslintrc.js │ ├── next-env.d.ts │ ├── tsconfig.json │ ├── next.config.js │ ├── .gitignore │ ├── theme.config.jsx │ └── package.json └── web │ ├── next.config.js │ ├── public │ └── favicon.ico │ ├── app │ ├── twitter-image.png │ ├── opengraph-image.png │ ├── page.tsx │ ├── layout.tsx │ └── globals.css │ ├── postcss.config.js │ ├── .eslintrc.js │ ├── lib │ └── utils.ts │ ├── next-env.d.ts │ ├── tsconfig.json │ ├── components.json │ ├── components │ ├── controls │ │ ├── controls-section-field.tsx │ │ ├── controls-mobile.tsx │ │ ├── controls.params.ts │ │ ├── controls-section.tsx │ │ ├── controls-desktop.tsx │ │ └── controls.tsx │ ├── usage │ │ ├── with-component.ts │ │ ├── index.tsx │ │ └── headless.tsx │ ├── ui │ │ ├── label.tsx │ │ ├── separator.tsx │ │ ├── input.tsx │ │ ├── checkbox.tsx │ │ ├── tooltip.tsx │ │ ├── code.tsx │ │ ├── popover.tsx │ │ ├── number-input.tsx │ │ ├── button.tsx │ │ ├── tabs.tsx │ │ ├── copy-button.tsx │ │ ├── drawer.tsx │ │ └── select.tsx │ ├── hero │ │ ├── logo-art.tsx │ │ ├── installation.tsx │ │ └── index.tsx │ └── demo │ │ ├── index.tsx │ │ └── window.tsx │ ├── .gitignore │ ├── CHANGELOG.md │ ├── package.json │ └── tailwind.config.js ├── examples └── react │ ├── fixed │ ├── src │ │ ├── vite-env.d.ts │ │ ├── main.tsx │ │ ├── globals.css │ │ ├── components │ │ │ ├── section.tsx │ │ │ ├── grid-columns │ │ │ │ ├── with-component.tsx │ │ │ │ └── headless.tsx │ │ │ ├── grid │ │ │ │ ├── with-component.tsx │ │ │ │ └── headless.tsx │ │ │ ├── list │ │ │ │ ├── with-component.tsx │ │ │ │ └── headless.tsx │ │ │ ├── grid-rows │ │ │ │ ├── with-component.tsx │ │ │ │ └── headless.tsx │ │ │ ├── grid-auto │ │ │ │ ├── with-component.tsx │ │ │ │ └── headless.tsx │ │ │ └── columns │ │ │ │ ├── with-component.tsx │ │ │ │ └── headless.tsx │ │ └── App.tsx │ ├── vite.config.ts │ ├── tsconfig.node.json │ ├── .gitignore │ ├── index.html │ ├── .eslintrc.cjs │ ├── CHANGELOG.md │ ├── tsconfig.json │ ├── package.json │ ├── README.md │ └── public │ │ └── vite.svg │ ├── scroll-margin │ ├── src │ │ ├── vite-env.d.ts │ │ ├── main.tsx │ │ ├── globals.css │ │ ├── App.tsx │ │ └── components │ │ │ ├── section.tsx │ │ │ ├── headless-vertical.tsx │ │ │ └── headless-horizontal.tsx │ ├── vite.config.ts │ ├── tsconfig.node.json │ ├── .gitignore │ ├── index.html │ ├── .eslintrc.cjs │ ├── CHANGELOG.md │ ├── tsconfig.json │ ├── package.json │ ├── README.md │ └── public │ │ └── vite.svg │ └── infinite-scroll │ ├── src │ ├── vite-env.d.ts │ ├── util │ │ └── fetch.ts │ ├── main.tsx │ ├── globals.css │ ├── components │ │ ├── section.tsx │ │ ├── grid │ │ │ ├── with-component.tsx │ │ │ └── headless.tsx │ │ ├── rows │ │ │ ├── with-component.tsx │ │ │ └── headless.tsx │ │ └── columns │ │ │ ├── with-component.tsx │ │ │ └── headless.tsx │ └── App.tsx │ ├── vite.config.ts │ ├── tsconfig.node.json │ ├── .gitignore │ ├── index.html │ ├── .eslintrc.cjs │ ├── CHANGELOG.md │ ├── tsconfig.json │ ├── package.json │ ├── README.md │ └── public │ └── vite.svg ├── assets └── hero.png ├── packages ├── shared │ ├── src │ │ ├── index.ts │ │ ├── types.ts │ │ └── util.ts │ ├── README.md │ ├── .eslintrc.js │ ├── tsconfig.json │ ├── tsup.config.ts │ ├── package.json │ └── CHANGELOG.md ├── react │ ├── src │ │ ├── components │ │ │ ├── index.tsx │ │ │ ├── load-more-trigger.tsx │ │ │ └── grid.tsx │ │ ├── index.tsx │ │ ├── useScrollMargin.tsx │ │ └── useGrid.tsx │ ├── tsconfig.json │ ├── tsup.config.ts │ ├── .eslintrc.js │ ├── README.md │ ├── package.json │ └── CHANGELOG.md ├── core │ ├── README.md │ ├── .eslintrc.js │ ├── src │ │ ├── index.ts │ │ ├── utils │ │ │ └── types.ts │ │ ├── types.ts │ │ └── grid.ts │ ├── tsconfig.json │ ├── tsup.config.ts │ ├── package.json │ └── CHANGELOG.md └── config │ ├── tsup │ ├── index.d.ts │ ├── index.js │ └── package.json │ ├── tsconfig │ ├── package.json │ ├── react-library.json │ ├── base.json │ └── nextjs.json │ └── eslint-config │ ├── next.js │ ├── react.js │ ├── package.json │ └── base.js ├── pnpm-workspace.yaml ├── .vscode └── settings.json ├── .changeset ├── config.json └── README.md ├── .prettierrc.cjs ├── .gitignore ├── README.MD ├── turbo.json ├── LICENSE.md ├── package.json └── .github └── workflows └── release.yml /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers = true 2 | -------------------------------------------------------------------------------- /apps/docs/pages/getting-started/vue.mdx: -------------------------------------------------------------------------------- 1 | # Vue (Coming Soon) 2 | -------------------------------------------------------------------------------- /apps/docs/pages/api-reference/_meta.json: -------------------------------------------------------------------------------- 1 | { "react": "React" } 2 | -------------------------------------------------------------------------------- /apps/docs/pages/getting-started/solid.mdx: -------------------------------------------------------------------------------- 1 | # Solid (Coming Soon) 2 | -------------------------------------------------------------------------------- /apps/docs/pages/getting-started/svelte.mdx: -------------------------------------------------------------------------------- 1 | # Svelte (Coming Soon) 2 | -------------------------------------------------------------------------------- /apps/web/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reactStrictMode: true 3 | }; 4 | -------------------------------------------------------------------------------- /examples/react/fixed/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /assets/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/niikeec/virtual-grid/HEAD/assets/hero.png -------------------------------------------------------------------------------- /examples/react/scroll-margin/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/shared/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './util'; 3 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'apps/*' 3 | - 'packages/**/*' 4 | - 'examples/**/*' 5 | -------------------------------------------------------------------------------- /packages/react/src/components/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './grid'; 2 | export * from './load-more-trigger'; 3 | -------------------------------------------------------------------------------- /apps/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/niikeec/virtual-grid/HEAD/apps/web/public/favicon.ico -------------------------------------------------------------------------------- /apps/docs/pages/index.mdx: -------------------------------------------------------------------------------- 1 | Simplified virtualization using [@tanstack/virtual](https://tanstack.com/virtual/v3). 2 | -------------------------------------------------------------------------------- /apps/web/app/twitter-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/niikeec/virtual-grid/HEAD/apps/web/app/twitter-image.png -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | ## @virtual-grid/core 2 | 3 | [Docs](https://docs.virtual-grid.com/getting-started/react) 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [ 3 | { 4 | "mode": "auto" 5 | } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /apps/web/app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/niikeec/virtual-grid/HEAD/apps/web/app/opengraph-image.png -------------------------------------------------------------------------------- /packages/shared/README.md: -------------------------------------------------------------------------------- 1 | ## @virtual-grid/shared 2 | 3 | [Docs](https://docs.virtual-grid.com/getting-started/react) 4 | -------------------------------------------------------------------------------- /packages/core/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['@virtual-grid/eslint-config/base'] 4 | }; 5 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './grid'; 2 | export type { GridItem, GridItemData, GridItemId } from './types'; 3 | -------------------------------------------------------------------------------- /packages/shared/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['@virtual-grid/eslint-config/base'] 4 | }; 5 | -------------------------------------------------------------------------------- /apps/web/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /apps/docs/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['@virtual-grid/eslint-config/base', '@virtual-grid/eslint-config/next'] 4 | }; 5 | -------------------------------------------------------------------------------- /apps/docs/pages/api-reference/react/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "use-grid": "useGrid", 3 | "grid": "Grid", 4 | "load-more-trigger": "LoadMoreTrigger" 5 | } 6 | -------------------------------------------------------------------------------- /apps/web/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['@virtual-grid/eslint-config/base', '@virtual-grid/eslint-config/next'] 4 | }; 5 | -------------------------------------------------------------------------------- /packages/config/tsup/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@virtual-grid/tsup-config' { 2 | import { Options } from 'tsup'; 3 | export const config: Options; 4 | } 5 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@virtual-grid/tsconfig/base.json", 3 | "include": [".", ".eslintrc.js"], 4 | "exclude": ["dist", "node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@virtual-grid/tsconfig/base.json", 3 | "include": [".", ".eslintrc.js"], 4 | "exclude": ["dist", "node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@virtual-grid/tsconfig/react-library.json", 3 | "include": [".", ".eslintrc.js"], 4 | "exclude": ["dist", "node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /apps/docs/pages/getting-started/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "react": "React", 3 | "solid": "Solid (Coming Soon)", 4 | "svelte": "Svelte (Coming Soon)", 5 | "vue": "Vue (Coming Soon)" 6 | } 7 | -------------------------------------------------------------------------------- /packages/react/src/index.tsx: -------------------------------------------------------------------------------- 1 | export { useVirtualizer } from '@tanstack/react-virtual'; 2 | export * from './components'; 3 | export * from './useGrid'; 4 | export * from './useScrollMargin'; 5 | -------------------------------------------------------------------------------- /packages/config/tsup/index.js: -------------------------------------------------------------------------------- 1 | /** @type {import("tsup").Options} */ 2 | const config = { 3 | sourcemap: true, 4 | dts: true, 5 | format: ['esm', 'cjs'] 6 | }; 7 | 8 | module.exports = { config }; 9 | -------------------------------------------------------------------------------- /apps/web/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /examples/react/fixed/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /examples/react/scroll-margin/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /apps/docs/pages/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "index": { 3 | "title": "Introduction" 4 | }, 5 | "getting-started": "Getting Started", 6 | "--1": { 7 | "type": "separator" 8 | }, 9 | "api-reference": "API Reference" 10 | } 11 | -------------------------------------------------------------------------------- /packages/core/src/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type RequireAtLeastOne = Pick> & 2 | { 3 | [K in Keys]-?: Required> & Partial>>; 4 | }[Keys]; 5 | -------------------------------------------------------------------------------- /packages/config/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@virtual-grid/tsconfig", 3 | "version": "0.0.0", 4 | "private": true, 5 | "files": [ 6 | "base.json", 7 | "nextjs.json", 8 | "react-library.json" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /apps/docs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. 6 | -------------------------------------------------------------------------------- /apps/web/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. 6 | -------------------------------------------------------------------------------- /packages/core/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | import { config } from '@virtual-grid/tsup-config'; 4 | 5 | export default defineConfig((opts) => ({ 6 | ...config, 7 | entry: ['./src/index.ts'], 8 | clean: !opts.watch 9 | })); 10 | -------------------------------------------------------------------------------- /packages/shared/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | import { config } from '@virtual-grid/tsup-config'; 4 | 5 | export default defineConfig((opts) => ({ 6 | ...config, 7 | entry: ['./src/index.ts'], 8 | clean: !opts.watch 9 | })); 10 | -------------------------------------------------------------------------------- /packages/config/eslint-config/next.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | const config = { 3 | extends: ['plugin:@next/next/recommended'], 4 | rules: { 5 | '@next/next/no-html-link-for-pages': 'off' 6 | } 7 | }; 8 | 9 | module.exports = config; 10 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/src/util/fetch.ts: -------------------------------------------------------------------------------- 1 | export const fetchServerPage = async (limit: number, offset: number = 0) => { 2 | const data = new Array(limit).fill(0); 3 | 4 | await new Promise((r) => setTimeout(r, 500)); 5 | 6 | return { data, nextOffset: offset + 1 }; 7 | }; 8 | -------------------------------------------------------------------------------- /packages/config/tsup/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@virtual-grid/tsup-config", 3 | "version": "0.0.0", 4 | "private": true, 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "devDependencies": { 8 | "@types/node": "20.8.9", 9 | "tsup": "8.2.4" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /apps/docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@virtual-grid/tsconfig/nextjs.json", 3 | "compilerOptions": { 4 | "plugins": [{ "name": "next" }] 5 | }, 6 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "theme.config.jsx"], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /examples/react/fixed/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /examples/react/scroll-margin/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/docs/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true 4 | }; 5 | 6 | const withNextra = require('nextra')({ 7 | theme: 'nextra-theme-docs', 8 | themeConfig: './theme.config.jsx' 9 | }); 10 | 11 | module.exports = withNextra(nextConfig); 12 | -------------------------------------------------------------------------------- /examples/react/fixed/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | 4 | import { App } from './App.tsx'; 5 | 6 | import './globals.css'; 7 | 8 | ReactDOM.createRoot(document.getElementById('root')!).render( 9 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /packages/config/tsconfig/react-library.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "React Library", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "jsx": "react-jsx", 7 | "lib": ["ES2015", "DOM"], 8 | "module": "ESNext", 9 | "target": "es6" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/react/scroll-margin/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | 4 | import App from './App.tsx'; 5 | 6 | import './globals.css'; 7 | 8 | ReactDOM.createRoot(document.getElementById('root')!).render( 9 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /apps/docs/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from 'next/app'; 2 | import { Analytics } from '@vercel/analytics/react'; 3 | 4 | function MyApp({ Component, pageProps }: AppProps) { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | ); 11 | } 12 | 13 | export default MyApp; 14 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | 4 | import { App } from './App.tsx'; 5 | 6 | import './globals.css'; 7 | 8 | ReactDOM.createRoot(document.getElementById('root')!).render( 9 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@virtual-grid/tsconfig/nextjs.json", 3 | "compilerOptions": { 4 | "plugins": [{ "name": "next" }], 5 | "baseUrl": ".", 6 | "paths": { 7 | "@/*": ["./*"] 8 | } 9 | }, 10 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".eslintrc.js"], 11 | "exclude": ["node_modules"] 12 | } 13 | -------------------------------------------------------------------------------- /examples/react/fixed/src/globals.css: -------------------------------------------------------------------------------- 1 | .list { 2 | border: 1px solid gray; 3 | } 4 | 5 | .item-even, 6 | .item-odd { 7 | height: 100%; 8 | width: 100%; 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | } 13 | 14 | .item-odd { 15 | background-color: #eeece5; 16 | } 17 | 18 | .item-even { 19 | background-color: #dedbd0; 20 | } 21 | -------------------------------------------------------------------------------- /packages/react/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | import { config } from '@virtual-grid/tsup-config'; 4 | 5 | export default defineConfig((opts) => ({ 6 | ...config, 7 | entry: ['./src/index.tsx'], 8 | clean: !opts.watch, 9 | esbuildOptions: (option) => { 10 | option.banner = { 11 | js: `"use client";` 12 | }; 13 | } 14 | })); 15 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/src/globals.css: -------------------------------------------------------------------------------- 1 | .list { 2 | border: 1px solid gray; 3 | } 4 | 5 | .item-even, 6 | .item-odd { 7 | height: 100%; 8 | width: 100%; 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | } 13 | 14 | .item-odd { 15 | background-color: #eeece5; 16 | } 17 | 18 | .item-even { 19 | background-color: #dedbd0; 20 | } 21 | -------------------------------------------------------------------------------- /examples/react/scroll-margin/src/globals.css: -------------------------------------------------------------------------------- 1 | .list { 2 | border: 1px solid gray; 3 | } 4 | 5 | .item-even, 6 | .item-odd { 7 | height: 100%; 8 | width: 100%; 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | } 13 | 14 | .item-odd { 15 | background-color: #eeece5; 16 | } 17 | 18 | .item-even { 19 | background-color: #dedbd0; 20 | } 21 | -------------------------------------------------------------------------------- /examples/react/fixed/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /apps/web/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "app/globals.css", 9 | "baseColor": "stone", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/react/scroll-margin/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /packages/react/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | '@virtual-grid/eslint-config/base', 5 | '@virtual-grid/eslint-config/react' 6 | ], 7 | rules: { 8 | 'react-hooks/exhaustive-deps': [ 9 | 'warn', 10 | { 11 | additionalHooks: 12 | '(useDeepCompareEffect|useDeepCompareCallback|useDeepCompareMemo)' 13 | } 14 | ] 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /apps/web/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Suspense } from 'react'; 4 | import { Demo } from '@/components/demo'; 5 | import { Hero } from '@/components/hero'; 6 | 7 | export default function Page(): JSX.Element { 8 | return ( 9 |
10 | 11 | 12 | 13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /examples/react/fixed/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/react/scroll-margin/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /apps/web/components/controls/controls-section-field.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { PropsWithChildren } from 'react'; 4 | 5 | import { Label } from '../ui/label'; 6 | 7 | export function ControlsSectionField({ 8 | label, 9 | children 10 | }: PropsWithChildren<{ label: string }>) { 11 | return ( 12 |
13 | 14 |
{children}
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /packages/config/eslint-config/react.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | const config = { 3 | extends: ['plugin:react/recommended', 'plugin:react-hooks/recommended'], 4 | rules: { 5 | 'react/prop-types': 'off', 6 | 'react-hooks/exhaustive-deps': 'warn' 7 | }, 8 | globals: { 9 | React: 'writable' 10 | }, 11 | settings: { 12 | react: { 13 | version: 'detect' 14 | } 15 | }, 16 | env: { 17 | browser: true 18 | } 19 | }; 20 | 21 | module.exports = config; 22 | -------------------------------------------------------------------------------- /examples/react/fixed/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended' 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true } 16 | ] 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /examples/react/scroll-margin/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { HeadlessHorizontal } from './components/headless-horizontal'; 2 | import { HeadlessVertical } from './components/headless-vertical'; 3 | import { Section } from './components/section'; 4 | 5 | function App() { 6 | return ( 7 | <> 8 |
9 | 10 |
11 | 12 |
13 | 14 |
15 | 16 | ); 17 | } 18 | 19 | export default App; 20 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config} */ 2 | const config = { 3 | singleQuote: true, 4 | trailingComma: 'none', 5 | importOrder: [ 6 | '^(react/(.*)$)|^(react$)', 7 | '^(next/(.*)$)|^(next$)', 8 | '', 9 | '', 10 | '^@virtual-grid/(.*)$', 11 | '', 12 | '^~/', 13 | '^[../]', 14 | '^[./]' 15 | ], 16 | importOrderTypeScriptVersion: '4.4.0', 17 | plugins: ['@ianvs/prettier-plugin-sort-imports', 'prettier-plugin-tailwindcss'] 18 | }; 19 | 20 | module.exports = config; 21 | -------------------------------------------------------------------------------- /examples/react/fixed/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # react-fixed 2 | 3 | ## 0.0.4 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [cd26778] 8 | - @virtual-grid/react@2.0.3 9 | 10 | ## 0.0.3 11 | 12 | ### Patch Changes 13 | 14 | - Updated dependencies [3473540] 15 | - @virtual-grid/react@2.0.2 16 | 17 | ## 0.0.2 18 | 19 | ### Patch Changes 20 | 21 | - Updated dependencies [62cfcf9] 22 | - @virtual-grid/react@2.0.1 23 | 24 | ## 0.0.1 25 | 26 | ### Patch Changes 27 | 28 | - Updated dependencies [6002f32] 29 | - @virtual-grid/react@2.0.0 30 | -------------------------------------------------------------------------------- /examples/react/fixed/src/components/section.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | 3 | export const Section = ({ 4 | name, 5 | children 6 | }: PropsWithChildren<{ name: string }>) => { 7 | return ( 8 |
9 |

{name}

10 |
18 | {children} 19 |
20 |
21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /examples/react/scroll-margin/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /apps/docs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /apps/web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /examples/react/scroll-margin/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # scroll-margin 2 | 3 | ## 0.0.4 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [cd26778] 8 | - @virtual-grid/react@2.0.3 9 | 10 | ## 0.0.3 11 | 12 | ### Patch Changes 13 | 14 | - Updated dependencies [3473540] 15 | - @virtual-grid/react@2.0.2 16 | 17 | ## 0.0.2 18 | 19 | ### Patch Changes 20 | 21 | - Updated dependencies [62cfcf9] 22 | - @virtual-grid/react@2.0.1 23 | 24 | ## 0.0.1 25 | 26 | ### Patch Changes 27 | 28 | - Updated dependencies [6002f32] 29 | - @virtual-grid/react@2.0.0 30 | -------------------------------------------------------------------------------- /apps/web/components/usage/with-component.ts: -------------------------------------------------------------------------------- 1 | export const withComponent = `import * as React from 'react'; 2 | import { Grid, useGrid } from '@virtual-grid/react'; 3 | 4 | const App = () => { 5 | const ref = React.useRef(null); 6 | 7 | const grid = useGrid({ 8 | scrollRef: ref, 9 | count: 1000, 10 | size: 120 11 | // ... 12 | }); 13 | 14 | return ( 15 |
16 | {(i) =>
...
}
17 |
18 | ); 19 | };`; 20 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # infinite-scroll 2 | 3 | ## 0.0.4 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [cd26778] 8 | - @virtual-grid/react@2.0.3 9 | 10 | ## 0.0.3 11 | 12 | ### Patch Changes 13 | 14 | - Updated dependencies [3473540] 15 | - @virtual-grid/react@2.0.2 16 | 17 | ## 0.0.2 18 | 19 | ### Patch Changes 20 | 21 | - Updated dependencies [62cfcf9] 22 | - @virtual-grid/react@2.0.1 23 | 24 | ## 0.0.1 25 | 26 | ### Patch Changes 27 | 28 | - Updated dependencies [6002f32] 29 | - @virtual-grid/react@2.0.0 30 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/src/components/section.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | 3 | export const Section = ({ 4 | name, 5 | children 6 | }: PropsWithChildren<{ name: string }>) => { 7 | return ( 8 |
9 |

{name}

10 |
18 | {children} 19 |
20 |
21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /examples/react/scroll-margin/src/components/section.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | 3 | export const Section = ({ 4 | name, 5 | children 6 | }: PropsWithChildren<{ name: string }>) => { 7 | return ( 8 |
9 |

{name}

10 |
18 | {children} 19 |
20 |
21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | dist 20 | 21 | # debug 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # local env files 27 | .env 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # turbo 34 | .turbo 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | ![hero](./assets/hero.png) 2 | 3 |

Virtual Grid

4 |

5 | Simplified virtualization using @tanstack/virtual. 6 |
7 |
8 | Website · 9 | Docs 10 |

11 | 12 | ## Contributing 13 | 14 | Contributions are welcome! Please feel free to submit a Pull Request. 15 | 16 | ## License 17 | 18 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 19 | -------------------------------------------------------------------------------- /packages/config/eslint-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@virtual-grid/eslint-config", 3 | "version": "0.0.0", 4 | "private": true, 5 | "devDependencies": { 6 | "@next/eslint-plugin-next": "^14.2.8", 7 | "@types/eslint": "^8.44.6", 8 | "@typescript-eslint/eslint-plugin": "^6.9.0", 9 | "@typescript-eslint/parser": "^6.9.0", 10 | "eslint-config-prettier": "^9.0.0", 11 | "eslint-config-turbo": "^1.10.16", 12 | "eslint-plugin-import": "^2.29.0", 13 | "eslint-plugin-react": "^7.33.2", 14 | "eslint-plugin-react-hooks": "^4.6.0", 15 | "typescript": "^4.5.2" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/config/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "composite": false, 6 | "declaration": true, 7 | "declarationMap": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "inlineSources": false, 11 | "isolatedModules": true, 12 | "moduleResolution": "node", 13 | "noUnusedLocals": false, 14 | "noUnusedParameters": false, 15 | "preserveWatchOutput": true, 16 | "skipLibCheck": true, 17 | "strict": true 18 | }, 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/core/src/types.ts: -------------------------------------------------------------------------------- 1 | export type GridItemId = number | string; 2 | 3 | export type GridItemData = unknown; 4 | 5 | export type GridPadding = { 6 | x?: number; 7 | y?: number; 8 | top?: number; 9 | bottom?: number; 10 | left?: number; 11 | right?: number; 12 | }; 13 | 14 | export type GridGap = { 15 | x?: number; 16 | y?: number; 17 | }; 18 | 19 | export type GridItem< 20 | IdT extends GridItemId = number, 21 | DataT extends GridItemData = undefined 22 | > = { 23 | index: number; 24 | id: IdT; 25 | row: number; 26 | column: number; 27 | rect: Omit; 28 | data: DataT; 29 | }; 30 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "globalDependencies": ["**/.env.*local"], 4 | "pipeline": { 5 | "topo": { 6 | "dependsOn": ["^topo"] 7 | }, 8 | "build": { 9 | "dependsOn": ["^build"], 10 | "outputs": ["dist/**", ".next/**", "!.next/cache/**"] 11 | }, 12 | "lint": { 13 | "dependsOn": ["^topo", "^build"], 14 | "outputs": [] 15 | }, 16 | "typecheck": { 17 | "dependsOn": ["^topo", "^build"] 18 | }, 19 | "dev": { 20 | "cache": false, 21 | "persistent": true 22 | }, 23 | "clean": { 24 | "cache": false 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/docs/theme.config.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('nextra-theme-docs').DocsThemeConfig} 3 | */ 4 | export default { 5 | logo: Virtual Grid, 6 | project: { 7 | link: 'https://github.com/niikeec/virtual-grid' 8 | }, 9 | docsRepositoryBase: 'https://github.com/niikeec/virtual-grid/apps/docs', 10 | useNextSeoProps() { 11 | return { 12 | titleTemplate: '%s – Virtual Grid' 13 | }; 14 | }, 15 | nextThemes: { 16 | defaultTheme: 'light' 17 | }, 18 | editLink: { 19 | component: () => null 20 | }, 21 | feedback: { 22 | content: () => null 23 | }, 24 | footer: { component: () => null }, 25 | primaryHue: 30, 26 | primarySaturation: 100 27 | }; 28 | -------------------------------------------------------------------------------- /examples/react/fixed/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /examples/react/scroll-margin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /apps/web/components/controls/controls-mobile.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Drawer, DrawerContent, DrawerFooter } from '@/components/ui/drawer'; 4 | import { isMobile } from 'react-device-detect'; 5 | 6 | import { Button } from '../ui/button'; 7 | import { Controls } from './controls'; 8 | import { useControls } from './controls.params'; 9 | 10 | export function ControlsMobile() { 11 | const { reset } = useControls(); 12 | 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /packages/config/tsconfig/nextjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Next.js", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "target": "es5", 7 | "lib": ["dom", "dom.iterable", "esnext"], 8 | "allowJs": true, 9 | "declaration": false, 10 | "declarationMap": false, 11 | "skipLibCheck": true, 12 | "strict": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "noEmit": true, 15 | "incremental": true, 16 | "esModuleInterop": true, 17 | "module": "esnext", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "jsx": "preserve" 21 | }, 22 | "include": ["src", "next-env.d.ts"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /examples/react/fixed/src/components/grid-columns/with-component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Grid, useGrid } from '@virtual-grid/react'; 4 | 5 | export const GridColumnsWithComponent = () => { 6 | const ref = React.useRef(null); 7 | 8 | const grid = useGrid({ 9 | scrollRef: ref, 10 | count: 1000, 11 | columns: 3 12 | }); 13 | 14 | return ( 15 |
20 | 21 | {(index) => ( 22 |
23 | {index} 24 |
25 | )} 26 |
27 |
28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /examples/react/fixed/src/components/grid/with-component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Grid, useGrid } from '@virtual-grid/react'; 4 | 5 | export const GridWithComponent = () => { 6 | const ref = React.useRef(null); 7 | 8 | const grid = useGrid({ 9 | scrollRef: ref, 10 | count: 1000, 11 | size: 100, 12 | columns: 100 13 | }); 14 | 15 | return ( 16 |
21 | 22 | {(index) => ( 23 |
24 | {index} 25 |
26 | )} 27 |
28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /examples/react/fixed/src/components/list/with-component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Grid, useGrid } from '@virtual-grid/react'; 4 | 5 | export const ListWithComponent = () => { 6 | const parentRef = React.useRef(null); 7 | 8 | const grid = useGrid({ 9 | scrollRef: parentRef, 10 | count: 1000, 11 | size: { height: 100 } 12 | }); 13 | 14 | return ( 15 |
20 | 21 | {(index) => ( 22 |
23 | {index} 24 |
25 | )} 26 |
27 |
28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /examples/react/fixed/src/components/grid-rows/with-component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Grid, useGrid } from '@virtual-grid/react'; 4 | 5 | export const GridRowsWithComponent = () => { 6 | const ref = React.useRef(null); 7 | 8 | const grid = useGrid({ 9 | scrollRef: ref, 10 | count: 1000, 11 | rows: 3, 12 | horizontal: true 13 | }); 14 | 15 | return ( 16 |
21 | 22 | {(index) => ( 23 |
24 | {index} 25 |
26 | )} 27 |
28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /examples/react/fixed/src/components/grid-auto/with-component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Grid, useGrid } from '@virtual-grid/react'; 4 | 5 | export const GridAutoColumnsWithComponent = () => { 6 | const ref = React.useRef(null); 7 | 8 | const grid = useGrid({ 9 | scrollRef: ref, 10 | count: 1000, 11 | columns: 'auto', 12 | size: 100 13 | }); 14 | 15 | return ( 16 |
21 | 22 | {(index) => ( 23 |
24 | {index} 25 |
26 | )} 27 |
28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /examples/react/fixed/src/components/columns/with-component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Grid, useGrid } from '@virtual-grid/react'; 4 | 5 | export const ColumnsWithComponent = () => { 6 | const parentRef = React.useRef(null); 7 | 8 | const grid = useGrid({ 9 | scrollRef: parentRef, 10 | count: 1000, 11 | size: { width: 100 }, 12 | horizontal: true 13 | }); 14 | 15 | return ( 16 |
21 | 22 | {(index) => ( 23 |
24 | {index} 25 |
26 | )} 27 |
28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /apps/docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "clean": "rm -rf .turbo node_modules", 7 | "dev": "next dev --port 3001", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint" 11 | }, 12 | "dependencies": { 13 | "@vercel/analytics": "^1.1.1", 14 | "next": "^14.2.8", 15 | "nextra": "^2.13.4", 16 | "nextra-theme-docs": "^2.13.4", 17 | "react": "^18.2.0", 18 | "react-dom": "^18.2.0" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^17.0.12", 22 | "@types/react": "^18.0.22", 23 | "@types/react-dom": "^18.0.7", 24 | "@virtual-grid/eslint-config": "workspace:*", 25 | "@virtual-grid/tsconfig": "workspace:*", 26 | "typescript": "^4.5.3" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /apps/web/components/controls/controls.params.ts: -------------------------------------------------------------------------------- 1 | import { 2 | parseAsBoolean, 3 | parseAsFloat, 4 | parseAsInteger, 5 | useQueryStates 6 | } from 'nuqs'; 7 | import { isMobile } from 'react-device-detect'; 8 | 9 | export function useControls() { 10 | const [controls, setControls] = useQueryStates({ 11 | size: parseAsBoolean.withDefault(false), 12 | sizeWidth: parseAsFloat.withDefault(140), 13 | sizeHeight: parseAsFloat.withDefault(140), 14 | 15 | columns: parseAsBoolean.withDefault(true), 16 | columnsCount: parseAsInteger.withDefault(isMobile ? 3 : 5), 17 | 18 | count: parseAsInteger.withDefault(100), 19 | padding: parseAsFloat.withDefault(12), 20 | gap: parseAsFloat.withDefault(8) 21 | }); 22 | 23 | return { controls, setControls, reset: () => setControls(null) }; 24 | } 25 | -------------------------------------------------------------------------------- /apps/web/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { cn } from '@/lib/utils'; 5 | import * as LabelPrimitive from '@radix-ui/react-label'; 6 | import { cva, type VariantProps } from 'class-variance-authority'; 7 | 8 | export const labelVariants = cva( 9 | 'text-sm font-light leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' 10 | ); 11 | 12 | const Label = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef & VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 17 | )); 18 | Label.displayName = LabelPrimitive.Root.displayName; 19 | 20 | export { Label }; 21 | -------------------------------------------------------------------------------- /apps/web/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { cn } from '@/lib/utils'; 5 | import * as SeparatorPrimitive from '@radix-ui/react-separator'; 6 | 7 | const Separator = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => ( 11 | 22 | )); 23 | Separator.displayName = SeparatorPrimitive.Root.displayName; 24 | 25 | export { Separator }; 26 | -------------------------------------------------------------------------------- /apps/web/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { cn } from '@/lib/utils'; 3 | 4 | export interface InputProps extends React.InputHTMLAttributes {} 5 | 6 | const Input = React.forwardRef( 7 | ({ className, type, ...props }, ref) => { 8 | return ( 9 | 18 | ); 19 | } 20 | ); 21 | Input.displayName = 'Input'; 22 | 23 | export { Input }; 24 | -------------------------------------------------------------------------------- /examples/react/fixed/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-fixed", 3 | "private": true, 4 | "version": "0.0.4", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@virtual-grid/react": "workspace:*", 14 | "react": "^18.2.0", 15 | "react-dom": "^18.2.0" 16 | }, 17 | "devDependencies": { 18 | "@types/react": "^18.2.43", 19 | "@types/react-dom": "^18.2.17", 20 | "@typescript-eslint/eslint-plugin": "^6.14.0", 21 | "@typescript-eslint/parser": "^6.14.0", 22 | "@vitejs/plugin-react": "^4.2.1", 23 | "eslint": "^8.55.0", 24 | "eslint-plugin-react-hooks": "^4.6.0", 25 | "eslint-plugin-react-refresh": "^0.4.5", 26 | "typescript": "^5.2.2", 27 | "vite": "^5.4.3" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/react/scroll-margin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scroll-margin", 3 | "private": true, 4 | "version": "0.0.4", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@virtual-grid/react": "workspace:*", 14 | "react": "^18.2.0", 15 | "react-dom": "^18.2.0" 16 | }, 17 | "devDependencies": { 18 | "@types/react": "^18.2.43", 19 | "@types/react-dom": "^18.2.17", 20 | "@typescript-eslint/eslint-plugin": "^6.14.0", 21 | "@typescript-eslint/parser": "^6.14.0", 22 | "@vitejs/plugin-react": "^4.2.1", 23 | "eslint": "^8.55.0", 24 | "eslint-plugin-react-hooks": "^4.6.0", 25 | "eslint-plugin-react-refresh": "^0.4.5", 26 | "typescript": "^5.2.2", 27 | "vite": "^5.4.3" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /apps/web/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # web 2 | 3 | ## 1.0.7 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [cd26778] 8 | - @virtual-grid/react@2.0.3 9 | 10 | ## 1.0.6 11 | 12 | ### Patch Changes 13 | 14 | - Updated dependencies [3473540] 15 | - @virtual-grid/react@2.0.2 16 | 17 | ## 1.0.5 18 | 19 | ### Patch Changes 20 | 21 | - Updated dependencies [62cfcf9] 22 | - @virtual-grid/react@2.0.1 23 | 24 | ## 1.0.4 25 | 26 | ### Patch Changes 27 | 28 | - Updated dependencies [6002f32] 29 | - @virtual-grid/react@2.0.0 30 | 31 | ## 1.0.3 32 | 33 | ### Patch Changes 34 | 35 | - Updated dependencies 36 | - @virtual-grid/react@1.1.0 37 | 38 | ## 1.0.2 39 | 40 | ### Patch Changes 41 | 42 | - Updated dependencies 43 | - @virtual-grid/react@1.0.2 44 | 45 | ## 1.0.1 46 | 47 | ### Patch Changes 48 | 49 | - Updated dependencies 50 | - @virtual-grid/react@1.0.1 51 | 52 | ## 1.0.0 53 | 54 | ### Patch Changes 55 | 56 | - Updated dependencies [9ab06ae] 57 | - @virtual-grid/react@1.0.0 58 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "infinite-scroll", 3 | "private": true, 4 | "version": "0.0.4", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@tanstack/react-query": "^5.17.9", 14 | "@virtual-grid/react": "workspace:*", 15 | "react": "^18.2.0", 16 | "react-dom": "^18.2.0" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^18.2.43", 20 | "@types/react-dom": "^18.2.17", 21 | "@typescript-eslint/eslint-plugin": "^6.14.0", 22 | "@typescript-eslint/parser": "^6.14.0", 23 | "@vitejs/plugin-react": "^4.2.1", 24 | "eslint": "^8.55.0", 25 | "eslint-plugin-react-hooks": "^4.6.0", 26 | "eslint-plugin-react-refresh": "^0.4.5", 27 | "typescript": "^5.2.2", 28 | "vite": "^5.4.3" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/docs/pages/api-reference/react/grid.mdx: -------------------------------------------------------------------------------- 1 | ## Grid 2 | 3 | This component renders the grid. 4 | 5 | ```tsx copy 6 | import * as React from 'react'; 7 | import { Grid, useGrid } from '@virtual-grid/react'; 8 | 9 | const Page = () => { 10 | const ref = React.useRef(null); 11 | 12 | const grid = useGrid({ 13 | scrollRef: ref, 14 | count: 1000 15 | // ... 16 | }); 17 | 18 | return ( 19 |
20 | {(i) =>
...
}
21 |
22 | ); 23 | }; 24 | ``` 25 | 26 | | Option | Type | Required | Description | 27 | | :----- | :-------- | :------- | ------------------------------------------------------------------------------------------------ | 28 | | grid | GridProps | Yes | Returned value by the [useGrid](https://docs.virtual-grid.com/api-reference/react/use-grid) hook | 29 | -------------------------------------------------------------------------------- /packages/react/README.md: -------------------------------------------------------------------------------- 1 | ## @virtual-grid/react 2 | 3 | Simplified virtualization using [@tanstack/virtual](https://tanstack.com/virtual/v3) 4 | 5 | [Demo](https://www.virtual-grid.com/) - [Docs](https://docs.virtual-grid.com/getting-started/react) - [Examples](https://github.com/niikeec/virtual-grid/examples/react) 6 | 7 | ## Installation 8 | 9 | ``` 10 | npm install @virtual-grid/react 11 | ``` 12 | 13 | ## Usage 14 | 15 | Example with the provided `` component: 16 | 17 | ```typescript 18 | import * as React from 'react'; 19 | import { Grid, useGrid } from '@virtual-grid/react'; 20 | 21 | const App = () => { 22 | const ref = React.useRef(null); 23 | 24 | const grid = useGrid({ 25 | scrollRef: ref, 26 | count: 1000 27 | // ... 28 | }); 29 | 30 | return ( 31 |
32 | {(i) =>
...
}
33 |
34 | ); 35 | }; 36 | ``` 37 | 38 | ## License 39 | 40 | MIT 41 | -------------------------------------------------------------------------------- /apps/web/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'react'; 2 | import type { Metadata, Viewport } from 'next'; 3 | import { Analytics } from '@vercel/analytics/react'; 4 | import { GeistSans } from 'geist/font/sans'; 5 | import { NuqsAdapter } from 'nuqs/adapters/next/app'; 6 | 7 | import './globals.css'; 8 | 9 | export const metadata: Metadata = { 10 | title: 'Virtual Grid', 11 | description: 'Virtualized grid powered by @tanstack/virtual' 12 | }; 13 | 14 | export const viewport: Viewport = { 15 | width: 'device-width', 16 | initialScale: 1, 17 | maximumScale: 1, 18 | userScalable: false 19 | }; 20 | 21 | export default function RootLayout({ children }: PropsWithChildren) { 22 | return ( 23 | 24 | 25 | 26 | 27 |
28 | {children} 29 |
30 |
31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /apps/web/components/controls/controls-section.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { PropsWithChildren } from 'react'; 4 | 5 | import { Checkbox } from '../ui/checkbox'; 6 | import { labelVariants } from '../ui/label'; 7 | 8 | interface SectionProps extends PropsWithChildren { 9 | label?: string; 10 | enabled?: boolean; 11 | onEnabledChange?: (val: boolean) => void; 12 | } 13 | 14 | export function ControlsSection({ 15 | label, 16 | enabled, 17 | onEnabledChange, 18 | children 19 | }: SectionProps) { 20 | return ( 21 |
22 | {label && ( 23 |
24 | 25 | {label} 26 | 27 | 28 | {enabled !== undefined && onEnabledChange && ( 29 | 30 | )} 31 |
32 | )} 33 |
{children}
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Nikec 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 2 | 3 | import { ColumnsHeadless } from './components/columns/headless'; 4 | import { ColumnsWithComponent } from './components/columns/with-component'; 5 | import { GridHeadless } from './components/grid/headless'; 6 | import { GridWithComponent } from './components/grid/with-component'; 7 | import { RowsHeadless } from './components/rows/headless'; 8 | import { RowsWithComponent } from './components/rows/with-component'; 9 | import { Section } from './components/section'; 10 | 11 | const queryClient = new QueryClient(); 12 | 13 | export const App = () => { 14 | return ( 15 | 16 |
17 | 18 | 19 |
20 | 21 |
22 | 23 | 24 |
25 | 26 |
27 | 28 | 29 |
30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /apps/web/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { cn } from '@/lib/utils'; 5 | import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; 6 | import { CheckIcon } from '@radix-ui/react-icons'; 7 | 8 | const Checkbox = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 21 | 22 | 23 | 24 | )); 25 | Checkbox.displayName = CheckboxPrimitive.Root.displayName; 26 | 27 | export { Checkbox }; 28 | -------------------------------------------------------------------------------- /packages/react/src/components/load-more-trigger.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useInView } from 'react-intersection-observer'; 3 | 4 | import { LoadMoreTriggerDefaults } from '@virtual-grid/shared'; 5 | 6 | export interface LoadMoreTriggerProps extends LoadMoreTriggerDefaults { 7 | style?: React.CSSProperties; 8 | className?: string; 9 | } 10 | 11 | export const LoadMoreTrigger = ({ 12 | position = 'bottom', 13 | size = 0, 14 | onLoadMore, 15 | ...props 16 | }: LoadMoreTriggerProps) => { 17 | const vertical = position === 'top' || position === 'bottom'; 18 | 19 | const { ref, inView } = useInView(); 20 | 21 | React.useEffect(() => { 22 | inView && onLoadMore?.(); 23 | }, [inView, onLoadMore]); 24 | 25 | return ( 26 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /apps/web/components/usage/index.tsx: -------------------------------------------------------------------------------- 1 | import { Code } from '../ui/code'; 2 | import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; 3 | import { headless } from './headless'; 4 | import { withComponent } from './with-component'; 5 | 6 | type UsageTypes = 'component' | 'headless'; 7 | 8 | const codeBlocks: Record = { 9 | component: withComponent, 10 | headless: headless 11 | }; 12 | 13 | export const Usage = () => { 14 | return ( 15 |
16 | 17 | 18 | 19 | Component 20 | 21 | 22 | Headless 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@virtual-grid/core", 3 | "version": "2.0.1", 4 | "license": "MIT", 5 | "exports": { 6 | "./package.json": "./package.json", 7 | ".": { 8 | "types": "./dist/index.d.ts", 9 | "import": "./dist/index.mjs", 10 | "require": "./dist/index.js" 11 | } 12 | }, 13 | "types": "./dist/index.d.ts", 14 | "files": [ 15 | "dist" 16 | ], 17 | "scripts": { 18 | "build": "tsup", 19 | "dev": "tsup --watch", 20 | "clean": "rm -rf .turbo node_modules dist", 21 | "lint": "eslint .", 22 | "typecheck": "tsc --noEmit" 23 | }, 24 | "devDependencies": { 25 | "@types/node": "^20.8.9", 26 | "@virtual-grid/eslint-config": "workspace:*", 27 | "@virtual-grid/tsconfig": "workspace:*", 28 | "@virtual-grid/tsup-config": "workspace:*", 29 | "tsup": "8.2.4", 30 | "typescript": "^4.5.2" 31 | }, 32 | "publishConfig": { 33 | "access": "public" 34 | }, 35 | "homepage": "https://www.virtual-grid.com/", 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/niikeec/virtual-grid.git" 39 | }, 40 | "keywords": [ 41 | "virtual", 42 | "grid" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /examples/react/fixed/src/components/list/headless.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { useGrid, useVirtualizer } from '@virtual-grid/react'; 4 | 5 | export const HeadlessList = () => { 6 | const ref = React.useRef(null); 7 | 8 | const grid = useGrid({ 9 | scrollRef: ref, 10 | count: 1000, 11 | size: { height: 100 } 12 | }); 13 | 14 | const rowVirtualizer = useVirtualizer(grid.rowVirtualizer); 15 | 16 | return ( 17 |
22 |
29 | {rowVirtualizer.getVirtualItems().map((row) => { 30 | const item = grid.getVirtualItem({ row }); 31 | if (!item) return null; 32 | 33 | return ( 34 |
35 |
36 | {item.index} 37 |
38 |
39 | ); 40 | })} 41 |
42 |
43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@virtual-grid/shared", 3 | "version": "2.0.1", 4 | "license": "MIT", 5 | "exports": { 6 | "./package.json": "./package.json", 7 | ".": { 8 | "types": "./dist/index.d.ts", 9 | "import": "./dist/index.mjs", 10 | "require": "./dist/index.js" 11 | } 12 | }, 13 | "types": "./dist/index.d.ts", 14 | "files": [ 15 | "dist" 16 | ], 17 | "scripts": { 18 | "build": "tsup", 19 | "dev": "tsup --watch", 20 | "clean": "rm -rf .turbo node_modules", 21 | "lint": "eslint .", 22 | "typecheck": "tsc --noEmit" 23 | }, 24 | "devDependencies": { 25 | "@tanstack/virtual-core": "3.0.0", 26 | "@types/node": "^20.8.9", 27 | "@virtual-grid/core": "^2.0.1", 28 | "@virtual-grid/eslint-config": "workspace:*", 29 | "@virtual-grid/tsconfig": "workspace:*", 30 | "@virtual-grid/tsup-config": "workspace:*", 31 | "tsup": "8.2.4", 32 | "typescript": "^4.5.2" 33 | }, 34 | "publishConfig": { 35 | "access": "public" 36 | }, 37 | "homepage": "https://www.virtual-grid.com/", 38 | "repository": { 39 | "type": "git", 40 | "url": "git+https://github.com/niikeec/virtual-grid.git" 41 | }, 42 | "keywords": [ 43 | "virtual", 44 | "grid" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /apps/web/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { cn } from '@/lib/utils'; 5 | import * as TooltipPrimitive from '@radix-ui/react-tooltip'; 6 | 7 | const TooltipProvider = TooltipPrimitive.Provider; 8 | 9 | const Tooltip = TooltipPrimitive.Root; 10 | 11 | const TooltipTrigger = TooltipPrimitive.Trigger; 12 | 13 | const TooltipContent = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, sideOffset = 4, ...props }, ref) => ( 17 | 26 | )); 27 | TooltipContent.displayName = TooltipPrimitive.Content.displayName; 28 | 29 | export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }; 30 | -------------------------------------------------------------------------------- /packages/react/src/useScrollMargin.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const useScrollMargin = ({ 4 | scrollRef, 5 | gridRef 6 | }: { 7 | scrollRef: React.RefObject; 8 | gridRef: React.RefObject; 9 | }) => { 10 | const [top, setTop] = React.useState(0); 11 | const [left, setLeft] = React.useState(0); 12 | 13 | React.useEffect(() => { 14 | const scrollNode = scrollRef.current; 15 | if (!scrollNode) return; 16 | 17 | const gridNode = gridRef.current; 18 | if (!gridNode) return; 19 | 20 | const observer = new MutationObserver(() => { 21 | setTop(gridNode.offsetTop - scrollNode.offsetTop); 22 | setLeft(gridNode.offsetLeft - scrollNode.offsetLeft); 23 | }); 24 | 25 | observer.observe(scrollNode, { childList: true }); 26 | 27 | return () => observer.disconnect(); 28 | }, [gridRef, scrollRef]); 29 | 30 | React.useLayoutEffect(() => { 31 | const scrollNode = scrollRef.current; 32 | if (!scrollNode) return; 33 | 34 | const gridNode = gridRef.current; 35 | if (!gridNode) return; 36 | 37 | setTop(gridNode.offsetTop - scrollNode.offsetTop); 38 | setLeft(gridNode.offsetLeft - scrollNode.offsetLeft); 39 | }, [gridRef, scrollRef]); 40 | 41 | return { scrollMargin: { top, left } }; 42 | }; 43 | -------------------------------------------------------------------------------- /apps/web/components/hero/logo-art.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils'; 2 | 3 | export const LogoArt = () => { 4 | return ( 5 |
6 |
7 |
8 |
9 |
10 | 11 |
12 |
13 | {[...Array(9)].map((_, i) => ( 14 |
23 | ))} 24 |
25 |
26 |
27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /examples/react/fixed/src/components/columns/headless.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { useGrid, useVirtualizer } from '@virtual-grid/react'; 4 | 5 | export const HeadlessColumns = () => { 6 | const ref = React.useRef(null); 7 | 8 | const grid = useGrid({ 9 | scrollRef: ref, 10 | count: 1000, 11 | size: { width: 100 }, 12 | horizontal: true 13 | }); 14 | 15 | const columnVirtualizer = useVirtualizer(grid.columnVirtualizer); 16 | 17 | return ( 18 |
23 |
30 | {columnVirtualizer.getVirtualItems().map((column) => { 31 | const item = grid.getVirtualItem({ column }); 32 | if (!item) return null; 33 | 34 | return ( 35 |
36 |
37 | {item.index} 38 |
39 |
40 | ); 41 | })} 42 |
43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "packageManager": "pnpm@8.9.2", 4 | "engines": { 5 | "node": ">=v18.18.2", 6 | "pnpm": ">=8.9.2" 7 | }, 8 | "scripts": { 9 | "build": "turbo run build", 10 | "build:packages": "turbo run build --filter \"./packages/*\"", 11 | "build:apps": "turbo run build --filter \"./apps/*\"", 12 | "dev": "turbo run dev", 13 | "dev:apps": "turbo run dev --filter \"./apps/*\"", 14 | "dev:packages": "turbo run dev --filter \"./packages/*\"", 15 | "dev:react": "turbo run dev --filter=\"./packages/*\" --filter=\"!./packages/solid\" --filter=\"!./packages/vue\" --filter=\"./examples/react/*\"", 16 | "clean": "turbo run clean && rm -rf node_modules", 17 | "lint": "turbo run lint", 18 | "typecheck": "turbo run typecheck", 19 | "format": "prettier --write \"**/*.{ts,tsx,md,mdx}\"", 20 | "publish-packages": "turbo run build lint typecheck --filter \"./packages/*\" && changeset version && changeset publish" 21 | }, 22 | "devDependencies": { 23 | "@changesets/cli": "^2.27.8", 24 | "@ianvs/prettier-plugin-sort-imports": "^4.1.1", 25 | "@virtual-grid/eslint-config": "workspace:*", 26 | "eslint": "^8.52.0", 27 | "prettier": "^3.0.3", 28 | "prettier-plugin-tailwindcss": "^0.5.6", 29 | "turbo": "^1.11.2", 30 | "typescript": "^4.5.2" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/components/ui/code.tsx: -------------------------------------------------------------------------------- 1 | import copy from 'copy-to-clipboard'; 2 | import { Highlight, PrismTheme } from 'prism-react-renderer'; 3 | 4 | import { CopyButton } from './copy-button'; 5 | 6 | const theme = { 7 | plain: {}, 8 | styles: [ 9 | { 10 | types: ['atrule', 'keyword', 'attr-name', 'selector', 'punctuation', 'comment'], 11 | style: { 12 | color: 'hsl(var(--muted-foreground))' 13 | } 14 | } 15 | ] 16 | } satisfies PrismTheme; 17 | 18 | export const Code = ({ code }: { code: string }) => { 19 | return ( 20 | 21 | {({ style, tokens, getLineProps, getTokenProps }) => ( 22 |
23 |
24 |             {tokens.map((line, i) => (
25 |               
26 | {line.map((token, key) => ( 27 | 28 | ))} 29 |
30 | ))} 31 |
32 | copy(code)} className="bg-background absolute right-3 top-3" /> 33 |
34 | )} 35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /apps/web/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { cn } from '@/lib/utils'; 5 | import * as PopoverPrimitive from '@radix-ui/react-popover'; 6 | 7 | const Popover = PopoverPrimitive.Root; 8 | 9 | const PopoverTrigger = PopoverPrimitive.Trigger; 10 | 11 | const PopoverContent = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( 15 | 16 | 26 | 27 | )); 28 | PopoverContent.displayName = PopoverPrimitive.Content.displayName; 29 | 30 | export { Popover, PopoverTrigger, PopoverContent }; 31 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/src/components/grid/with-component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useInfiniteQuery } from '@tanstack/react-query'; 3 | 4 | import { Grid, useGrid } from '@virtual-grid/react'; 5 | 6 | import { fetchServerPage } from '../../util/fetch'; 7 | 8 | export const GridWithComponent = () => { 9 | const { data, isFetchingNextPage, fetchNextPage, hasNextPage } = 10 | useInfiniteQuery({ 11 | queryKey: ['grid-with-component'], 12 | queryFn: (ctx) => fetchServerPage(10, ctx.pageParam), 13 | getNextPageParam: (_lastGroup, groups) => groups.length, 14 | initialPageParam: 0 15 | }); 16 | 17 | const items = data ? data.pages.flatMap(({ data }) => data) : []; 18 | 19 | const ref = React.useRef(null); 20 | 21 | const grid = useGrid({ 22 | scrollRef: ref, 23 | count: items.length, 24 | columns: 3, 25 | onLoadMore: () => { 26 | if (hasNextPage && !isFetchingNextPage) { 27 | fetchNextPage(); 28 | } 29 | } 30 | }); 31 | 32 | return ( 33 |
38 | 39 | {(index) => ( 40 |
41 | {index} 42 |
43 | )} 44 |
45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/src/components/rows/with-component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useInfiniteQuery } from '@tanstack/react-query'; 3 | 4 | import { Grid, useGrid } from '@virtual-grid/react'; 5 | 6 | import { fetchServerPage } from '../../util/fetch'; 7 | 8 | export const RowsWithComponent = () => { 9 | const { data, isFetchingNextPage, fetchNextPage, hasNextPage } = 10 | useInfiniteQuery({ 11 | queryKey: ['rows-with-component'], 12 | queryFn: (ctx) => fetchServerPage(10, ctx.pageParam), 13 | getNextPageParam: (_lastGroup, groups) => groups.length, 14 | initialPageParam: 0 15 | }); 16 | 17 | const rows = data ? data.pages.flatMap(({ data }) => data) : []; 18 | 19 | const ref = React.useRef(null); 20 | 21 | const grid = useGrid({ 22 | scrollRef: ref, 23 | count: rows.length, 24 | size: { height: 100 }, 25 | onLoadMore: () => { 26 | if (hasNextPage && !isFetchingNextPage) { 27 | fetchNextPage(); 28 | } 29 | } 30 | }); 31 | 32 | return ( 33 |
38 | 39 | {(index) => ( 40 |
41 | {index} 42 |
43 | )} 44 |
45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /examples/react/fixed/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default { 18 | // other rules... 19 | parserOptions: { 20 | ecmaVersion: 'latest', 21 | sourceType: 'module', 22 | project: ['./tsconfig.json', './tsconfig.node.json'], 23 | tsconfigRootDir: __dirname, 24 | }, 25 | } 26 | ``` 27 | 28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | release: 9 | name: Release 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - name: Setup pnpm 16 | uses: pnpm/action-setup@v4 17 | 18 | - name: Setup Node 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | cache: pnpm 23 | 24 | - name: Install dependencies 25 | run: pnpm install 26 | 27 | - name: Check packages 28 | run: pnpm turbo --filter "./packages/*" build lint typecheck 29 | 30 | - name: Create Release 31 | id: changeset 32 | uses: changesets/action@v1.4.8 33 | with: 34 | commit: 'chore(release): 📦 version packages' 35 | title: 'chore(release): 📦 version packages' 36 | publish: npx changeset publish 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 40 | 41 | - name: Sync lockfile 42 | if: steps.changeset.outputs.hasChangesets == 'true' 43 | run: | 44 | git checkout changeset-release/main 45 | pnpm install --no-frozen-lockfile 46 | git add . 47 | git commit -m "chore(release): 📦 sync lockfile" 48 | git push origin changeset-release/main 49 | -------------------------------------------------------------------------------- /apps/web/components/controls/controls-desktop.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | import { Sliders } from '@phosphor-icons/react'; 5 | import { isMobile } from 'react-device-detect'; 6 | 7 | import { Button, buttonVariants } from '../ui/button'; 8 | import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; 9 | import { Controls } from './controls'; 10 | import { useControls } from './controls.params'; 11 | 12 | export function ControlsDesktop() { 13 | const { reset } = useControls(); 14 | 15 | return ( 16 | 17 | 28 | 29 | 30 | 31 | 32 | 33 |
34 | 37 |
38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default { 18 | // other rules... 19 | parserOptions: { 20 | ecmaVersion: 'latest', 21 | sourceType: 'module', 22 | project: ['./tsconfig.json', './tsconfig.node.json'], 23 | tsconfigRootDir: __dirname, 24 | }, 25 | } 26 | ``` 27 | 28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 31 | -------------------------------------------------------------------------------- /examples/react/scroll-margin/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default { 18 | // other rules... 19 | parserOptions: { 20 | ecmaVersion: 'latest', 21 | sourceType: 'module', 22 | project: ['./tsconfig.json', './tsconfig.node.json'], 23 | tsconfigRootDir: __dirname, 24 | }, 25 | } 26 | ``` 27 | 28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 31 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/src/components/columns/with-component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useInfiniteQuery } from '@tanstack/react-query'; 3 | 4 | import { Grid, useGrid } from '@virtual-grid/react'; 5 | 6 | import { fetchServerPage } from '../../util/fetch'; 7 | 8 | export const ColumnsWithComponent = () => { 9 | const { data, isFetchingNextPage, fetchNextPage, hasNextPage } = 10 | useInfiniteQuery({ 11 | queryKey: ['columns-with-component'], 12 | queryFn: (ctx) => fetchServerPage(10, ctx.pageParam), 13 | getNextPageParam: (_lastGroup, groups) => groups.length, 14 | initialPageParam: 0 15 | }); 16 | 17 | const columns = data ? data.pages.flatMap(({ data }) => data) : []; 18 | 19 | const ref = React.useRef(null); 20 | 21 | const grid = useGrid({ 22 | scrollRef: ref, 23 | count: columns.length, 24 | size: { width: 100 }, 25 | horizontal: true, 26 | onLoadMore: () => { 27 | if (hasNextPage && !isFetchingNextPage) { 28 | fetchNextPage(); 29 | } 30 | } 31 | }); 32 | 33 | return ( 34 |
39 | 40 | {(index) => ( 41 |
42 | {index} 43 |
44 | )} 45 |
46 |
47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /examples/react/fixed/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/react/scroll-margin/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/docs/pages/api-reference/react/load-more-trigger.mdx: -------------------------------------------------------------------------------- 1 | ## LoadMoreTrigger 2 | 3 | This component renders the `onLoadMore` trigger area. **Should be only used headlessly**. 4 | 5 | ```tsx copy 6 | import * as React from 'react'; 7 | import { Grid, useGrid } from '@virtual-grid/react'; 8 | 9 | const Page = () => { 10 | const ref = React.useRef(null); 11 | 12 | const grid = useGrid({ 13 | scrollRef: ref, 14 | count: 1000 15 | // ... 16 | }); 17 | 18 | return ( 19 |
20 | // ... 21 | 22 |
23 | ); 24 | }; 25 | ``` 26 | 27 | | Option | Type | Required | Description | 28 | | :--------- | :--------------------------------------- | :------- | ----------------------------------------------------- | 29 | | position | `'top' \| 'bottom' \| 'left' \| 'right'` | No | **Default = 'bottom'**
Position of the trigger | 30 | | size | number | No | **Default = 0**
Size of the trigger | 31 | | onLoadMore | function | No | Callback function when the trigger is visible | 32 | | style | CSSProperties | No | Apply custom styles | 33 | | className | string | No | Apply custom classes | 34 | -------------------------------------------------------------------------------- /examples/react/scroll-margin/src/components/headless-vertical.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useGrid, useScrollMargin, useVirtualizer } from '@virtual-grid/react'; 4 | 5 | export const HeadlessVertical = () => { 6 | const scrollRef = React.useRef(null); 7 | const gridRef = React.useRef(null); 8 | 9 | const grid = useGrid({ 10 | scrollRef: scrollRef, 11 | count: 1000, 12 | size: { height: 100 } 13 | }); 14 | 15 | const { scrollMargin } = useScrollMargin({ scrollRef, gridRef }); 16 | 17 | const virtualizer = useVirtualizer({ 18 | ...grid.rowVirtualizer, 19 | scrollMargin: scrollMargin.top 20 | }); 21 | 22 | return ( 23 |
28 |
29 |
37 | {virtualizer.getVirtualItems().map((row) => { 38 | const item = grid.getVirtualItem({ row, scrollMargin }); 39 | if (!item) return null; 40 | 41 | return ( 42 |
43 |
44 | {item.index} 45 |
46 |
47 | ); 48 | })} 49 |
50 |
51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /packages/config/eslint-config/base.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('node:path'); 2 | 3 | const project = resolve(process.cwd(), 'tsconfig.json'); 4 | 5 | /** @type {import("eslint").Linter.Config} */ 6 | const config = { 7 | parser: '@typescript-eslint/parser', 8 | plugins: ['@typescript-eslint', 'import'], 9 | extends: [ 10 | 'turbo', 11 | 'eslint:recommended', 12 | 'plugin:@typescript-eslint/recommended-type-checked', 13 | 'plugin:@typescript-eslint/stylistic-type-checked', 14 | 'prettier', 15 | 'eslint-config-turbo' 16 | ], 17 | parserOptions: { project }, 18 | settings: { 19 | 'import/resolver': { 20 | typescript: { 21 | project 22 | } 23 | } 24 | }, 25 | rules: { 26 | '@typescript-eslint/no-unsafe-return': 'off', 27 | '@typescript-eslint/consistent-type-definitions': 'off', 28 | '@typescript-eslint/prefer-nullish-coalescing': 'off', 29 | '@typescript-eslint/no-unsafe-member-access': 'off', 30 | '@typescript-eslint/no-unsafe-argument': 'off', 31 | '@typescript-eslint/no-unsafe-assignment': 'off', 32 | '@typescript-eslint/no-unsafe-call': 'off', 33 | 'typescript-eslint/no-redundant-type-constituents': 'off', 34 | '@typescript-eslint/no-empty-interface': 'off', 35 | '@typescript-eslint/no-misused-promises': 'off', 36 | '@typescript-eslint/no-redundant-type-constituents': 'off', 37 | '@typescript-eslint/no-unused-vars': 'off' 38 | }, 39 | ignorePatterns: [ 40 | 'node_modules/', 41 | 'dist/', 42 | '**/.eslintrc.js', 43 | '**/*.config.js', 44 | 'packages/config/**', 45 | '.next', 46 | 'pnpm-lock.yaml' 47 | ] 48 | }; 49 | 50 | module.exports = config; 51 | -------------------------------------------------------------------------------- /examples/react/scroll-margin/src/components/headless-horizontal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useGrid, useScrollMargin, useVirtualizer } from '@virtual-grid/react'; 4 | 5 | export const HeadlessHorizontal = () => { 6 | const scrollRef = React.useRef(null); 7 | const gridRef = React.useRef(null); 8 | 9 | const grid = useGrid({ 10 | scrollRef: scrollRef, 11 | count: 1000, 12 | size: { width: 100 }, 13 | horizontal: true 14 | }); 15 | 16 | const { scrollMargin } = useScrollMargin({ scrollRef, gridRef }); 17 | 18 | const virtualizer = useVirtualizer({ 19 | ...grid.columnVirtualizer, 20 | scrollMargin: scrollMargin.left 21 | }); 22 | 23 | return ( 24 |
29 |
30 |
39 | {virtualizer.getVirtualItems().map((column) => { 40 | const item = grid.getVirtualItem({ column, scrollMargin }); 41 | if (!item) return null; 42 | 43 | return ( 44 |
45 |
46 | {item.index} 47 |
48 |
49 | ); 50 | })} 51 |
52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /apps/web/components/demo/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | 3 | import { Grid, useGrid } from '@virtual-grid/react'; 4 | 5 | import { ControlsDesktop } from '../controls/controls-desktop'; 6 | import { ControlsMobile } from '../controls/controls-mobile'; 7 | import { useControls } from '../controls/controls.params'; 8 | import { Window } from './window'; 9 | 10 | export const Demo = () => { 11 | const ref = useRef(null); 12 | 13 | const { controls } = useControls(); 14 | 15 | const grid = useGrid({ 16 | scrollRef: ref, 17 | count: controls.count, 18 | ...(controls.columns 19 | ? { 20 | columns: controls.columnsCount, 21 | ...(controls.size && { 22 | size: { 23 | width: controls.sizeWidth || undefined, 24 | height: controls.sizeHeight || undefined 25 | } 26 | }) 27 | } 28 | : controls.size 29 | ? { 30 | columns: 'auto', 31 | size: { 32 | width: controls.sizeWidth, 33 | height: controls.sizeHeight 34 | } 35 | } 36 | : {}), 37 | padding: controls.padding, 38 | gap: controls.gap 39 | }); 40 | 41 | return ( 42 | <> 43 | 44 | 45 |
46 | 47 | {(index) => ( 48 |
52 | )} 53 | 54 |
55 | 56 | 57 | 58 | 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /examples/react/fixed/src/components/grid/headless.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useGrid, useVirtualizer } from '@virtual-grid/react'; 4 | 5 | export const HeadlessGrid = () => { 6 | const ref = React.useRef(null); 7 | 8 | const grid = useGrid({ 9 | scrollRef: ref, 10 | count: 1000, 11 | size: 100, 12 | columns: 100 13 | }); 14 | 15 | const rowVirtualizer = useVirtualizer(grid.rowVirtualizer); 16 | const columnVirtualizer = useVirtualizer(grid.columnVirtualizer); 17 | 18 | return ( 19 |
24 |
31 | {rowVirtualizer.getVirtualItems().map((virtualRow) => ( 32 | 33 | {columnVirtualizer.getVirtualItems().map((virtualColumn) => { 34 | const item = grid.getVirtualItem({ 35 | row: virtualRow, 36 | column: virtualColumn 37 | }); 38 | 39 | if (!item) return null; 40 | 41 | return ( 42 |
43 |
46 | {item.index} 47 |
48 |
49 | ); 50 | })} 51 |
52 | ))} 53 |
54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /packages/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@virtual-grid/react", 3 | "version": "2.0.3", 4 | "license": "MIT", 5 | "exports": { 6 | "./package.json": "./package.json", 7 | ".": { 8 | "types": "./dist/index.d.ts", 9 | "import": "./dist/index.mjs", 10 | "require": "./dist/index.js" 11 | } 12 | }, 13 | "types": "./dist/index.d.ts", 14 | "files": [ 15 | "dist" 16 | ], 17 | "scripts": { 18 | "build": "tsup", 19 | "dev": "wait-on ../core/dist/index.mjs && tsup --watch", 20 | "clean": "rm -rf .turbo node_modules dist", 21 | "lint": "eslint .", 22 | "typecheck": "tsc --noEmit" 23 | }, 24 | "dependencies": { 25 | "@tanstack/react-virtual": "^3.0.1", 26 | "@virtual-grid/core": "^2.0.1", 27 | "@virtual-grid/shared": "^2.0.1", 28 | "react-intersection-observer": "^9.5.2", 29 | "use-deep-compare": "^1.2.1", 30 | "use-resize-observer": "^9.1.0" 31 | }, 32 | "devDependencies": { 33 | "@types/node": "^17.0.12", 34 | "@types/react": "^18.0.22", 35 | "@types/react-dom": "^18.0.7", 36 | "@virtual-grid/eslint-config": "workspace:*", 37 | "@virtual-grid/tsconfig": "workspace:*", 38 | "@virtual-grid/tsup-config": "workspace:*", 39 | "react": "^18.2.0", 40 | "tsup": "8.2.4", 41 | "typescript": "^4.5.2", 42 | "wait-on": "^8.0.0" 43 | }, 44 | "peerDependencies": { 45 | "react": "^17.0.2 || ^18.0.0" 46 | }, 47 | "publishConfig": { 48 | "access": "public" 49 | }, 50 | "homepage": "https://www.virtual-grid.com/", 51 | "repository": { 52 | "type": "git", 53 | "url": "git+https://github.com/niikeec/virtual-grid.git" 54 | }, 55 | "keywords": [ 56 | "react", 57 | "virtual", 58 | "grid", 59 | "tanstack" 60 | ] 61 | } 62 | -------------------------------------------------------------------------------- /apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "1.0.7", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "clean": "rm -rf .turbo node_modules", 10 | "lint": "next lint", 11 | "typecheck": "tsc --noEmit" 12 | }, 13 | "dependencies": { 14 | "@phosphor-icons/react": "^2.0.13", 15 | "@radix-ui/react-checkbox": "^1.0.4", 16 | "@radix-ui/react-dialog": "^1.1.2", 17 | "@radix-ui/react-icons": "^1.3.0", 18 | "@radix-ui/react-label": "^2.0.2", 19 | "@radix-ui/react-popover": "^1.0.7", 20 | "@radix-ui/react-select": "^2.0.0", 21 | "@radix-ui/react-separator": "^1.0.3", 22 | "@radix-ui/react-slot": "^1.0.2", 23 | "@radix-ui/react-tabs": "^1.0.4", 24 | "@radix-ui/react-tooltip": "^1.0.7", 25 | "@vercel/analytics": "^1.1.1", 26 | "@virtual-grid/react": "workspace:*", 27 | "class-variance-authority": "^0.7.0", 28 | "clsx": "^2.0.0", 29 | "copy-to-clipboard": "^3.3.3", 30 | "geist": "^1.3.1", 31 | "lucide-react": "^0.454.0", 32 | "next": "^14.2.8", 33 | "nuqs": "^2.1.1", 34 | "prism-react-renderer": "^2.1.0", 35 | "react": "^18.2.0", 36 | "react-aria-components": "^1.4.1", 37 | "react-device-detect": "^2.2.3", 38 | "react-dom": "^18.2.0", 39 | "tailwind-merge": "^1.14.0", 40 | "vaul": "^1.1.1" 41 | }, 42 | "devDependencies": { 43 | "@types/node": "^17.0.12", 44 | "@types/react": "^18.0.22", 45 | "@types/react-dom": "^18.0.7", 46 | "@virtual-grid/eslint-config": "workspace:*", 47 | "@virtual-grid/tsconfig": "workspace:*", 48 | "autoprefixer": "^10.4.16", 49 | "postcss": "^8.4.31", 50 | "tailwindcss": "^3.4.10", 51 | "tailwindcss-animate": "^1.0.7", 52 | "typescript": "^4.5.3" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /apps/web/components/hero/installation.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import copy from 'copy-to-clipboard'; 3 | 4 | import { CopyButton } from '../ui/copy-button'; 5 | import { 6 | Select, 7 | SelectContent, 8 | SelectItem, 9 | SelectTrigger, 10 | SelectValue 11 | } from '../ui/select'; 12 | import { Separator } from '../ui/separator'; 13 | 14 | type Manager = 'npm' | 'pnpm' | 'yarn' | 'bun'; 15 | 16 | const commands: Record = { 17 | npm: 'npm install', 18 | pnpm: 'pnpm add', 19 | yarn: 'yarn add', 20 | bun: 'bun add' 21 | }; 22 | 23 | export const Installation = () => { 24 | const [manager, setManager] = useState('pnpm'); 25 | 26 | const command = `${commands[manager]} @virtual-grid/react`; 27 | 28 | return ( 29 |
30 | 31 | {command} 32 | 33 | 34 | copy(command)} className="ml-3" /> 35 | 36 | 37 | 38 | 55 |
56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /apps/web/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 20 14.3% 4.1%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 20 14.3% 4.1%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 20 14.3% 4.1%; 15 | 16 | --primary: 24 9.8% 10%; 17 | --primary-foreground: 60 9.1% 97.8%; 18 | 19 | --secondary: 60 4.8% 95.9%; 20 | --secondary-foreground: 24 9.8% 10%; 21 | 22 | --muted: 60 4.8% 95.9%; 23 | --muted-foreground: 25 5.3% 44.7%; 24 | 25 | --accent: 60 4.8% 95.9%; 26 | --accent-foreground: 24 9.8% 10%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 60 9.1% 97.8%; 30 | 31 | --border: 20 5.9% 90%; 32 | --input: 20 5.9% 90%; 33 | --ring: 20 14.3% 4.1%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 20 14.3% 4.1%; 40 | --foreground: 60 9.1% 97.8%; 41 | 42 | --card: 20 14.3% 4.1%; 43 | --card-foreground: 60 9.1% 97.8%; 44 | 45 | --popover: 20 14.3% 4.1%; 46 | --popover-foreground: 60 9.1% 97.8%; 47 | 48 | --primary: 60 9.1% 97.8%; 49 | --primary-foreground: 24 9.8% 10%; 50 | 51 | --secondary: 12 6.5% 15.1%; 52 | --secondary-foreground: 60 9.1% 97.8%; 53 | 54 | --muted: 12 6.5% 15.1%; 55 | --muted-foreground: 24 5.4% 63.9%; 56 | 57 | --accent: 12 6.5% 15.1%; 58 | --accent-foreground: 60 9.1% 97.8%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 60 9.1% 97.8%; 62 | 63 | --border: 12 6.5% 15.1%; 64 | --input: 12 6.5% 15.1%; 65 | --ring: 24 5.7% 82.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /apps/web/components/hero/index.tsx: -------------------------------------------------------------------------------- 1 | import { Book, GithubLogo } from '@phosphor-icons/react'; 2 | 3 | import { buttonVariants } from '../ui/button'; 4 | import { Installation } from './installation'; 5 | 6 | export const Hero = () => { 7 | return ( 8 |
9 |
10 | 11 |
12 |

Virtual Grid

13 |

14 | Simplified virtualization using{' '} 15 | 20 | @tanstack/virtual 21 | 22 |

23 | 24 | 45 |
46 | 47 | 48 |
49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /examples/react/fixed/src/components/grid-auto/headless.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useGrid, useVirtualizer } from '@virtual-grid/react'; 4 | 5 | export const HeadlessGridAutoColumns = () => { 6 | const ref = React.useRef(null); 7 | 8 | const grid = useGrid({ 9 | scrollRef: ref, 10 | count: 1000, 11 | columns: 'auto', 12 | size: 100 13 | }); 14 | 15 | const rowVirtualizer = useVirtualizer(grid.rowVirtualizer); 16 | const columnVirtualizer = useVirtualizer(grid.columnVirtualizer); 17 | 18 | React.useEffect(() => { 19 | columnVirtualizer.measure(); 20 | }, [columnVirtualizer, grid.virtualItemWidth]); 21 | 22 | return ( 23 |
28 |
35 | {rowVirtualizer.getVirtualItems().map((virtualRow) => ( 36 | 37 | {columnVirtualizer.getVirtualItems().map((virtualColumn) => { 38 | const item = grid.getVirtualItem({ 39 | row: virtualRow, 40 | column: virtualColumn 41 | }); 42 | 43 | if (!item) return null; 44 | 45 | return ( 46 |
47 |
50 | {item.index} 51 |
52 |
53 | ); 54 | })} 55 |
56 | ))} 57 |
58 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /packages/shared/src/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | VirtualItem, 3 | Virtualizer, 4 | VirtualizerOptions as VirtualizerOptionsCore 5 | } from '@tanstack/virtual-core'; 6 | 7 | import * as Core from '@virtual-grid/core'; 8 | 9 | export type GridProps< 10 | IdT extends Core.GridItemId = Core.GridItemId, 11 | DataT extends Core.GridItemData = unknown 12 | > = ( 13 | | Core.BaseGridProps 14 | | Core.AutoColumnsGridProps 15 | | Core.HorizontalGridProps 16 | ) & { 17 | /** 18 | * Renders an area which triggers `onLoadMore` when scrolled into view. 19 | */ 20 | onLoadMore?: () => void; 21 | /** 22 | * Set the size of the load more area. 23 | */ 24 | loadMoreSize?: number; 25 | /** 26 | * The number of items to render beyond the visible area. 27 | */ 28 | overscan?: number; 29 | }; 30 | 31 | export type VirtualizerOptions = VirtualizerOptionsCore; 32 | export type PartialVirtualizerOptions = Partial; 33 | 34 | export type ScrollMargin = { top: number; left: number }; 35 | 36 | export type GetVirtualItemProps = ( 37 | | { row: VirtualItem; column: VirtualItem } 38 | | { row: VirtualItem; column?: undefined } 39 | | { row?: undefined; column: VirtualItem } 40 | ) & { scrollMargin?: Partial }; 41 | 42 | export interface LoadMoreTriggerDefaults { 43 | /** 44 | * Position of the trigger. 45 | * @defaultValue bottom 46 | */ 47 | position?: 'top' | 'bottom' | 'left' | 'right'; 48 | /** 49 | * Size of the trigger. 50 | * @defaultValue 0 51 | */ 52 | size?: number; 53 | /** 54 | * Callback when the trigger is visible. 55 | */ 56 | onLoadMore?: () => void; 57 | } 58 | 59 | export type GetLoadMoreTriggerProps = Pick< 60 | Core.Grid, 61 | 'rowCount' | 'columnCount' | 'getItemRect' 62 | > & { 63 | virtualizer: Virtualizer; 64 | size?: number; 65 | }; 66 | -------------------------------------------------------------------------------- /apps/web/components/ui/number-input.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ChevronDown, ChevronUp } from 'lucide-react'; 4 | import { 5 | Button, 6 | Group, 7 | Input, 8 | NumberField, 9 | NumberFieldProps 10 | } from 'react-aria-components'; 11 | 12 | export function NumberInput(props: NumberFieldProps) { 13 | return ( 14 | 15 | 16 | 17 |
18 | 24 | 30 |
31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /examples/react/fixed/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { HeadlessColumns } from './components/columns/headless'; 2 | import { ColumnsWithComponent } from './components/columns/with-component'; 3 | import { HeadlessGridAutoColumns } from './components/grid-auto/headless'; 4 | import { GridAutoColumnsWithComponent } from './components/grid-auto/with-component'; 5 | import { HeadlessGridColumns } from './components/grid-columns/headless'; 6 | import { GridColumnsWithComponent } from './components/grid-columns/with-component'; 7 | import { HeadlessGridRows } from './components/grid-rows/headless'; 8 | import { GridRowsWithComponent } from './components/grid-rows/with-component'; 9 | import { HeadlessGrid } from './components/grid/headless'; 10 | import { GridWithComponent } from './components/grid/with-component'; 11 | import { HeadlessList } from './components/list/headless'; 12 | import { ListWithComponent } from './components/list/with-component'; 13 | import { Section } from './components/section'; 14 | 15 | export const App = () => { 16 | const isDev = import.meta.env.DEV; 17 | 18 | return ( 19 | <> 20 |
21 | 22 | {isDev && } 23 |
24 | 25 |
26 | 27 | {isDev && } 28 |
29 | 30 |
31 | 32 | {isDev && } 33 |
34 | 35 |
36 | 37 | {isDev && } 38 |
39 | 40 |
41 | 42 | {isDev && } 43 |
44 | 45 |
46 | 47 | {isDev && } 48 |
49 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /apps/web/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { cn } from '@/lib/utils'; 3 | import { Slot } from '@radix-ui/react-slot'; 4 | import { cva, type VariantProps } from 'class-variance-authority'; 5 | 6 | const buttonVariants = cva( 7 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', 8 | { 9 | variants: { 10 | variant: { 11 | default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', 12 | destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', 13 | outline: 14 | 'border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground', 15 | secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', 16 | ghost: 'hover:bg-accent hover:text-accent-foreground', 17 | link: 'text-primary underline-offset-4 hover:underline' 18 | }, 19 | size: { 20 | default: 'h-9 px-4 py-2', 21 | sm: 'h-8 rounded-md px-3 text-xs', 22 | lg: 'h-10 rounded-md px-8', 23 | icon: 'h-7 w-7' 24 | } 25 | }, 26 | defaultVariants: { 27 | variant: 'default', 28 | size: 'default' 29 | } 30 | } 31 | ); 32 | 33 | export interface ButtonProps 34 | extends React.ButtonHTMLAttributes, 35 | VariantProps { 36 | asChild?: boolean; 37 | } 38 | 39 | const Button = React.forwardRef( 40 | ({ className, variant, size, asChild = false, ...props }, ref) => { 41 | const Comp = asChild ? Slot : 'button'; 42 | return ( 43 | 44 | ); 45 | } 46 | ); 47 | Button.displayName = 'Button'; 48 | 49 | export { Button, buttonVariants }; 50 | -------------------------------------------------------------------------------- /examples/react/fixed/src/components/grid-columns/headless.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useGrid, useVirtualizer } from '@virtual-grid/react'; 4 | 5 | export const HeadlessGridColumns = () => { 6 | const ref = React.useRef(null); 7 | 8 | const grid = useGrid({ 9 | scrollRef: ref, 10 | count: 1000, 11 | columns: 3 12 | }); 13 | 14 | const rowVirtualizer = useVirtualizer(grid.rowVirtualizer); 15 | const columnVirtualizer = useVirtualizer(grid.columnVirtualizer); 16 | 17 | React.useEffect(() => { 18 | rowVirtualizer.measure(); 19 | }, [rowVirtualizer, grid.virtualItemHeight]); 20 | 21 | React.useEffect(() => { 22 | columnVirtualizer.measure(); 23 | }, [columnVirtualizer, grid.virtualItemWidth]); 24 | 25 | return ( 26 |
31 |
38 | {rowVirtualizer.getVirtualItems().map((virtualRow) => ( 39 | 40 | {columnVirtualizer.getVirtualItems().map((virtualColumn) => { 41 | const item = grid.getVirtualItem({ 42 | row: virtualRow, 43 | column: virtualColumn 44 | }); 45 | 46 | if (!item) return null; 47 | 48 | return ( 49 |
50 |
53 | {item.index} 54 |
55 |
56 | ); 57 | })} 58 |
59 | ))} 60 |
61 |
62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/src/components/rows/headless.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useInfiniteQuery } from '@tanstack/react-query'; 3 | 4 | import { LoadMoreTrigger, useGrid, useVirtualizer } from '@virtual-grid/react'; 5 | 6 | import { fetchServerPage } from '../../util/fetch'; 7 | 8 | export const RowsHeadless = () => { 9 | const { data, isFetchingNextPage, fetchNextPage, hasNextPage } = 10 | useInfiniteQuery({ 11 | queryKey: ['rows-headless'], 12 | queryFn: (ctx) => fetchServerPage(10, ctx.pageParam), 13 | getNextPageParam: (_lastGroup, groups) => groups.length, 14 | initialPageParam: 0 15 | }); 16 | 17 | const rows = data ? data.pages.flatMap(({ data }) => data) : []; 18 | 19 | const ref = React.useRef(null); 20 | 21 | const grid = useGrid({ 22 | scrollRef: ref, 23 | count: rows.length, 24 | size: { height: 100 }, 25 | onLoadMore: () => { 26 | if (hasNextPage && !isFetchingNextPage) { 27 | fetchNextPage(); 28 | } 29 | } 30 | }); 31 | 32 | const virtualizer = useVirtualizer(grid.rowVirtualizer); 33 | 34 | return ( 35 |
40 |
47 | {virtualizer.getVirtualItems().map((row) => { 48 | const item = grid.getVirtualItem({ row }); 49 | if (!item) return null; 50 | 51 | return ( 52 |
53 |
54 | {item.index} 55 |
56 |
57 | ); 58 | })} 59 | 60 | 61 |
62 |
63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /examples/react/fixed/src/components/grid-rows/headless.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useGrid, useVirtualizer } from '@virtual-grid/react'; 4 | 5 | export const HeadlessGridRows = () => { 6 | const ref = React.useRef(null); 7 | 8 | const grid = useGrid({ 9 | scrollRef: ref, 10 | count: 1000, 11 | rows: 3, 12 | horizontal: true 13 | }); 14 | 15 | const rowVirtualizer = useVirtualizer(grid.rowVirtualizer); 16 | const columnVirtualizer = useVirtualizer(grid.columnVirtualizer); 17 | 18 | React.useEffect(() => { 19 | rowVirtualizer.measure(); 20 | }, [rowVirtualizer, grid.virtualItemHeight]); 21 | 22 | React.useEffect(() => { 23 | columnVirtualizer.measure(); 24 | }, [columnVirtualizer, grid.virtualItemWidth]); 25 | 26 | return ( 27 |
32 |
39 | {rowVirtualizer.getVirtualItems().map((virtualRow) => ( 40 | 41 | {columnVirtualizer.getVirtualItems().map((virtualColumn) => { 42 | const item = grid.getVirtualItem({ 43 | row: virtualRow, 44 | column: virtualColumn 45 | }); 46 | 47 | if (!item) return null; 48 | 49 | return ( 50 |
51 |
54 | {item.index} 55 |
56 |
57 | ); 58 | })} 59 |
60 | ))} 61 |
62 |
63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/src/components/columns/headless.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useInfiniteQuery } from '@tanstack/react-query'; 3 | 4 | import { LoadMoreTrigger, useGrid, useVirtualizer } from '@virtual-grid/react'; 5 | 6 | import { fetchServerPage } from '../../util/fetch'; 7 | 8 | export const ColumnsHeadless = () => { 9 | const { data, isFetchingNextPage, fetchNextPage, hasNextPage } = 10 | useInfiniteQuery({ 11 | queryKey: ['columns-headless'], 12 | queryFn: (ctx) => fetchServerPage(10, ctx.pageParam), 13 | getNextPageParam: (_lastGroup, groups) => groups.length, 14 | initialPageParam: 0 15 | }); 16 | 17 | const columns = data ? data.pages.flatMap(({ data }) => data) : []; 18 | 19 | const ref = React.useRef(null); 20 | 21 | const grid = useGrid({ 22 | scrollRef: ref, 23 | count: columns.length, 24 | size: { width: 100 }, 25 | horizontal: true, 26 | onLoadMore: () => { 27 | if (hasNextPage && !isFetchingNextPage) { 28 | fetchNextPage(); 29 | } 30 | } 31 | }); 32 | 33 | const virtualizer = useVirtualizer(grid.columnVirtualizer); 34 | 35 | return ( 36 |
41 |
48 | {virtualizer.getVirtualItems().map((column) => { 49 | const item = grid.getVirtualItem({ column }); 50 | if (!item) return null; 51 | 52 | return ( 53 |
54 |
55 | {item.index} 56 |
57 |
58 | ); 59 | })} 60 | 61 | 62 |
63 |
64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /apps/web/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { cn } from '@/lib/utils'; 5 | import * as TabsPrimitive from '@radix-ui/react-tabs'; 6 | 7 | const Tabs = TabsPrimitive.Root; 8 | 9 | const TabsList = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | )); 22 | TabsList.displayName = TabsPrimitive.List.displayName; 23 | 24 | const TabsTrigger = React.forwardRef< 25 | React.ElementRef, 26 | React.ComponentPropsWithoutRef 27 | >(({ className, ...props }, ref) => ( 28 | 36 | )); 37 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; 38 | 39 | const TabsContent = React.forwardRef< 40 | React.ElementRef, 41 | React.ComponentPropsWithoutRef 42 | >(({ className, ...props }, ref) => ( 43 | 51 | )); 52 | TabsContent.displayName = TabsPrimitive.Content.displayName; 53 | 54 | export { Tabs, TabsList, TabsTrigger, TabsContent }; 55 | -------------------------------------------------------------------------------- /apps/web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ['class'], 4 | content: [ 5 | './pages/**/*.{ts,tsx}', 6 | './components/**/*.{ts,tsx}', 7 | './app/**/*.{ts,tsx}', 8 | './src/**/*.{ts,tsx}' 9 | ], 10 | theme: { 11 | container: { 12 | center: true, 13 | padding: '2rem', 14 | screens: { 15 | '2xl': '1400px' 16 | } 17 | }, 18 | extend: { 19 | colors: { 20 | border: 'hsl(var(--border))', 21 | input: 'hsl(var(--input))', 22 | ring: 'hsl(var(--ring))', 23 | background: 'hsl(var(--background))', 24 | foreground: 'hsl(var(--foreground))', 25 | primary: { 26 | DEFAULT: 'hsl(var(--primary))', 27 | foreground: 'hsl(var(--primary-foreground))' 28 | }, 29 | secondary: { 30 | DEFAULT: 'hsl(var(--secondary))', 31 | foreground: 'hsl(var(--secondary-foreground))' 32 | }, 33 | destructive: { 34 | DEFAULT: 'hsl(var(--destructive))', 35 | foreground: 'hsl(var(--destructive-foreground))' 36 | }, 37 | muted: { 38 | DEFAULT: 'hsl(var(--muted))', 39 | foreground: 'hsl(var(--muted-foreground))' 40 | }, 41 | accent: { 42 | DEFAULT: 'hsl(var(--accent))', 43 | foreground: 'hsl(var(--accent-foreground))' 44 | }, 45 | popover: { 46 | DEFAULT: 'hsl(var(--popover))', 47 | foreground: 'hsl(var(--popover-foreground))' 48 | }, 49 | card: { 50 | DEFAULT: 'hsl(var(--card))', 51 | foreground: 'hsl(var(--card-foreground))' 52 | } 53 | }, 54 | borderRadius: { 55 | lg: 'var(--radius)', 56 | md: 'calc(var(--radius) - 2px)', 57 | sm: 'calc(var(--radius) - 4px)' 58 | }, 59 | keyframes: { 60 | 'accordion-down': { 61 | from: { height: 0 }, 62 | to: { height: 'var(--radix-accordion-content-height)' } 63 | }, 64 | 'accordion-up': { 65 | from: { height: 'var(--radix-accordion-content-height)' }, 66 | to: { height: 0 } 67 | } 68 | }, 69 | animation: { 70 | 'accordion-down': 'accordion-down 0.2s ease-out', 71 | 'accordion-up': 'accordion-up 0.2s ease-out' 72 | }, 73 | backgroundImage: { 74 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))' 75 | } 76 | } 77 | }, 78 | plugins: [require('tailwindcss-animate')] 79 | }; 80 | -------------------------------------------------------------------------------- /packages/react/src/components/grid.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { LoadMoreTrigger, useVirtualizer, type useGrid } from '..'; 4 | import { useScrollMargin } from '../useScrollMargin'; 5 | 6 | export interface GridProps { 7 | grid: ReturnType; 8 | children: (index: number) => React.ReactNode; 9 | } 10 | 11 | export const Grid = ({ grid, children }: GridProps) => { 12 | const ref = React.useRef(null); 13 | 14 | const { scrollMargin } = useScrollMargin({ 15 | scrollRef: grid.scrollRef, 16 | gridRef: ref 17 | }); 18 | 19 | const rowVirtualizer = useVirtualizer({ 20 | ...grid.rowVirtualizer, 21 | scrollMargin: scrollMargin.top 22 | }); 23 | 24 | const columnVirtualizer = useVirtualizer({ 25 | ...grid.columnVirtualizer, 26 | scrollMargin: scrollMargin.left 27 | }); 28 | 29 | React.useEffect(() => { 30 | rowVirtualizer.measure(); 31 | }, [rowVirtualizer, grid.virtualItemHeight]); 32 | 33 | React.useEffect(() => { 34 | columnVirtualizer.measure(); 35 | }, [columnVirtualizer, grid.virtualItemWidth]); 36 | 37 | return ( 38 |
46 | {rowVirtualizer.getVirtualItems().map((virtualRow) => ( 47 | 48 | {columnVirtualizer.getVirtualItems().map((virtualColumn) => { 49 | const virtualItem = grid.getVirtualItem({ 50 | row: virtualRow, 51 | column: virtualColumn, 52 | scrollMargin: scrollMargin 53 | }); 54 | 55 | if (!virtualItem) return null; 56 | 57 | return ( 58 |
59 | {children(virtualItem.index)} 60 |
61 | ); 62 | })} 63 |
64 | ))} 65 | 66 | 73 |
74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /apps/web/components/ui/copy-button.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLAttributes, useEffect, useState } from 'react'; 2 | import { 3 | Tooltip, 4 | TooltipContent, 5 | TooltipProvider, 6 | TooltipTrigger 7 | } from '@/components/ui/tooltip'; 8 | import { cn } from '@/lib/utils'; 9 | import { Check } from '@phosphor-icons/react'; 10 | import { CopyIcon } from '@radix-ui/react-icons'; 11 | 12 | import { Button } from './button'; 13 | 14 | export const CopyButton = ({ 15 | onClick, 16 | className, 17 | ...props 18 | }: HTMLAttributes) => { 19 | const [copying, setCopying] = useState(undefined); 20 | 21 | useEffect(() => { 22 | if (copying === undefined) return; 23 | const timeout = setTimeout(() => setCopying(false), 1000); 24 | return () => clearTimeout(timeout); 25 | }, [copying]); 26 | 27 | return ( 28 | 29 | 30 | 31 | 57 | 58 | Copy to clipboard 59 | 60 | 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /packages/shared/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @virtual-grid/shared 2 | 3 | ## 2.0.1 4 | 5 | ### Patch Changes 6 | 7 | - 3473540: Make totalCount public 8 | 9 | ## 2.0.0 10 | 11 | ### Major Changes 12 | 13 | - 6002f32: ## Breaking changes 14 | 15 | - `grid.virtualizer` has been replaces by `grid.rowVirtualizer` and `grid.columnVirtualizer` 16 | - `onLoadMore` and `loadMoreSize` are not exported by `useGrid` anymore 17 | - `rowVirtualizer` and `columnVirtualizer` can't be passed to `useGrid` anymore 18 | - Properties of `grid.padding` and `grid.gap` are now partial 19 | 20 | ## Simplified headless integration 21 | 22 | `useGrid` now exports two new functions `getVirtualItem` and `getLoadMoreTrigger` which immensely reduce boilerplate code when integrating a headless solution. 23 | 24 | Example using `getVirtualItem`: 25 | 26 | ``` 27 | {rowVirtualizer.getVirtualItems().map((row) => { 28 | const item = grid.getVirtualItem({ row }); 29 | if (!item) return null; 30 | 31 | return ( 32 |
33 | ... 34 |
35 | ); 36 | })} 37 | ``` 38 | 39 | Example using `getLoadMoreTrigger` in combination with the new `` component. 40 | [See Example](https://github.com/niikeec/virtual-grid/examples/react/infinite-scroll) 41 | 42 | ``` 43 | 44 | ``` 45 | 46 | Scroll margin has also been simplified with the new `useScrollMargin` hook. 47 | [See Example](https://github.com/niikeec/virtual-grid/examples/react/scroll-margin) 48 | 49 | ``` 50 | const { scrollMargin } = useScrollMargin({ scrollRef, gridRef }); 51 | 52 | // ... 53 | 54 | const item = grid.getVirtualItem({ row, scrollMargin }); 55 | ``` 56 | 57 | ## Other changes 58 | 59 | - `rows` and `columns` are now independent of `horizontal` 60 | - Rows are now also resizable based on the height of the grid and number of rows using `horizontal`. Previously this was only possible with columns. 61 | - Improved performance by only measuring on demand 62 | - Bump @tanstack/react-virtual to 3.0.1 63 | 64 | ## What's up next? 65 | 66 | - Improve docs 67 | - Add more examples 68 | - SolidJS integration. We got an active PR [here](https://github.com/niikeec/virtual-grid/pull/7). 69 | 70 | [PR](https://github.com/niikeec/virtual-grid/pull/6) - [Documentation](https://docs.virtual-grid.com/getting-started/react) 71 | -------------------------------------------------------------------------------- /apps/web/components/controls/controls.tsx: -------------------------------------------------------------------------------- 1 | import { NumberInput } from '../ui/number-input'; 2 | import { ControlsSection } from './controls-section'; 3 | import { ControlsSectionField } from './controls-section-field'; 4 | import { useControls } from './controls.params'; 5 | 6 | export function Controls() { 7 | const { controls, setControls } = useControls(); 8 | 9 | return ( 10 |
11 | setControls({ size: val })} 15 | > 16 | 17 | setControls({ sizeWidth: val })} 21 | /> 22 | 23 | 24 | 25 | setControls({ sizeHeight: val })} 29 | /> 30 | 31 | 32 | 33 | setControls({ columns: val })} 37 | > 38 | 39 | setControls({ columnsCount: val })} 43 | /> 44 | 45 | 46 | 47 | 48 | 49 | setControls({ count: val })} 53 | /> 54 | 55 | 56 | 57 | setControls({ padding: val })} 61 | /> 62 | 63 | 64 | 65 | setControls({ gap: val })} 69 | /> 70 | 71 | 72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /packages/core/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @virtual-grid/core 2 | 3 | ## 2.0.1 4 | 5 | ### Patch Changes 6 | 7 | - 3473540: Make totalCount public 8 | 9 | ## 2.0.0 10 | 11 | ### Major Changes 12 | 13 | - 6002f32: ## Breaking changes 14 | 15 | - `grid.virtualizer` has been replaces by `grid.rowVirtualizer` and `grid.columnVirtualizer` 16 | - `onLoadMore` and `loadMoreSize` are not exported by `useGrid` anymore 17 | - `rowVirtualizer` and `columnVirtualizer` can't be passed to `useGrid` anymore 18 | - Properties of `grid.padding` and `grid.gap` are now partial 19 | 20 | ## Simplified headless integration 21 | 22 | `useGrid` now exports two new functions `getVirtualItem` and `getLoadMoreTrigger` which immensely reduce boilerplate code when integrating a headless solution. 23 | 24 | Example using `getVirtualItem`: 25 | 26 | ``` 27 | {rowVirtualizer.getVirtualItems().map((row) => { 28 | const item = grid.getVirtualItem({ row }); 29 | if (!item) return null; 30 | 31 | return ( 32 |
33 | ... 34 |
35 | ); 36 | })} 37 | ``` 38 | 39 | Example using `getLoadMoreTrigger` in combination with the new `` component. 40 | [See Example](https://github.com/niikeec/virtual-grid/examples/react/infinite-scroll) 41 | 42 | ``` 43 | 44 | ``` 45 | 46 | Scroll margin has also been simplified with the new `useScrollMargin` hook. 47 | [See Example](https://github.com/niikeec/virtual-grid/examples/react/scroll-margin) 48 | 49 | ``` 50 | const { scrollMargin } = useScrollMargin({ scrollRef, gridRef }); 51 | 52 | // ... 53 | 54 | const item = grid.getVirtualItem({ row, scrollMargin }); 55 | ``` 56 | 57 | ## Other changes 58 | 59 | - `rows` and `columns` are now independent of `horizontal` 60 | - Rows are now also resizable based on the height of the grid and number of rows using `horizontal`. Previously this was only possible with columns. 61 | - Improved performance by only measuring on demand 62 | - Bump @tanstack/react-virtual to 3.0.1 63 | 64 | ## What's up next? 65 | 66 | - Improve docs 67 | - Add more examples 68 | - SolidJS integration. We got an active PR [here](https://github.com/niikeec/virtual-grid/pull/7). 69 | 70 | [PR](https://github.com/niikeec/virtual-grid/pull/6) - [Documentation](https://docs.virtual-grid.com/getting-started/react) 71 | 72 | ## 1.1.0 73 | 74 | ### Minor Changes 75 | 76 | - Grid horizontal support 77 | 78 | ## 1.0.1 79 | 80 | ### Patch Changes 81 | 82 | - Fix grid offset & load more trigger height 83 | 84 | ## 1.0.0 85 | 86 | ### Major Changes 87 | 88 | - 9ab06ae: release 89 | -------------------------------------------------------------------------------- /examples/react/infinite-scroll/src/components/grid/headless.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useInfiniteQuery } from '@tanstack/react-query'; 3 | 4 | import { LoadMoreTrigger, useGrid, useVirtualizer } from '@virtual-grid/react'; 5 | 6 | import { fetchServerPage } from '../../util/fetch'; 7 | 8 | export const GridHeadless = () => { 9 | const { data, isFetchingNextPage, fetchNextPage, hasNextPage } = 10 | useInfiniteQuery({ 11 | queryKey: ['grid-headless'], 12 | queryFn: (ctx) => fetchServerPage(10, ctx.pageParam), 13 | getNextPageParam: (_lastGroup, groups) => groups.length, 14 | initialPageParam: 0 15 | }); 16 | 17 | const items = data ? data.pages.flatMap(({ data }) => data) : []; 18 | 19 | const ref = React.useRef(null); 20 | 21 | const grid = useGrid({ 22 | scrollRef: ref, 23 | count: items.length, 24 | columns: 3, 25 | onLoadMore: () => { 26 | if (hasNextPage && !isFetchingNextPage) { 27 | fetchNextPage(); 28 | } 29 | } 30 | }); 31 | const rowVirtualizer = useVirtualizer(grid.rowVirtualizer); 32 | const columnVirtualizer = useVirtualizer(grid.columnVirtualizer); 33 | 34 | React.useEffect(() => { 35 | rowVirtualizer.measure(); 36 | }, [rowVirtualizer, grid.virtualItemHeight]); 37 | 38 | React.useEffect(() => { 39 | columnVirtualizer.measure(); 40 | }, [columnVirtualizer, grid.virtualItemWidth]); 41 | 42 | return ( 43 |
48 |
55 | {rowVirtualizer.getVirtualItems().map((virtualRow) => ( 56 | 57 | {columnVirtualizer.getVirtualItems().map((virtualColumn) => { 58 | const item = grid.getVirtualItem({ 59 | row: virtualRow, 60 | column: virtualColumn 61 | }); 62 | 63 | if (!item) return null; 64 | 65 | return ( 66 |
67 |
70 | {item.index} 71 |
72 |
73 | ); 74 | })} 75 |
76 | ))} 77 | 78 | 79 |
80 |
81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /apps/web/components/usage/headless.tsx: -------------------------------------------------------------------------------- 1 | export const headless = `import * as React from 'react'; 2 | import { useGrid, useVirtualizer } from '@virtual-grid/react'; 3 | 4 | const App = () => { 5 | const ref = React.useRef(null); 6 | 7 | const grid = useGrid({ 8 | scrollRef: ref, 9 | count: 1000, 10 | size: 120 11 | // ... 12 | }); 13 | 14 | const rowVirtualizer = useVirtualizer(grid.virtualizer.rowVirtualizer); 15 | const columnVirtualizer = useVirtualizer(grid.virtualizer.columnVirtualizer); 16 | 17 | const virtualRows = rowVirtualizer.getVirtualItems(); 18 | const virtualColumns = columnVirtualizer.getVirtualItems(); 19 | 20 | // Uncomment if grid is not static 21 | // React.useEffect(() => { 22 | // rowVirtualizer.measure(); 23 | // }, [rowVirtualizer, grid.virtualItemSize.height]); 24 | 25 | // React.useEffect(() => { 26 | // columnVirtualizer.measure(); 27 | // }, [columnVirtualizer, grid.virtualItemSize.width]); 28 | 29 | return ( 30 |
31 |
38 | {virtualRows.map((virtualRow) => ( 39 | 40 | {virtualColumns.map((virtualColumn) => { 41 | // let index = virtualRow.index * grid.columnCount + virtualColumn.index; 42 | // if (index >= grid.count) return null; 43 | 44 | return ( 45 |
58 |
65 | ... 66 |
67 |
68 | ); 69 | })} 70 |
71 | ))} 72 |
73 |
74 | ); 75 | };`; 76 | -------------------------------------------------------------------------------- /apps/docs/pages/getting-started/react.mdx: -------------------------------------------------------------------------------- 1 | import { Steps, Tabs } from 'nextra-theme-docs'; 2 | 3 | # React Setup 4 | 5 | 6 | ### Install package 7 | 8 | ```bash copy npm2yarn 9 | npm install @virtual-grid/react 10 | ``` 11 | 12 | ### Render grid 13 | 14 | 15 | 16 | ```tsx copy 17 | import * as React from 'react'; 18 | import { Grid, useGrid } from '@virtual-grid/react'; 19 | 20 | const App = () => { 21 | const ref = React.useRef(null); 22 | 23 | const grid = useGrid({ 24 | scrollRef: ref, 25 | count: 1000 26 | // ... 27 | }); 28 | 29 | return ( 30 |
31 | {(i) =>
...
}
32 |
33 | ); 34 | }; 35 | ``` 36 | 37 |
38 | 39 | ```tsx copy 40 | import * as React from 'react'; 41 | import { useGrid, useVirtualizer } from '@virtual-grid/react'; 42 | 43 | const App = () => { 44 | const ref = React.useRef(null); 45 | 46 | const grid = useGrid({ 47 | scrollRef: ref, 48 | count: 1000, 49 | columns: 3 50 | // ... 51 | }); 52 | 53 | const rowVirtualizer = useVirtualizer(grid.rowVirtualizer); 54 | const columnVirtualizer = useVirtualizer(grid.columnVirtualizer); 55 | 56 | React.useEffect(() => { 57 | rowVirtualizer.measure(); 58 | }, [rowVirtualizer, grid.virtualItemHeight]); 59 | 60 | React.useEffect(() => { 61 | columnVirtualizer.measure(); 62 | }, [columnVirtualizer, grid.virtualItemWidth]); 63 | 64 | return ( 65 |
66 |
73 | {rowVirtualizer.getVirtualItems().map((virtualRow) => ( 74 | 75 | {columnVirtualizer.getVirtualItems().map((virtualColumn) => { 76 | const item = grid.getVirtualItem({ 77 | row: virtualRow, 78 | column: virtualColumn 79 | }); 80 | 81 | if (!item) return null; 82 | 83 | return ( 84 |
85 | ... 86 |
87 | ); 88 | })} 89 |
90 | ))} 91 |
92 |
93 | ); 94 | }; 95 | ``` 96 | 97 |
98 |
99 | 100 |
101 | -------------------------------------------------------------------------------- /apps/web/components/demo/window.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState, type PropsWithChildren } from 'react'; 2 | import { DotsThree } from '@phosphor-icons/react'; 3 | 4 | const MIN_WIDTH = 300; 5 | const MIN_HEIGHT = 300; 6 | 7 | export const Window = ({ children }: PropsWithChildren) => { 8 | const ref = useRef(null); 9 | 10 | const resizing = useRef(false); 11 | const resizingDirection = useRef<'x' | 'y'>(); 12 | 13 | const [width, setWidth] = useState(); 14 | const [height, setHeight] = useState(); 15 | 16 | const setInitialSize = () => { 17 | if (!ref.current) return; 18 | 19 | const { width, height } = ref.current.getBoundingClientRect(); 20 | 21 | setWidth(width); 22 | setHeight(height); 23 | }; 24 | 25 | useEffect(() => { 26 | const handleMouseMove = (event: MouseEvent) => { 27 | if (!resizing.current || !resizingDirection.current) return; 28 | 29 | if (resizingDirection.current === 'x') { 30 | setWidth((width) => (width ?? 0) + event.movementX * 2); 31 | } else if (resizingDirection.current === 'y') { 32 | setHeight((height) => (height ?? 0) + event.movementY); 33 | } 34 | }; 35 | 36 | const handleMouseUp = () => { 37 | resizing.current = false; 38 | resizingDirection.current = undefined; 39 | document.body.style.cursor = 'auto'; 40 | }; 41 | 42 | document.addEventListener('mousemove', handleMouseMove); 43 | document.addEventListener('mouseup', handleMouseUp); 44 | 45 | return () => { 46 | document.removeEventListener('mousemove', handleMouseMove); 47 | document.removeEventListener('mouseup', handleMouseUp); 48 | }; 49 | }, []); 50 | 51 | return ( 52 |
62 |
63 | {children} 64 |
65 | 66 |
{ 69 | resizing.current = true; 70 | resizingDirection.current = 'y'; 71 | document.body.style.cursor = 'ns-resize'; 72 | setInitialSize(); 73 | }} 74 | > 75 | 76 |
77 | 78 |
{ 81 | resizing.current = true; 82 | resizingDirection.current = 'x'; 83 | document.body.style.cursor = 'ew-resize'; 84 | setInitialSize(); 85 | }} 86 | > 87 | 88 |
89 |
90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /apps/web/components/ui/drawer.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { cn } from '@/lib/utils'; 5 | import { Drawer as DrawerPrimitive } from 'vaul'; 6 | 7 | const Drawer = ({ 8 | shouldScaleBackground = true, 9 | ...props 10 | }: React.ComponentProps) => ( 11 | 15 | ); 16 | Drawer.displayName = 'Drawer'; 17 | 18 | const DrawerTrigger = DrawerPrimitive.Trigger; 19 | 20 | const DrawerPortal = DrawerPrimitive.Portal; 21 | 22 | const DrawerClose = DrawerPrimitive.Close; 23 | 24 | const DrawerOverlay = React.forwardRef< 25 | React.ElementRef, 26 | React.ComponentPropsWithoutRef 27 | >(({ className, ...props }, ref) => ( 28 | 33 | )); 34 | DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; 35 | 36 | const DrawerContent = React.forwardRef< 37 | React.ElementRef, 38 | React.ComponentPropsWithoutRef 39 | >(({ className, children, ...props }, ref) => ( 40 | 41 | 42 | 50 |
51 | {children} 52 | 53 | 54 | )); 55 | DrawerContent.displayName = 'DrawerContent'; 56 | 57 | const DrawerHeader = ({ 58 | className, 59 | ...props 60 | }: React.HTMLAttributes) => ( 61 |
65 | ); 66 | DrawerHeader.displayName = 'DrawerHeader'; 67 | 68 | const DrawerFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
76 | ); 77 | DrawerFooter.displayName = 'DrawerFooter'; 78 | 79 | const DrawerTitle = React.forwardRef< 80 | React.ElementRef, 81 | React.ComponentPropsWithoutRef 82 | >(({ className, ...props }, ref) => ( 83 | 91 | )); 92 | DrawerTitle.displayName = DrawerPrimitive.Title.displayName; 93 | 94 | const DrawerDescription = React.forwardRef< 95 | React.ElementRef, 96 | React.ComponentPropsWithoutRef 97 | >(({ className, ...props }, ref) => ( 98 | 103 | )); 104 | DrawerDescription.displayName = DrawerPrimitive.Description.displayName; 105 | 106 | export { 107 | Drawer, 108 | DrawerPortal, 109 | DrawerOverlay, 110 | DrawerTrigger, 111 | DrawerClose, 112 | DrawerContent, 113 | DrawerHeader, 114 | DrawerFooter, 115 | DrawerTitle, 116 | DrawerDescription 117 | }; 118 | -------------------------------------------------------------------------------- /packages/react/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @virtual-grid/react 2 | 3 | ## 2.0.3 4 | 5 | ### Patch Changes 6 | 7 | - cd26778: fix rerender issue 8 | 9 | ## 2.0.2 10 | 11 | ### Patch Changes 12 | 13 | - 3473540: Make totalCount public 14 | - Updated dependencies [3473540] 15 | - @virtual-grid/core@2.0.1 16 | - @virtual-grid/shared@2.0.1 17 | 18 | ## 2.0.1 19 | 20 | ### Patch Changes 21 | 22 | - 62cfcf9: Fix passing width and height props as undefined, which would previously override internal sizes. 23 | 24 | ## 2.0.0 25 | 26 | ### Major Changes 27 | 28 | - 6002f32: ## Breaking changes 29 | 30 | - `grid.virtualizer` has been replaces by `grid.rowVirtualizer` and `grid.columnVirtualizer` 31 | - `onLoadMore` and `loadMoreSize` are not exported by `useGrid` anymore 32 | - `rowVirtualizer` and `columnVirtualizer` can't be passed to `useGrid` anymore 33 | - Properties of `grid.padding` and `grid.gap` are now partial 34 | 35 | ## Simplified headless integration 36 | 37 | `useGrid` now exports two new functions `getVirtualItem` and `getLoadMoreTrigger` which immensely reduce boilerplate code when integrating a headless solution. 38 | 39 | Example using `getVirtualItem`: 40 | 41 | ``` 42 | {rowVirtualizer.getVirtualItems().map((row) => { 43 | const item = grid.getVirtualItem({ row }); 44 | if (!item) return null; 45 | 46 | return ( 47 |
48 | ... 49 |
50 | ); 51 | })} 52 | ``` 53 | 54 | Example using `getLoadMoreTrigger` in combination with the new `` component. 55 | [See Example](https://github.com/niikeec/virtual-grid/examples/react/infinite-scroll) 56 | 57 | ``` 58 | 59 | ``` 60 | 61 | Scroll margin has also been simplified with the new `useScrollMargin` hook. 62 | [See Example](https://github.com/niikeec/virtual-grid/examples/react/scroll-margin) 63 | 64 | ``` 65 | const { scrollMargin } = useScrollMargin({ scrollRef, gridRef }); 66 | 67 | // ... 68 | 69 | const item = grid.getVirtualItem({ row, scrollMargin }); 70 | ``` 71 | 72 | ## Other changes 73 | 74 | - `rows` and `columns` are now independent of `horizontal` 75 | - Rows are now also resizable based on the height of the grid and number of rows using `horizontal`. Previously this was only possible with columns. 76 | - Improved performance by only measuring on demand 77 | - Bump @tanstack/react-virtual to 3.0.1 78 | 79 | ## What's up next? 80 | 81 | - Improve docs 82 | - Add more examples 83 | - SolidJS integration. We got an active PR [here](https://github.com/niikeec/virtual-grid/pull/7). 84 | 85 | [PR](https://github.com/niikeec/virtual-grid/pull/6) - [Documentation](https://docs.virtual-grid.com/getting-started/react) 86 | 87 | ### Patch Changes 88 | 89 | - Updated dependencies [6002f32] 90 | - @virtual-grid/shared@2.0.0 91 | - @virtual-grid/core@2.0.0 92 | 93 | ## 1.1.0 94 | 95 | ### Minor Changes 96 | 97 | - Grid horizontal support 98 | 99 | ### Patch Changes 100 | 101 | - Updated dependencies 102 | - @virtual-grid/core@1.1.0 103 | 104 | ## 1.0.2 105 | 106 | ### Patch Changes 107 | 108 | - Fix grid offset & load more trigger height 109 | - Updated dependencies 110 | - @virtual-grid/core@1.0.1 111 | 112 | ## 1.0.1 113 | 114 | ### Patch Changes 115 | 116 | - Render improvement of grid items and load more trigger 117 | 118 | ## 1.0.0 119 | 120 | ### Major Changes 121 | 122 | - 9ab06ae: release 123 | 124 | ### Patch Changes 125 | 126 | - Updated dependencies [9ab06ae] 127 | - @virtual-grid/core@1.0.0 128 | -------------------------------------------------------------------------------- /apps/docs/pages/api-reference/react/use-grid.mdx: -------------------------------------------------------------------------------- 1 | ## useGrid 2 | 3 | ```tsx copy 4 | import * as React from 'react'; 5 | import { useGrid } from '@virtual-grid/react'; 6 | 7 | const Page = () => { 8 | const ref = React.useRef(null); 9 | 10 | const grid = useGrid({ 11 | scrollRef: ref, 12 | count: 1000 13 | // ... 14 | }); 15 | 16 | // ... 17 | }; 18 | ``` 19 | 20 | ## Configurations 21 | 22 | | Option | Type | Required | Description | 23 | | :----------- | :------------------------------------------------------------------------------------------------- | :------- | ------------------------------------------------------------------------------------------------------------------- | 24 | | scrollRef | `RefObject` | Yes | Reference to the scrollable element | 25 | | count | number | Yes | Number of items to render | 26 | | totalCount | number | No | Total number of items to render. Can be used to achieve a seamless scroll behaviour when combined with `onLoadMore` | 27 | | size | number \| `{width: number, height: number}` | No / Yes | Size of grid items | 28 | | columns | number \| "auto" | No | Number of columns to render | 29 | | rows | number | No | Number of rows to render | 30 | | width | number | No | Width of the grid container | 31 | | height | number | No | Height of the grid container | 32 | | padding | number \| `{x?: number, y?: number, top?: number, bottom?: number, left?: number, right?: number}` | No | Grid padding | 33 | | gap | number \| `{x?: number, y?: number}` | No | Grid gap | 34 | | invert | boolean | No | Invert items in grid | 35 | | horizontal | boolean | No | Horizontal mode places items in rows from top to bottom. `onLoadMore` trigger is placed on the x-axis | 36 | | getItemId | function | No | Callback for grid item id | 37 | | getItemData | function | No | Callback for grid item data | 38 | | onLoadMore | function | No | Renders an area which triggers the callback when scrolled into view | 39 | | loadMoreSize | number | No | Set the size of the load more trigger | 40 | -------------------------------------------------------------------------------- /apps/web/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { cn } from '@/lib/utils'; 5 | import { CaretSortIcon, CheckIcon } from '@radix-ui/react-icons'; 6 | import * as SelectPrimitive from '@radix-ui/react-select'; 7 | 8 | const Select = SelectPrimitive.Root; 9 | 10 | const SelectGroup = SelectPrimitive.Group; 11 | 12 | const SelectValue = SelectPrimitive.Value; 13 | 14 | const SelectTrigger = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, children, ...props }, ref) => ( 18 | 26 | {children} 27 | 28 | 29 | 30 | 31 | )); 32 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; 33 | 34 | const SelectContent = React.forwardRef< 35 | React.ElementRef, 36 | React.ComponentPropsWithoutRef 37 | >(({ className, children, position = 'popper', ...props }, ref) => ( 38 | 39 | 50 | 57 | {children} 58 | 59 | 60 | 61 | )); 62 | SelectContent.displayName = SelectPrimitive.Content.displayName; 63 | 64 | const SelectLabel = React.forwardRef< 65 | React.ElementRef, 66 | React.ComponentPropsWithoutRef 67 | >(({ className, ...props }, ref) => ( 68 | 73 | )); 74 | SelectLabel.displayName = SelectPrimitive.Label.displayName; 75 | 76 | const SelectItem = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef 79 | >(({ className, children, ...props }, ref) => ( 80 | 88 | 89 | 90 | 91 | 92 | 93 | {children} 94 | 95 | )); 96 | SelectItem.displayName = SelectPrimitive.Item.displayName; 97 | 98 | const SelectSeparator = React.forwardRef< 99 | React.ElementRef, 100 | React.ComponentPropsWithoutRef 101 | >(({ className, ...props }, ref) => ( 102 | 107 | )); 108 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName; 109 | 110 | export { 111 | Select, 112 | SelectGroup, 113 | SelectValue, 114 | SelectTrigger, 115 | SelectContent, 116 | SelectLabel, 117 | SelectItem, 118 | SelectSeparator 119 | }; 120 | -------------------------------------------------------------------------------- /packages/react/src/useGrid.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Virtualizer } from '@tanstack/react-virtual'; 3 | import { useDeepCompareMemo } from 'use-deep-compare'; 4 | import useResizeObserver from 'use-resize-observer'; 5 | 6 | import * as Core from '@virtual-grid/core'; 7 | import { 8 | getColumnVirtualizerOptions, 9 | getLoadMoreTriggerHeight, 10 | getLoadMoreTriggerWidth, 11 | getRowVirtualizerOptions, 12 | getVirtualItemIndex, 13 | GetVirtualItemProps, 14 | getVirtualItemStyle, 15 | GridProps, 16 | observeGridSize, 17 | PartialVirtualizerOptions 18 | } from '@virtual-grid/shared'; 19 | 20 | import { LoadMoreTriggerProps } from './components'; 21 | 22 | export type UseGridProps< 23 | IdT extends Core.GridItemId = Core.GridItemId, 24 | DataT extends Core.GridItemData = Core.GridItemData 25 | > = GridProps & { scrollRef: React.RefObject }; 26 | 27 | export const useGrid = < 28 | IdT extends Core.GridItemId, 29 | DataT extends Core.GridItemData 30 | >( 31 | props: UseGridProps 32 | ) => { 33 | const { scrollRef, overscan, onLoadMore, loadMoreSize, ...options } = props; 34 | const { getItemId, getItemData, invert, ...measureOptions } = options; 35 | 36 | const rerender = React.useReducer(() => ({}), {})[1]; 37 | 38 | const [gridWidth, setGridWidth] = React.useState(0); 39 | const [gridHeight, setGridHeight] = React.useState(0); 40 | 41 | const width = options.width ?? gridWidth; 42 | const height = options.height ?? gridHeight; 43 | 44 | // Initialize grid instance 45 | const [{ setOptions, measure, ...grid }] = React.useState( 46 | () => new Core.Grid({ ...options, width, height }) 47 | ); 48 | 49 | // Update grid options 50 | setOptions({ ...options, width, height }); 51 | 52 | // Measure grid when options that require a measure change 53 | useDeepCompareMemo(() => { 54 | measure(); 55 | rerender(); 56 | }, [measure, rerender, measureOptions, width, height]); 57 | 58 | // Check if grid size should be observed 59 | const observeGrid = observeGridSize(props); 60 | 61 | // Observe grid 62 | useResizeObserver({ 63 | ref: observeGrid ? scrollRef : undefined, 64 | round: React.useCallback((val: number) => val, []), 65 | onResize: ({ width, height }) => { 66 | width !== undefined && setGridWidth(width); 67 | height !== undefined && setGridHeight(height); 68 | } 69 | }); 70 | 71 | // Measure grid before resize observer 72 | React.useLayoutEffect(() => { 73 | const node = scrollRef.current; 74 | if (!node || !observeGrid) return; 75 | 76 | const { width, height } = node.getBoundingClientRect(); 77 | 78 | const { 79 | borderLeftWidth, 80 | borderRightWidth, 81 | borderTopWidth, 82 | borderBottomWidth 83 | } = getComputedStyle(node); 84 | 85 | const borderX = parseFloat(borderLeftWidth) + parseFloat(borderRightWidth); 86 | const borderY = parseFloat(borderTopWidth) + parseFloat(borderBottomWidth); 87 | 88 | // Remove border from width/height so we have the un-rounded client width/height 89 | setGridWidth(width - borderX); 90 | setGridHeight(height - borderY); 91 | }, [scrollRef, observeGrid]); 92 | 93 | const rowVirtualizer = { 94 | ...getRowVirtualizerOptions(grid), 95 | getScrollElement: () => scrollRef.current, 96 | overscan: overscan 97 | } satisfies PartialVirtualizerOptions; 98 | 99 | const columnVirtualizer = { 100 | ...getColumnVirtualizerOptions(grid), 101 | getScrollElement: () => scrollRef.current, 102 | overscan: overscan 103 | } satisfies PartialVirtualizerOptions; 104 | 105 | const getVirtualItem = (props: GetVirtualItemProps) => { 106 | const index = getVirtualItemIndex(grid, props); 107 | if (!grid.isIndexValid(index)) return; 108 | 109 | const { size, padding, translate } = getVirtualItemStyle(grid, props); 110 | 111 | const style = { 112 | position: 'absolute', 113 | top: '0px', 114 | left: '0px', 115 | width: size.width !== undefined ? `${size.width}px` : '100%', 116 | height: size.height !== undefined ? `${size.height}px` : '100%', 117 | transform: `translateX(${translate.x}px) translateY(${translate.y}px)`, 118 | paddingLeft: `${padding.left}px`, 119 | paddingRight: `${padding.right}px`, 120 | paddingTop: `${padding.top}px`, 121 | paddingBottom: `${padding.bottom}px`, 122 | boxSizing: 'border-box' 123 | } satisfies React.CSSProperties; 124 | 125 | return { index, style }; 126 | }; 127 | 128 | const getLoadMoreTrigger = ({ 129 | virtualizer 130 | }: { 131 | virtualizer?: Virtualizer; 132 | } = {}) => { 133 | const position = grid.options.horizontal ? 'right' : 'bottom'; 134 | 135 | const getSize = grid.options.horizontal 136 | ? getLoadMoreTriggerWidth 137 | : getLoadMoreTriggerHeight; 138 | 139 | const size = virtualizer 140 | ? getSize({ ...grid, virtualizer, size: loadMoreSize }) 141 | : loadMoreSize; 142 | 143 | return { position, size, onLoadMore } satisfies LoadMoreTriggerProps; 144 | }; 145 | 146 | return { 147 | ...grid, 148 | scrollRef, 149 | rowVirtualizer, 150 | columnVirtualizer, 151 | getVirtualItem, 152 | getLoadMoreTrigger 153 | }; 154 | }; 155 | -------------------------------------------------------------------------------- /packages/shared/src/util.ts: -------------------------------------------------------------------------------- 1 | import * as Core from '@virtual-grid/core'; 2 | 3 | import { 4 | GetLoadMoreTriggerProps, 5 | GetVirtualItemProps, 6 | GridProps, 7 | PartialVirtualizerOptions 8 | } from './types'; 9 | 10 | export const getRowVirtualizerOptions = ( 11 | grid: Pick 12 | ) => { 13 | return { 14 | count: grid.totalRowCount, 15 | estimateSize: grid.getItemHeight, 16 | paddingStart: grid.padding.top, 17 | paddingEnd: grid.padding.bottom 18 | } satisfies PartialVirtualizerOptions; 19 | }; 20 | 21 | export const getColumnVirtualizerOptions = ( 22 | grid: Pick 23 | ) => { 24 | return { 25 | horizontal: true, 26 | count: grid.totalColumnCount, 27 | estimateSize: grid.getItemWidth, 28 | paddingStart: grid.padding.left, 29 | paddingEnd: grid.padding.right 30 | } satisfies PartialVirtualizerOptions; 31 | }; 32 | 33 | export const observeGridSize = (props: GridProps) => { 34 | if (props.horizontal) { 35 | // Don't observe if grid height is set 36 | if (props.height !== undefined) return false; 37 | 38 | // Don't observe if rows is set to 0 or less 39 | if (props.rows !== undefined && props.rows <= 0) return false; 40 | 41 | // Don't observe if item height is set 42 | if (typeof props.size === 'object' && props.size.height !== undefined) { 43 | return false; 44 | } 45 | 46 | // Don't observe if item size is defined 47 | if (typeof props.size === 'number') return false; 48 | 49 | return true; 50 | } 51 | 52 | // Don't observe if grid width is set 53 | if (props.width !== undefined) return false; 54 | 55 | if (props.columns === 'auto') { 56 | // Don't observe if item size is set to 0 or less 57 | if (typeof props.size === 'number' && props.size <= 0) { 58 | return false; 59 | } 60 | 61 | // Don't observe if item width is set to 0 or less 62 | if (typeof props.size === 'object' && props.size.width <= 0) { 63 | return false; 64 | } 65 | // Don't observe if columns is set to 0 or less 66 | } else if (props.columns !== undefined && props.columns <= 0) { 67 | return false; 68 | // Don't observe if item width is set 69 | } else if (typeof props.size === 'object' && props.size.width !== undefined) { 70 | return false; 71 | // Don't observe if item size is set 72 | } else if (typeof props.size === 'number') { 73 | return false; 74 | } 75 | 76 | return true; 77 | }; 78 | 79 | export const getVirtualItemIndex = ( 80 | grid: Pick, 81 | { row, column }: GetVirtualItemProps 82 | ) => { 83 | let index: number; 84 | 85 | if (row && column) { 86 | index = grid.getItemIndex(row.index, column.index); 87 | } else { 88 | index = row ? row.index : column.index; 89 | if (grid.options.invert) index = grid.invertIndex(index); 90 | } 91 | 92 | return index; 93 | }; 94 | 95 | export const getVirtualItemStyle = ( 96 | grid: Pick, 97 | { row, column, scrollMargin }: GetVirtualItemProps 98 | ) => { 99 | const gap = { 100 | x: (column && column.index !== 0 && grid.gap.x) || 0, 101 | y: (row && row.index !== 0 && grid.gap.y) || 0 102 | }; 103 | 104 | const gridPadding = { 105 | bottom: (!row && grid.padding.bottom) || 0, 106 | top: (!row && grid.padding.top) || 0, 107 | right: (!column && grid.padding.right) || 0, 108 | left: (!column && grid.padding.left) || 0 109 | }; 110 | 111 | const offsetPadding = 112 | column && grid.itemWidth ? (column.size - gap.x - grid.itemWidth) / 2 : 0; 113 | 114 | const size = { 115 | width: column?.size ?? grid.itemWidth, 116 | height: row?.size ?? grid.itemHeight 117 | }; 118 | 119 | const padding = { 120 | left: gridPadding.left + offsetPadding + gap.x, 121 | right: gridPadding.right + offsetPadding, 122 | top: gridPadding.top + gap.y, 123 | bottom: gridPadding.bottom 124 | }; 125 | 126 | const translate = { 127 | x: (column?.start ?? 0) - (scrollMargin?.left ?? 0), 128 | y: (row?.start ?? 0) - (scrollMargin?.top ?? 0) 129 | }; 130 | 131 | return { size, padding, translate }; 132 | }; 133 | 134 | export const getLoadMoreTriggerHeight = ( 135 | props: GetLoadMoreTriggerProps & Pick 136 | ) => { 137 | if (props.totalRowCount === props.rowCount) return props.size; 138 | 139 | const rect = props.getItemRect(props.rowCount * props.columnCount); 140 | if (!rect) return; 141 | 142 | const loadMoreHeight = 143 | props.size ?? props.virtualizer.scrollElement?.clientHeight ?? 0; 144 | 145 | const virtualizerHeight = props.virtualizer.getTotalSize(); 146 | 147 | const triggerHeight = virtualizerHeight - rect.top + loadMoreHeight; 148 | 149 | return Math.min(virtualizerHeight, triggerHeight); 150 | }; 151 | 152 | export const getLoadMoreTriggerWidth = ( 153 | props: GetLoadMoreTriggerProps & Pick 154 | ) => { 155 | if (props.totalColumnCount === props.columnCount) return props.size; 156 | 157 | const rect = props.getItemRect(props.rowCount * props.columnCount); 158 | if (!rect) return; 159 | 160 | const loadMoreWidth = 161 | props.size ?? props.virtualizer.scrollElement?.clientWidth ?? 0; 162 | 163 | const virtualizerWidth = props.virtualizer.getTotalSize(); 164 | 165 | const triggerWidth = virtualizerWidth - rect.left + loadMoreWidth; 166 | 167 | return Math.min(virtualizerWidth, triggerWidth); 168 | }; 169 | -------------------------------------------------------------------------------- /packages/core/src/grid.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | GridGap, 3 | GridItem, 4 | GridItemData, 5 | GridItemId, 6 | GridPadding 7 | } from './types'; 8 | import { RequireAtLeastOne } from './utils/types'; 9 | 10 | export interface DefaultGridProps< 11 | IdT extends GridItemId, 12 | DataT extends GridItemData 13 | > { 14 | /** 15 | * Number of grid items. 16 | */ 17 | count: number; 18 | /** 19 | * Total number of grid items. 20 | */ 21 | totalCount?: number; 22 | /** 23 | * Number of columns. 24 | * @defaultValue 1 25 | */ 26 | columns?: number | 'auto'; 27 | /** 28 | * Number of rows. 29 | * @defaultValue 1 30 | */ 31 | rows?: number; 32 | /** 33 | * Grid item size. 34 | */ 35 | size?: number | { width?: number; height?: number }; 36 | /** 37 | * Grid width. 38 | */ 39 | width?: number; 40 | /** 41 | * Grid height. 42 | */ 43 | height?: number; 44 | /** 45 | * Grid padding. 46 | */ 47 | padding?: number | GridPadding; 48 | /** 49 | * Grid gap. 50 | */ 51 | gap?: number | GridGap; 52 | /** 53 | * Invert items in grid. 54 | */ 55 | invert?: boolean; 56 | /** 57 | * Horizontal mode places items in rows from top to bottom. `onLoadMore` area is placed on the x-axis. 58 | */ 59 | horizontal?: boolean; 60 | /** 61 | * Callback function for grid item `id` in `getItem` function. 62 | */ 63 | getItemId?: (index: number) => IdT | undefined; 64 | /** 65 | * Callback function for grid item `data` in `getItem` function. 66 | */ 67 | getItemData?: (index: number) => DataT; 68 | } 69 | 70 | export interface BaseGridProps< 71 | IdT extends GridItemId, 72 | DataT extends GridItemData 73 | > extends DefaultGridProps { 74 | columns?: number; 75 | horizontal?: false; 76 | } 77 | 78 | export interface AutoColumnsGridProps< 79 | IdT extends GridItemId, 80 | DataT extends GridItemData 81 | > extends DefaultGridProps { 82 | columns: 'auto'; 83 | horizontal?: false; 84 | size: number | { width: number; height: number }; 85 | } 86 | 87 | export interface HorizontalGridProps< 88 | IdT extends GridItemId, 89 | DataT extends GridItemData 90 | > extends DefaultGridProps { 91 | columns?: number; 92 | horizontal: true; 93 | size?: number | { width: number; height?: number }; 94 | } 95 | 96 | export type GridProps = 97 | | RequireAtLeastOne, 'width'> 98 | | RequireAtLeastOne, 'width'> 99 | | RequireAtLeastOne, 'height'>; 100 | 101 | export class Grid< 102 | IdT extends GridItemId = GridItemId, 103 | DataT extends GridItemData = GridItemData 104 | > { 105 | options: GridProps; 106 | 107 | rowCount = 0; 108 | columnCount = 0; 109 | 110 | totalCount = 0; 111 | totalRowCount = 0; 112 | totalColumnCount = 0; 113 | 114 | itemWidth: number | undefined = undefined; 115 | itemHeight: number | undefined = undefined; 116 | 117 | virtualItemWidth = 0; 118 | virtualItemHeight = 0; 119 | 120 | padding: Omit = {}; 121 | gap: GridGap = {}; 122 | 123 | constructor(props: GridProps) { 124 | this.options = props; 125 | this.initOptions(); 126 | this.measure(); 127 | } 128 | 129 | private getPadding = (key: keyof GridPadding) => { 130 | return typeof this.options.padding === 'object' 131 | ? this.options.padding[key] 132 | : this.options.padding; 133 | }; 134 | 135 | private getRowCount = (count: number, columns: number) => { 136 | return (columns && Math.ceil(count / columns)) || 0; 137 | }; 138 | 139 | private getColumnCount = (count: number, rows: number) => { 140 | return (rows && Math.ceil(count / rows)) || 0; 141 | }; 142 | 143 | private isPositiveInt = (value: number) => { 144 | return value >= 0 && Number.isInteger(value); 145 | }; 146 | 147 | isIndexValid = (index: number, mode: 'default' | 'total' = 'default') => { 148 | return ( 149 | this.isPositiveInt(index) && 150 | index < (mode === 'default' ? this.options.count : this.totalCount) 151 | ); 152 | }; 153 | 154 | invertIndex = (index: number) => { 155 | const invertedIndex = this.totalCount - 1 - index; 156 | return invertedIndex < 0 ? index : invertedIndex; 157 | }; 158 | 159 | setOptions = (props: GridProps) => { 160 | this.options = props; 161 | this.initOptions(); 162 | }; 163 | 164 | private initOptions = () => { 165 | const { gap, size } = this.options; 166 | 167 | this.padding = { 168 | top: this.getPadding('top') ?? this.getPadding('y'), 169 | bottom: this.getPadding('bottom') ?? this.getPadding('y'), 170 | left: this.getPadding('left') ?? this.getPadding('x'), 171 | right: this.getPadding('right') ?? this.getPadding('x') 172 | }; 173 | 174 | this.gap = { 175 | x: typeof gap === 'object' ? gap.x : gap, 176 | y: typeof gap === 'object' ? gap.y : gap 177 | }; 178 | 179 | this.itemWidth = typeof size === 'object' ? size.width : size; 180 | this.itemHeight = typeof size === 'object' ? size.height : size; 181 | }; 182 | 183 | measure = () => { 184 | const { options } = this; 185 | 186 | const count = options.count; 187 | if (!this.isPositiveInt(count)) { 188 | throw new Error(`Invalid option count -> ${count}`); 189 | } 190 | 191 | const totalCount = options.totalCount; 192 | if (typeof totalCount === 'number' && !this.isPositiveInt(totalCount)) { 193 | throw new Error(`Invalid option totalCount -> ${totalCount}`); 194 | } 195 | 196 | this.totalCount = !totalCount ? count : Math.max(count, totalCount); 197 | 198 | const rows = options.rows; 199 | if (typeof rows === 'number' && !this.isPositiveInt(rows)) { 200 | throw new Error(`Invalid option rows -> ${rows}`); 201 | } 202 | 203 | const columns = options.columns; 204 | if (typeof columns === 'number' && !this.isPositiveInt(columns)) { 205 | throw new Error(`Invalid option columns -> ${columns}`); 206 | } 207 | 208 | const gridWidth = options.width 209 | ? options.width - ((this.padding.left ?? 0) + (this.padding.right ?? 0)) 210 | : undefined; 211 | 212 | const gridHeight = options.height 213 | ? options.height - ((this.padding.top ?? 0) + (this.padding.bottom ?? 0)) 214 | : undefined; 215 | 216 | // Column count 217 | if (typeof columns === 'number' || (!columns && !this.options.horizontal)) { 218 | this.columnCount = columns ?? 1; 219 | this.totalColumnCount = columns ?? 1; 220 | } else if (this.options.horizontal) { 221 | this.columnCount = this.getColumnCount(count, rows ?? 1); 222 | this.totalColumnCount = this.getColumnCount(this.totalCount, rows ?? 1); 223 | } else if (columns === 'auto' && gridWidth) { 224 | if (!this.itemWidth || !this.isPositiveInt(this.itemWidth)) { 225 | throw new Error(`Invalid option itemWidth -> ${this.itemWidth}`); 226 | } 227 | 228 | this.columnCount = Math.floor(gridWidth / this.itemWidth); 229 | 230 | if (this.gap.x) { 231 | const space = gridWidth - (this.columnCount - 1) * this.gap.x; 232 | this.columnCount = Math.floor(space / this.itemWidth); 233 | } 234 | 235 | this.totalColumnCount = this.columnCount; 236 | } 237 | 238 | // Row count 239 | if (rows !== undefined || this.options.horizontal) { 240 | this.rowCount = rows ?? 1; 241 | this.totalRowCount = rows ?? 1; 242 | } else if (!this.options.horizontal) { 243 | this.rowCount = this.getRowCount(count, this.columnCount); 244 | this.totalRowCount = this.getRowCount(this.totalCount, this.columnCount); 245 | } 246 | 247 | // Virtual item width 248 | if (columns !== 'auto' && this.itemWidth !== undefined) { 249 | this.virtualItemWidth = this.itemWidth; 250 | } else if (this.options.horizontal && this.rowCount && gridHeight) { 251 | const space = gridHeight - (this.rowCount - 1) * (this.gap.y ?? 0); 252 | this.virtualItemWidth = space / this.rowCount; 253 | } else if (this.columnCount && gridWidth) { 254 | const space = gridWidth - (this.columnCount - 1) * (this.gap.x ?? 0); 255 | this.virtualItemWidth = space / this.columnCount; 256 | } 257 | 258 | // Virtual item height 259 | if (this.itemHeight !== undefined) { 260 | this.virtualItemHeight = this.itemHeight; 261 | } else if (!this.options.horizontal) { 262 | this.virtualItemHeight = this.virtualItemWidth; 263 | } else if (this.rowCount && gridHeight) { 264 | const space = gridHeight - (this.rowCount - 1) * (this.gap.y ?? 0); 265 | this.virtualItemHeight = space / this.rowCount; 266 | } 267 | }; 268 | 269 | getItemPosition = (index: number) => { 270 | if (!this.isIndexValid(index, 'total')) return; 271 | 272 | if (this.options.invert) index = this.invertIndex(index); 273 | 274 | const row = this.options.horizontal 275 | ? index % this.rowCount 276 | : Math.trunc(index / this.columnCount); 277 | 278 | const column = this.options.horizontal 279 | ? Math.trunc(index / this.rowCount) 280 | : index % this.columnCount; 281 | 282 | return { row, column }; 283 | }; 284 | 285 | getItemRect = (index: number) => { 286 | const position = this.getItemPosition(index); 287 | if (!position) return; 288 | 289 | const row = 290 | this.options.invert && !this.options.horizontal 291 | ? this.options.count - 1 - position.row 292 | : position.row; 293 | 294 | const column = 295 | this.options.invert && this.options.horizontal 296 | ? this.options.count - 1 - position.column 297 | : position.column; 298 | 299 | const x = this.virtualItemWidth 300 | ? (this.padding?.left ?? 0) + 301 | (column !== 0 ? this.gap?.x ?? 0 : 0) * column + 302 | this.virtualItemWidth * column 303 | : 0; 304 | 305 | const y = this.virtualItemHeight 306 | ? (this.padding?.top ?? 0) + 307 | (row !== 0 ? this.gap?.y ?? 0 : 0) * row + 308 | this.virtualItemHeight * row 309 | : 0; 310 | 311 | return { 312 | height: this.virtualItemHeight, 313 | width: this.virtualItemWidth, 314 | top: y, 315 | bottom: y + this.virtualItemHeight, 316 | left: x, 317 | right: x + this.virtualItemWidth, 318 | x, 319 | y 320 | } satisfies GridItem['rect']; 321 | }; 322 | 323 | getItemHeight = (index: number) => { 324 | return this.virtualItemHeight + ((index > 0 && this.gap.y) || 0); 325 | }; 326 | 327 | getItemWidth = (index: number) => { 328 | return this.virtualItemWidth + ((index > 0 && this.gap.x) || 0); 329 | }; 330 | 331 | getItem = (index: number) => { 332 | if (!this.isIndexValid(index)) return; 333 | 334 | const id = this.options.getItemId?.(index) ?? index; 335 | const data = this.options.getItemData?.(index); 336 | 337 | const { row, column } = this.getItemPosition(index)!; 338 | const rect = this.getItemRect(index)!; 339 | 340 | return { index, id, data, row, column, rect }; 341 | }; 342 | 343 | getItemIndex = (row: number, column: number) => { 344 | const index = this.options.horizontal 345 | ? column * this.rowCount + row 346 | : row * this.columnCount + column; 347 | 348 | return this.options.invert ? this.invertIndex(index) : index; 349 | }; 350 | } 351 | --------------------------------------------------------------------------------