├── .gitignore ├── types ├── use-force-update.d.ts ├── elements-cache.d.ts ├── interval-tree.d.ts ├── index.d.ts ├── list.d.ts ├── use-scroller.d.ts ├── use-resize-observer.d.ts ├── use-scroll-to-index.d.ts ├── use-container-position.d.ts ├── masonry-scroller.d.ts ├── use-infinite-loader.d.ts ├── masonry.d.ts ├── use-positioner.d.ts └── use-masonry.d.ts ├── babel.config.js ├── src ├── elements-cache.ts ├── __mocks__ │ ├── @essentials │ │ └── request-timeout.js │ └── resize-observer-polyfill.js ├── use-force-update.ts ├── index.tsx ├── list.tsx ├── use-scroller.ts ├── use-container-position.ts ├── masonry-scroller.tsx ├── use-resize-observer.ts ├── __snapshots__ │ └── index.test.tsx.snap ├── masonry.tsx ├── interval-tree.test.ts ├── use-scroll-to-index.ts ├── use-infinite-loader.ts ├── interval-tree.ts ├── use-positioner.ts ├── use-masonry.tsx └── index.test.tsx ├── test ├── setup.js └── resolve-snapshot.js ├── .travis.yml ├── tsconfig.json ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── pull_request_template.md ├── LICENSE ├── benchmarks └── index.ts ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md ├── CHANGELOG.md ├── package.json └── docs └── v2.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | *.log -------------------------------------------------------------------------------- /types/use-force-update.d.ts: -------------------------------------------------------------------------------- 1 | export declare function useForceUpdate(): () => void 2 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('lundle').babelConfig('test', {react: true}) 2 | -------------------------------------------------------------------------------- /types/elements-cache.d.ts: -------------------------------------------------------------------------------- 1 | export declare const elementsCache: WeakMap 2 | -------------------------------------------------------------------------------- /src/elements-cache.ts: -------------------------------------------------------------------------------- 1 | export const elementsCache: WeakMap = new WeakMap() 2 | -------------------------------------------------------------------------------- /src/__mocks__/@essentials/request-timeout.js: -------------------------------------------------------------------------------- 1 | export const requestTimeout = (...args) => setTimeout(...args) 2 | export const clearRequestTimeout = (...args) => clearTimeout(...args) 3 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import {ensureMocksReset} from '@shopify/jest-dom-mocks' 2 | // This file is for setting up Jest test environments 3 | afterEach(() => { 4 | jest.clearAllMocks() 5 | ensureMocksReset() 6 | }) 7 | -------------------------------------------------------------------------------- /src/use-force-update.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export function useForceUpdate() { 4 | const setState = React.useState(emptyObj)[1] 5 | return React.useRef(() => setState({})).current 6 | } 7 | 8 | const emptyObj = {} 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | cache: 4 | yarn: true 5 | directories: 6 | - node_modules 7 | notifications: 8 | email: false 9 | node_js: '12' 10 | install: yarn install 11 | script: yarn validate 12 | after_script: npx codecov@3 13 | branches: 14 | only: 15 | - master 16 | -------------------------------------------------------------------------------- /types/interval-tree.d.ts: -------------------------------------------------------------------------------- 1 | export interface IIntervalTree { 2 | insert(low: number, high: number, index: number): void 3 | remove(index: number): void 4 | search( 5 | low: number, 6 | high: number, 7 | callback: (index: number, low: number) => any 8 | ): void 9 | size: number 10 | } 11 | export declare function createIntervalTree(): IIntervalTree 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], 4 | "compilerOptions": { 5 | "target": "es5", 6 | "lib": ["esnext", "dom", "dom.iterable"], 7 | "jsx": "react", 8 | "moduleResolution": "node", 9 | "esModuleInterop": true, 10 | "allowSyntheticDefaultImports": true, 11 | "strict": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './list' 2 | export * from './masonry' 3 | export * from './masonry-scroller' 4 | export * from './use-container-position' 5 | export * from './use-infinite-loader' 6 | export * from './use-masonry' 7 | export * from './use-positioner' 8 | export * from './use-resize-observer' 9 | export * from './use-scroll-to-index' 10 | export * from './use-scroller' 11 | export * from './interval-tree' 12 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './list' 2 | export * from './masonry' 3 | export * from './masonry-scroller' 4 | export * from './use-container-position' 5 | export * from './use-infinite-loader' 6 | export * from './use-masonry' 7 | export * from './use-positioner' 8 | export * from './use-resize-observer' 9 | export * from './use-scroll-to-index' 10 | export * from './use-scroller' 11 | export * from './interval-tree' 12 | -------------------------------------------------------------------------------- /src/__mocks__/resize-observer-polyfill.js: -------------------------------------------------------------------------------- 1 | class ResizeObserver { 2 | els = [] 3 | callback = () => {} 4 | constructor(callback) { 5 | this.callback = callback 6 | } 7 | observe(el) { 8 | // do nothing 9 | this.callback([{target: el}]) 10 | } 11 | unobserve() { 12 | // do nothing 13 | } 14 | disconnect() {} 15 | } 16 | 17 | window.ResizeObserver = ResizeObserver 18 | 19 | export default ResizeObserver 20 | -------------------------------------------------------------------------------- /test/resolve-snapshot.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const snapshots = '__snapshots__' 3 | 4 | module.exports = { 5 | resolveSnapshotPath: (testPath, snapshotExtension) => 6 | path.join( 7 | testPath.split('/').slice(0, -1).join('/'), 8 | snapshots, 9 | testPath.split('/').pop() + snapshotExtension 10 | ), 11 | resolveTestPath: (snapshotFilePath, snapshotExtension) => 12 | path.join( 13 | snapshotFilePath.split('/').slice(0, -2).join('/'), 14 | snapshotFilePath.split('/').pop().slice(0, -snapshotExtension.length) 15 | ), 16 | testPathForConsistencyCheck: 'src/foo.test.js', 17 | } 18 | -------------------------------------------------------------------------------- /types/list.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import type {MasonryProps} from './masonry' 3 | /** 4 | * This is just a single-column `` component with `rowGutter` prop instead of 5 | * a `columnGutter` prop. 6 | */ 7 | export declare function List(props: ListProps): JSX.Element 8 | export declare namespace List { 9 | var displayName: string 10 | } 11 | export interface ListProps 12 | extends Omit< 13 | MasonryProps, 14 | 'columGutter' | 'columnCount' | 'columnWidth' 15 | > { 16 | /** 17 | * The amount of vertical space in pixels to add between the list cells. 18 | * @default 0 19 | */ 20 | rowGutter?: number 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | > IMPORTANT: Please do not create a Pull Request without creating an issue first. 2 | > **Any change needs to be discussed before proceeding.** Failure to do so may result 3 | > in the rejection of the pull request. 4 | 5 | ## Thank you for contributing to Masonic 6 | 7 | ### Before submitting 8 | 9 | Read the [CONTRIBUTING.md](https://github.com/jaredLunde/react-hook/blob/master/CONTRIBUTING.md) file and confirm that you're following 10 | all of the guidelines 11 | 12 | ### Please provide enough information so that others can review your pull request 13 | 14 | - What does this implement/fix? Explain your changes. 15 | - Does this close any currently open issues? 16 | 17 | ### Testing 18 | 19 | Confirm that your pull request contains tests and that all existing tests pass. 20 | 21 | ### Closing issues 22 | 23 | Put closes #XXXX in your comment to auto-close the issue that your PR fixes (if such). 24 | -------------------------------------------------------------------------------- /src/list.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {Masonry} from './masonry' 3 | import type {MasonryProps} from './masonry' 4 | 5 | /** 6 | * This is just a single-column `` component with `rowGutter` prop instead of 7 | * a `columnGutter` prop. 8 | */ 9 | export function List(props: ListProps) { 10 | return ( 11 | 12 | role='list' 13 | columnGutter={props.rowGutter} 14 | columnCount={1} 15 | columnWidth={1} 16 | {...props} 17 | /> 18 | ) 19 | } 20 | 21 | export interface ListProps 22 | extends Omit< 23 | MasonryProps, 24 | 'columGutter' | 'columnCount' | 'columnWidth' 25 | > { 26 | /** 27 | * The amount of vertical space in pixels to add between the list cells. 28 | * @default 0 29 | */ 30 | rowGutter?: number 31 | } 32 | 33 | if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') { 34 | List.displayName = 'List' 35 | } 36 | -------------------------------------------------------------------------------- /types/use-scroller.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A hook for tracking whether the `window` is currently being scrolled and it's scroll position on 3 | * the y-axis. These values are used for determining which grid cells to render and when 4 | * to add styles to the masonry container that maximize scroll performance. 5 | * 6 | * @param offset The vertical space in pixels between the top of the grid container and the top 7 | * of the browser `document.documentElement`. 8 | * @param fps This determines how often (in frames per second) to update the scroll position of the 9 | * browser `window` in state, and as a result the rate the masonry grid recalculates its visible cells. 10 | * The default value of `12` has been very reasonable in my own testing, but if you have particularly 11 | * heavy `render` components it may be prudent to reduce this number. 12 | */ 13 | export declare function useScroller( 14 | offset?: number, 15 | fps?: number 16 | ): { 17 | scrollTop: number 18 | isScrolling: boolean 19 | } 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Smartphone (please complete the following information):** 33 | 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /types/use-resize-observer.d.ts: -------------------------------------------------------------------------------- 1 | import ResizeObserver from 'resize-observer-polyfill' 2 | import type {Positioner} from './use-positioner' 3 | /** 4 | * Creates a resize observer that forces updates to the grid cell positions when mutations are 5 | * made to cells affecting their height. 6 | * 7 | * @param positioner The masonry cell positioner created by the `usePositioner()` hook. 8 | */ 9 | export declare function useResizeObserver( 10 | positioner: Positioner 11 | ): ResizeObserver 12 | /** 13 | * Creates a resize observer that fires an `updater` callback whenever the height of 14 | * one or many cells change. The `useResizeObserver()` hook is using this under the hood. 15 | * 16 | * @param positioner A cell positioner created by the `usePositioner()` hook or the `createPositioner()` utility 17 | * @param updater A callback that fires whenever one or many cell heights change. 18 | */ 19 | export declare const createResizeObserver: ( 20 | positioner: Positioner, 21 | updater: (updates: number[]) => void 22 | ) => ResizeObserver 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jared Lunde 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. -------------------------------------------------------------------------------- /benchmarks/index.ts: -------------------------------------------------------------------------------- 1 | import bench from '@essentials/benchmark' 2 | import randInt from 'rand-int' 3 | import createIntervalTree from '../src/IntervalTree' 4 | 5 | bench('IntervalTree.search()', ({duration}) => { 6 | duration(4000) 7 | 8 | const tree = createIntervalTree() 9 | for (let i = 0; i < 5000; i++) { 10 | const lower = randInt(0, 200000) 11 | tree.insert(lower, lower + randInt(200, 400), i) 12 | } 13 | const cb = () => {} 14 | 15 | return () => { 16 | tree.search(0, 300000, cb) 17 | } 18 | }) 19 | 20 | bench('IntervalTree.insert()', ({duration}) => { 21 | duration(4000) 22 | const tree = createIntervalTree() 23 | let i = 0 24 | 25 | return () => { 26 | tree.insert(randInt(0, 200000), randInt(200001, 40000000), i++) 27 | } 28 | }) 29 | 30 | bench('IntervalTree.remove()', ({duration}) => { 31 | duration(4000) 32 | const intervals: number[][] = [] 33 | const tree = createIntervalTree() 34 | let i = 0 35 | for (; i < 5000000; i++) { 36 | const interval = [randInt(0, 200000), randInt(200001, 40000000), i] 37 | intervals.push(interval) 38 | tree.insert.apply(null, interval) 39 | } 40 | 41 | return () => { 42 | tree.remove.apply(null, intervals[--i]) 43 | } 44 | }) 45 | -------------------------------------------------------------------------------- /types/use-scroll-to-index.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import type {Positioner} from './use-positioner' 3 | /** 4 | * A hook that creates a callback for scrolling to a specific index in 5 | * the "items" array. 6 | * 7 | * @param positioner A positioner created by the `usePositioner()` hook 8 | * @param options Configuration options 9 | */ 10 | export declare function useScrollToIndex( 11 | positioner: Positioner, 12 | options: UseScrollToIndexOptions 13 | ): (index: number) => void 14 | export declare type UseScrollToIndexOptions = { 15 | /** 16 | * The window element or a React ref for the window element. That is, 17 | * this is the grid container. 18 | * @default window 19 | */ 20 | element?: Window | HTMLElement | React.RefObject | null 21 | /** 22 | * Sets the vertical alignment of the cell within the grid container. 23 | * @default "top" 24 | */ 25 | align?: 'center' | 'top' | 'bottom' 26 | /** 27 | * The height of the grid. 28 | * @default window.innerHeight 29 | */ 30 | height?: number 31 | /** 32 | * The vertical space in pixels between the top of the grid container and the top 33 | * of the window. 34 | * @default 0 35 | */ 36 | offset?: number 37 | } 38 | -------------------------------------------------------------------------------- /types/use-container-position.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | /** 3 | * A hook for measuring the width of the grid container, as well as its distance 4 | * from the top of the document. These values are necessary to correctly calculate the number/width 5 | * of columns to render, as well as the number of rows to render. 6 | * 7 | * @param elementRef A `ref` object created by `React.useRef()`. That ref should be provided to the 8 | * `containerRef` property in `useMasonry()`. 9 | * @param deps You can force this hook to recalculate the `offset` and `width` whenever this 10 | * dependencies list changes. A common dependencies list might look like `[windowWidth, windowHeight]`, 11 | * which would force the hook to recalculate any time the size of the browser `window` changed. 12 | */ 13 | export declare function useContainerPosition( 14 | elementRef: React.MutableRefObject, 15 | deps?: React.DependencyList 16 | ): ContainerPosition 17 | export interface ContainerPosition { 18 | /** 19 | * The distance in pixels between the top of the element in `elementRef` and the top of 20 | * the `document.documentElement`. 21 | */ 22 | offset: number 23 | /** 24 | * The `offsetWidth` of the element in `elementRef`. 25 | */ 26 | width: number 27 | } 28 | -------------------------------------------------------------------------------- /types/masonry-scroller.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import type {UseMasonryOptions} from './use-masonry' 3 | /** 4 | * A heavily-optimized component that updates `useMasonry()` when the scroll position of the browser `window` 5 | * changes. This bare-metal component is used by `` under the hood. 6 | */ 7 | export declare function MasonryScroller( 8 | props: MasonryScrollerProps 9 | ): JSX.Element 10 | export declare namespace MasonryScroller { 11 | var displayName: string 12 | } 13 | export interface MasonryScrollerProps 14 | extends Omit, 'scrollTop' | 'isScrolling'> { 15 | /** 16 | * This determines how often (in frames per second) to update the scroll position of the 17 | * browser `window` in state, and as a result the rate the masonry grid recalculates its visible cells. 18 | * The default value of `12` has been very reasonable in my own testing, but if you have particularly 19 | * heavy `render` components it may be prudent to reduce this number. 20 | * @default 12 21 | */ 22 | scrollFps?: number 23 | /** 24 | * The vertical space in pixels between the top of the grid container and the top 25 | * of the browser `document.documentElement`. 26 | * @default 0 27 | */ 28 | offset?: number 29 | } 30 | -------------------------------------------------------------------------------- /src/use-scroller.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import useScrollPosition from '@react-hook/window-scroll' 3 | import {requestTimeout, clearRequestTimeout} from '@essentials/request-timeout' 4 | 5 | /** 6 | * A hook for tracking whether the `window` is currently being scrolled and it's scroll position on 7 | * the y-axis. These values are used for determining which grid cells to render and when 8 | * to add styles to the masonry container that maximize scroll performance. 9 | * 10 | * @param offset The vertical space in pixels between the top of the grid container and the top 11 | * of the browser `document.documentElement`. 12 | * @param fps This determines how often (in frames per second) to update the scroll position of the 13 | * browser `window` in state, and as a result the rate the masonry grid recalculates its visible cells. 14 | * The default value of `12` has been very reasonable in my own testing, but if you have particularly 15 | * heavy `render` components it may be prudent to reduce this number. 16 | */ 17 | export function useScroller( 18 | offset = 0, 19 | fps = 12 20 | ): {scrollTop: number; isScrolling: boolean} { 21 | const scrollTop = useScrollPosition(fps) 22 | const [isScrolling, setIsScrolling] = React.useState(false) 23 | const didMount = React.useRef(0) 24 | 25 | React.useEffect(() => { 26 | if (didMount.current === 1) setIsScrolling(true) 27 | const to = requestTimeout(() => { 28 | // This is here to prevent premature bail outs while maintaining high resolution 29 | // unsets. Without it there will always bee a lot of unnecessary DOM writes to style. 30 | setIsScrolling(false) 31 | }, 40 + 1000 / fps) 32 | didMount.current = 1 33 | return () => clearRequestTimeout(to) 34 | }, [fps, scrollTop]) 35 | 36 | return {scrollTop: Math.max(0, scrollTop - offset), isScrolling} 37 | } 38 | -------------------------------------------------------------------------------- /types/use-infinite-loader.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A utility hook for seamlessly adding infinite scroll behavior to the `useMasonry()` hook. This 3 | * hook invokes a callback each time the last rendered index surpasses the total number of items 4 | * in your items array or the number defined in the `totalItems` option. 5 | * 6 | * @param loadMoreItems This callback is invoked when more rows must be loaded. It will be used to 7 | * determine when to refresh the list with the newly-loaded data. This callback may be called multiple 8 | * times in reaction to a single scroll event, so it's important to memoize its arguments. If you're 9 | * creating this callback inside of a functional component, make sure you wrap it in `React.useCallback()`, 10 | * as well. 11 | * @param options 12 | */ 13 | export declare function useInfiniteLoader< 14 | Item, 15 | T extends LoadMoreItemsCallback 16 | >( 17 | loadMoreItems: T, 18 | options?: UseInfiniteLoaderOptions 19 | ): LoadMoreItemsCallback 20 | export interface UseInfiniteLoaderOptions { 21 | /** 22 | * A callback responsible for determining the loaded state of each item. Should return `true` 23 | * if the item has already been loaded and `false` if not. 24 | * @default (index: number, items: any[]) => boolean 25 | */ 26 | isItemLoaded?: (index: number, items: Item[]) => boolean 27 | /** 28 | * The minimum number of new items to be loaded at a time. This property can be used to 29 | * batch requests and reduce HTTP requests. 30 | * @default 16 31 | */ 32 | minimumBatchSize?: number 33 | /** 34 | * The threshold at which to pre-fetch data. A threshold X means that new data should start 35 | * loading when a user scrolls within X cells of the end of your `items` array. 36 | * @default 16 37 | */ 38 | threshold?: number 39 | /** 40 | * The total number of items you'll need to eventually load (if known). This can 41 | * be arbitrarily high if not known. 42 | * @default 9e9 43 | */ 44 | totalItems?: number 45 | } 46 | export declare type LoadMoreItemsCallback = ( 47 | startIndex: number, 48 | stopIndex: number, 49 | items: Item[] 50 | ) => any 51 | -------------------------------------------------------------------------------- /src/use-container-position.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import useLayoutEffect from '@react-hook/passive-layout-effect' 3 | 4 | /** 5 | * A hook for measuring the width of the grid container, as well as its distance 6 | * from the top of the document. These values are necessary to correctly calculate the number/width 7 | * of columns to render, as well as the number of rows to render. 8 | * 9 | * @param elementRef A `ref` object created by `React.useRef()`. That ref should be provided to the 10 | * `containerRef` property in `useMasonry()`. 11 | * @param deps You can force this hook to recalculate the `offset` and `width` whenever this 12 | * dependencies list changes. A common dependencies list might look like `[windowWidth, windowHeight]`, 13 | * which would force the hook to recalculate any time the size of the browser `window` changed. 14 | */ 15 | export function useContainerPosition( 16 | elementRef: React.MutableRefObject, 17 | deps: React.DependencyList = emptyArr 18 | ): ContainerPosition { 19 | const [containerPosition, setContainerPosition] = React.useState< 20 | ContainerPosition 21 | >({offset: 0, width: 0}) 22 | 23 | useLayoutEffect(() => { 24 | const {current} = elementRef 25 | if (current !== null) { 26 | let offset = 0 27 | let el = current 28 | 29 | do { 30 | offset += el.offsetTop || 0 31 | el = el.offsetParent as HTMLElement 32 | } while (el) 33 | 34 | if ( 35 | offset !== containerPosition.offset || 36 | current.offsetWidth !== containerPosition.width 37 | ) { 38 | setContainerPosition({ 39 | offset, 40 | width: current.offsetWidth, 41 | }) 42 | } 43 | } 44 | // eslint-disable-next-line react-hooks/exhaustive-deps 45 | }, deps) 46 | 47 | return containerPosition 48 | } 49 | 50 | export interface ContainerPosition { 51 | /** 52 | * The distance in pixels between the top of the element in `elementRef` and the top of 53 | * the `document.documentElement`. 54 | */ 55 | offset: number 56 | /** 57 | * The `offsetWidth` of the element in `elementRef`. 58 | */ 59 | width: number 60 | } 61 | 62 | const emptyArr: [] = [] 63 | -------------------------------------------------------------------------------- /types/masonry.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import type {MasonryScrollerProps} from './masonry-scroller' 3 | import type {UsePositionerOptions} from './use-positioner' 4 | import type {UseScrollToIndexOptions} from './use-scroll-to-index' 5 | /** 6 | * A "batteries included" masonry grid which includes all of the implementation details below. This component is the 7 | * easiest way to get off and running in your app, before switching to more advanced implementations, if necessary. 8 | * It will change its column count to fit its container's width and will decide how many rows to render based upon 9 | * the height of the browser `window`. 10 | */ 11 | export declare function Masonry( 12 | props: MasonryProps 13 | ): React.FunctionComponentElement> 14 | export declare namespace Masonry { 15 | var displayName: string 16 | } 17 | export interface MasonryProps 18 | extends Omit< 19 | MasonryScrollerProps, 20 | 'offset' | 'width' | 'height' | 'containerRef' | 'positioner' 21 | >, 22 | Pick { 23 | /** 24 | * Scrolls to a given index within the grid. The grid will re-scroll 25 | * any time the index changes. 26 | */ 27 | scrollToIndex?: 28 | | number 29 | | { 30 | index: number 31 | align: UseScrollToIndexOptions['align'] 32 | } 33 | /** 34 | * This is the width that will be used for the browser `window` when rendering this component in SSR. 35 | * This prop isn't relevant for client-side only apps. 36 | */ 37 | ssrWidth?: number 38 | /** 39 | * This is the height that will be used for the browser `window` when rendering this component in SSR. 40 | * This prop isn't relevant for client-side only apps. 41 | */ 42 | ssrHeight?: number 43 | /** 44 | * This determines how often (in frames per second) to update the scroll position of the 45 | * browser `window` in state, and as a result the rate the masonry grid recalculates its visible cells. 46 | * The default value of `12` has been very reasonable in my own testing, but if you have particularly 47 | * heavy `render` components it may be prudent to reduce this number. 48 | * @default 12 49 | */ 50 | scrollFps?: number 51 | } 52 | -------------------------------------------------------------------------------- /src/masonry-scroller.tsx: -------------------------------------------------------------------------------- 1 | import {useScroller} from './use-scroller' 2 | import {useMasonry} from './use-masonry' 3 | import type {UseMasonryOptions} from './use-masonry' 4 | /** 5 | * A heavily-optimized component that updates `useMasonry()` when the scroll position of the browser `window` 6 | * changes. This bare-metal component is used by `` under the hood. 7 | */ 8 | export function MasonryScroller(props: MasonryScrollerProps) { 9 | // We put this in its own layer because it's the thing that will trigger the most updates 10 | // and we don't want to slower ourselves by cycling through all the functions, objects, and effects 11 | // of other hooks 12 | const {scrollTop, isScrolling} = useScroller(props.offset, props.scrollFps) 13 | // This is an update-heavy phase and while we could just Object.assign here, 14 | // it is way faster to inline and there's a relatively low hit to he bundle 15 | // size. 16 | return useMasonry({ 17 | scrollTop, 18 | isScrolling, 19 | positioner: props.positioner, 20 | resizeObserver: props.resizeObserver, 21 | items: props.items, 22 | onRender: props.onRender, 23 | as: props.as, 24 | id: props.id, 25 | className: props.className, 26 | style: props.style, 27 | role: props.role, 28 | tabIndex: props.tabIndex, 29 | containerRef: props.containerRef, 30 | itemAs: props.itemAs, 31 | itemStyle: props.itemStyle, 32 | itemHeightEstimate: props.itemHeightEstimate, 33 | itemKey: props.itemKey, 34 | overscanBy: props.overscanBy, 35 | height: props.height, 36 | render: props.render, 37 | }) 38 | } 39 | 40 | export interface MasonryScrollerProps 41 | extends Omit, 'scrollTop' | 'isScrolling'> { 42 | /** 43 | * This determines how often (in frames per second) to update the scroll position of the 44 | * browser `window` in state, and as a result the rate the masonry grid recalculates its visible cells. 45 | * The default value of `12` has been very reasonable in my own testing, but if you have particularly 46 | * heavy `render` components it may be prudent to reduce this number. 47 | * @default 12 48 | */ 49 | scrollFps?: number 50 | /** 51 | * The vertical space in pixels between the top of the grid container and the top 52 | * of the browser `document.documentElement`. 53 | * @default 0 54 | */ 55 | offset?: number 56 | } 57 | 58 | if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') { 59 | MasonryScroller.displayName = 'MasonryScroller' 60 | } 61 | -------------------------------------------------------------------------------- /src/use-resize-observer.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import trieMemoize from 'trie-memoize' 3 | import ResizeObserver from 'resize-observer-polyfill' 4 | import rafSchd from 'raf-schd' 5 | import {elementsCache} from './elements-cache' 6 | import {useForceUpdate} from './use-force-update' 7 | import type {Positioner} from './use-positioner' 8 | 9 | /** 10 | * Creates a resize observer that forces updates to the grid cell positions when mutations are 11 | * made to cells affecting their height. 12 | * 13 | * @param positioner The masonry cell positioner created by the `usePositioner()` hook. 14 | */ 15 | export function useResizeObserver(positioner: Positioner) { 16 | const forceUpdate = useForceUpdate() 17 | const resizeObserver = createResizeObserver(positioner, forceUpdate) 18 | // Cleans up the resize observers when they change or the 19 | // component unmounts 20 | React.useEffect(() => () => resizeObserver.disconnect(), [resizeObserver]) 21 | return resizeObserver 22 | } 23 | 24 | /** 25 | * Creates a resize observer that fires an `updater` callback whenever the height of 26 | * one or many cells change. The `useResizeObserver()` hook is using this under the hood. 27 | * 28 | * @param positioner A cell positioner created by the `usePositioner()` hook or the `createPositioner()` utility 29 | * @param updater A callback that fires whenever one or many cell heights change. 30 | */ 31 | export const createResizeObserver = trieMemoize( 32 | [WeakMap], 33 | // TODO: figure out a way to test this 34 | /* istanbul ignore next */ 35 | (positioner: Positioner, updater: (updates: number[]) => void) => { 36 | const handleEntries = rafSchd(((entries) => { 37 | const updates: number[] = [] 38 | let i = 0 39 | 40 | for (; i < entries.length; i++) { 41 | const entry = entries[i] 42 | const height = (entry.target as HTMLElement).offsetHeight 43 | 44 | if (height > 0) { 45 | const index = elementsCache.get(entry.target) 46 | 47 | if (index !== void 0) { 48 | const position = positioner.get(index) 49 | 50 | if (position !== void 0 && height !== position.height) 51 | updates.push(index, height) 52 | } 53 | } 54 | } 55 | 56 | if (updates.length > 0) { 57 | // Updates the size/positions of the cell with the resize 58 | // observer updates 59 | positioner.update(updates) 60 | updater(updates) 61 | } 62 | }) as ResizeObserverCallback) 63 | 64 | const ro = new ResizeObserver(handleEntries) 65 | // Overrides the original disconnect to include cancelling handling the entries. 66 | // Ideally this would be its own method but that would result in a breaking 67 | // change. 68 | const disconnect = ro.disconnect.bind(ro) 69 | ro.disconnect = () => { 70 | disconnect() 71 | handleEntries.cancel() 72 | } 73 | 74 | return ro 75 | } 76 | ) 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Masonic 2 | 3 | To contribute to this project, first: 4 | 5 | - Fork this repo to your account 6 | - `git clone https://github.com/[your-username]/masonic.git` 7 | - `cd masonic` 8 | - `yarn install` 9 | 10 | ## Before you contribute 11 | 12 | Before you submit PRs to this repo I ask that you consider the following: 13 | 14 | - Creating an issue first. **Any change needs to be discussed before proceeding.** Failure to do so may result in the rejection of the pull request. 15 | - Is this useful? That is, does it fix something that is broken? Does it add a feature that is a _real_ need? 16 | - Is this better implemented in user space or in its own package? 17 | - Will this bloat the bundle size? 18 | 19 | Before your PR will be considered I will look for: 20 | 21 | - **Documentation** Please submit updates to the docs when public-facing APIs are changed. 22 | - **Tests** Your PR will not be accepted if it doesn't have well-designed tests. Additionally, make sure 23 | that you run `yarn validate` before you submit your PR and make sure your PR passes the linting rules, 24 | type checking, and tests that already exist. 25 | - **Types** Your types should be as strict as possible. 26 | - **Comments** If your PR implements non-obvious logic, I fully expect you to explain the rationale in 27 | the form of code comments. I also expect you to update existing comments if the PR changes the behavior 28 | of existing code that could make those comments stale. 29 | 30 | ## Development 31 | 32 | Here's what you need to know to start devleoping Masonic. 33 | 34 | ### Package scripts 35 | 36 | #### `build` 37 | 38 | Builds types, commonjs, and module distributions 39 | 40 | #### `build-main` 41 | 42 | Builds the commonjs distribution 43 | 44 | #### `build-module` 45 | 46 | Builds the module distribution 47 | 48 | #### `build-types` 49 | 50 | Builds the TypeScript type definitions 51 | 52 | #### `check-types` 53 | 54 | Runs a type check on the project using the local `tsconfig.json` 55 | 56 | #### `format` 57 | 58 | Formats all of the applicable source files with prettier as defined by `.prettierrc` 59 | 60 | #### `lint` 61 | 62 | Runs `eslint` on the package source 63 | 64 | #### `prepublishOnly` 65 | 66 | Runs before the package is published. This calls `lint`, `build`, `test`, and `format` scripts 67 | 68 | #### `test` 69 | 70 | Tests the package with `jest` as defined by options in `jest.config.js` 71 | 72 | #### `validate` 73 | 74 | Runs `check-types`, `lint`, `test`, and `format` scripts 75 | 76 | --- 77 | 78 | ### Husky hooks 79 | 80 | #### `pre-commit` 81 | 82 | Runs `lint-staged` and the `build-types` script 83 | 84 | --- 85 | 86 | ### Lint staged 87 | 88 | Used for calling commands on git staged files that match a glob pattern 89 | 90 | #### `**/*.{ts,tsx,js,jsx}` 91 | 92 | Calls `eslint` and `prettier --write` to lint and format the staged files 93 | 94 | #### `**/*.{md,yml,json,eslintrc,prettierrc}` 95 | 96 | Calls `prettier --write` to format the staged files 97 | -------------------------------------------------------------------------------- /src/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` should update when the size of the window changes: Should display one element 1`] = ` 4 | 5 |
10 |
14 |
17 | 18 | 0 19 | 20 | Hello 21 |
22 |
23 |
24 |
25 | `; 26 | 27 | exports[` should update when the size of the window changes: Should display two elements 1`] = ` 28 | 29 |
34 |
38 |
41 | 42 | 0 43 | 44 | Hello 45 |
46 |
47 |
51 |
54 | 55 | 1 56 | 57 | Hello 58 |
59 |
60 |
61 |
62 | `; 63 | 64 | exports[` should update when scrolling: pointer-events: none IS defined 1`] = ` 65 | 66 |
71 |
75 |
78 | 79 | 0 80 | 81 | Hello 82 |
83 |
84 |
85 |
86 | `; 87 | 88 | exports[` should update when scrolling: pointer-events: none is NOT defined 1`] = ` 89 | 90 |
95 |
99 |
102 | 103 | 0 104 | 105 | Hello 106 |
107 |
108 |
109 |
110 | `; 111 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at jared.lunde@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [3.4.0](https://github.com/jaredLunde/masonic/compare/v3.3.10...v3.4.0) (2020-12-29) 6 | 7 | ### Features 8 | 9 | - expose `createIntervalTree` ([e0c0a20](https://github.com/jaredLunde/masonic/commit/e0c0a208ae5054eb42cc813ccf96979693c9ae50)) 10 | 11 | ### [3.3.10](https://github.com/jaredLunde/masonic/compare/v3.3.9...v3.3.10) (2020-09-11) 12 | 13 | ### Bug Fixes 14 | 15 | - **use-masonry:** fix onRender type ([1f0af01](https://github.com/jaredLunde/masonic/commit/1f0af0141c055ab9dc86d37e1c8f25e993d17f99)), closes [#43](https://github.com/jaredLunde/masonic/issues/43) 16 | 17 | ### [3.3.9](https://github.com/jaredLunde/masonic/compare/v3.3.8...v3.3.9) (2020-09-11) 18 | 19 | ### Bug Fixes 20 | 21 | - **use-positioner:** re-initialization in StrictMode ([ebe6b9c](https://github.com/jaredLunde/masonic/commit/ebe6b9cf164ef881fa4dc808df1142d679fe3ecc)) 22 | 23 | ### [3.3.8](https://github.com/jaredLunde/masonic/compare/v3.3.7...v3.3.8) (2020-09-09) 24 | 25 | ### Bug Fixes 26 | 27 | - **use-resize-observer:** fix ResizeObserver loop limit exceeded ([140883d](https://github.com/jaredLunde/masonic/commit/140883d7360a3f24b9aba251eb29575fdd2e8377)), closes [#39](https://github.com/jaredLunde/masonic/issues/39) 28 | 29 | ### [3.3.7](https://github.com/jaredLunde/masonic/compare/v3.3.6...v3.3.7) (2020-09-09) 30 | 31 | ### Bug Fixes 32 | 33 | - **use-positioner:** re-initialize positioner instance before render ([fbaff55](https://github.com/jaredLunde/masonic/commit/fbaff55b29a1cddad5437d7f76f69a5213a5a452)), closes [#12](https://github.com/jaredLunde/masonic/issues/12) 34 | 35 | ### [3.3.6](https://github.com/jaredLunde/masonic/compare/v3.3.3...v3.3.6) (2020-09-09) 36 | 37 | ### [3.3.3](https://github.com/jaredLunde/masonic/compare/v3.3.2...v3.3.3) (2020-07-21) 38 | 39 | ### Bug Fixes 40 | 41 | - **use-masonry:** fix "Cannot assign to readonly property" error ([49aad2f](https://github.com/jaredLunde/masonic/commit/49aad2f210b434dd3aec91fd320a007b21267df8)), closes [#31](https://github.com/jaredLunde/masonic/issues/31) 42 | 43 | ### [3.3.2](https://github.com/jaredLunde/masonic/compare/v3.3.1...v3.3.2) (2020-07-17) 44 | 45 | ### Bug Fixes 46 | 47 | - **use-resize-observer:** fix height measurement in Chrome 84 ([ae40ece](https://github.com/jaredLunde/masonic/commit/ae40ecec906340b9fb17821acab471f5820091c1)), closes [#28](https://github.com/jaredLunde/masonic/issues/28) 48 | 49 | ### [3.3.1](https://github.com/jaredLunde/masonic/compare/v3.3.0...v3.3.1) (2020-07-04) 50 | 51 | ### Bug Fixes 52 | 53 | - **masonry:** fix loop in scrollToIndex effect ([dae9984](https://github.com/jaredLunde/masonic/commit/dae99847fe29d7c9b50141f8035968143680b292)) 54 | 55 | ## [3.3.0](https://github.com/jaredLunde/masonic/compare/v3.2.0...v3.3.0) (2020-07-04) 56 | 57 | ### Features 58 | 59 | - **masonry:** add scrollToIndex ([8847c07](https://github.com/jaredLunde/masonic/commit/8847c074dd171fd2a53cc9fec2aae76e814e0aa2)), closes [#19](https://github.com/jaredLunde/masonic/issues/19) 60 | - add generic typing to masonry components/hooks ([45e0380](https://github.com/jaredLunde/masonic/commit/45e0380f0b366c1729436fe6d7370ae3fd36fdf2)) 61 | 62 | ### Bug Fixes 63 | 64 | - **use-masonry:** add a descriptive error message when data is undefined ([b69f52f](https://github.com/jaredLunde/masonic/commit/b69f52f6821ac9cd95bfa6bf97a81a9efba008c2)) 65 | - **use-positioner:** fix positioner not clearing before DOM updates ([d599e62](https://github.com/jaredLunde/masonic/commit/d599e62b29f31153343c9a83c87134c5144ecb8d)) 66 | 67 | ## 3.2.0 (2020-06-17) 68 | -------------------------------------------------------------------------------- /src/masonry.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {useWindowSize} from '@react-hook/window-size' 3 | import {MasonryScroller} from './masonry-scroller' 4 | import type {MasonryScrollerProps} from './masonry-scroller' 5 | import {useContainerPosition} from './use-container-position' 6 | import {useResizeObserver} from './use-resize-observer' 7 | import {usePositioner} from './use-positioner' 8 | import type {UsePositionerOptions} from './use-positioner' 9 | import {useScrollToIndex} from './use-scroll-to-index' 10 | import type {UseScrollToIndexOptions} from './use-scroll-to-index' 11 | 12 | /** 13 | * A "batteries included" masonry grid which includes all of the implementation details below. This component is the 14 | * easiest way to get off and running in your app, before switching to more advanced implementations, if necessary. 15 | * It will change its column count to fit its container's width and will decide how many rows to render based upon 16 | * the height of the browser `window`. 17 | */ 18 | export function Masonry(props: MasonryProps) { 19 | const containerRef = React.useRef(null) 20 | const windowSize = useWindowSize({ 21 | initialWidth: props.ssrWidth, 22 | initialHeight: props.ssrHeight, 23 | }) 24 | const containerPos = useContainerPosition(containerRef, windowSize) 25 | const nextProps = Object.assign( 26 | { 27 | offset: containerPos.offset, 28 | width: containerPos.width || windowSize[0], 29 | height: windowSize[1], 30 | containerRef, 31 | }, 32 | props 33 | ) as any 34 | nextProps.positioner = usePositioner(nextProps) 35 | nextProps.resizeObserver = useResizeObserver(nextProps.positioner) 36 | const scrollToIndex = useScrollToIndex(nextProps.positioner, { 37 | height: nextProps.height, 38 | offset: containerPos.offset, 39 | align: 40 | typeof props.scrollToIndex === 'object' 41 | ? props.scrollToIndex.align 42 | : void 0, 43 | }) 44 | const index = 45 | props.scrollToIndex && 46 | (typeof props.scrollToIndex === 'number' 47 | ? props.scrollToIndex 48 | : props.scrollToIndex.index) 49 | 50 | React.useEffect(() => { 51 | if (index !== void 0) scrollToIndex(index) 52 | }, [index, scrollToIndex]) 53 | 54 | return React.createElement(MasonryScroller, nextProps) 55 | } 56 | 57 | export interface MasonryProps 58 | extends Omit< 59 | MasonryScrollerProps, 60 | 'offset' | 'width' | 'height' | 'containerRef' | 'positioner' 61 | >, 62 | Pick { 63 | /** 64 | * Scrolls to a given index within the grid. The grid will re-scroll 65 | * any time the index changes. 66 | */ 67 | scrollToIndex?: 68 | | number 69 | | { 70 | index: number 71 | align: UseScrollToIndexOptions['align'] 72 | } 73 | /** 74 | * This is the width that will be used for the browser `window` when rendering this component in SSR. 75 | * This prop isn't relevant for client-side only apps. 76 | */ 77 | ssrWidth?: number 78 | /** 79 | * This is the height that will be used for the browser `window` when rendering this component in SSR. 80 | * This prop isn't relevant for client-side only apps. 81 | */ 82 | ssrHeight?: number 83 | /** 84 | * This determines how often (in frames per second) to update the scroll position of the 85 | * browser `window` in state, and as a result the rate the masonry grid recalculates its visible cells. 86 | * The default value of `12` has been very reasonable in my own testing, but if you have particularly 87 | * heavy `render` components it may be prudent to reduce this number. 88 | * @default 12 89 | */ 90 | scrollFps?: number 91 | } 92 | 93 | if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') { 94 | Masonry.displayName = 'Masonry' 95 | } 96 | -------------------------------------------------------------------------------- /types/use-positioner.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | /** 3 | * This hook creates the grid cell positioner and cache required by `useMasonry()`. This is 4 | * the meat of the grid's layout algorithm, determining which cells to render at a given scroll 5 | * position, as well as where to place new items in the grid. 6 | * 7 | * @param options Properties that determine the number of columns in the grid, as well 8 | * as their widths. 9 | * @param deps This hook will create a new positioner, clearing all existing cached positions, 10 | * whenever the dependencies in this list change. 11 | */ 12 | export declare function usePositioner( 13 | {width, columnWidth, columnGutter, columnCount}: UsePositionerOptions, 14 | deps?: React.DependencyList 15 | ): Positioner 16 | export interface UsePositionerOptions { 17 | /** 18 | * The width of the container you're rendering the grid within, i.e. the container 19 | * element's `element.offsetWidth` 20 | */ 21 | width: number 22 | /** 23 | * The minimum column width. The `usePositioner()` hook will automatically size the 24 | * columns to fill their container based upon the `columnWidth` and `columnGutter` values. 25 | * It will never render anything smaller than this width unless its container itself is 26 | * smaller than its value. This property is optional if you're using a static `columnCount`. 27 | * @default 200 28 | */ 29 | columnWidth?: number 30 | /** 31 | * This sets the vertical and horizontal space between grid cells in pixels. 32 | */ 33 | columnGutter?: number 34 | /** 35 | * By default, `usePositioner()` derives the column count from the `columnWidth`, `columnGutter`, 36 | * and `width` props. However, in some situations it is nice to be able to override that behavior 37 | * (e.g. creating a `List` component). 38 | */ 39 | columnCount?: number 40 | } 41 | /** 42 | * Creates a cell positioner for the `useMasonry()` hook. The `usePositioner()` hook uses 43 | * this utility under the hood. 44 | * 45 | * @param columnCount The number of columns in the grid 46 | * @param columnWidth The width of each column in the grid 47 | * @param columnGutter The amount of horizontal and vertical space in pixels to render 48 | * between each grid item. 49 | */ 50 | export declare const createPositioner: ( 51 | columnCount: number, 52 | columnWidth: number, 53 | columnGutter?: number 54 | ) => Positioner 55 | export interface Positioner { 56 | /** 57 | * The number of columns in the grid 58 | */ 59 | columnCount: number 60 | /** 61 | * The width of each column in the grid 62 | */ 63 | columnWidth: number 64 | /** 65 | * Sets the position for the cell at `index` based upon the cell's height 66 | */ 67 | set: (index: number, height: number) => void 68 | /** 69 | * Gets the `PositionerItem` for the cell at `index` 70 | */ 71 | get: (index: number) => PositionerItem | undefined 72 | /** 73 | * Updates cells based on their indexes and heights 74 | * positioner.update([index, height, index, height, index, height...]) 75 | */ 76 | update: (updates: number[]) => void 77 | /** 78 | * Searches the interval tree for grid cells with a `top` value in 79 | * betwen `lo` and `hi` and invokes the callback for each item that 80 | * is discovered 81 | */ 82 | range: ( 83 | lo: number, 84 | hi: number, 85 | renderCallback: (index: number, left: number, top: number) => void 86 | ) => void 87 | /** 88 | * Returns the number of grid cells in the cache 89 | */ 90 | size: () => number 91 | /** 92 | * Estimates the total height of the grid 93 | */ 94 | estimateHeight: (itemCount: number, defaultItemHeight: number) => number 95 | /** 96 | * Returns the height of the shortest column in the grid 97 | */ 98 | shortestColumn: () => number 99 | } 100 | export interface PositionerItem { 101 | /** 102 | * This is how far from the top edge of the grid container in pixels the 103 | * item is placed 104 | */ 105 | top: number 106 | /** 107 | * This is how far from the left edge of the grid container in pixels the 108 | * item is placed 109 | */ 110 | left: number 111 | /** 112 | * This is the height of the grid cell 113 | */ 114 | height: number 115 | /** 116 | * This is the column number containing the grid cell 117 | */ 118 | column: number 119 | } 120 | -------------------------------------------------------------------------------- /src/interval-tree.test.ts: -------------------------------------------------------------------------------- 1 | import {createIntervalTree} from './interval-tree' 2 | const toIdSorted = (result) => result.map(([id]) => id).sort((a, b) => a - b) 3 | const toExpectedIdSorted = (result) => 4 | result.map(([, , id]) => id).sort((a, b) => a - b) 5 | 6 | const shuffle = (original) => { 7 | const array = original.concat() 8 | for (let i = array.length - 1; i > 0; i--) { 9 | const j = Math.floor(Math.random() * (i + 1)) 10 | ;[array[i], array[j]] = [array[j], array[i]] 11 | } 12 | return array 13 | } 14 | 15 | const getRandomInt = (min, max) => { 16 | min = Math.ceil(min) 17 | max = Math.floor(max) 18 | return Math.floor(Math.random() * (max - min + 1)) + min 19 | } 20 | 21 | const search = (tree, low, high) => { 22 | const results: any[] = [] 23 | tree.search(low, high, (...args) => results.push(args)) 24 | return results 25 | } 26 | 27 | const expectSearch = (records, tree, low, high) => { 28 | const expectation = records.filter((record) => { 29 | if (!record) { 30 | return false 31 | } 32 | const [otherLow, otherHigh] = record 33 | return otherLow <= high && otherHigh >= low 34 | }) 35 | 36 | expect(toIdSorted(search(tree, low, high)).join(',')).toEqual( 37 | toExpectedIdSorted(expectation).join(',') 38 | ) 39 | } 40 | 41 | describe('tree', () => { 42 | it('should insert, remove, and find', () => { 43 | const tree = createIntervalTree() 44 | 45 | const list = [ 46 | [15, 23, 1], 47 | [8, 9, 2], 48 | [25, 30, 3], 49 | [19, 20, 4], 50 | [16, 21, 5], 51 | [5, 8, 6], 52 | [26, 26, 7], 53 | [0, 21, 8], 54 | [17, 19, 9], 55 | [6, 10, 10], 56 | ] 57 | 58 | for (const [low, high, id] of list) { 59 | tree.insert(low, high, id) 60 | } 61 | 62 | const results = [ 63 | [0, 30, '1,2,3,4,5,6,7,8,9,10'], 64 | [7, 8, '2,6,8,10'], 65 | [0, 1, '8'], 66 | [-2, -1, ''], 67 | ] 68 | 69 | for (let i = 0; i < 10000; ++i) { 70 | for (const [, , id] of shuffle(list)) { 71 | tree.remove(id) 72 | } 73 | 74 | expect(tree.size).toBe(0) 75 | 76 | for (const [low, high, id] of shuffle(list)) { 77 | tree.insert(low, high, id) 78 | } 79 | 80 | expect(tree.size).toBe(10) 81 | 82 | for (const [low, high, result] of results) { 83 | expect(toIdSorted(search(tree, low, high)).join(',')).toEqual(result) 84 | } 85 | } 86 | }) 87 | 88 | it('should insert and remove multiple', () => { 89 | const tree = createIntervalTree() 90 | 91 | const records = [] 92 | 93 | for (let i = 0; i < 1000; ++i) { 94 | const low = getRandomInt(0, 100) 95 | const high = getRandomInt(low, low + getRandomInt(0, 100)) 96 | 97 | records.push([low, high, i]) 98 | tree.insert(low, high, i) 99 | 100 | expectSearch(records, tree, low, high) 101 | expect(tree.size).toBe(records.length) 102 | } 103 | 104 | const toRemove = shuffle(records.concat()) 105 | for (let i = 0; i < toRemove.length; ++i) { 106 | const [low, high, id] = toRemove[i] 107 | toRemove[i] = undefined 108 | tree.remove(id) 109 | expectSearch(toRemove, tree, low, high) 110 | 111 | for (let j = 0; j < 100; ++j) { 112 | const low = getRandomInt(0, 100) 113 | const high = getRandomInt(low, low + getRandomInt(0, 100)) 114 | expectSearch(toRemove, tree, low, high) 115 | } 116 | expect(tree.size).toBe(records.length - i - 1) 117 | } 118 | }) 119 | 120 | it('should insert and remove multiple randomly', () => { 121 | const list = [] 122 | const tree = createIntervalTree() 123 | let id = 0 124 | 125 | const removeAnItem = (list, tree) => { 126 | if (list.length === 0) { 127 | return 128 | } 129 | const idx = getRandomInt(0, list.length - 1) 130 | const item = list[idx] 131 | list.splice(idx, 1) 132 | tree.remove(item[2]) 133 | } 134 | 135 | const addAnItem = (list, tree) => { 136 | const low = getRandomInt(0, 100) 137 | const record = [low, low + getRandomInt(0, 100), ++id] 138 | list.push(record) 139 | tree.insert(record[0], record[1], record[2]) 140 | } 141 | 142 | for (let i = 0; i < 1000; ++i) { 143 | const action = getRandomInt(0, 3) 144 | if (action === 0) { 145 | removeAnItem(list, tree) 146 | } 147 | if (action === 1) { 148 | addAnItem(list, tree) 149 | } 150 | expect(list.length).toEqual(tree.size) 151 | for (let j = 0; j < 10; ++j) { 152 | const low = getRandomInt(0, 100) 153 | const high = low + getRandomInt(0, 100) 154 | expectSearch(list, tree, low, high) 155 | } 156 | } 157 | }) 158 | 159 | it('should insert and find', () => { 160 | const list = [] 161 | const tree = createIntervalTree() 162 | let id = 0 163 | 164 | const addAnItem = (list, tree) => { 165 | const low = getRandomInt(0, 100) 166 | const record = [low, low + getRandomInt(0, 100), ++id] 167 | list.push(record) 168 | tree.insert(record[0], record[1], record[2]) 169 | } 170 | 171 | for (let i = 0; i < 1000; ++i) { 172 | addAnItem(list, tree) 173 | expect(list.length).toEqual(tree.size) 174 | for (let j = 0; j < 10; ++j) { 175 | const low = getRandomInt(0, 100) 176 | const high = low + getRandomInt(0, 100) 177 | expectSearch(list, tree, low, high) 178 | } 179 | } 180 | }) 181 | }) 182 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "masonic", 3 | "version": "3.4.0", 4 | "homepage": "https://github.com/jaredLunde/masonic#readme", 5 | "repository": "github:jaredLunde/masonic", 6 | "bugs": "https://github.com/jaredLunde/masonic/issues", 7 | "author": "Jared Lunde (https://jaredLunde.com)", 8 | "license": "MIT", 9 | "description": "An autosizing masonry component that only renders items currently visible in the window.", 10 | "keywords": [ 11 | "react", 12 | "reactjs", 13 | "react component", 14 | "virtual", 15 | "list", 16 | "masonry", 17 | "masonry component", 18 | "react masonry component", 19 | "infinite", 20 | "infinite list", 21 | "infinite masonry", 22 | "infinite scrolling", 23 | "scrolling", 24 | "virtualized", 25 | "masonic", 26 | "grid component", 27 | "react grid", 28 | "masonry grid", 29 | "react masonry grid" 30 | ], 31 | "main": "dist/main/index.js", 32 | "module": "dist/module/index.js", 33 | "unpkg": "dist/umd/masonic.js", 34 | "source": "src/index.tsx", 35 | "types": "types/index.d.ts", 36 | "files": [ 37 | "/dist", 38 | "/src", 39 | "/types" 40 | ], 41 | "exports": { 42 | ".": { 43 | "browser": "./dist/module/index.js", 44 | "import": "./dist/esm/index.mjs", 45 | "require": "./dist/main/index.js", 46 | "umd": "./dist/umd/masonic.js", 47 | "source": "./src/index.tsx", 48 | "types": "./types/index.d.ts", 49 | "default": "./dist/main/index.js" 50 | }, 51 | "./package.json": "./package.json", 52 | "./": "./" 53 | }, 54 | "sideEffects": false, 55 | "scripts": { 56 | "build": "lundle build", 57 | "check-types": "lundle check-types", 58 | "dev": "lundle build -f module,cjs -w", 59 | "format": "prettier --write \"{,!(node_modules|dist|coverage)/**/}*.{ts,tsx,js,jsx,md,yml,json}\"", 60 | "lint": "eslint . --ext .ts,.tsx", 61 | "prepublishOnly": "cli-confirm \"Did you run 'yarn release' first? (y/N)\"", 62 | "prerelease": "npm run validate && npm run build", 63 | "release": "git add . && standard-version -a", 64 | "test": "jest", 65 | "validate": "lundle check-types && npm run lint && jest --coverage" 66 | }, 67 | "husky": { 68 | "hooks": { 69 | "pre-commit": "lundle check-types && lint-staged", 70 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 71 | } 72 | }, 73 | "lint-staged": { 74 | "**/*.{ts,tsx,js,jsx}": [ 75 | "eslint", 76 | "prettier --write" 77 | ], 78 | "**/*.{md,yml,json}": [ 79 | "prettier --write" 80 | ] 81 | }, 82 | "commitlint": { 83 | "extends": [ 84 | "@commitlint/config-conventional" 85 | ] 86 | }, 87 | "config": { 88 | "commitizen": { 89 | "path": "./node_modules/cz-conventional-changelog" 90 | } 91 | }, 92 | "eslintConfig": { 93 | "extends": [ 94 | "lunde" 95 | ] 96 | }, 97 | "eslintIgnore": [ 98 | "benchmarks", 99 | "node_modules", 100 | "coverage", 101 | "dist", 102 | "test", 103 | "/types", 104 | "*.config.js" 105 | ], 106 | "jest": { 107 | "moduleDirectories": [ 108 | "node_modules", 109 | "src", 110 | "test" 111 | ], 112 | "testMatch": [ 113 | "/src/**/?(*.)test.{ts,tsx}" 114 | ], 115 | "collectCoverageFrom": [ 116 | "**/src/**/*.{ts,tsx}" 117 | ], 118 | "setupFilesAfterEnv": [ 119 | "./test/setup.js" 120 | ], 121 | "snapshotResolver": "./test/resolve-snapshot.js", 122 | "globals": { 123 | "__DEV__": true 124 | } 125 | }, 126 | "prettier": { 127 | "semi": false, 128 | "singleQuote": true, 129 | "jsxSingleQuote": true, 130 | "bracketSpacing": false 131 | }, 132 | "devDependencies": { 133 | "@commitlint/cli": "^8.3.5", 134 | "@commitlint/config-conventional": "^8.3.4", 135 | "@essentials/benchmark": "^1.0.6", 136 | "@shopify/jest-dom-mocks": "^2.9.0", 137 | "@testing-library/jest-dom": "latest", 138 | "@testing-library/react": "latest", 139 | "@testing-library/react-hooks": "latest", 140 | "@testing-library/user-event": "latest", 141 | "@types/jest": "latest", 142 | "@types/raf-schd": "^4.0.0", 143 | "@types/react": "latest", 144 | "@types/react-dom": "latest", 145 | "babel-jest": "latest", 146 | "cli-confirm": "^1.0.1", 147 | "cz-conventional-changelog": "3.2.0", 148 | "eslint": "latest", 149 | "eslint-config-lunde": "^0.2.1", 150 | "husky": "latest", 151 | "jest": "latest", 152 | "lint-staged": "latest", 153 | "lundle": "^0.4.9", 154 | "node-fetch": "^2.6.0", 155 | "prettier": "latest", 156 | "rand-int": "^1.0.0", 157 | "react": "latest", 158 | "react-dom": "latest", 159 | "react-test-renderer": "latest", 160 | "standard-version": "^8.0.2", 161 | "typescript": "latest" 162 | }, 163 | "dependencies": { 164 | "@essentials/memoize-one": "^1.1.0", 165 | "@essentials/one-key-map": "^1.2.0", 166 | "@essentials/request-timeout": "^1.3.0", 167 | "@react-hook/event": "^1.2.2", 168 | "@react-hook/latest": "^1.0.3", 169 | "@react-hook/passive-layout-effect": "^1.2.0", 170 | "@react-hook/throttle": "^2.2.0", 171 | "@react-hook/window-scroll": "^1.3.0", 172 | "@react-hook/window-size": "^3.0.6", 173 | "raf-schd": "^4.0.2", 174 | "resize-observer-polyfill": "^1.5.1", 175 | "trie-memoize": "^1.2.0" 176 | }, 177 | "peerDependencies": { 178 | "react": ">=16.8" 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/use-scroll-to-index.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import useLatest from '@react-hook/latest' 3 | import useEvent from '@react-hook/event' 4 | import {useThrottleCallback} from '@react-hook/throttle' 5 | import type {Positioner, PositionerItem} from './use-positioner' 6 | 7 | /** 8 | * A hook that creates a callback for scrolling to a specific index in 9 | * the "items" array. 10 | * 11 | * @param positioner A positioner created by the `usePositioner()` hook 12 | * @param options Configuration options 13 | */ 14 | export function useScrollToIndex( 15 | positioner: Positioner, 16 | options: UseScrollToIndexOptions 17 | ) { 18 | const { 19 | align = 'top', 20 | element = typeof window !== 'undefined' && window, 21 | offset = 0, 22 | height = typeof window !== 'undefined' ? window.innerHeight : 0, 23 | } = options 24 | const latestOptions = useLatest({ 25 | positioner, 26 | element, 27 | align, 28 | offset, 29 | height, 30 | } as const) 31 | const getTarget = React.useRef(() => { 32 | const latestElement = latestOptions.current.element 33 | return latestElement && 'current' in latestElement 34 | ? latestElement.current 35 | : latestElement 36 | }).current 37 | const [state, dispatch] = React.useReducer( 38 | ( 39 | state: { 40 | position: PositionerItem | undefined 41 | index: number | undefined 42 | prevTop: number | undefined 43 | }, 44 | action: 45 | | {type: 'scrollToIndex'; value: number | undefined} 46 | | {type: 'setPosition'; value: PositionerItem | undefined} 47 | | {type: 'setPrevTop'; value: number | undefined} 48 | | {type: 'reset'} 49 | ) => { 50 | const nextState = { 51 | position: state.position, 52 | index: state.index, 53 | prevTop: state.prevTop, 54 | } 55 | 56 | /* istanbul ignore next */ 57 | if (action.type === 'scrollToIndex') { 58 | return { 59 | position: latestOptions.current.positioner.get(action.value ?? -1), 60 | index: action.value, 61 | prevTop: void 0, 62 | } 63 | } else if (action.type === 'setPosition') { 64 | nextState.position = action.value 65 | } else if (action.type === 'setPrevTop') { 66 | nextState.prevTop = action.value 67 | } else if (action.type === 'reset') { 68 | return defaultState 69 | } 70 | 71 | return nextState 72 | }, 73 | defaultState 74 | ) 75 | const throttledDispatch = useThrottleCallback(dispatch, 15) 76 | 77 | // If we find the position along the way we can immediately take off 78 | // to the correct spot. 79 | useEvent(getTarget() as Window, 'scroll', () => { 80 | if (!state.position && state.index) { 81 | const position = latestOptions.current.positioner.get(state.index) 82 | 83 | if (position) { 84 | dispatch({type: 'setPosition', value: position}) 85 | } 86 | } 87 | }) 88 | 89 | // If the top changes out from under us in the case of dynamic cells, we 90 | // want to keep following it. 91 | const currentTop = 92 | state.index !== void 0 && 93 | latestOptions.current.positioner.get(state.index)?.top 94 | 95 | React.useEffect(() => { 96 | const target = getTarget() 97 | if (!target) return 98 | const {height, align, offset, positioner} = latestOptions.current 99 | 100 | if (state.position) { 101 | let scrollTop = state.position.top 102 | 103 | if (align === 'bottom') { 104 | scrollTop = scrollTop - height + state.position.height 105 | } else if (align === 'center') { 106 | scrollTop -= (height - state.position.height) / 2 107 | } 108 | 109 | target.scrollTo(0, Math.max(0, (scrollTop += offset))) 110 | // Resets state after 400ms, an arbitrary time I determined to be 111 | // still visually pleasing if there is a slow network reply in dynamic 112 | // cells 113 | let didUnsubscribe = false 114 | const timeout = setTimeout( 115 | () => !didUnsubscribe && dispatch({type: 'reset'}), 116 | 400 117 | ) 118 | return () => { 119 | didUnsubscribe = true 120 | clearTimeout(timeout) 121 | } 122 | } else if (state.index !== void 0) { 123 | // Estimates the top based upon the average height of current cells 124 | let estimatedTop = 125 | (positioner.shortestColumn() / positioner.size()) * state.index 126 | if (state.prevTop) 127 | estimatedTop = Math.max(estimatedTop, state.prevTop + height) 128 | target.scrollTo(0, estimatedTop) 129 | throttledDispatch({type: 'setPrevTop', value: estimatedTop}) 130 | } 131 | }, [currentTop, state, latestOptions, getTarget, throttledDispatch]) 132 | 133 | return React.useRef((index: number) => { 134 | dispatch({type: 'scrollToIndex', value: index}) 135 | }).current 136 | } 137 | 138 | const defaultState = { 139 | index: void 0, 140 | position: void 0, 141 | prevTop: void 0, 142 | } as const 143 | 144 | export type UseScrollToIndexOptions = { 145 | /** 146 | * The window element or a React ref for the window element. That is, 147 | * this is the grid container. 148 | * @default window 149 | */ 150 | element?: Window | HTMLElement | React.RefObject | null 151 | /** 152 | * Sets the vertical alignment of the cell within the grid container. 153 | * @default "top" 154 | */ 155 | align?: 'center' | 'top' | 'bottom' 156 | /** 157 | * The height of the grid. 158 | * @default window.innerHeight 159 | */ 160 | height?: number 161 | /** 162 | * The vertical space in pixels between the top of the grid container and the top 163 | * of the window. 164 | * @default 0 165 | */ 166 | offset?: number 167 | } 168 | -------------------------------------------------------------------------------- /src/use-infinite-loader.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import useLatest from '@react-hook/latest' 3 | 4 | /** 5 | * A utility hook for seamlessly adding infinite scroll behavior to the `useMasonry()` hook. This 6 | * hook invokes a callback each time the last rendered index surpasses the total number of items 7 | * in your items array or the number defined in the `totalItems` option. 8 | * 9 | * @param loadMoreItems This callback is invoked when more rows must be loaded. It will be used to 10 | * determine when to refresh the list with the newly-loaded data. This callback may be called multiple 11 | * times in reaction to a single scroll event, so it's important to memoize its arguments. If you're 12 | * creating this callback inside of a functional component, make sure you wrap it in `React.useCallback()`, 13 | * as well. 14 | * @param options 15 | */ 16 | export function useInfiniteLoader>( 17 | loadMoreItems: T, 18 | options: UseInfiniteLoaderOptions = emptyObj 19 | ): LoadMoreItemsCallback { 20 | const { 21 | isItemLoaded, 22 | minimumBatchSize = 16, 23 | threshold = 16, 24 | totalItems = 9e9, 25 | } = options 26 | const storedLoadMoreItems = useLatest(loadMoreItems) 27 | const storedIsItemLoaded = useLatest(isItemLoaded) 28 | 29 | return React.useCallback( 30 | (startIndex, stopIndex, items) => { 31 | const unloadedRanges = scanForUnloadedRanges( 32 | storedIsItemLoaded.current, 33 | minimumBatchSize, 34 | items, 35 | totalItems, 36 | Math.max(0, startIndex - threshold), 37 | Math.min(totalItems - 1, (stopIndex || 0) + threshold) 38 | ) 39 | // The user is responsible for memoizing their loadMoreItems() function 40 | // because we don't want to make assumptions about how they want to deal 41 | // with `items` 42 | for (let i = 0; i < unloadedRanges.length - 1; ++i) 43 | storedLoadMoreItems.current( 44 | unloadedRanges[i], 45 | unloadedRanges[++i], 46 | items 47 | ) 48 | }, 49 | [ 50 | totalItems, 51 | minimumBatchSize, 52 | threshold, 53 | storedLoadMoreItems, 54 | storedIsItemLoaded, 55 | ] 56 | ) 57 | } 58 | 59 | /** 60 | * Returns all of the ranges within a larger range that contain unloaded rows. 61 | */ 62 | function scanForUnloadedRanges( 63 | isItemLoaded: UseInfiniteLoaderOptions< 64 | Item 65 | >['isItemLoaded'] = defaultIsItemLoaded, 66 | minimumBatchSize: UseInfiniteLoaderOptions['minimumBatchSize'] = 16, 67 | items: any[], 68 | totalItems: UseInfiniteLoaderOptions['totalItems'] = 9e9, 69 | startIndex: number, 70 | stopIndex: number 71 | ): number[] { 72 | const unloadedRanges: number[] = [] 73 | let rangeStartIndex: number | undefined, 74 | rangeStopIndex: number | undefined, 75 | index = startIndex 76 | 77 | /* istanbul ignore next */ 78 | for (; index <= stopIndex; index++) { 79 | if (!isItemLoaded(index, items)) { 80 | rangeStopIndex = index 81 | if (rangeStartIndex === void 0) rangeStartIndex = index 82 | } else if (rangeStartIndex !== void 0 && rangeStopIndex !== void 0) { 83 | unloadedRanges.push(rangeStartIndex, rangeStopIndex) 84 | rangeStartIndex = rangeStopIndex = void 0 85 | } 86 | } 87 | 88 | // If :rangeStopIndex is not null it means we haven't run out of unloaded rows. 89 | // Scan forward to try filling our :minimumBatchSize. 90 | if (rangeStartIndex !== void 0 && rangeStopIndex !== void 0) { 91 | const potentialStopIndex = Math.min( 92 | Math.max(rangeStopIndex, rangeStartIndex + minimumBatchSize - 1), 93 | totalItems - 1 94 | ) 95 | 96 | /* istanbul ignore next */ 97 | for (index = rangeStopIndex + 1; index <= potentialStopIndex; index++) { 98 | if (!isItemLoaded(index, items)) { 99 | rangeStopIndex = index 100 | } else { 101 | break 102 | } 103 | } 104 | 105 | unloadedRanges.push(rangeStartIndex, rangeStopIndex) 106 | } 107 | 108 | // Check to see if our first range ended prematurely. 109 | // In this case we should scan backwards to try filling our :minimumBatchSize. 110 | /* istanbul ignore next */ 111 | if (unloadedRanges.length) { 112 | let firstUnloadedStart = unloadedRanges[0] 113 | const firstUnloadedStop = unloadedRanges[1] 114 | 115 | while ( 116 | firstUnloadedStop - firstUnloadedStart + 1 < minimumBatchSize && 117 | firstUnloadedStart > 0 118 | ) { 119 | const index = firstUnloadedStart - 1 120 | 121 | if (!isItemLoaded(index, items)) { 122 | unloadedRanges[0] = firstUnloadedStart = index 123 | } else { 124 | break 125 | } 126 | } 127 | } 128 | 129 | return unloadedRanges 130 | } 131 | 132 | const defaultIsItemLoaded = (index: number, items: Item[]): boolean => 133 | items[index] !== void 0 134 | 135 | export interface UseInfiniteLoaderOptions { 136 | /** 137 | * A callback responsible for determining the loaded state of each item. Should return `true` 138 | * if the item has already been loaded and `false` if not. 139 | * @default (index: number, items: any[]) => boolean 140 | */ 141 | isItemLoaded?: (index: number, items: Item[]) => boolean 142 | /** 143 | * The minimum number of new items to be loaded at a time. This property can be used to 144 | * batch requests and reduce HTTP requests. 145 | * @default 16 146 | */ 147 | minimumBatchSize?: number 148 | /** 149 | * The threshold at which to pre-fetch data. A threshold X means that new data should start 150 | * loading when a user scrolls within X cells of the end of your `items` array. 151 | * @default 16 152 | */ 153 | threshold?: number 154 | /** 155 | * The total number of items you'll need to eventually load (if known). This can 156 | * be arbitrarily high if not known. 157 | * @default 9e9 158 | */ 159 | totalItems?: number 160 | } 161 | 162 | export type LoadMoreItemsCallback = ( 163 | startIndex: number, 164 | stopIndex: number, 165 | items: Item[] 166 | ) => any 167 | 168 | const emptyObj = {} 169 | -------------------------------------------------------------------------------- /types/use-masonry.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import type {Positioner} from './use-positioner' 3 | /** 4 | * This hook handles the render phases of the masonry layout and returns the grid as a React element. 5 | * 6 | * @param options Options for configuring the masonry layout renderer. See `UseMasonryOptions`. 7 | */ 8 | export declare function useMasonry({ 9 | positioner, 10 | resizeObserver, 11 | items, 12 | as: ContainerComponent, 13 | id, 14 | className, 15 | style, 16 | role, 17 | tabIndex, 18 | containerRef, 19 | itemAs: ItemComponent, 20 | itemStyle, 21 | itemHeightEstimate, 22 | itemKey, 23 | overscanBy, 24 | scrollTop, 25 | isScrolling, 26 | height, 27 | render: RenderComponent, 28 | onRender, 29 | }: UseMasonryOptions): JSX.Element 30 | export interface UseMasonryOptions { 31 | /** 32 | * An array containing the data used by the grid items. 33 | */ 34 | items: Item[] 35 | /** 36 | * A grid cell positioner and cache created by the `usePositioner()` hook or 37 | * the `createPositioner` utility. 38 | */ 39 | positioner: Positioner 40 | /** 41 | * A resize observer that tracks mutations to the grid cells and forces the 42 | * Masonry grid to recalculate its layout if any cells affect column heights 43 | * change. Check out the `useResizeObserver()` hook. 44 | */ 45 | resizeObserver?: { 46 | observe: ResizeObserver['observe'] 47 | disconnect: ResizeObserver['observe'] 48 | unobserve: ResizeObserver['unobserve'] 49 | } 50 | /** 51 | * This is the type of element the grid container will be rendered as. 52 | * @default "div"` 53 | */ 54 | as?: keyof JSX.IntrinsicElements | React.ComponentType 55 | /** 56 | * Optionally gives the grid container an `id` prop. 57 | */ 58 | id?: string 59 | /** 60 | * Optionally gives the grid container a `className` prop. 61 | */ 62 | className?: string 63 | /** 64 | * Adds extra `style` attributes to the container in addition to those 65 | * created by the `useMasonry()` hook. 66 | */ 67 | style?: React.CSSProperties 68 | /** 69 | * Optionally swap out the accessibility `role` prop of the container and its items. 70 | * @default "grid" 71 | */ 72 | role?: 'grid' | 'list' 73 | /** 74 | * Change the `tabIndex` of the grid container. 75 | * @default 0 76 | */ 77 | tabIndex?: number 78 | /** 79 | * Forwards a React ref to the grid container. 80 | */ 81 | containerRef?: 82 | | ((element: HTMLElement) => void) 83 | | React.MutableRefObject 84 | /** 85 | * This is the type of element the grid items will be rendered as. 86 | * @default "div" 87 | */ 88 | itemAs?: keyof JSX.IntrinsicElements | React.ComponentType 89 | /** 90 | * Adds extra `style` attributes to the grid items in addition to those 91 | * created by the `useMasonry()` hook. 92 | */ 93 | itemStyle?: React.CSSProperties 94 | /** 95 | * This value is used for estimating the initial height of the masonry grid. It is important for 96 | * the UX of the scrolling behavior and in determining how many `items` to render in a batch, so it's 97 | * wise to set this value with some level accuracy, though it doesn't need to be perfect. 98 | * @default 300 99 | */ 100 | itemHeightEstimate?: number 101 | /** 102 | * The value returned here must be unique to the item. By default, the key is the item's index. This is ok 103 | * if your collection of items is never modified. Setting this property ensures that the component in `render` 104 | * is reused each time the masonry grid is reflowed. A common pattern would be to return the item's database 105 | * ID here if there is one, e.g. `data => data.id` 106 | * @default (data, index) => index` 107 | */ 108 | itemKey?: (data: Item, index: number) => string | number 109 | /** 110 | * This number is used for determining the number of grid cells outside of the visible window to render. 111 | * The default value is `2` which means "render 2 windows worth (2 * `height`) of content before and after 112 | * the items in the visible window". A value of `3` would be 3 windows worth of grid cells, so it's a 113 | * linear relationship. 114 | * 115 | * Overscanning is important for preventing tearing when scrolling through items in the grid, but setting 116 | * too high of a vaimport { useForceUpdate } from './use-force-update'; 117 | lue may create too much work for React to handle, so it's best that you tune this 118 | * value accordingly. 119 | * @default 2 120 | */ 121 | overscanBy?: number 122 | /** 123 | * This is the height of the window. If you're rendering the grid relative to the browser `window`, 124 | * the current `document.documentElement.clientHeight` is the value you'll want to set here. If you're 125 | * rendering the grid inside of another HTML element, you'll want to provide the current `element.offsetHeight` 126 | * here. 127 | */ 128 | height: number 129 | /** 130 | * The current scroll progress in pixel of the window the grid is rendered in. If you're rendering 131 | * the grid relative to the browser `window`, you'll want the most current `window.scrollY` here. 132 | * If you're rendering the grid inside of another HTML element, you'll want the current `element.scrollTop` 133 | * value here. The `useScroller()` hook and `` components will help you if you're 134 | * rendering the grid relative to the browser `window`. 135 | */ 136 | scrollTop: number 137 | /** 138 | * This property is used for determining whether or not the grid container should add styles that 139 | * dramatically increase scroll performance. That is, turning off `pointer-events` and adding a 140 | * `will-change: contents;` value to the style string. You can forgo using this prop, but I would 141 | * not recommend that. The `useScroller()` hook and `` components will help you if 142 | * you're rendering the grid relative to the browser `window`. 143 | * @default false 144 | */ 145 | isScrolling?: boolean 146 | /** 147 | * This component is rendered for each item of your `items` prop array. It should accept three props: 148 | * `index`, `width`, and `data`. See RenderComponentProps. 149 | */ 150 | render: React.ComponentType> 151 | /** 152 | * This callback is invoked any time the items currently being rendered by the grid change. 153 | */ 154 | onRender?: (startIndex: number, stopIndex: number, items: Item[]) => void 155 | } 156 | export interface RenderComponentProps { 157 | /** 158 | * The index of the cell in the `items` prop array. 159 | */ 160 | index: number 161 | /** 162 | * The rendered width of the cell's column. 163 | */ 164 | width: number 165 | /** 166 | * The data at `items[index]` of your `items` prop array. 167 | */ 168 | data: Item 169 | } 170 | -------------------------------------------------------------------------------- /src/interval-tree.ts: -------------------------------------------------------------------------------- 1 | type Color = 0 | 1 | 2 2 | const RED = 0 3 | const BLACK = 1 4 | const NIL = 2 5 | 6 | const DELETE = 0 7 | const KEEP = 1 8 | 9 | type ListNode = { 10 | index: number 11 | high: number 12 | next: ListNode | null 13 | } 14 | 15 | interface TreeNode { 16 | max: number 17 | low: number 18 | high: number 19 | // color 20 | C: Color 21 | // P 22 | P: TreeNode 23 | // right 24 | R: TreeNode 25 | // left 26 | L: TreeNode 27 | list: ListNode 28 | } 29 | 30 | interface Tree { 31 | root: TreeNode 32 | size: number 33 | } 34 | 35 | function addInterval(treeNode: TreeNode, high: number, index: number): boolean { 36 | let node: ListNode | null = treeNode.list 37 | let prevNode: ListNode | undefined 38 | 39 | while (node) { 40 | if (node.index === index) return false 41 | if (high > node.high) break 42 | prevNode = node 43 | node = node.next 44 | } 45 | 46 | if (!prevNode) treeNode.list = {index, high, next: node} 47 | if (prevNode) prevNode.next = {index, high, next: prevNode.next} 48 | 49 | return true 50 | } 51 | 52 | function removeInterval(treeNode: TreeNode, index: number) { 53 | let node: ListNode | null = treeNode.list 54 | if (node.index === index) { 55 | if (node.next === null) return DELETE 56 | treeNode.list = node.next 57 | return KEEP 58 | } 59 | 60 | let prevNode: ListNode | undefined = node 61 | node = node.next 62 | 63 | while (node !== null) { 64 | if (node.index === index) { 65 | prevNode.next = node.next 66 | return KEEP 67 | } 68 | prevNode = node 69 | node = node.next 70 | } 71 | } 72 | 73 | const NULL_NODE: TreeNode = { 74 | low: 0, 75 | max: 0, 76 | high: 0, 77 | C: NIL, 78 | // @ts-ignore 79 | P: undefined, 80 | // @ts-ignore 81 | R: undefined, 82 | // @ts-ignore 83 | L: undefined, 84 | // @ts-ignore 85 | list: undefined, 86 | } 87 | 88 | NULL_NODE.P = NULL_NODE 89 | NULL_NODE.L = NULL_NODE 90 | NULL_NODE.R = NULL_NODE 91 | 92 | function updateMax(node: TreeNode) { 93 | const max = node.high 94 | if (node.L === NULL_NODE && node.R === NULL_NODE) node.max = max 95 | else if (node.L === NULL_NODE) node.max = Math.max(node.R.max, max) 96 | else if (node.R === NULL_NODE) node.max = Math.max(node.L.max, max) 97 | else node.max = Math.max(Math.max(node.L.max, node.R.max), max) 98 | } 99 | 100 | function updateMaxUp(node: TreeNode) { 101 | let x = node 102 | 103 | while (x.P !== NULL_NODE) { 104 | updateMax(x.P) 105 | x = x.P 106 | } 107 | } 108 | 109 | function rotateLeft(tree: Tree, x: TreeNode) { 110 | if (x.R === NULL_NODE) return 111 | const y = x.R 112 | x.R = y.L 113 | if (y.L !== NULL_NODE) y.L.P = x 114 | y.P = x.P 115 | 116 | if (x.P === NULL_NODE) tree.root = y 117 | else { 118 | if (x === x.P.L) x.P.L = y 119 | else x.P.R = y 120 | } 121 | 122 | y.L = x 123 | x.P = y 124 | 125 | updateMax(x) 126 | updateMax(y) 127 | } 128 | 129 | function rotateRight(tree: Tree, x: TreeNode) { 130 | if (x.L === NULL_NODE) return 131 | const y = x.L 132 | x.L = y.R 133 | if (y.R !== NULL_NODE) y.R.P = x 134 | y.P = x.P 135 | 136 | if (x.P === NULL_NODE) tree.root = y 137 | else { 138 | if (x === x.P.R) x.P.R = y 139 | else x.P.L = y 140 | } 141 | 142 | y.R = x 143 | x.P = y 144 | 145 | updateMax(x) 146 | updateMax(y) 147 | } 148 | 149 | function replaceNode(tree: Tree, x: TreeNode, y: TreeNode) { 150 | if (x.P === NULL_NODE) tree.root = y 151 | else if (x === x.P.L) x.P.L = y 152 | else x.P.R = y 153 | y.P = x.P 154 | } 155 | 156 | function fixRemove(tree: Tree, x: TreeNode) { 157 | let w 158 | 159 | while (x !== NULL_NODE && x.C === BLACK) { 160 | if (x === x.P.L) { 161 | w = x.P.R 162 | 163 | if (w.C === RED) { 164 | w.C = BLACK 165 | x.P.C = RED 166 | rotateLeft(tree, x.P) 167 | w = x.P.R 168 | } 169 | 170 | if (w.L.C === BLACK && w.R.C === BLACK) { 171 | w.C = RED 172 | x = x.P 173 | } else { 174 | if (w.R.C === BLACK) { 175 | w.L.C = BLACK 176 | w.C = RED 177 | rotateRight(tree, w) 178 | w = x.P.R 179 | } 180 | 181 | w.C = x.P.C 182 | x.P.C = BLACK 183 | w.R.C = BLACK 184 | rotateLeft(tree, x.P) 185 | x = tree.root 186 | } 187 | } else { 188 | w = x.P.L 189 | 190 | if (w.C === RED) { 191 | w.C = BLACK 192 | x.P.C = RED 193 | rotateRight(tree, x.P) 194 | w = x.P.L 195 | } 196 | 197 | if (w.R.C === BLACK && w.L.C === BLACK) { 198 | w.C = RED 199 | x = x.P 200 | } else { 201 | if (w.L.C === BLACK) { 202 | w.R.C = BLACK 203 | w.C = RED 204 | rotateLeft(tree, w) 205 | w = x.P.L 206 | } 207 | 208 | w.C = x.P.C 209 | x.P.C = BLACK 210 | w.L.C = BLACK 211 | rotateRight(tree, x.P) 212 | x = tree.root 213 | } 214 | } 215 | } 216 | 217 | x.C = BLACK 218 | } 219 | 220 | function minimumTree(x: TreeNode) { 221 | while (x.L !== NULL_NODE) x = x.L 222 | return x 223 | } 224 | 225 | function fixInsert(tree: Tree, z: TreeNode) { 226 | let y: TreeNode 227 | while (z.P.C === RED) { 228 | if (z.P === z.P.P.L) { 229 | y = z.P.P.R 230 | 231 | if (y.C === RED) { 232 | z.P.C = BLACK 233 | y.C = BLACK 234 | z.P.P.C = RED 235 | z = z.P.P 236 | } else { 237 | if (z === z.P.R) { 238 | z = z.P 239 | rotateLeft(tree, z) 240 | } 241 | 242 | z.P.C = BLACK 243 | z.P.P.C = RED 244 | rotateRight(tree, z.P.P) 245 | } 246 | } else { 247 | y = z.P.P.L 248 | 249 | if (y.C === RED) { 250 | z.P.C = BLACK 251 | y.C = BLACK 252 | z.P.P.C = RED 253 | z = z.P.P 254 | } else { 255 | if (z === z.P.L) { 256 | z = z.P 257 | rotateRight(tree, z) 258 | } 259 | 260 | z.P.C = BLACK 261 | z.P.P.C = RED 262 | rotateLeft(tree, z.P.P) 263 | } 264 | } 265 | } 266 | tree.root.C = BLACK 267 | } 268 | 269 | export interface IIntervalTree { 270 | insert(low: number, high: number, index: number): void 271 | remove(index: number): void 272 | search( 273 | low: number, 274 | high: number, 275 | callback: (index: number, low: number) => any 276 | ): void 277 | size: number 278 | } 279 | 280 | export function createIntervalTree(): IIntervalTree { 281 | const tree = { 282 | root: NULL_NODE, 283 | size: 0, 284 | } 285 | // we know these indexes are a consistent, safe way to make look ups 286 | // for our case so it's a solid O(1) alternative to 287 | // the O(log n) searchNode() in typical interval trees 288 | const indexMap: Record = {} 289 | 290 | return { 291 | insert(low, high, index) { 292 | let x: TreeNode = tree.root 293 | let y: TreeNode = NULL_NODE 294 | 295 | while (x !== NULL_NODE) { 296 | y = x 297 | if (low === y.low) break 298 | if (low < x.low) x = x.L 299 | else x = x.R 300 | } 301 | 302 | if (low === y.low && y !== NULL_NODE) { 303 | if (!addInterval(y, high, index)) return 304 | y.high = Math.max(y.high, high) 305 | updateMax(y) 306 | updateMaxUp(y) 307 | indexMap[index] = y 308 | tree.size++ 309 | return 310 | } 311 | 312 | const z: TreeNode = { 313 | low, 314 | high, 315 | max: high, 316 | C: RED, 317 | P: y, 318 | L: NULL_NODE, 319 | R: NULL_NODE, 320 | list: {index, high, next: null}, 321 | } 322 | 323 | if (y === NULL_NODE) { 324 | tree.root = z 325 | } else { 326 | if (z.low < y.low) y.L = z 327 | else y.R = z 328 | updateMaxUp(z) 329 | } 330 | 331 | fixInsert(tree, z) 332 | indexMap[index] = z 333 | tree.size++ 334 | }, 335 | 336 | remove(index) { 337 | const z = indexMap[index] 338 | if (z === void 0) return 339 | delete indexMap[index] 340 | 341 | const intervalResult = removeInterval(z, index) 342 | if (intervalResult === void 0) return 343 | if (intervalResult === KEEP) { 344 | z.high = z.list.high 345 | updateMax(z) 346 | updateMaxUp(z) 347 | tree.size-- 348 | return 349 | } 350 | 351 | let y = z 352 | let originalYColor = y.C 353 | let x: TreeNode 354 | 355 | if (z.L === NULL_NODE) { 356 | x = z.R 357 | replaceNode(tree, z, z.R) 358 | } else if (z.R === NULL_NODE) { 359 | x = z.L 360 | replaceNode(tree, z, z.L) 361 | } else { 362 | y = minimumTree(z.R) 363 | originalYColor = y.C 364 | x = y.R 365 | 366 | if (y.P === z) { 367 | x.P = y 368 | } else { 369 | replaceNode(tree, y, y.R) 370 | y.R = z.R 371 | y.R.P = y 372 | } 373 | 374 | replaceNode(tree, z, y) 375 | y.L = z.L 376 | y.L.P = y 377 | y.C = z.C 378 | } 379 | 380 | updateMax(x) 381 | updateMaxUp(x) 382 | 383 | if (originalYColor === BLACK) fixRemove(tree, x) 384 | tree.size-- 385 | }, 386 | 387 | search(low, high, callback) { 388 | const stack = [tree.root] 389 | while (stack.length !== 0) { 390 | const node = stack.pop() as TreeNode 391 | if (node === NULL_NODE || low > node.max) continue 392 | if (node.L !== NULL_NODE) stack.push(node.L) 393 | if (node.R !== NULL_NODE) stack.push(node.R) 394 | if (node.low <= high && node.high >= low) { 395 | let curr: ListNode | null = node.list 396 | while (curr !== null) { 397 | if (curr.high >= low) callback(curr.index, node.low) 398 | curr = curr.next 399 | } 400 | } 401 | } 402 | }, 403 | 404 | get size() { 405 | return tree.size 406 | }, 407 | } 408 | } 409 | -------------------------------------------------------------------------------- /src/use-positioner.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {createIntervalTree} from './interval-tree' 3 | 4 | /** 5 | * This hook creates the grid cell positioner and cache required by `useMasonry()`. This is 6 | * the meat of the grid's layout algorithm, determining which cells to render at a given scroll 7 | * position, as well as where to place new items in the grid. 8 | * 9 | * @param options Properties that determine the number of columns in the grid, as well 10 | * as their widths. 11 | * @param deps This hook will create a new positioner, clearing all existing cached positions, 12 | * whenever the dependencies in this list change. 13 | */ 14 | export function usePositioner( 15 | { 16 | width, 17 | columnWidth = 200, 18 | columnGutter = 0, 19 | columnCount, 20 | }: UsePositionerOptions, 21 | deps: React.DependencyList = emptyArr 22 | ): Positioner { 23 | const initPositioner = (): Positioner => { 24 | const [computedColumnWidth, computedColumnCount] = getColumns( 25 | width, 26 | columnWidth, 27 | columnGutter, 28 | columnCount 29 | ) 30 | return createPositioner( 31 | computedColumnCount, 32 | computedColumnWidth, 33 | columnGutter 34 | ) 35 | } 36 | const positionerRef = React.useRef() 37 | if (positionerRef.current === undefined) 38 | positionerRef.current = initPositioner() 39 | 40 | const prevDeps = React.useRef(deps) 41 | const opts = [width, columnWidth, columnGutter, columnCount] 42 | const prevOpts = React.useRef(opts) 43 | const optsChanged = !opts.every((item, i) => prevOpts.current[i] === item) 44 | 45 | if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') { 46 | if (deps.length !== prevDeps.current.length) { 47 | throw new Error( 48 | 'usePositioner(): The length of your dependencies array changed.' 49 | ) 50 | } 51 | } 52 | 53 | // Create a new positioner when the dependencies or sizes change 54 | // Thanks to https://github.com/khmm12 for pointing this out 55 | // https://github.com/jaredLunde/masonic/pull/41 56 | if (optsChanged || !deps.every((item, i) => prevDeps.current[i] === item)) { 57 | const prevPositioner = positionerRef.current 58 | const positioner = initPositioner() 59 | prevDeps.current = deps 60 | prevOpts.current = opts 61 | 62 | if (optsChanged) { 63 | const cacheSize = prevPositioner.size() 64 | for (let index = 0; index < cacheSize; index++) { 65 | const pos = prevPositioner.get(index) 66 | positioner.set(index, pos !== void 0 ? pos.height : 0) 67 | } 68 | } 69 | 70 | positionerRef.current = positioner 71 | } 72 | 73 | return positionerRef.current 74 | } 75 | 76 | export interface UsePositionerOptions { 77 | /** 78 | * The width of the container you're rendering the grid within, i.e. the container 79 | * element's `element.offsetWidth` 80 | */ 81 | width: number 82 | /** 83 | * The minimum column width. The `usePositioner()` hook will automatically size the 84 | * columns to fill their container based upon the `columnWidth` and `columnGutter` values. 85 | * It will never render anything smaller than this width unless its container itself is 86 | * smaller than its value. This property is optional if you're using a static `columnCount`. 87 | * @default 200 88 | */ 89 | columnWidth?: number 90 | /** 91 | * This sets the vertical and horizontal space between grid cells in pixels. 92 | */ 93 | columnGutter?: number 94 | /** 95 | * By default, `usePositioner()` derives the column count from the `columnWidth`, `columnGutter`, 96 | * and `width` props. However, in some situations it is nice to be able to override that behavior 97 | * (e.g. creating a `List` component). 98 | */ 99 | columnCount?: number 100 | } 101 | 102 | /** 103 | * Creates a cell positioner for the `useMasonry()` hook. The `usePositioner()` hook uses 104 | * this utility under the hood. 105 | * 106 | * @param columnCount The number of columns in the grid 107 | * @param columnWidth The width of each column in the grid 108 | * @param columnGutter The amount of horizontal and vertical space in pixels to render 109 | * between each grid item. 110 | */ 111 | export const createPositioner = ( 112 | columnCount: number, 113 | columnWidth: number, 114 | columnGutter = 0 115 | ): Positioner => { 116 | // O(log(n)) lookup of cells to render for a given viewport size 117 | // Store tops and bottoms of each cell for fast intersection lookup. 118 | const intervalTree = createIntervalTree() 119 | // Track the height of each column. 120 | // Layout algorithm below always inserts into the shortest column. 121 | const columnHeights: number[] = new Array(columnCount) 122 | // Used for O(1) item access 123 | const items: PositionerItem[] = [] 124 | // Tracks the item indexes within an individual column 125 | const columnItems: number[][] = new Array(columnCount) 126 | 127 | for (let i = 0; i < columnCount; i++) { 128 | columnHeights[i] = 0 129 | columnItems[i] = [] 130 | } 131 | 132 | return { 133 | columnCount, 134 | columnWidth, 135 | set: (index, height = 0) => { 136 | let column = 0 137 | 138 | // finds the shortest column and uses it 139 | for (let i = 1; i < columnHeights.length; i++) { 140 | if (columnHeights[i] < columnHeights[column]) column = i 141 | } 142 | 143 | const top = columnHeights[column] || 0 144 | columnHeights[column] = top + height + columnGutter 145 | columnItems[column].push(index) 146 | items[index] = { 147 | left: column * (columnWidth + columnGutter), 148 | top, 149 | height, 150 | column, 151 | } 152 | intervalTree.insert(top, top + height, index) 153 | }, 154 | get: (index) => items[index], 155 | // This only updates items in the specific columns that have changed, on and after the 156 | // specific items that have changed 157 | update: (updates) => { 158 | const columns: number[] = new Array(columnCount) 159 | let i = 0, 160 | j = 0 161 | 162 | // determines which columns have items that changed, as well as the minimum index 163 | // changed in that column, as all items after that index will have their positions 164 | // affected by the change 165 | for (; i < updates.length - 1; i++) { 166 | const index = updates[i] 167 | const item = items[index] 168 | item.height = updates[++i] 169 | intervalTree.remove(index) 170 | intervalTree.insert(item.top, item.top + item.height, index) 171 | columns[item.column] = 172 | columns[item.column] === void 0 173 | ? index 174 | : Math.min(index, columns[item.column]) 175 | } 176 | 177 | for (i = 0; i < columns.length; i++) { 178 | // bails out if the column didn't change 179 | if (columns[i] === void 0) continue 180 | const itemsInColumn = columnItems[i] 181 | // the index order is sorted with certainty so binary search is a great solution 182 | // here as opposed to Array.indexOf() 183 | const startIndex = binarySearch(itemsInColumn, columns[i]) 184 | const index = columnItems[i][startIndex] 185 | const startItem = items[index] 186 | columnHeights[i] = startItem.top + startItem.height + columnGutter 187 | 188 | for (j = startIndex + 1; j < itemsInColumn.length; j++) { 189 | const index = itemsInColumn[j] 190 | const item = items[index] 191 | item.top = columnHeights[i] 192 | columnHeights[i] = item.top + item.height + columnGutter 193 | intervalTree.remove(index) 194 | intervalTree.insert(item.top, item.top + item.height, index) 195 | } 196 | } 197 | }, 198 | // Render all cells visible within the viewport range defined. 199 | range: (lo, hi, renderCallback) => 200 | intervalTree.search(lo, hi, (index, top) => 201 | renderCallback(index, items[index].left, top) 202 | ), 203 | estimateHeight: (itemCount, defaultItemHeight): number => { 204 | const tallestColumn = Math.max(0, Math.max.apply(null, columnHeights)) 205 | 206 | return itemCount === intervalTree.size 207 | ? tallestColumn 208 | : tallestColumn + 209 | Math.ceil((itemCount - intervalTree.size) / columnCount) * 210 | defaultItemHeight 211 | }, 212 | shortestColumn: () => { 213 | if (columnHeights.length > 1) return Math.min.apply(null, columnHeights) 214 | return columnHeights[0] || 0 215 | }, 216 | size(): number { 217 | return intervalTree.size 218 | }, 219 | } 220 | } 221 | 222 | export interface Positioner { 223 | /** 224 | * The number of columns in the grid 225 | */ 226 | columnCount: number 227 | /** 228 | * The width of each column in the grid 229 | */ 230 | columnWidth: number 231 | /** 232 | * Sets the position for the cell at `index` based upon the cell's height 233 | */ 234 | set: (index: number, height: number) => void 235 | /** 236 | * Gets the `PositionerItem` for the cell at `index` 237 | */ 238 | get: (index: number) => PositionerItem | undefined 239 | /** 240 | * Updates cells based on their indexes and heights 241 | * positioner.update([index, height, index, height, index, height...]) 242 | */ 243 | update: (updates: number[]) => void 244 | /** 245 | * Searches the interval tree for grid cells with a `top` value in 246 | * betwen `lo` and `hi` and invokes the callback for each item that 247 | * is discovered 248 | */ 249 | range: ( 250 | lo: number, 251 | hi: number, 252 | renderCallback: (index: number, left: number, top: number) => void 253 | ) => void 254 | /** 255 | * Returns the number of grid cells in the cache 256 | */ 257 | 258 | size: () => number 259 | /** 260 | * Estimates the total height of the grid 261 | */ 262 | 263 | estimateHeight: (itemCount: number, defaultItemHeight: number) => number 264 | /** 265 | * Returns the height of the shortest column in the grid 266 | */ 267 | 268 | shortestColumn: () => number 269 | } 270 | 271 | export interface PositionerItem { 272 | /** 273 | * This is how far from the top edge of the grid container in pixels the 274 | * item is placed 275 | */ 276 | top: number 277 | /** 278 | * This is how far from the left edge of the grid container in pixels the 279 | * item is placed 280 | */ 281 | left: number 282 | /** 283 | * This is the height of the grid cell 284 | */ 285 | height: number 286 | /** 287 | * This is the column number containing the grid cell 288 | */ 289 | column: number 290 | } 291 | 292 | /* istanbul ignore next */ 293 | const binarySearch = (a: number[], y: number): number => { 294 | let l = 0 295 | let h = a.length - 1 296 | 297 | while (l <= h) { 298 | const m = (l + h) >>> 1 299 | const x = a[m] 300 | if (x === y) return m 301 | else if (x <= y) l = m + 1 302 | else h = m - 1 303 | } 304 | 305 | return -1 306 | } 307 | 308 | const getColumns = ( 309 | width = 0, 310 | minimumWidth = 0, 311 | gutter = 8, 312 | columnCount?: number 313 | ): [number, number] => { 314 | columnCount = columnCount || Math.floor(width / (minimumWidth + gutter)) || 1 315 | const columnWidth = Math.floor( 316 | (width - gutter * (columnCount - 1)) / columnCount 317 | ) 318 | return [columnWidth, columnCount] 319 | } 320 | 321 | const emptyArr: [] = [] 322 | -------------------------------------------------------------------------------- /src/use-masonry.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import trieMemoize from 'trie-memoize' 3 | import OneKeyMap from '@essentials/one-key-map' 4 | import memoizeOne from '@essentials/memoize-one' 5 | import useLatest from '@react-hook/latest' 6 | import {elementsCache} from './elements-cache' 7 | import {useForceUpdate} from './use-force-update' 8 | import type {Positioner} from './use-positioner' 9 | 10 | /** 11 | * This hook handles the render phases of the masonry layout and returns the grid as a React element. 12 | * 13 | * @param options Options for configuring the masonry layout renderer. See `UseMasonryOptions`. 14 | */ 15 | export function useMasonry({ 16 | // Measurement and layout 17 | positioner, 18 | resizeObserver, 19 | // Grid items 20 | items, 21 | // Container props 22 | as: ContainerComponent = 'div', 23 | id, 24 | className, 25 | style, 26 | role = 'grid', 27 | tabIndex = 0, 28 | containerRef, 29 | // Item props 30 | itemAs: ItemComponent = 'div', 31 | itemStyle, 32 | itemHeightEstimate = 300, 33 | itemKey = defaultGetItemKey, 34 | // Rendering props 35 | overscanBy = 2, 36 | scrollTop, 37 | isScrolling, 38 | height, 39 | render: RenderComponent, 40 | onRender, 41 | }: UseMasonryOptions) { 42 | let startIndex = 0 43 | let stopIndex: number | undefined 44 | const forceUpdate = useForceUpdate() 45 | const setItemRef = getRefSetter(positioner, resizeObserver) 46 | const itemCount = items.length 47 | const { 48 | columnWidth, 49 | columnCount, 50 | range, 51 | estimateHeight, 52 | size, 53 | shortestColumn, 54 | } = positioner 55 | const measuredCount = size() 56 | const shortestColumnSize = shortestColumn() 57 | const children: React.ReactElement[] = [] 58 | const itemRole = role + 'item' 59 | const storedOnRender = useLatest(onRender) 60 | 61 | overscanBy = height * overscanBy 62 | const rangeEnd = scrollTop + overscanBy 63 | const needsFreshBatch = 64 | shortestColumnSize < rangeEnd && measuredCount < itemCount 65 | 66 | range( 67 | // We overscan in both directions because users scroll both ways, 68 | // though one must admit scrolling down is more common and thus 69 | // we only overscan by half the downward overscan amount 70 | Math.max(0, scrollTop - overscanBy / 2), 71 | rangeEnd, 72 | (index, left, top) => { 73 | const data = items[index] 74 | const key = itemKey(data, index) 75 | const phaseTwoStyle: React.CSSProperties = { 76 | top, 77 | left, 78 | width: columnWidth, 79 | writingMode: 'horizontal-tb', 80 | position: 'absolute', 81 | } 82 | 83 | /* istanbul ignore next */ 84 | if ( 85 | typeof process !== 'undefined' && 86 | process.env.NODE_ENV !== 'production' 87 | ) { 88 | throwWithoutData(data, index) 89 | } 90 | 91 | children.push( 92 | 102 | {createRenderElement(RenderComponent, index, data, columnWidth)} 103 | 104 | ) 105 | 106 | if (stopIndex === void 0) { 107 | startIndex = index 108 | stopIndex = index 109 | } else { 110 | startIndex = Math.min(startIndex, index) 111 | stopIndex = Math.max(stopIndex, index) 112 | } 113 | } 114 | ) 115 | 116 | if (needsFreshBatch) { 117 | const batchSize = Math.min( 118 | itemCount - measuredCount, 119 | Math.ceil( 120 | ((scrollTop + overscanBy - shortestColumnSize) / itemHeightEstimate) * 121 | columnCount 122 | ) 123 | ) 124 | 125 | let index = measuredCount 126 | const phaseOneStyle = getCachedSize(columnWidth) 127 | 128 | for (; index < measuredCount + batchSize; index++) { 129 | const data = items[index] 130 | const key = itemKey(data, index) 131 | 132 | /* istanbul ignore next */ 133 | if ( 134 | typeof process !== 'undefined' && 135 | process.env.NODE_ENV !== 'production' 136 | ) { 137 | throwWithoutData(data, index) 138 | } 139 | 140 | children.push( 141 | 151 | {createRenderElement(RenderComponent, index, data, columnWidth)} 152 | 153 | ) 154 | } 155 | } 156 | 157 | // Calls the onRender callback if the rendered indices changed 158 | React.useEffect(() => { 159 | if (typeof storedOnRender.current === 'function' && stopIndex !== void 0) 160 | storedOnRender.current(startIndex, stopIndex, items) 161 | 162 | didEverMount = '1' 163 | }, [startIndex, stopIndex, items, storedOnRender]) 164 | // If we needed a fresh batch we should reload our components with the measured 165 | // sizes 166 | React.useEffect(() => { 167 | if (needsFreshBatch) forceUpdate() 168 | // eslint-disable-next-line 169 | }, [needsFreshBatch]) 170 | 171 | // gets the container style object based upon the estimated height and whether or not 172 | // the page is being scrolled 173 | const containerStyle = getContainerStyle( 174 | isScrolling, 175 | estimateHeight(itemCount, itemHeightEstimate) 176 | ) 177 | 178 | return ( 179 | 193 | ) 194 | } 195 | 196 | /* istanbul ignore next */ 197 | function throwWithoutData(data: any, index: number) { 198 | if (!data) { 199 | throw new Error( 200 | `No data was found at index: ${index}\n\n` + 201 | `This usually happens when you've mutated or changed the "items" array in a ` + 202 | `way that makes it shorter than the previous "items" array. Masonic knows nothing ` + 203 | `about your underlying data and when it caches cell positions, it assumes you aren't ` + 204 | `mutating the underlying "items".\n\n` + 205 | `See https://codesandbox.io/s/masonic-w-react-router-example-2b5f9?file=/src/index.js for ` + 206 | `an example that gets around this limitations. For advanced implementations, see ` + 207 | `https://codesandbox.io/s/masonic-w-react-router-and-advanced-config-example-8em42?file=/src/index.js\n\n` + 208 | `If this was the result of your removing an item from your "items", see this issue: ` + 209 | `https://github.com/jaredLunde/masonic/issues/12` 210 | ) 211 | } 212 | } 213 | 214 | // This is for triggering a remount after SSR has loaded in the client w/ hydrate() 215 | let didEverMount = '0' 216 | 217 | export interface UseMasonryOptions { 218 | /** 219 | * An array containing the data used by the grid items. 220 | */ 221 | items: Item[] 222 | /** 223 | * A grid cell positioner and cache created by the `usePositioner()` hook or 224 | * the `createPositioner` utility. 225 | */ 226 | positioner: Positioner 227 | /** 228 | * A resize observer that tracks mutations to the grid cells and forces the 229 | * Masonry grid to recalculate its layout if any cells affect column heights 230 | * change. Check out the `useResizeObserver()` hook. 231 | */ 232 | resizeObserver?: { 233 | observe: ResizeObserver['observe'] 234 | disconnect: ResizeObserver['observe'] 235 | unobserve: ResizeObserver['unobserve'] 236 | } 237 | /** 238 | * This is the type of element the grid container will be rendered as. 239 | * @default "div"` 240 | */ 241 | as?: keyof JSX.IntrinsicElements | React.ComponentType 242 | /** 243 | * Optionally gives the grid container an `id` prop. 244 | */ 245 | id?: string 246 | /** 247 | * Optionally gives the grid container a `className` prop. 248 | */ 249 | className?: string 250 | /** 251 | * Adds extra `style` attributes to the container in addition to those 252 | * created by the `useMasonry()` hook. 253 | */ 254 | style?: React.CSSProperties 255 | /** 256 | * Optionally swap out the accessibility `role` prop of the container and its items. 257 | * @default "grid" 258 | */ 259 | role?: 'grid' | 'list' 260 | /** 261 | * Change the `tabIndex` of the grid container. 262 | * @default 0 263 | */ 264 | tabIndex?: number 265 | /** 266 | * Forwards a React ref to the grid container. 267 | */ 268 | containerRef?: 269 | | ((element: HTMLElement) => void) 270 | | React.MutableRefObject 271 | /** 272 | * This is the type of element the grid items will be rendered as. 273 | * @default "div" 274 | */ 275 | itemAs?: keyof JSX.IntrinsicElements | React.ComponentType 276 | /** 277 | * Adds extra `style` attributes to the grid items in addition to those 278 | * created by the `useMasonry()` hook. 279 | */ 280 | itemStyle?: React.CSSProperties 281 | /** 282 | * This value is used for estimating the initial height of the masonry grid. It is important for 283 | * the UX of the scrolling behavior and in determining how many `items` to render in a batch, so it's 284 | * wise to set this value with some level accuracy, though it doesn't need to be perfect. 285 | * @default 300 286 | */ 287 | itemHeightEstimate?: number 288 | /** 289 | * The value returned here must be unique to the item. By default, the key is the item's index. This is ok 290 | * if your collection of items is never modified. Setting this property ensures that the component in `render` 291 | * is reused each time the masonry grid is reflowed. A common pattern would be to return the item's database 292 | * ID here if there is one, e.g. `data => data.id` 293 | * @default (data, index) => index` 294 | */ 295 | itemKey?: (data: Item, index: number) => string | number 296 | /** 297 | * This number is used for determining the number of grid cells outside of the visible window to render. 298 | * The default value is `2` which means "render 2 windows worth (2 * `height`) of content before and after 299 | * the items in the visible window". A value of `3` would be 3 windows worth of grid cells, so it's a 300 | * linear relationship. 301 | * 302 | * Overscanning is important for preventing tearing when scrolling through items in the grid, but setting 303 | * too high of a vaimport { useForceUpdate } from './use-force-update'; 304 | lue may create too much work for React to handle, so it's best that you tune this 305 | * value accordingly. 306 | * @default 2 307 | */ 308 | overscanBy?: number 309 | 310 | /** 311 | * This is the height of the window. If you're rendering the grid relative to the browser `window`, 312 | * the current `document.documentElement.clientHeight` is the value you'll want to set here. If you're 313 | * rendering the grid inside of another HTML element, you'll want to provide the current `element.offsetHeight` 314 | * here. 315 | */ 316 | height: number 317 | /** 318 | * The current scroll progress in pixel of the window the grid is rendered in. If you're rendering 319 | * the grid relative to the browser `window`, you'll want the most current `window.scrollY` here. 320 | * If you're rendering the grid inside of another HTML element, you'll want the current `element.scrollTop` 321 | * value here. The `useScroller()` hook and `` components will help you if you're 322 | * rendering the grid relative to the browser `window`. 323 | */ 324 | scrollTop: number 325 | /** 326 | * This property is used for determining whether or not the grid container should add styles that 327 | * dramatically increase scroll performance. That is, turning off `pointer-events` and adding a 328 | * `will-change: contents;` value to the style string. You can forgo using this prop, but I would 329 | * not recommend that. The `useScroller()` hook and `` components will help you if 330 | * you're rendering the grid relative to the browser `window`. 331 | * @default false 332 | */ 333 | isScrolling?: boolean 334 | /** 335 | * This component is rendered for each item of your `items` prop array. It should accept three props: 336 | * `index`, `width`, and `data`. See RenderComponentProps. 337 | */ 338 | render: React.ComponentType> 339 | /** 340 | * This callback is invoked any time the items currently being rendered by the grid change. 341 | */ 342 | onRender?: (startIndex: number, stopIndex: number, items: Item[]) => void 343 | } 344 | 345 | export interface RenderComponentProps { 346 | /** 347 | * The index of the cell in the `items` prop array. 348 | */ 349 | index: number 350 | /** 351 | * The rendered width of the cell's column. 352 | */ 353 | width: number 354 | /** 355 | * The data at `items[index]` of your `items` prop array. 356 | */ 357 | data: Item 358 | } 359 | 360 | // 361 | // Render-phase utilities 362 | 363 | // ~5.5x faster than createElement without the memo 364 | const createRenderElement = trieMemoize( 365 | [OneKeyMap, {}, WeakMap, OneKeyMap], 366 | (RenderComponent, index, data, columnWidth) => ( 367 | 368 | ) 369 | ) 370 | 371 | const getContainerStyle = memoizeOne( 372 | (isScrolling: boolean | undefined, estimateHeight: number) => ({ 373 | position: 'relative', 374 | width: '100%', 375 | maxWidth: '100%', 376 | height: Math.ceil(estimateHeight), 377 | maxHeight: Math.ceil(estimateHeight), 378 | willChange: isScrolling ? 'contents' : void 0, 379 | pointerEvents: isScrolling ? 'none' : void 0, 380 | }) 381 | ) 382 | 383 | const cmp2 = (args: IArguments, pargs: IArguments | any[]): boolean => 384 | args[0] === pargs[0] && args[1] === pargs[1] 385 | 386 | const assignUserStyle = memoizeOne( 387 | (containerStyle, userStyle) => Object.assign({}, containerStyle, userStyle), 388 | // @ts-ignore 389 | cmp2 390 | ) 391 | 392 | function defaultGetItemKey(_: Item, i: number) { 393 | return i 394 | } 395 | 396 | // the below memoizations for for ensuring shallow equal is reliable for pure 397 | // component children 398 | const getCachedSize = memoizeOne( 399 | (width: number): React.CSSProperties => ({ 400 | width, 401 | zIndex: -1000, 402 | visibility: 'hidden', 403 | position: 'absolute', 404 | writingMode: 'horizontal-tb', 405 | }), 406 | (args, pargs) => args[0] === pargs[0] 407 | ) 408 | 409 | const getRefSetter = memoizeOne( 410 | ( 411 | positioner: Positioner, 412 | resizeObserver?: UseMasonryOptions['resizeObserver'] 413 | ) => (index: number) => (el: HTMLElement | null): void => { 414 | if (el === null) return 415 | if (resizeObserver) { 416 | resizeObserver.observe(el) 417 | elementsCache.set(el, index) 418 | } 419 | if (positioner.get(index) === void 0) positioner.set(index, el.offsetHeight) 420 | }, 421 | // @ts-ignore 422 | cmp2 423 | ) 424 | -------------------------------------------------------------------------------- /src/index.test.tsx: -------------------------------------------------------------------------------- 1 | /* jest */ 2 | import * as React from 'react' 3 | import {render, act, screen} from '@testing-library/react' 4 | import {renderHook, act as hookAct} from '@testing-library/react-hooks' 5 | import {dimension} from '@shopify/jest-dom-mocks' 6 | import { 7 | List, 8 | Masonry, 9 | MasonryScroller, 10 | useMasonry, 11 | usePositioner, 12 | useContainerPosition, 13 | useResizeObserver, 14 | useInfiniteLoader, 15 | useScroller, 16 | createResizeObserver, 17 | } from './index' 18 | 19 | jest.useFakeTimers() 20 | 21 | beforeEach(() => { 22 | dimension.mock({ 23 | offsetHeight: (element) => { 24 | let el = element[Object.keys(element)[0]] 25 | 26 | while (el) { 27 | const height = el.pendingProps?.style?.height 28 | if (height) return parseInt(height) 29 | el = el.child 30 | } 31 | 32 | return 0 33 | }, 34 | offsetWidth: (element) => { 35 | let el = element[Object.keys(element)[0]] 36 | while (el) { 37 | const width = el.pendingProps?.style?.width 38 | if (width) return parseInt(width) 39 | el = el.child 40 | } 41 | 42 | return 0 43 | }, 44 | }) 45 | 46 | perf.install() 47 | }) 48 | 49 | afterEach(() => { 50 | resetSize() 51 | resetScroll() 52 | dimension.restore() 53 | perf.uninstall() 54 | }) 55 | 56 | describe('useMasonry()', () => { 57 | const renderBasicMasonry = ( 58 | withProps, 59 | initialProps: Record = {scrollTop: 0} 60 | ) => 61 | renderHook( 62 | (props) => { 63 | const positioner = usePositioner({width: 1280}) 64 | return useMasonry({ 65 | height: 720, 66 | positioner, 67 | items: getFakeItems(1), 68 | render: FakeCard, 69 | ...withProps, 70 | ...props, 71 | }) 72 | }, 73 | {initialProps} 74 | ) 75 | 76 | it('should apply default styles to the container', () => { 77 | const {result} = renderBasicMasonry({ 78 | items: getFakeItems(1), 79 | overscanBy: 1, 80 | itemHeightEstimate: 240, 81 | }) 82 | 83 | expect(result.current.props.style).toEqual({ 84 | width: '100%', 85 | maxWidth: '100%', 86 | height: 240, 87 | maxHeight: 240, 88 | position: 'relative', 89 | willChange: undefined, 90 | pointerEvents: undefined, 91 | }) 92 | }) 93 | 94 | it('should apply "isScrolling" styles to the container', () => { 95 | const {result} = renderBasicMasonry({}, {scrollTop: 0, isScrolling: true}) 96 | 97 | expect(result.current.props.style).toEqual( 98 | expect.objectContaining({ 99 | willChange: 'contents', 100 | pointerEvents: 'none', 101 | }) 102 | ) 103 | }) 104 | 105 | it('should estimate the height of the container', () => { 106 | const {result} = renderHook( 107 | (props) => { 108 | const positioner = usePositioner({width: 1280, columnWidth: 1280 / 4}) 109 | return useMasonry({ 110 | height: 720, 111 | positioner, 112 | items: getFakeItems(16 * 4), 113 | overscanBy: 1, 114 | itemHeightEstimate: 720 / 4, 115 | render: FakeCard, 116 | ...props, 117 | }) 118 | }, 119 | { 120 | initialProps: {scrollTop: 0}, 121 | } 122 | ) 123 | 124 | expect(result.current.props.style).toEqual( 125 | expect.objectContaining({ 126 | height: 720 * 4, 127 | maxHeight: 720 * 4, 128 | }) 129 | ) 130 | }) 131 | 132 | it('should adjust the estimated height of the container based upon the first phase measurements', () => { 133 | const hook = renderHook( 134 | (props) => { 135 | const positioner = usePositioner({width: 1280, columnWidth: 1280 / 4}) 136 | return useMasonry({ 137 | height: 720, 138 | positioner, 139 | items: getFakeItems(4 * 4, 360), 140 | overscanBy: 1, 141 | itemHeightEstimate: 720 / 4, 142 | render: FakeCard, 143 | ...props, 144 | }) 145 | }, 146 | { 147 | initialProps: {scrollTop: 0}, 148 | } 149 | ) 150 | 151 | expect(hook.result.current.props.style).toEqual( 152 | expect.objectContaining({ 153 | height: 720, 154 | maxHeight: 720, 155 | }) 156 | ) 157 | 158 | renderPhase2(hook) 159 | 160 | expect(hook.result.current.props.style).toEqual( 161 | expect.objectContaining({ 162 | height: 4 * 360, 163 | maxHeight: 4 * 360, 164 | }) 165 | ) 166 | }) 167 | 168 | it('should render in batches', () => { 169 | const hook = renderHook( 170 | (props) => { 171 | const positioner = usePositioner({width: 1280, columnWidth: 320}) 172 | return useMasonry({ 173 | height: 720, 174 | positioner, 175 | items: getFakeItems(100 * 4, 720), 176 | itemHeightEstimate: 720, 177 | overscanBy: 1, 178 | render: FakeCard, 179 | ...props, 180 | }) 181 | }, 182 | { 183 | initialProps: {scrollTop: 0}, 184 | } 185 | ) 186 | 187 | expect(hook.result.current.props.children.length).toEqual(4) 188 | renderPhase2(hook) 189 | expect(hook.result.current.props.children.length).toEqual(4) 190 | hook.rerender({scrollTop: 720}) 191 | expect(hook.result.current.props.children.length).toEqual(8) 192 | // The first batch should retain their styles 193 | for (let i = 0; i < 3; i++) { 194 | expect(hook.result.current.props.children[i].props.style).not.toEqual( 195 | prerenderStyles(320) 196 | ) 197 | } 198 | // The new batch should get prerender styles 199 | for (let i = 4; i < 8; i++) { 200 | expect(hook.result.current.props.children[i].props.style).toEqual( 201 | prerenderStyles(320) 202 | ) 203 | } 204 | 205 | renderPhase2(hook) 206 | expect(hook.result.current.props.children.length).toEqual(8) 207 | // The new batch should get measured styles 208 | for (let i = 4; i < 8; i++) { 209 | expect(hook.result.current.props.children[i].props.style).not.toEqual( 210 | prerenderStyles(320) 211 | ) 212 | } 213 | }) 214 | 215 | it('should fire onRender function when new cells render', () => { 216 | const onRender = jest.fn() 217 | const items = getFakeItems(12, 720) 218 | 219 | const hook = renderHook( 220 | (props) => { 221 | const positioner = usePositioner({width: 1280, columnWidth: 320}) 222 | return useMasonry({ 223 | height: 720, 224 | positioner, 225 | items, 226 | itemHeightEstimate: 720, 227 | overscanBy: 1, 228 | onRender, 229 | render: FakeCard, 230 | ...props, 231 | }) 232 | }, 233 | { 234 | initialProps: {scrollTop: 0}, 235 | } 236 | ) 237 | 238 | expect(onRender).not.toBeCalled() 239 | 240 | renderPhase2(hook, {scrollTop: 0}) 241 | // Needs to cycle through another useEffect() after phase 2 242 | hook.rerender({scrollTop: 0}) 243 | expect(onRender).toBeCalledTimes(1) 244 | expect(onRender).toBeCalledWith(0, 3, items) 245 | 246 | hook.rerender({scrollTop: 720}) 247 | renderPhase2(hook, {scrollTop: 720}) 248 | // Needs to cycle through another useEffect() after phase 2 249 | hook.rerender({scrollTop: 720}) 250 | expect(onRender).toBeCalledTimes(2) 251 | expect(onRender).toBeCalledWith(0, 7, items) 252 | 253 | hook.rerender({scrollTop: 1440}) 254 | 255 | expect(onRender).toBeCalledTimes(3) 256 | expect(onRender).toBeCalledWith(4, 7, items) 257 | 258 | renderPhase2(hook, {scrollTop: 1440}) 259 | expect(onRender).toBeCalledTimes(4) 260 | expect(onRender).toBeCalledWith(4, 11, items) 261 | }) 262 | 263 | it('should add custom "style" to the container', () => { 264 | const {result} = renderBasicMasonry({style: {backgroundColor: '#000'}}) 265 | expect(result.current.props.style).toEqual( 266 | expect.objectContaining({ 267 | backgroundColor: '#000', 268 | }) 269 | ) 270 | }) 271 | 272 | it('should add custom "style" to its items', () => { 273 | const {result} = renderBasicMasonry({itemStyle: {backgroundColor: '#000'}}) 274 | expect(result.current.props.children[0].props.style).toEqual( 275 | expect.objectContaining({ 276 | backgroundColor: '#000', 277 | }) 278 | ) 279 | }) 280 | 281 | it('should add custom "key" to its items', () => { 282 | const {result} = renderBasicMasonry({itemKey: (data) => `id:${data.id}`}) 283 | expect(result.current.props.children[0].key).toEqual('id:0') 284 | }) 285 | 286 | it('should add custom "role" to its container and items', () => { 287 | const {result} = renderBasicMasonry({role: 'list'}) 288 | expect(result.current.props.role).toEqual('list') 289 | expect(result.current.props.children[0].props.role).toEqual('listitem') 290 | }) 291 | 292 | it('should add "tabIndex" to container', () => { 293 | const {result} = renderBasicMasonry({tabIndex: -1}) 294 | expect(result.current.props.tabIndex).toEqual(-1) 295 | }) 296 | 297 | it('should add "className" to container', () => { 298 | const {result} = renderBasicMasonry({className: 'foo'}) 299 | expect(result.current.props.className).toEqual('foo') 300 | }) 301 | }) 302 | 303 | describe('usePositioner()', () => { 304 | it('should automatically derive column count and fill its container width', () => { 305 | const {result, rerender} = renderHook((props) => usePositioner(props), { 306 | initialProps: {width: 1280, columnWidth: 318}, 307 | }) 308 | 309 | expect(result.current.columnCount).toBe(4) 310 | expect(result.current.columnWidth).toBe(320) 311 | 312 | rerender({width: 600, columnWidth: 318}) 313 | expect(result.current.columnCount).toBe(1) 314 | expect(result.current.columnWidth).toBe(600) 315 | }) 316 | 317 | it('should automatically derive column count and fill its container width accounting for "columnGutter"', () => { 318 | const {result, rerender} = renderHook((props) => usePositioner(props), { 319 | initialProps: {width: 1280, columnWidth: 310, columnGutter: 10}, 320 | }) 321 | 322 | expect(result.current.columnCount).toBe(4) 323 | expect(result.current.columnWidth).toBe(312) 324 | 325 | rerender({width: 600, columnWidth: 280, columnGutter: 12}) 326 | expect(result.current.columnCount).toBe(2) 327 | expect(result.current.columnWidth).toBe(294) 328 | }) 329 | 330 | it('should automatically derive column width when a static column count is defined', () => { 331 | const {result, rerender} = renderHook((props) => usePositioner(props), { 332 | initialProps: {width: 1280, columnCount: 4, columnGutter: 10}, 333 | }) 334 | 335 | expect(result.current.columnCount).toBe(4) 336 | expect(result.current.columnWidth).toBe(312) 337 | 338 | rerender({width: 1280, columnCount: 3, columnGutter: 12}) 339 | expect(result.current.columnCount).toBe(3) 340 | expect(result.current.columnWidth).toBe(418) 341 | }) 342 | 343 | it('should create a new positioner when sizing deps change', () => { 344 | const {result, rerender} = renderHook((props) => usePositioner(props), { 345 | initialProps: {width: 1280, columnCount: 4, columnGutter: 10}, 346 | }) 347 | 348 | const initialPositioner = result.current 349 | rerender({width: 1280, columnCount: 4, columnGutter: 10}) 350 | expect(result.current).toBe(initialPositioner) 351 | 352 | rerender({width: 1280, columnCount: 2, columnGutter: 10}) 353 | expect(result.current).not.toBe(initialPositioner) 354 | }) 355 | 356 | it('should copy existing positions into the new positioner when sizing deps change', () => { 357 | const {result, rerender} = renderHook((props) => usePositioner(props), { 358 | initialProps: {width: 1280, columnCount: 4, columnGutter: 10}, 359 | }) 360 | 361 | result.current.set(0, 200) 362 | expect(result.current.size()).toBe(1) 363 | 364 | rerender({width: 1280, columnCount: 2, columnGutter: 10}) 365 | expect(result.current.size()).toBe(1) 366 | }) 367 | 368 | it('should update existing cells', () => { 369 | const {result} = renderHook((props) => usePositioner(props), { 370 | initialProps: {width: 400, columnCount: 1}, 371 | }) 372 | 373 | result.current.set(0, 200) 374 | result.current.set(1, 200) 375 | result.current.set(2, 200) 376 | result.current.set(3, 200) 377 | expect(result.current.size()).toBe(4) 378 | expect(result.current.shortestColumn()).toBe(800) 379 | result.current.update([1, 204]) 380 | expect(result.current.shortestColumn()).toBe(804) 381 | }) 382 | 383 | it('should create a new positioner when deps change', () => { 384 | const {result, rerender} = renderHook( 385 | ({deps, ...props}) => usePositioner(props, deps), 386 | { 387 | initialProps: {width: 1280, columnCount: 1, deps: [1]}, 388 | } 389 | ) 390 | 391 | const initialPositioner = result.current 392 | rerender({width: 1280, columnCount: 1, deps: [1]}) 393 | expect(result.current).toBe(initialPositioner) 394 | 395 | rerender({width: 1280, columnCount: 1, deps: [2]}) 396 | expect(result.current).not.toBe(initialPositioner) 397 | }) 398 | }) 399 | 400 | describe('useContainerPosition()', () => { 401 | it('should provide a width', () => { 402 | render(
) 403 | 404 | const fakeRef: {current: HTMLElement} = { 405 | current: screen.getByTestId('div'), 406 | } 407 | 408 | const {result} = renderHook( 409 | ({deps}) => useContainerPosition(fakeRef, deps), 410 | {initialProps: {deps: []}} 411 | ) 412 | expect(result.current.width).toBe(800) 413 | expect(result.current.offset).toBe(0) 414 | }) 415 | 416 | it('should update when deps change', () => { 417 | const element = render(
) 418 | const fakeRef: {current: HTMLElement} = { 419 | current: screen.getByTestId('div'), 420 | } 421 | const {result, rerender} = renderHook( 422 | ({deps}) => useContainerPosition(fakeRef, deps), 423 | {initialProps: {deps: [1]}} 424 | ) 425 | 426 | expect(result.current.width).toBe(800) 427 | expect(result.current.offset).toBe(0) 428 | 429 | element.rerender(
) 430 | fakeRef.current = screen.getByTestId('div2') 431 | 432 | rerender({deps: [2]}) 433 | expect(result.current.width).toBe(640) 434 | }) 435 | }) 436 | 437 | describe('useResizeObserver()', () => { 438 | it('should disconnect on mount', () => { 439 | const {result, unmount} = renderHook(() => { 440 | const positioner = usePositioner({width: 1280}) 441 | return useResizeObserver(positioner) 442 | }) 443 | 444 | const disconnect = jest.spyOn(result.current, 'disconnect') 445 | expect(disconnect).not.toBeCalled() 446 | expect(typeof result.current.observe).toBe('function') 447 | unmount() 448 | expect(disconnect).toBeCalled() 449 | }) 450 | 451 | it('should disconnect and create a new one when the positioner changes', () => { 452 | const {result, rerender} = renderHook( 453 | (props) => { 454 | const positioner = usePositioner(props) 455 | return useResizeObserver(positioner) 456 | }, 457 | { 458 | initialProps: { 459 | width: 1280, 460 | }, 461 | } 462 | ) 463 | 464 | const disconnect = jest.spyOn(result.current, 'disconnect') 465 | expect(disconnect).not.toBeCalled() 466 | const prev = result.current 467 | rerender({width: 1200}) 468 | expect(disconnect).toBeCalled() 469 | expect(result.current).not.toBe(prev) 470 | }) 471 | 472 | it('should call updater', () => { 473 | const updater = jest.fn() 474 | renderHook( 475 | (props) => { 476 | const positioner = usePositioner(props) 477 | return createResizeObserver(positioner, updater) 478 | }, 479 | { 480 | initialProps: { 481 | width: 1280, 482 | }, 483 | } 484 | ) 485 | 486 | renderHook(() => { 487 | const positioner = usePositioner({width: 1280}) 488 | return useMasonry({ 489 | height: 720, 490 | positioner, 491 | items: getFakeItems(1), 492 | render: FakeCard, 493 | scrollTop: 0, 494 | }) 495 | }) 496 | 497 | expect(updater).not.toBeCalled() 498 | // TODO: make this check somehow 499 | expect(true).toBe(true) 500 | }) 501 | }) 502 | 503 | describe('useScroller()', () => { 504 | beforeEach(() => { 505 | perf.install() 506 | resetScroll() 507 | }) 508 | 509 | afterEach(() => { 510 | perf.uninstall() 511 | }) 512 | 513 | it('should unset "isScrolling" after timeout', () => { 514 | const original = window.requestAnimationFrame 515 | window.requestAnimationFrame = undefined 516 | 517 | const {result} = renderHook(() => useScroller()) 518 | 519 | expect(result.current.isScrolling).toBe(false) 520 | 521 | hookAct(() => { 522 | scrollTo(300) 523 | perf.advanceBy(40 + 1000 / 12) 524 | }) 525 | 526 | hookAct(() => { 527 | scrollTo(301) 528 | perf.advanceBy(40 + 1000 / 12) 529 | }) 530 | 531 | expect(result.current.isScrolling).toBe(true) 532 | 533 | hookAct(() => { 534 | jest.advanceTimersByTime(40 + 1000 / 12) 535 | }) 536 | 537 | expect(result.current.isScrolling).toBe(false) 538 | window.requestAnimationFrame = original 539 | }) 540 | }) 541 | 542 | describe('useInfiniteLoader()', () => { 543 | it('should call "loadMoreItems" on render', () => { 544 | const loadMoreItems = jest.fn() 545 | let items = getFakeItems(1, 200) 546 | const loaderOptions = { 547 | minimumBatchSize: 12, 548 | threshold: 12, 549 | } 550 | const hook = renderHook( 551 | ({items, scrollTop, options}) => { 552 | const positioner = usePositioner({width: 1280, columnWidth: 320}) 553 | const infiniteLoader = useInfiniteLoader(loadMoreItems, options) 554 | return useMasonry({ 555 | height: 600, 556 | positioner, 557 | items, 558 | scrollTop, 559 | render: FakeCard, 560 | onRender: infiniteLoader, 561 | }) 562 | }, 563 | { 564 | initialProps: { 565 | items, 566 | scrollTop: 0, 567 | options: loaderOptions, 568 | }, 569 | } 570 | ) 571 | 572 | expect(loadMoreItems).not.toBeCalled() 573 | renderPhase2(hook, {items, scrollTop: 0, options: loaderOptions}) 574 | hook.rerender({items, scrollTop: 0, options: loaderOptions}) 575 | expect(loadMoreItems).toBeCalledTimes(1) 576 | // '1' because '0' has already loaded 577 | expect(loadMoreItems).toBeCalledWith(1, 12, items) 578 | // Adds another item to the items list, so the expectation is that the next range 579 | // will be 1 + 1, 12 + 1 580 | items = getFakeItems(2, 200) 581 | renderPhase2(hook, {items, scrollTop: 0, options: loaderOptions}) 582 | hook.rerender({items, scrollTop: 0, options: loaderOptions}) 583 | expect(loadMoreItems).toBeCalledTimes(2) 584 | expect(loadMoreItems).toBeCalledWith(2, 13, items) 585 | }) 586 | 587 | it('should call custom "isItemLoaded" function', () => { 588 | const loadMoreItems = jest.fn() 589 | const items = getFakeItems(1, 200) 590 | const loaderOptions = { 591 | isItemLoaded: () => true, 592 | } 593 | 594 | const hook = renderHook( 595 | ({items, scrollTop, options}) => { 596 | const positioner = usePositioner({width: 1280, columnWidth: 320}) 597 | const infiniteLoader = useInfiniteLoader(loadMoreItems, options) 598 | return useMasonry({ 599 | height: 600, 600 | positioner, 601 | items, 602 | scrollTop, 603 | render: FakeCard, 604 | onRender: infiniteLoader, 605 | }) 606 | }, 607 | { 608 | initialProps: { 609 | items, 610 | scrollTop: 0, 611 | options: loaderOptions, 612 | }, 613 | } 614 | ) 615 | 616 | expect(loadMoreItems).not.toBeCalled() 617 | renderPhase2(hook, {items, scrollTop: 0, options: loaderOptions}) 618 | hook.rerender({items, scrollTop: 0, options: loaderOptions}) 619 | // All the items have loaded so it should not be called 620 | expect(loadMoreItems).not.toBeCalled() 621 | }) 622 | 623 | it('should not load more items if "totalItems" constraint is satisfied', () => { 624 | const loadMoreItems = jest.fn() 625 | const items = getFakeItems(1, 200) 626 | const loaderOptions = { 627 | totalItems: 1, 628 | } 629 | 630 | const hook = renderHook( 631 | ({items, scrollTop, options}) => { 632 | const positioner = usePositioner({width: 1280, columnWidth: 320}) 633 | const infiniteLoader = useInfiniteLoader(loadMoreItems, options) 634 | return useMasonry({ 635 | height: 600, 636 | positioner, 637 | items, 638 | scrollTop, 639 | render: FakeCard, 640 | onRender: infiniteLoader, 641 | }) 642 | }, 643 | { 644 | initialProps: { 645 | items, 646 | scrollTop: 0, 647 | options: loaderOptions, 648 | }, 649 | } 650 | ) 651 | 652 | expect(loadMoreItems).not.toBeCalled() 653 | renderPhase2(hook, {items, scrollTop: 0, options: loaderOptions}) 654 | hook.rerender({items, scrollTop: 0, options: loaderOptions}) 655 | // All the items have loaded so it should not be called 656 | expect(loadMoreItems).not.toBeCalled() 657 | }) 658 | 659 | it('should return a new callback if any of the options change', () => { 660 | const loadMoreItems = jest.fn() 661 | const loaderOptions = { 662 | minimumBatchSize: 16, 663 | threshold: 16, 664 | totalItems: 9e9, 665 | } 666 | 667 | const {result, rerender} = renderHook( 668 | ({loadMoreItems, options}) => useInfiniteLoader(loadMoreItems, options), 669 | { 670 | initialProps: { 671 | loadMoreItems, 672 | options: loaderOptions, 673 | }, 674 | } 675 | ) 676 | 677 | let prev = result.current 678 | rerender({loadMoreItems, options: loaderOptions}) 679 | expect(result.current).toBe(prev) 680 | 681 | rerender({loadMoreItems, options: {...loaderOptions, totalItems: 2}}) 682 | expect(result.current).not.toBe(prev) 683 | prev = result.current 684 | 685 | rerender({loadMoreItems, options: {...loaderOptions, minimumBatchSize: 12}}) 686 | expect(result.current).not.toBe(prev) 687 | prev = result.current 688 | 689 | rerender({loadMoreItems, options: {...loaderOptions, threshold: 12}}) 690 | expect(result.current).not.toBe(prev) 691 | }) 692 | }) 693 | 694 | describe('', () => { 695 | it('should update when scrolling', () => { 696 | const Component = () => { 697 | const positioner = usePositioner({width: 1280, columnWidth: 320}) 698 | return ( 699 | 705 | ) 706 | } 707 | 708 | const result = render() 709 | expect(result.asFragment()).toMatchSnapshot( 710 | 'pointer-events: none is NOT defined' 711 | ) 712 | 713 | act(() => { 714 | scrollTo(720) 715 | }) 716 | 717 | expect(result.asFragment()).toMatchSnapshot( 718 | 'pointer-events: none IS defined' 719 | ) 720 | }) 721 | }) 722 | 723 | describe('', () => { 724 | it('should update when the size of the window changes', () => { 725 | resizeTo(400, 200) 726 | const Component = () => { 727 | return ( 728 | 734 | ) 735 | } 736 | 737 | const result = render() 738 | expect(result.asFragment()).toMatchSnapshot('Should display one element') 739 | 740 | act(() => { 741 | resizeTo(1280, 800) 742 | jest.advanceTimersByTime(100) 743 | }) 744 | 745 | result.rerender() 746 | expect(result.asFragment()).toMatchSnapshot('Should display two elements') 747 | }) 748 | 749 | it('should scroll to index', () => { 750 | resizeTo(400, 200) 751 | const Component = () => { 752 | return ( 753 | 760 | ) 761 | } 762 | // @ts-ignore 763 | window.scrollTo = jest.fn() 764 | render() 765 | expect(window.scrollTo).toBeCalled() 766 | }) 767 | 768 | it('should scroll to cached index', () => { 769 | resizeTo(400, 200) 770 | const Component = () => { 771 | return ( 772 | 779 | ) 780 | } 781 | // @ts-ignore 782 | window.scrollTo = jest.fn() 783 | render() 784 | expect(window.scrollTo).toBeCalledWith(0, 0) 785 | }) 786 | }) 787 | 788 | describe('', () => { 789 | it('should have a row gutter', () => { 790 | const Component = () => { 791 | return ( 792 | 793 | ) 794 | } 795 | 796 | render() 797 | expect( 798 | // @ts-ignore 799 | screen.getByText('0').parentNode.parentNode.style.top 800 | ).toBe('0px') 801 | expect( 802 | // @ts-ignore 803 | screen.getByText('1').parentNode.parentNode.style.top 804 | ).toBe('232px') 805 | expect( 806 | // @ts-ignore 807 | screen.getByText('2').parentNode.parentNode.style.top 808 | ).toBe('464px') 809 | }) 810 | }) 811 | 812 | const prerenderStyles = (width) => ({ 813 | width, 814 | zIndex: -1000, 815 | visibility: 'hidden', 816 | position: 'absolute', 817 | writingMode: 'horizontal-tb', 818 | }) 819 | 820 | const renderPhase2 = ({result, rerender}, props?: Record) => { 821 | // Enter phase two by rendering the element in React 822 | render(result.current) 823 | // Creates a new element with the phase two styles 824 | rerender(props) 825 | } 826 | 827 | const heights = [360, 420, 372, 460, 520, 356, 340, 376, 524] 828 | const getHeight = (i) => heights[i % heights.length] 829 | 830 | const getFakeItems = (n = 10, height = 0): {id: number; height: number}[] => { 831 | const fakeItems: {id: number; height: number}[] = [] 832 | for (let i = 0; i < n; i++) 833 | fakeItems.push({id: i, height: height || getHeight(i)}) 834 | return fakeItems 835 | } 836 | 837 | const FakeCard = ({data: {height}, index}): React.ReactElement => ( 838 |
839 | 840 | Hello 841 |
842 | ) 843 | 844 | // Simulate scroll events 845 | const scrollEvent = document.createEvent('Event') 846 | scrollEvent.initEvent('scroll', true, true) 847 | const setScroll = (value): void => { 848 | Object.defineProperty(window, 'scrollY', {value, configurable: true}) 849 | } 850 | const scrollTo = (value): void => { 851 | setScroll(value) 852 | window.dispatchEvent(scrollEvent) 853 | } 854 | const resetScroll = (): void => { 855 | setScroll(0) 856 | } 857 | 858 | // Simulate window resize event 859 | const resizeEvent = document.createEvent('Event') 860 | resizeEvent.initEvent('resize', true, true) 861 | const orientationEvent = document.createEvent('Event') 862 | orientationEvent.initEvent('orientationchange', true, true) 863 | 864 | const setWindowSize = (width, height) => { 865 | Object.defineProperty(document.documentElement, 'clientWidth', { 866 | value: width, 867 | configurable: true, 868 | }) 869 | Object.defineProperty(document.documentElement, 'clientHeight', { 870 | value: height, 871 | configurable: true, 872 | }) 873 | } 874 | 875 | const resizeTo = (width, height) => { 876 | setWindowSize(width, height) 877 | window.dispatchEvent(resizeEvent) 878 | } 879 | 880 | const resetSize = () => { 881 | setWindowSize(1280, 720) 882 | } 883 | 884 | // performance.now mock 885 | const mockPerf = () => { 886 | // @ts-ignore 887 | const original = global?.performance 888 | let ts = (typeof performance !== 'undefined' ? performance : Date).now() 889 | 890 | return { 891 | install: () => { 892 | ts = Date.now() 893 | const perfNowStub = jest 894 | .spyOn(performance, 'now') 895 | .mockImplementation(() => ts) 896 | global.performance = { 897 | // @ts-ignore 898 | now: perfNowStub, 899 | } 900 | }, 901 | advanceBy: (amt: number) => (ts += amt), 902 | advanceTo: (t: number) => (ts = t), 903 | uninstall: () => { 904 | if (original) { 905 | //@ts-ignore 906 | global.performance = original 907 | } 908 | }, 909 | } 910 | } 911 | 912 | const perf = mockPerf() 913 | -------------------------------------------------------------------------------- /docs/v2.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | 🧱 masonic 5 |

6 |
7 | 8 |

9 | 10 | Bundlephobia 11 | 12 | 13 | Types 14 | 15 | 20 | 21 | Build status 22 | 23 | 24 | NPM Version 25 | 26 | 27 | MIT License 28 | 29 |

30 | 31 |
npm i masonic
32 |
33 | 34 | A virtualized masonry grid component for React based 35 | on Brian Vaughn's [react-virtualized](https://github.com/bvaughn/react-virtualized) 36 | and further inspired by [react-window](https://github.com/bvaughn/react-window). 37 | 38 | ## Features 39 | 40 | - **Easy to use** It only takes two minutes to start creating your own masonry grid with this component. 41 | For reals, [check out the demo on CodeSandbox](https://codesandbox.io/s/0oyxozv75v). 42 | - **Blazing™ fast** This component can seamlessly render hundreds of thousands of grid items 43 | without issue via virtualization and intelligent data structures. It uses a [red black interval tree](https://www.geeksforgeeks.org/interval-tree/) 44 | to determine which grid items with `O(log n + m)` lookup performance to render based upon the scroll position and size of the window. 45 | - **TypeScript** Woohoo, superior autocomplete and type safety means fewer bugs in your implementation. 46 | - **Versatility** All of the autosizing [``](#masonry)'s constituent parts are provided via exports so you're 47 | not locked into to the implementation. At times it will be useful to have access to those internals. It's also 48 | possible to kick the virtualization out of the equation by providing an infinite value to the `overscanBy` prop, though 49 | this would be a terrible idea for large lists. 50 | - **Autosizing** The grid will automatically resize itself and its items if the content of the 51 | grid items changes or resizes. For example, when an image lazily loads this component will 52 | automatically do the work of recalculating the size of that grid item using 53 | [`resize-observer-polyfill`](https://www.npmjs.com/package/resize-observer-polyfill). 54 | 55 | ## Quick Start 56 | 57 | #### [Check out the demo on CodeSandbox](https://codesandbox.io/s/0oyxozv75v) 58 | 59 | ```jsx harmony 60 | import {Masonry} from 'masonic' 61 | 62 | let i = 0 63 | const items = Array.from(Array(5000), () => ({id: i++})) 64 | 65 | const EasyMasonryComponent = (props) => ( 66 | 67 | ) 68 | 69 | const MasonryCard = ({index, data: {id}, width}) => ( 70 |
71 |
Index: {index}
72 |
ID: {id}
73 |
Column width: {width}
74 |
75 | ) 76 | ``` 77 | 78 | ## API 79 | 80 | ### Components 81 | 82 | | Component | Description | 83 | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 84 | | [``](#masonry) | An autosizing masonry grid component that only renders items currently viewable in the window. This component will change its column count to fit its container's width and will decide how many rows to render based upon the height of the `window`. To facilitate this, it uses [``](#freemasonry), [`useContainerRect()`](#usecontainerrect), and [`useWindowScroller()`](#usewindowscroller) under the hood. | 85 | | [``](#freemasonry) | A more flexible masonry grid component that lets you define your own `width`, `height`, `scrollTop`, and `isScrolling` props. | 86 | | [``](#list) | This is just a single-column [``](#masonry) component. | 87 | 88 | ### Hooks 89 | 90 | | Hook | Description | 91 | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 92 | | [`useInfiniteLoader()`](#useinfiniteloader) | A utility hook for seamlessly adding infinite scroll behavior to the [``](#masonry) component. This hook invokes a callback each time the last rendered index surpasses the total number of items in your items array, or the number defined in the `totalItems` option of this hook. | 93 | | [`useContainerRect()`](#usecontainerrect) | A hook used for measuring and tracking the width of the masonry component's container, as well as its distance from the top of your document. These values are necessary to correctly calculate the number/width of columns to render, as well as the number of rows to render. | 94 | | [`useWindowScroller()`](#usewindowscroller) | A hook used for measuring the size of the browser window, whether or not the window is currently being scrolled, and the window's scroll position. These values are used when calculating the number of rows to render and determining when we should disable pointer events on the masonry container to maximize scroll performance. | 95 | 96 | ### `` 97 | 98 | An autosizing masonry grid component that only renders items currently viewable in the window. This 99 | component will change its column count to fit its container's width and will decide how many rows 100 | to render based upon the height of the `window`. To facilitate this, it uses [``](#freemasonry), 101 | [`useContainerRect()`](#usecontainerrect), and [`useWindowScroller()`](#usewindowscroller) under the hood. 102 | 103 | #### Props 104 | 105 | ##### Columns 106 | 107 | Props for tuning the column width, count, and gutter of your component. 108 | 109 | | Prop | Type | Default | Required? | Description | 110 | | ------------ | -------- | ----------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 111 | | columnWidth | `number` | `240` | Yes | This is the minimum column width. `Masonic` will automatically size your columns to fill its container based on your provided `columnWidth` and `columnGutter` values. It will never render anything smaller than this defined width unless its container is smaller than its value. | 112 | | columnGutter | `number` | `0` | No | This sets the amount (px) of vertical and horizontal space between grid items. | 113 | | columnCount | `number` | `undefined` | No | By default, `Masonic` derives the column count from the `columnWidth` prop. However, in some situations it is nice to be able to override that behavior (e.g. when creating a [``](#list). | 114 | 115 | ##### Item rendering 116 | 117 | Props that dictate how individual grid items are rendered. 118 | 119 | | Prop | Type | Default | Required? | Description | 120 | | ------------------ | ----------------------------------------------- | --------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 121 | | render | React.ComponentClass|React.FC | `undefined` | Yes | The component provided here is rendered for each item of your `items` array (see below). The component here should handle [the `render` props defined below](#render-props). | 122 | | items | `any[]` | `undefined` | Yes | An array of items to render. The data contained at each index is passed to the `data` prop of your `render` component. It is also passed to the `onRender` callback and the `itemKey` generator. Its length is used for determining the estimated height of the container. | 123 | | itemHeightEstimate | `number` | `300` | No | This value is used for estimating the initial height of the masonry grid. it is vital to the UX of the scrolling behavior and in determining how many `items` to initially render, so its wise to set this value with some accuracy. | 124 | | itemAs | `React.ReactNode` | `"div"` | No | Your `render` component is wrapped with an element that has a `style` prop which sets the position of the grid item in its container. This is the type of element created for that wrapper. One common use case would be changing this property to `li` and the Masonry component's `as` prop to `ul`. | 125 | | itemStyle | `React.CSSProperties` | `undefined` | No | You can add additional styles to the wrapper discussed in `itemAs` by setting this property. | 126 | | itemKey | `(data: any, index: number) => string` | `(_, index) => index` | No | The value returned here must be unique to the item. By default, the key is the item's index. This is ok if your collection of items is never modified. Setting this property ensures that the component in `render` is reused each time the masonry grid is reflowed. A common pattern would be to return the item's database ID here if there is one, e.g. `data => data.id` | 127 | | overscanBy | `number` | `2` | No | This number is used for determining the number of grid items outside of the visible window to render. The default value is `2` which means "render 2 windows worth of content before and after the items in the visible window". A value of `3` would be 3 windows worth of grid items, so it's a linear relationship. Overscanning is important for preventing tearing when scrolling through items in the grid, but setting too high of a value may create too much work for React to handle, so it's best that you tune this value accordingly. | 128 | 129 | ###### `render` props 130 | 131 | These are the props provided to the component you set in your `render` prop. 132 | 133 | | Prop | Type | Description | 134 | | ----- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | 135 | | data | `any` | This is the data contained at `items[index]` of your `items` prop array. | 136 | | index | `number` | The index of the item in your `items` prop array. | 137 | | width | `number` | The width of the collumn containing this component. This is super useful for doing things like determining the dimensions of images. | 138 | 139 | ##### Customizing the container element 140 | 141 | These props customize how the masonry grid element is rendered. 142 | 143 | | Prop | Type | Default | Required? | Description | 144 | | --------- | --------------------- | ----------- | --------- | ---------------------------------------------------------------------------------------------------------------------- | 145 | | as | `React.ReactNode` | `"div"` | No | This sets the element type of the masonry grid. A common use case would be changing this to `ul` and `itemAs` to `li`. | 146 | | id | `string` | `undefined` | No | Add an ID to the masonry grid container. | 147 | | className | `string` | `undefined` | No | Add a class to the masonry grid container. | 148 | | style | `React.CSSProperties` | `undefined` | No | Add inline styles to the masonry grid container. | 149 | | role | `string` | `"grid"` | No | Change the aria/a11y role of the container. | 150 | | tabIndex | `number` | `0` | No | Change the tabIndex of the container. By default the container is tabbable. | 151 | 152 | ##### Customizing the window for SSR 153 | 154 | These are useful values to set when using SSR because in SSR land we don't have access to the 155 | width and height of the window, and thus have no idea how many items to render. 156 | 157 | | Prop | Type | Default | Required? | Description | 158 | | ------------- | -------- | ------- | --------- | -------------------------------- | 159 | | initialWidth | `number` | `1280` | No | The width of the window in SSR. | 160 | | initialHeight | `number` | `720` | No | The height of the window in SSR. | 161 | 162 | ##### Callbacks 163 | 164 | | Prop | Type | Default | Required? | Description | 165 | | -------- | --------------------------------------------------------------- | ----------- | --------- | ------------------------------------------------------------------------- | 166 | | onRender | `(startIndex: number, stopIndex: number, items: any[]) => void` | `undefined` | No | This callback is invoked any time the items rendered in the grid changes. | 167 | 168 | ###### `onRender()` arguments 169 | 170 | | Argument | Type | Description | 171 | | ---------- | -------- | ------------------------------------------------------------------ | 172 | | startIndex | `number` | The index of the first item currently being rendered in the window | 173 | | stopIndex | `number` | The index of the last item currently being rendered in the window | 174 | | items | `any[]` | The array of items you provided to the `items` prop | 175 | 176 | ##### Methods 177 | 178 | When a `ref` is provided to this component, you'll have access to the following 179 | imperative methods: 180 | 181 | | Method | Type | Description | 182 | | -------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 183 | | clearPositions | `() => void` | Invoking this method will create a new position cache, clearing all previous stored position values. This is useful if you want the component to reflow when adding new items to the `items` array, however the best way to trigger a reflow is setting a different unique `key` prop on the `` component each time that happens. | 184 | 185 | --- 186 | 187 | ### `` 188 | 189 | This is a bare bones masonry grid without [`useWindowScroller()`](#usewindowscroller) and [`useContainerRect()`](#usecontainerrect) 190 | hooks doing any magic. It accepts all of the props from [``](#masonry) except `initialWidth` and `initialHeight`. 191 | 192 | #### Additional props 193 | 194 | | Prop | Type | Default | Required? | Description | 195 | | ------------ | ---------------------------------------------------------------------------------------------------- | ----------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 196 | | width | `number` | `undefined` | Yes | This sets the width of the grid. | 197 | | height | `number` | `undefined` | Yes | This is the height of the grid's window. If you're rendering `` inside of a scrollable `div` for example, this would be the height of that div. | 198 | | scrollTop | `number` | `undefined` | Yes | The scroll position of the window `` is rendering inside. Either the `window` object scroll position or the scroll position of say, a scrollable `div` you're rendering inside. | 199 | | isScrolling | `boolean` | `false` | No | When this value is `true`, `pointer-events: none;` and `will-change: contents, height;` styles are applied to the grid to maximize scroll performance. | 200 | | containerRef | ((element: HTMLElement) => void) | React.MutableRefObject | `undefined` | No | Sets a `ref` prop on the grid container. | 201 | 202 | --- 203 | 204 | ### `` 205 | 206 | This is a single-column `` component. It accepts all of the properties defined in [``], 207 | except `columnGutter`, `columnWidth`, and `columnCount`. 208 | 209 | #### Additional props 210 | 211 | | Prop | Type | Default | Required? | Description | 212 | | --------- | -------- | ------- | --------- | ----------------------------------------------------------------------------- | 213 | | rowGutter | `number` | `0` | No | This sets the amount of vertical space in pixels between rendered list items. | 214 | 215 | --- 216 | 217 | ### `useInfiniteLoader()` 218 | 219 | A React hook for seamlessly adding infinite scrolling behavior to [``](#masonry) and 220 | [``](#list) components. 221 | 222 | ```jsx harmony 223 | import {Masonry, useInfiniteLoader} from 'masonic' 224 | import memoize from 'trie-memoize' 225 | 226 | const fetchMoreItems = memoize( 227 | [{}, {}, {}], 228 | (startIndex, stopIndex, currentItems) => 229 | fetch( 230 | `/api/get-more?after=${startIndex}&limit=${startIndex + stopIndex}` 231 | ).then((items) => { 232 | // do something to add the new items to your state 233 | }) 234 | ) 235 | 236 | const InfiniteMasonry = (props) => { 237 | const maybeLoadMore = useInfiniteLoader(fetchMoreItems) 238 | const items = useItemsFromInfiniteLoader() 239 | return 240 | } 241 | ``` 242 | 243 | #### Arguments 244 | 245 | | Argument | Type | Description | 246 | | ------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 247 | | loadMoreItems | `(startIndex: number, stopIndex: number, items: any[]) => any` | This callback will be invoked when more items must be loaded. It may be called multiple times in reaction to a single scroll event. As such, you are expected to memoize/track whether or not you've already received the `startIndex`, `stopIndex`, `items` values to prevent loading data more than once. | 248 | | options | `InfiniteLoaderOptions` | Configuration object for your loader, see [`InfiniteLoaderOptions`](#infiniteloaderoptions) below. | 249 | 250 | #### InfiniteLoaderOptions 251 | 252 | | Property | Type | Default | Description | 253 | | ---------------- | ------------------------------------------ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | 254 | | isItemLoaded | `(index: number, items: any[]) => boolean` | `(index, items) => items[index] !== undefined` | A callback responsible for determining the loaded state of each item. Return `true` if the item has already been loaded and `false` if not. | 255 | | minimumBatchSize | `number` | `16` | | 256 | | threshold | `number` | `16` | The default value of `16` means that data will start loading when a user scrolls within `16` items of the end of your `items` prop array. | 257 | | totalItems | `number` | `9E9` | The total number of items you'll need to eventually load (if known). This can be arbitrarily high if not known (e.g., the default value). | 258 | 259 | --- 260 | 261 | ### `useWindowScroller()` 262 | 263 | A hook used for measuring the size of the browser window, whether or not the window is currently being scrolled, 264 | and the window's scroll position. These values are used when calculating the number of rows to render and determining 265 | when we should disable pointer events on the masonry container to maximize scroll performance. 266 | 267 | ```jsx harmony 268 | import React from 'react' 269 | import {FreeMasonry, useWindowScroller, useContainerRect} from 'masonic' 270 | 271 | const MyCustomMasonry = (props) => { 272 | const {width, height, scrollY, isScrolling} = useWindowScroller(), 273 | [rect, containerRef] = useContainerRect(width, height) 274 | 275 | return React.createElement( 276 | FreeMasonry, 277 | Object.assign( 278 | { 279 | width: rect.width, 280 | height, 281 | scrollTop: Math.max(0, scrollY - (rect.top + scrollY)), 282 | isScrolling, 283 | containerRef, 284 | }, 285 | props 286 | ) 287 | ) 288 | } 289 | ``` 290 | 291 | #### Arguments 292 | 293 | | Argument | Type | Description | 294 | | ------------- | ----------------------- | ------------------------------------------------------------------------------------------------- | 295 | | initialWidth | `number` | The width of the window when render on the server side. This has no effect client side. | 296 | | initialHeight | `number` | The height of the window when render on the server side. This has no effect client side. | 297 | | options | `WindowScrollerOptions` | A configuration object for the hook. See [`WindowScrollerOptions`](#windowscrolleroptions) below. | 298 | 299 | ##### `WindowScrollerOptions` 300 | 301 | ```typescript 302 | interface WindowScrollerOptions { 303 | size?: { 304 | // Debounces for this amount of time in ms 305 | // before updating the size of the window 306 | // in state 307 | // 308 | // Defaults to: 120 309 | wait?: number 310 | } 311 | scroll?: { 312 | // The rate in frames per second to update 313 | // the state of the scroll position 314 | // 315 | // Defaults to: 8 316 | fps?: number 317 | } 318 | } 319 | ``` 320 | 321 | #### Returns `WindowScrollerResult` 322 | 323 | ```typescript 324 | interface WindowScrollerResult { 325 | // The width of the browser window 326 | width: number 327 | // The height of the browser window 328 | height: number 329 | // The scroll position of the window on its y-axis 330 | scrollY: number 331 | // Is the window currently being scrolled? 332 | isScrolling: boolean 333 | } 334 | ``` 335 | 336 | --- 337 | 338 | ### `useContainerRect()` 339 | 340 | A hook used for measuring and tracking the width of the masonry component's container, as well as its distance from 341 | the top of your document. These values are necessary to correctly calculate the number/width of columns to render, as well as the number of rows to render. 342 | 343 | ```jsx harmony 344 | import React from 'react' 345 | import {FreeMasonry, useWindowScroller, useContainerRect} from 'masonic' 346 | 347 | const MyCustomMasonry = (props) => { 348 | const {width, height, scrollY, isScrolling} = useWindowScroller(), 349 | [rect, containerRef] = useContainerRect(width, height) 350 | 351 | return React.createElement( 352 | FreeMasonry, 353 | Object.assign( 354 | { 355 | width: rect.width, 356 | height, 357 | scrollTop: Math.max(0, scrollY - (rect.top + scrollY)), 358 | isScrolling, 359 | containerRef, 360 | }, 361 | props 362 | ) 363 | ) 364 | } 365 | ``` 366 | 367 | #### Arguments 368 | 369 | | Argument | Type | Description | 370 | | ------------ | -------- | ------------------------------------------------------------------------------------------------- | 371 | | windowWidth | `number` | The width of the window. Used for updating the `ContainerRect` when the window's width changes. | 372 | | windowHeight | `number` | The height of the window. Used for updating the `ContainerRect` when the window's height changes. | 373 | 374 | #### Returns `[ContainerRect, (element: HTMLElement) => void]` 375 | 376 | ##### `ContainerRect` 377 | 378 | | Property | Type | Description | 379 | | -------- | -------- | -------------------------------------------------------- | 380 | | top | `number` | The `top` value from `element.getBoundingClientRect()` | 381 | | width | `number` | The `width` value from `element.getBoundingClientRect()` | 382 | 383 | --- 384 | 385 | ## Differences from `react-virtualized/Masonry` 386 | 387 | There are actually quite a few differences between these components and 388 | the originals, despite the overall design being highly inspired by them. 389 | 390 | 1. The `react-virtualized` component requires a ``, 391 | `cellPositioner`, and `cellMeasurerCache` and a ton of custom implementation 392 | to get off the ground. It's very difficult to work with. In `Masonic` this 393 | functionality is built in using [`resize-observer-polyfill`](https://github.com/que-etc/resize-observer-polyfill) 394 | for tracking cell size changes. 395 | 396 | 2. This component will auto-calculate the number of columns to render based 397 | upon the defined `columnWidth` property. The column count will update 398 | any time it changes. 399 | 400 | 3. The implementation for updating cell positions and sizes is also much more 401 | efficient in this component because only specific cells and columns are 402 | updated when cell sizes change, whereas in the original a complete reflow 403 | is triggered. 404 | 405 | 4. The API is a complete rewrite and because of much of what is mentioned 406 | above, is much easier to use in my opinion. 407 | 408 | ## LICENSE 409 | 410 | MIT 411 | --------------------------------------------------------------------------------