├── .github └── workflows │ └── website.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── example ├── index.html └── index.tsx ├── package-lock.json ├── package.json ├── src └── GridList.tsx ├── tsconfig.json └── types └── ResizeObserver.d.ts /.github/workflows/website.yml: -------------------------------------------------------------------------------- 1 | name: Website Deploy 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | jobs: 8 | build-and-deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 🛎️ 12 | uses: actions/checkout@v2 13 | with: 14 | persist-credentials: false 15 | 16 | - uses: actions/cache@v1 17 | with: 18 | path: ~/.npm 19 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 20 | restore-keys: | 21 | ${{ runner.os }}-node- 22 | 23 | - name: Install 24 | run: npm install 25 | 26 | - uses: actions/cache@v1 27 | with: 28 | path: .cache 29 | key: ${{ runner.os }}-build-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('src/**') }}-${{ hashFiles('example/**') }} 30 | restore-keys: | 31 | ${{ runner.os }}-build- 32 | 33 | - name: Build 34 | run: npm run build:example 35 | 36 | - name: Deploy 37 | uses: JamesIves/github-pages-deploy-action@releases/v3 38 | with: 39 | ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} 40 | BRANCH: gh-pages 41 | FOLDER: example-dist 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .rts2_cache_* 4 | dist 5 | .cache 6 | example-dist 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .*ignore 2 | LICENSE 3 | package-lock.json 4 | dist 5 | .cache 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "semi": false, 4 | "trailingComma": "all", 5 | "overrides": [ 6 | { 7 | "files": "**/*.{md,json}", 8 | "options": { 9 | "useTabs": false, 10 | "proseWrap": "always" 11 | } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020-present Jamie Kyle 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React GridList 2 | 3 | > A virtual-scrolling GridList component based on CSS Grids. 4 | 5 | - Render anything (not just images) of a known width/height inside. 6 | - Variable height items in the same row. 7 | - Highly performant virtual scrolling (aka windowing) for buttery smoothness. 8 | - Customizable & Responsive. 9 | - [Very small bundle size](https://bundlephobia.com/result?p=react-gridlist) 10 | 11 | ## Install 12 | 13 | ```sh 14 | npm install --save react-gridlist 15 | ``` 16 | 17 | ## Example 18 | 19 | ```js 20 | import React from "react" 21 | import GridList from "react-gridlist" 22 | 23 | function getGridGap(elementWidth: number, windowHeight: number) { 24 | if (elementWidth > 720 && windowHeight > 480) { 25 | return 10 26 | } else { 27 | return 5 28 | } 29 | } 30 | 31 | function getColumnCount(elementWidth: number, gridGap: number) { 32 | return Math.floor((elementWidth + gridGap) / (300 + gridGap)) 33 | } 34 | 35 | function getWindowMargin(windowHeight: number) { 36 | return Math.round(windowHeight * 1.5) 37 | } 38 | 39 | function getItemData(image: Image, columnWidth: number) { 40 | let imageRatio = image.height / image.width 41 | let adjustedHeight = Math.round(columnWidth * imageRatio) 42 | 43 | return { 44 | key: image.url, 45 | height: adjustedHeight, 46 | } 47 | } 48 | 49 | function Example(props) { 50 | return ( 51 | { 58 | return ( 59 | 65 | ) 66 | }} 67 | /> 68 | ) 69 | } 70 | ``` 71 | 72 | ## Fixed Column Width 73 | 74 | You can also pass a `fixedColumnWidth` to lock the columns to a specific pixel 75 | width. 76 | 77 | ```js 78 | 82 | ``` 83 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React GridList 7 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react" 2 | import { css } from "emotion" 3 | import { render } from "react-dom" 4 | import GitHubButton from "react-github-btn" 5 | import GridList from "../src/GridList" 6 | 7 | import ResizeObserver from "resize-observer-polyfill" 8 | ;(window as any).ResizeObserver = ResizeObserver 9 | 10 | const ITEM_WIDTH = 300 11 | 12 | interface Image { 13 | url: string 14 | width: number 15 | height: number 16 | } 17 | 18 | function getGridGap(elementWidth: number, windowHeight: number) { 19 | if (elementWidth > 720 && windowHeight > 480) { 20 | return 10 21 | } else { 22 | return 5 23 | } 24 | } 25 | 26 | function getColumnCount(elementWidth: number, gridGap: number) { 27 | return Math.floor((elementWidth + gridGap) / (ITEM_WIDTH + gridGap)) 28 | } 29 | 30 | function getWindowMargin(windowHeight: number) { 31 | return Math.round(windowHeight * 1.5) 32 | } 33 | 34 | function getItemData(image: Image, columnWidth: number) { 35 | let imageRatio = image.height / image.width 36 | let adjustedHeight = Math.round(columnWidth * imageRatio) 37 | 38 | return { 39 | key: image.url, 40 | height: adjustedHeight, 41 | } 42 | } 43 | 44 | function getFixedItemData(image: Image) { 45 | return { 46 | key: image.url, 47 | height: image.height, 48 | } 49 | } 50 | 51 | function ImageGridList(props: { images: Image[]; fixed: boolean }) { 52 | return ( 53 | { 61 | return ( 62 | 68 | ) 69 | }} 70 | /> 71 | ) 72 | } 73 | 74 | function random(low: number, high: number) { 75 | return Math.floor(Math.random() * high) + low 76 | } 77 | 78 | const IMAGES = Array.from({ length: 80 }, (_, index) => { 79 | let width = 300 80 | let height = random(200, 300) 81 | return { 82 | url: `https://picsum.photos/id/${index + 1}/${width}/${height}.jpg`, 83 | width, 84 | height, 85 | } 86 | }) 87 | 88 | let styles = { 89 | headerLink: css` 90 | text-decoration: none; 91 | color: inherit; 92 | `, 93 | header: css` 94 | display: flex; 95 | justify-content: center; 96 | margin: 100px 0; 97 | transform: rotate(-2deg); 98 | `, 99 | title: css` 100 | margin: 0; 101 | color: white; 102 | text-align: center; 103 | font-size: 10vw; 104 | font-weight: 900; 105 | `, 106 | circle: css` 107 | display: flex; 108 | padding: 20px 40px; 109 | justify-content: center; 110 | align-items: center; 111 | background: hsl(265, 100%, 50%); 112 | flex-direction: column; 113 | `, 114 | switch: css` 115 | display: flex; 116 | justify-content: center; 117 | padding-bottom: 60px; 118 | `, 119 | btn: css` 120 | background: hsl(265, 100%, 50%); 121 | font-family: inherit; 122 | border: 0; 123 | color: white; 124 | font-weight: bold; 125 | font-size: 20px; 126 | padding: 15px 25px; 127 | cursor: pointer; 128 | opacity: 0.5; 129 | `, 130 | heading: css` 131 | margin: 100px 0; 132 | font-size: 10vw; 133 | font-weight: 900; 134 | line-height: 1.1; 135 | `, 136 | image: css` 137 | position: relative; 138 | width: 100%; 139 | height: auto; 140 | vertical-align: top; 141 | background: hsl(0, 0%, 98%); 142 | 143 | transition: 100ms ease; 144 | transition-property: transform box-shadow; 145 | 146 | &:hover { 147 | z-index: 1; 148 | transform: scale(1.25); 149 | box-shadow: 0 11px 15px -7px rgba(0, 0, 0, 0.2), 150 | 0 24px 38px 3px rgba(0, 0, 0, 0.14), 0 9px 46px 8px rgba(0, 0, 0, 0.12); 151 | } 152 | `, 153 | } 154 | 155 | const App = () => { 156 | const [fixed, setFixed] = useState(false) 157 | 158 | return ( 159 | <> 160 |
161 | 165 |
166 |

{"React "}

167 | 174 | Star 175 | 176 |
177 |
178 |
179 | 180 |
181 | 188 | 195 |
196 | 197 | 198 |

Look ma, more grid...

199 | 200 | 201 | ) 202 | } 203 | 204 | render(, document.getElementById("root")) 205 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-gridlist", 3 | "version": "1.1.0", 4 | "description": "A virtual-scrolling GridList component based on CSS Grids.", 5 | "author": "Jamie Kyle ", 6 | "license": "MIT", 7 | "repository": "jamiebuilds/react-gridlist", 8 | "source": "src/GridList.tsx", 9 | "main": "dist/GridList.js", 10 | "module": "dist/GridList.module.js", 11 | "unpkg": "dist/GridList.umd.js", 12 | "keywords": [ 13 | "react", 14 | "grid", 15 | "table", 16 | "list", 17 | "listview", 18 | "virtual", 19 | "scroll", 20 | "scrolling", 21 | "scrollable", 22 | "window", 23 | "windowing", 24 | "columns", 25 | "rows", 26 | "image", 27 | "content", 28 | "css", 29 | "typescript", 30 | "ts", 31 | "type", 32 | "types" 33 | ], 34 | "scripts": { 35 | "check": "tsc --noEmit", 36 | "build": "rm -rf dist && microbundle --tsconfig tsconfig.build.json --external react,resize-observer-polyfill --globals resize-observer-polyfill=ResizeObserver --name GridList", 37 | "build:example": "rm -rf example-dist && parcel build example/index.html -d example-dist --public-url ./", 38 | "start": "rm -rf example-dist && parcel example/index.html -d example-dist", 39 | "prepublishOnly": "npm run -s build" 40 | }, 41 | "peerDependencies": { 42 | "react": "^16.13.1" 43 | }, 44 | "devDependencies": { 45 | "@types/react": "^16.9.27", 46 | "@types/react-dom": "^16.9.5", 47 | "emotion": "^10.0.27", 48 | "husky": "^4.2.3", 49 | "lint-staged": "^10.0.10", 50 | "microbundle": "^0.11.0", 51 | "parcel": "^1.12.4", 52 | "prettier": "^2.0.2", 53 | "react": "^16.13.1", 54 | "react-dom": "^16.13.1", 55 | "react-github-btn": "^1.1.1", 56 | "typescript": "^3.8.3" 57 | }, 58 | "dependencies": { 59 | "resize-observer-polyfill": "^1.5.1" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/GridList.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useRef, 3 | useMemo, 4 | useState, 5 | useEffect, 6 | RefObject, 7 | MutableRefObject, 8 | } from "react" 9 | 10 | /** 11 | * ============================================================================ 12 | * Generic Types 13 | * ============================================================================ 14 | */ 15 | 16 | type ConstRef = Readonly> 17 | 18 | interface ElementSize { 19 | width: number 20 | height: number 21 | } 22 | 23 | interface ElementScroll { 24 | x: number 25 | y: number 26 | } 27 | 28 | /** 29 | * ============================================================================ 30 | * Generic Utils 31 | * ============================================================================ 32 | */ 33 | 34 | function isSameElementSize(a: ElementSize, b: ElementSize) { 35 | return a.width === b.width && a.height === b.height 36 | } 37 | 38 | function getWindowSize(): ElementSize { 39 | return { 40 | width: window.innerWidth, 41 | height: window.innerHeight, 42 | } 43 | } 44 | 45 | function getElementSize(element: Element): ElementSize { 46 | let rect = element.getBoundingClientRect() 47 | return { 48 | width: rect.width, 49 | height: rect.height, 50 | } 51 | } 52 | 53 | function isSameElementScroll(a: ElementScroll, b: ElementScroll) { 54 | return a.x === b.x && a.y === b.y 55 | } 56 | 57 | function getWindowScroll(): ElementScroll { 58 | return { 59 | x: window.scrollX, 60 | y: window.scrollY, 61 | } 62 | } 63 | 64 | function getElementOffset(element: Element) { 65 | return window.scrollY + element.getBoundingClientRect().top 66 | } 67 | 68 | /** 69 | * ============================================================================ 70 | * Utility Hooks 71 | * ============================================================================ 72 | */ 73 | 74 | function useConstRef(value: T): ConstRef { 75 | let ref = useRef(value) 76 | ref.current = value 77 | return ref 78 | } 79 | 80 | function useWindowSize(): ElementSize { 81 | let [windowSize, setWindowSize] = useState(() => getWindowSize()) 82 | let windowSizeRef = useConstRef(windowSize) 83 | 84 | useEffect(() => { 85 | function onResize() { 86 | let nextWindowSize = getWindowSize() 87 | if (!isSameElementSize(windowSizeRef.current, nextWindowSize)) { 88 | setWindowSize(nextWindowSize) 89 | } 90 | } 91 | window.addEventListener("resize", onResize) 92 | return () => window.removeEventListener("resize", onResize) 93 | }, [windowSizeRef]) 94 | 95 | return windowSize 96 | } 97 | 98 | function useElementSize(ref: RefObject): ElementSize | null { 99 | let [elementSize, setElementSize] = useState(() => { 100 | if (ref.current) { 101 | return getElementSize(ref.current) 102 | } else { 103 | return null 104 | } 105 | }) 106 | 107 | let elementSizeRef = useConstRef(elementSize) 108 | 109 | useEffect(() => { 110 | let observer = new ResizeObserver((entries) => { 111 | let nextElementSize = getElementSize(entries[0].target) 112 | if ( 113 | elementSizeRef.current === null || 114 | !isSameElementSize(elementSizeRef.current, nextElementSize) 115 | ) { 116 | setElementSize(nextElementSize) 117 | } 118 | }) 119 | if (ref.current) observer.observe(ref.current) 120 | return () => observer.disconnect() 121 | }, [ref]) 122 | 123 | return elementSize 124 | } 125 | 126 | function useWindowScroll(): ElementScroll { 127 | let [scrollPosition, setScrollPosition] = useState(getWindowScroll()) 128 | let ref = useConstRef(scrollPosition) 129 | 130 | useEffect(() => { 131 | function update() { 132 | let nextScrollPosition = getWindowScroll() 133 | if (!isSameElementScroll(ref.current, nextScrollPosition)) { 134 | setScrollPosition(nextScrollPosition) 135 | } 136 | } 137 | 138 | window.addEventListener("scroll", update) 139 | window.addEventListener("resize", update) 140 | 141 | return () => { 142 | window.removeEventListener("scroll", update) 143 | window.removeEventListener("resize", update) 144 | } 145 | }, [ref]) 146 | 147 | return scrollPosition 148 | } 149 | 150 | function useElementWindowOffset(ref: RefObject) { 151 | let [elementOffset, setElementOffset] = useState(() => { 152 | if (ref.current) { 153 | return getElementOffset(ref.current) 154 | } else { 155 | return null 156 | } 157 | }) 158 | 159 | useEffect(() => { 160 | let observer = new ResizeObserver((entries) => { 161 | setElementOffset(getElementOffset(entries[0].target)) 162 | }) 163 | if (ref.current) observer.observe(ref.current) 164 | return () => observer.disconnect() 165 | }, [ref]) 166 | 167 | return elementOffset 168 | } 169 | 170 | function useIntersecting(ref: RefObject, rootMargin: string) { 171 | let [intersecting, setIntersecting] = useState(false) 172 | 173 | useEffect(() => { 174 | let observer = new IntersectionObserver( 175 | (entries) => { 176 | setIntersecting(entries[0].isIntersecting) 177 | }, 178 | { rootMargin }, 179 | ) 180 | if (ref.current) observer.observe(ref.current) 181 | return () => observer.disconnect() 182 | }, [ref, rootMargin]) 183 | 184 | return intersecting 185 | } 186 | 187 | /** 188 | * ============================================================================ 189 | * GridList Types 190 | * ============================================================================ 191 | */ 192 | 193 | interface GridListItemData { 194 | key: string 195 | height: number 196 | } 197 | 198 | interface GridListEntry

{ 199 | item: P 200 | data: GridListItemData 201 | } 202 | 203 | interface GridListConfigData

{ 204 | windowMargin: number 205 | gridGap: number 206 | columnCount: number 207 | entries: GridListEntry

[] 208 | } 209 | 210 | interface GridListContainerData { 211 | windowSize: ElementSize 212 | elementSize: ElementSize | null 213 | windowScroll: ElementScroll 214 | elementWindowOffset: number | null 215 | } 216 | 217 | interface GridListCell

{ 218 | key: string 219 | columnNumber: number 220 | rowNumber: number 221 | offset: number 222 | height: number 223 | item: P 224 | } 225 | 226 | interface GridListLayoutData

{ 227 | totalHeight: number 228 | cells: GridListCell

[] 229 | } 230 | 231 | interface GridListRenderData

{ 232 | cellsToRender: GridListCell

[] 233 | firstRenderedRowNumber: number | null 234 | firstRenderedRowOffset: number | null 235 | } 236 | 237 | /** 238 | * ============================================================================ 239 | * GridList Utils 240 | * ============================================================================ 241 | */ 242 | 243 | function getColumnWidth( 244 | columnCount: number | null, 245 | gridGap: number | null, 246 | elementWidth: number | null, 247 | ) { 248 | if (columnCount === null || gridGap === null || elementWidth === null) { 249 | return null 250 | } 251 | 252 | let totalGapSpace = (columnCount - 1) * gridGap 253 | let columnWidth = Math.round((elementWidth - totalGapSpace) / columnCount) 254 | 255 | return columnWidth 256 | } 257 | 258 | function getGridRowStart

( 259 | cell: GridListCell

, 260 | renderData: GridListRenderData

| null, 261 | ) { 262 | if (renderData === null) return undefined 263 | 264 | let offset = 265 | renderData.firstRenderedRowNumber !== null 266 | ? renderData.firstRenderedRowNumber - 1 267 | : 0 268 | let gridRowStart = cell.rowNumber - offset 269 | 270 | return `${gridRowStart}` 271 | } 272 | 273 | /** 274 | * ============================================================================ 275 | * GridList Hooks 276 | * ============================================================================ 277 | */ 278 | 279 | function useGridListContainerData( 280 | ref: RefObject, 281 | ): GridListContainerData { 282 | let windowSize = useWindowSize() 283 | let windowScroll = useWindowScroll() 284 | let elementWindowOffset = useElementWindowOffset(ref) 285 | let elementSize = useElementSize(ref) 286 | 287 | return useMemo(() => { 288 | return { 289 | windowSize, 290 | windowScroll, 291 | elementWindowOffset, 292 | elementSize, 293 | } 294 | }, [windowSize, windowScroll, elementWindowOffset, elementSize]) 295 | } 296 | 297 | function useGridListConfigData

( 298 | containerData: GridListContainerData, 299 | props: GridListProps

, 300 | ): GridListConfigData

| null { 301 | let { 302 | items, 303 | getWindowMargin, 304 | getGridGap, 305 | getColumnCount, 306 | getItemData, 307 | } = props 308 | 309 | let elementWidth = containerData.elementSize 310 | ? containerData.elementSize.width 311 | : null 312 | 313 | let windowMargin = useMemo(() => { 314 | if (getWindowMargin) { 315 | return getWindowMargin(containerData.windowSize.height) 316 | } else { 317 | return containerData.windowSize.height 318 | } 319 | }, [containerData.windowSize.height, getWindowMargin]) 320 | 321 | let gridGap = useMemo(() => { 322 | if (elementWidth === null) return null 323 | if (getGridGap) { 324 | return getGridGap(elementWidth, containerData.windowSize.height) 325 | } else { 326 | return 0 327 | } 328 | }, [elementWidth, containerData.windowSize.height, getGridGap]) 329 | 330 | let columnCount = useMemo(() => { 331 | if (elementWidth === null) return null 332 | if (gridGap === null) return null 333 | return getColumnCount(elementWidth, gridGap) 334 | }, [getColumnCount, elementWidth, gridGap]) 335 | 336 | let columnWidth = getColumnWidth(columnCount, gridGap, elementWidth) 337 | 338 | let entries = useMemo(() => { 339 | if (columnWidth === null) return null 340 | let safeColumnWidth = columnWidth 341 | return items.map((item) => { 342 | return { 343 | data: getItemData(item, safeColumnWidth), 344 | item, 345 | } 346 | }) 347 | }, [items, columnWidth, getItemData]) 348 | 349 | return useMemo(() => { 350 | if ( 351 | windowMargin === null || 352 | gridGap === null || 353 | columnCount === null || 354 | entries === null 355 | ) { 356 | return null 357 | } 358 | return { 359 | windowMargin, 360 | gridGap, 361 | columnCount, 362 | entries, 363 | } 364 | }, [windowMargin, gridGap, columnCount, entries]) 365 | } 366 | 367 | function useGridListLayoutData

( 368 | configData: GridListConfigData

| null, 369 | ): GridListLayoutData

| null { 370 | return useMemo(() => { 371 | if (configData === null) return null 372 | 373 | let currentRowNumber = 1 374 | let prevRowsTotalHeight = 0 375 | let currentRowMaxHeight = 0 376 | 377 | let cells = configData.entries.map((entry, index) => { 378 | let key = entry.data.key 379 | 380 | let columnNumber = (index % configData.columnCount) + 1 381 | let rowNumber = Math.floor(index / configData.columnCount) + 1 382 | 383 | if (rowNumber !== currentRowNumber) { 384 | currentRowNumber = rowNumber 385 | prevRowsTotalHeight += currentRowMaxHeight + configData.gridGap 386 | currentRowMaxHeight = 0 387 | } 388 | 389 | let offset = prevRowsTotalHeight 390 | let height = Math.round(entry.data.height) 391 | 392 | currentRowMaxHeight = Math.max(currentRowMaxHeight, height) 393 | 394 | return { key, columnNumber, rowNumber, offset, height, item: entry.item } 395 | }) 396 | 397 | let totalHeight = prevRowsTotalHeight + currentRowMaxHeight 398 | 399 | return { totalHeight, cells } 400 | }, [configData]) 401 | } 402 | 403 | function useGridListRenderData

( 404 | containerData: GridListContainerData, 405 | configData: GridListConfigData

| null, 406 | layoutData: GridListLayoutData

| null, 407 | ): GridListRenderData

| null { 408 | return useMemo(() => { 409 | if (layoutData === null || configData === null) return null 410 | let cellsToRender: GridListCell

[] = [] 411 | let firstRenderedRowNumber: null | number = null 412 | let firstRenderedRowOffset: null | number = null 413 | 414 | if (containerData.elementWindowOffset !== null) { 415 | let elementWindowOffset = containerData.elementWindowOffset 416 | 417 | for (let cell of layoutData.cells) { 418 | let cellTop = elementWindowOffset + cell.offset 419 | let cellBottom = cellTop + cell.height 420 | 421 | let windowTop = containerData.windowScroll.y 422 | let windowBottom = windowTop + containerData.windowSize.height 423 | 424 | let renderTop = windowTop - configData.windowMargin 425 | let renderBottom = windowBottom + configData.windowMargin 426 | 427 | if (cellTop > renderBottom) continue 428 | if (cellBottom < renderTop) continue 429 | 430 | if (firstRenderedRowNumber === null) { 431 | firstRenderedRowNumber = cell.rowNumber 432 | } 433 | 434 | if (cell.rowNumber === firstRenderedRowNumber) { 435 | firstRenderedRowOffset = firstRenderedRowOffset 436 | ? Math.min(firstRenderedRowOffset, cell.offset) 437 | : cell.offset 438 | } 439 | 440 | cellsToRender.push(cell) 441 | } 442 | } 443 | 444 | return { cellsToRender, firstRenderedRowNumber, firstRenderedRowOffset } 445 | }, [ 446 | layoutData, 447 | configData, 448 | containerData.windowScroll.y, 449 | containerData.windowSize.height, 450 | containerData.elementWindowOffset, 451 | ]) 452 | } 453 | 454 | /** 455 | * ============================================================================ 456 | * GridList 457 | * ============================================================================ 458 | */ 459 | 460 | export interface GridListProps

{ 461 | items: P[] 462 | getGridGap?: (elementWidth: number, windowHeight: number) => number 463 | getWindowMargin?: (windowHeight: number) => number 464 | getColumnCount: (elementWidth: number, gridGap: number) => number 465 | getItemData: (item: P, columnWidth: number) => GridListItemData 466 | renderItem: (item: P) => React.ReactNode 467 | fixedColumnWidth?: number 468 | } 469 | 470 | export default function GridList

(props: GridListProps

) { 471 | let ref = useRef(null) 472 | 473 | let containerData = useGridListContainerData(ref) 474 | let configData = useGridListConfigData(containerData, props) 475 | let layoutData = useGridListLayoutData(configData) 476 | let renderData = useGridListRenderData(containerData, configData, layoutData) 477 | 478 | let intersecting = useIntersecting( 479 | ref, 480 | `${configData !== null ? configData.windowMargin : 0}px`, 481 | ) 482 | 483 | const colWidth = props.fixedColumnWidth 484 | ? `${props.fixedColumnWidth}px` 485 | : "1fr" 486 | 487 | return ( 488 |

499 | {intersecting && ( 500 |
512 | {renderData !== null && 513 | renderData.cellsToRender.map((cell) => { 514 | return ( 515 |
523 | {props.renderItem(cell.item)} 524 |
525 | ) 526 | })} 527 |
528 | )} 529 |
530 | ) 531 | } 532 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | // prettier-ignore 2 | { 3 | "include": ["src/**/*", "types/*"], 4 | "compilerOptions": { 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "esnext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | "lib": ["es2015", "dom"], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | // "outDir": "./", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | 65 | /* Advanced Options */ 66 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /types/ResizeObserver.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The **ResizeObserver** interface reports changes to the dimensions of an 3 | * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element)'s content 4 | * or border box, or the bounding box of an 5 | * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement). 6 | * 7 | * > **Note**: The content box is the box in which content can be placed, 8 | * > meaning the border box minus the padding and border width. The border box 9 | * > encompasses the content, padding, and border. See 10 | * > [The box model](https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/The_box_model) 11 | * > for further explanation. 12 | * 13 | * `ResizeObserver` avoids infinite callback loops and cyclic dependencies that 14 | * are often created when resizing via a callback function. It does this by only 15 | * processing elements deeper in the DOM in subsequent frames. Implementations 16 | * should, if they follow the specification, invoke resize events before paint 17 | * and after layout. 18 | * 19 | * @see https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver 20 | */ 21 | declare class ResizeObserver { 22 | /** 23 | * The **ResizeObserver** constructor creates a new `ResizeObserver` object, 24 | * which can be used to report changes to the content or border box of an 25 | * `Element` or the bounding box of an `SVGElement`. 26 | * 27 | * @example 28 | * var ResizeObserver = new ResizeObserver(callback) 29 | * 30 | * @param callback 31 | * The function called whenever an observed resize occurs. The function is 32 | * called with two parameters: 33 | * * **entries** 34 | * An array of 35 | * [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry) 36 | * objects that can be used to access the new dimensions of the element 37 | * after each change. 38 | * * **observer** 39 | * A reference to the `ResizeObserver` itself, so it will definitely be 40 | * accessible from inside the callback, should you need it. This could be 41 | * used for example to automatically unobserve the observer when a certain 42 | * condition is reached, but you can omit it if you don't need it. 43 | * 44 | * The callback will generally follow a pattern along the lines of: 45 | * ```js 46 | * function(entries, observer) { 47 | * for (let entry of entries) { 48 | * // Do something to each entry 49 | * // and possibly something to the observer itself 50 | * } 51 | * } 52 | * ``` 53 | * 54 | * The following snippet is taken from the 55 | * [resize-observer-text.html](https://mdn.github.io/dom-examples/resize-observer/resize-observer-text.html) 56 | * ([see source](https://github.com/mdn/dom-examples/blob/master/resize-observer/resize-observer-text.html)) 57 | * example: 58 | * @example 59 | * const resizeObserver = new ResizeObserver(entries => { 60 | * for (let entry of entries) { 61 | * if(entry.contentBoxSize) { 62 | * h1Elem.style.fontSize = Math.max(1.5, entry.contentBoxSize.inlineSize/200) + 'rem'; 63 | * pElem.style.fontSize = Math.max(1, entry.contentBoxSize.inlineSize/600) + 'rem'; 64 | * } else { 65 | * h1Elem.style.fontSize = Math.max(1.5, entry.contentRect.width/200) + 'rem'; 66 | * pElem.style.fontSize = Math.max(1, entry.contentRect.width/600) + 'rem'; 67 | * } 68 | * } 69 | * }); 70 | * 71 | * resizeObserver.observe(divElem); 72 | */ 73 | constructor(callback: ResizeObserverCallback) 74 | 75 | /** 76 | * The **disconnect()** method of the 77 | * [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) 78 | * interface unobserves all observed 79 | * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or 80 | * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) 81 | * targets. 82 | */ 83 | disconnect: () => void 84 | 85 | /** 86 | * The `observe()` method of the 87 | * [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) 88 | * interface starts observing the specified 89 | * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or 90 | * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement). 91 | * 92 | * @example 93 | * resizeObserver.observe(target, options); 94 | * 95 | * @param target 96 | * A reference to an 97 | * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or 98 | * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) 99 | * to be observed. 100 | * 101 | * @param options 102 | * An options object allowing you to set options for the observation. 103 | * Currently this only has one possible option that can be set. 104 | */ 105 | observe: (target: Element, options?: ResizeObserverObserveOptions) => void 106 | 107 | /** 108 | * The **unobserve()** method of the 109 | * [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) 110 | * interface ends the observing of a specified 111 | * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or 112 | * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement). 113 | */ 114 | unobserve: (target: Element) => void 115 | } 116 | 117 | interface ResizeObserverObserveOptions { 118 | /** 119 | * Sets which box model the observer will observe changes to. Possible values 120 | * are `content-box` (the default), and `border-box`. 121 | * 122 | * @default "content-box" 123 | */ 124 | box?: "content-box" | "border-box" 125 | } 126 | 127 | /** 128 | * The function called whenever an observed resize occurs. The function is 129 | * called with two parameters: 130 | * 131 | * @param entries 132 | * An array of 133 | * [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry) 134 | * objects that can be used to access the new dimensions of the element after 135 | * each change. 136 | * 137 | * @param observer 138 | * A reference to the `ResizeObserver` itself, so it will definitely be 139 | * accessible from inside the callback, should you need it. This could be used 140 | * for example to automatically unobserve the observer when a certain condition 141 | * is reached, but you can omit it if you don't need it. 142 | * 143 | * The callback will generally follow a pattern along the lines of: 144 | * @example 145 | * function(entries, observer) { 146 | * for (let entry of entries) { 147 | * // Do something to each entry 148 | * // and possibly something to the observer itself 149 | * } 150 | * } 151 | * 152 | * @example 153 | * const resizeObserver = new ResizeObserver(entries => { 154 | * for (let entry of entries) { 155 | * if(entry.contentBoxSize) { 156 | * h1Elem.style.fontSize = Math.max(1.5, entry.contentBoxSize.inlineSize/200) + 'rem'; 157 | * pElem.style.fontSize = Math.max(1, entry.contentBoxSize.inlineSize/600) + 'rem'; 158 | * } else { 159 | * h1Elem.style.fontSize = Math.max(1.5, entry.contentRect.width/200) + 'rem'; 160 | * pElem.style.fontSize = Math.max(1, entry.contentRect.width/600) + 'rem'; 161 | * } 162 | * } 163 | * }); 164 | * 165 | * resizeObserver.observe(divElem); 166 | */ 167 | type ResizeObserverCallback = ( 168 | entries: ResizeObserverEntry[], 169 | observer: ResizeObserver, 170 | ) => void 171 | 172 | /** 173 | * The **ResizeObserverEntry** interface represents the object passed to the 174 | * [ResizeObserver()](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/ResizeObserver) 175 | * constructor's callback function, which allows you to access the new 176 | * dimensions of the 177 | * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or 178 | * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) 179 | * being observed. 180 | */ 181 | interface ResizeObserverEntry { 182 | /** 183 | * An object containing the new border box size of the observed element when 184 | * the callback is run. 185 | */ 186 | readonly borderBoxSize: ResizeObserverEntryBoxSize 187 | 188 | /** 189 | * An object containing the new content box size of the observed element when 190 | * the callback is run. 191 | */ 192 | readonly contentBoxSize: ResizeObserverEntryBoxSize 193 | 194 | /** 195 | * A [DOMRectReadOnly](https://developer.mozilla.org/en-US/docs/Web/API/DOMRectReadOnly) 196 | * object containing the new size of the observed element when the callback is 197 | * run. Note that this is better supported than the above two properties, but 198 | * it is left over from an earlier implementation of the Resize Observer API, 199 | * is still included in the spec for web compat reasons, and may be deprecated 200 | * in future versions. 201 | */ 202 | // node_modules/typescript/lib/lib.dom.d.ts 203 | readonly contentRect: DOMRectReadOnly 204 | 205 | /** 206 | * A reference to the 207 | * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or 208 | * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) 209 | * being observed. 210 | */ 211 | readonly target: Element 212 | } 213 | 214 | /** 215 | * The **borderBoxSize** read-only property of the 216 | * [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry) 217 | * interface returns an object containing the new border box size of the 218 | * observed element when the callback is run. 219 | */ 220 | interface ResizeObserverEntryBoxSize { 221 | /** 222 | * The length of the observed element's border box in the block dimension. For 223 | * boxes with a horizontal 224 | * [writing-mode](https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode), 225 | * this is the vertical dimension, or height; if the writing-mode is vertical, 226 | * this is the horizontal dimension, or width. 227 | */ 228 | blockSize: number 229 | 230 | /** 231 | * The length of the observed element's border box in the inline dimension. 232 | * For boxes with a horizontal 233 | * [writing-mode](https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode), 234 | * this is the horizontal dimension, or width; if the writing-mode is 235 | * vertical, this is the vertical dimension, or height. 236 | */ 237 | inlineSize: number 238 | } 239 | 240 | interface Window { 241 | ResizeObserver: ResizeObserver 242 | } 243 | --------------------------------------------------------------------------------